Targeted source-only patch from reviewed local e370485. Adds bounded realtime/catch-up recovery and moves the setup wizard under System. Static checks only; regression tests, full build and runtime verification have not been run. No deployment. Separate hardening-review branch is not merged. Expected tree: 6235478b9a.
322 lines
12 KiB
JavaScript
322 lines
12 KiB
JavaScript
import assert from "node:assert/strict"
|
|
import { readFileSync } from "node:fs"
|
|
import test from "node:test"
|
|
import vm from "node:vm"
|
|
import ts from "typescript"
|
|
|
|
const deferred = () => {
|
|
let resolve
|
|
const promise = new Promise((done) => { resolve = done })
|
|
return { promise, resolve }
|
|
}
|
|
const conversation = (id) => ({ id, status: 3, customerUnreadCount: 0 })
|
|
const message = (id, conversationId) => ({ id, conversationId, senderType: "agent",
|
|
senderId: 1, messageType: "html", content: "Reply", sendStatus: 1,
|
|
customerRead: false, agentRead: false })
|
|
const page = (results = []) => ({ results, hasMore: false })
|
|
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
|
|
|
|
// Run actual store and merge helpers with fake transport, APIs, visibility and
|
|
// clock. These regressions never open a network socket or touch browser data.
|
|
function harness(overrides = {}) {
|
|
let state, realtime, now = 0, nextTimer = 0, connects = 0, disconnects = 0
|
|
const timers = new Map(), modules = new Map(), sent = []
|
|
const config = { channelId: "fixture", externalId: "visitor-1" }
|
|
const document = { visibilityState: "visible" }
|
|
const api = {
|
|
ensureCustomerSession: async () => ({}),
|
|
fetchImWidgetConfig: async () => ({}),
|
|
fetchImConversationList: async () => page([conversation(1)]),
|
|
createOrMatchImConversation: async () => conversation(1),
|
|
createImConversation: async () => conversation(2),
|
|
fetchImMessages: async () => page(),
|
|
closeImConversation: async () => {},
|
|
applyCustomerSessionRefresh: () => {},
|
|
...overrides,
|
|
}
|
|
const mocks = {
|
|
zustand: { create: (init) => {
|
|
const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } }
|
|
state = init(set, () => state)
|
|
return { getState: () => state, setState: set }
|
|
} },
|
|
"@/lib/api/im": api,
|
|
"@/lib/im-realtime": { createImRealtimeConnection: () => {} },
|
|
"@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null },
|
|
"@/lib/utils": { generateUUID: () => "fixture" },
|
|
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
|
"@/lib/sdk/runtime-config": {
|
|
readSupportChatRuntimeConfig: () => config,
|
|
setSupportChatRuntimeConfig: (next) => Object.assign(config, next),
|
|
},
|
|
"@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => {
|
|
realtime = options
|
|
return {
|
|
connect: async () => { connects++; options.onStatusChange("connecting") },
|
|
disconnect: () => { disconnects++; options.onSocketChange(null); options.onStatusChange("disconnected") },
|
|
}
|
|
} },
|
|
}
|
|
function load(name) {
|
|
if (mocks[name]) return mocks[name]
|
|
if (modules.has(name)) return modules.get(name)
|
|
const url = name.startsWith("@/")
|
|
? new URL(`../../${name.slice(2)}.ts`, import.meta.url)
|
|
: new URL(name, import.meta.url)
|
|
const source = readFileSync(url, "utf8")
|
|
const compiled = ts.transpileModule(source, { compilerOptions: {
|
|
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
|
|
} })
|
|
const module = { exports: {} }
|
|
modules.set(name, module.exports)
|
|
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
|
|
require: load, console, document, AbortController, WebSocket: { OPEN: 1, CONNECTING: 0 },
|
|
setTimeout: (callback, delay) => {
|
|
const id = ++nextTimer
|
|
timers.set(id, { callback, at: now + delay })
|
|
return id
|
|
},
|
|
clearTimeout: (id) => timers.delete(id),
|
|
})
|
|
return module.exports
|
|
}
|
|
const store = load("./support-chat.ts").useSupportChatStore
|
|
store.setState({ initialized: true, isOpen: true, isVisible: true,
|
|
conversations: [conversation(1)], conversation: conversation(1), activeConversationId: 1 })
|
|
return { store, api, config, document, timers, sent,
|
|
get connects() { return connects }, get disconnects() { return disconnects },
|
|
status: (status) => realtime.onStatusChange(status),
|
|
emit: (type, data) => realtime.onMessage({ data: JSON.stringify({ type, data }) }),
|
|
open: () => {
|
|
const socket = { readyState: 1, send: (value) => sent.push(JSON.parse(value)) }
|
|
realtime.onSocketChange(socket)
|
|
realtime.onStatusChange("connected")
|
|
realtime.onOpen(socket)
|
|
return socket
|
|
},
|
|
advance: async (ms) => {
|
|
const until = now + ms
|
|
for (;;) {
|
|
const next = [...timers].filter(([, timer]) => timer.at <= until)
|
|
.sort((a, b) => a[1].at - b[1].at)[0]
|
|
if (!next) break
|
|
now = next[1].at
|
|
timers.delete(next[0])
|
|
next[1].callback()
|
|
await flush()
|
|
}
|
|
now = until
|
|
await flush()
|
|
},
|
|
}
|
|
}
|
|
|
|
test("new chat after previous one receives a reply later than seven seconds while WS is unavailable", async () => {
|
|
let reply = false
|
|
const h = harness({ fetchImMessages: async ({ conversationId }) => page(reply ? [message(20, conversationId)] : []) })
|
|
await h.store.getState().startNewConversation()
|
|
await h.advance(8000)
|
|
reply = true
|
|
await h.advance(2000)
|
|
assert.equal(h.store.getState().messages[0].id, 20)
|
|
assert.notEqual(h.store.getState().status, "connected", "HTTP fallback must not claim WS is online")
|
|
})
|
|
|
|
test("new chat reuses an open socket, resubscribes and repairs a stale status", async () => {
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
const socket = h.open()
|
|
await flush()
|
|
h.store.setState({ status: "disconnected" })
|
|
await h.store.getState().startNewConversation()
|
|
assert.equal(h.store.getState().socket, socket)
|
|
assert.equal(h.store.getState().status, "connected")
|
|
assert.equal(h.sent.at(-2).type, "unsubscribe")
|
|
assert.equal(h.sent.at(-1).topics[0], "conversation:2")
|
|
})
|
|
|
|
test("fallback stops on connection, hiding, closing and unmount, and resumes on visibility", async () => {
|
|
let reads = 0
|
|
const h = harness({ fetchImMessages: async () => { reads++; return page() } })
|
|
await h.store.getState().retry()
|
|
h.document.visibilityState = "hidden"
|
|
h.store.getState().recoverConnection()
|
|
const hiddenReads = reads
|
|
await h.advance(15000)
|
|
assert.equal(reads, hiddenReads)
|
|
h.document.visibilityState = "visible"
|
|
h.store.getState().recoverConnection()
|
|
await h.advance(5000)
|
|
assert.ok(reads > hiddenReads)
|
|
h.store.getState().setIsVisible(false)
|
|
const minimizedReads = reads
|
|
await h.advance(15000)
|
|
assert.equal(reads, minimizedReads)
|
|
h.store.getState().setIsVisible(true)
|
|
h.open()
|
|
await flush()
|
|
const connectedReads = reads
|
|
await h.advance(15000)
|
|
assert.equal(reads, connectedReads)
|
|
h.status("connecting")
|
|
h.store.getState().disconnectSocket()
|
|
const unmountedReads = reads
|
|
await h.advance(15000)
|
|
assert.equal(reads, unmountedReads)
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
|
|
test("fallback has one in-flight latest refresh and cannot restart after unmount", async () => {
|
|
const pending = deferred()
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
let reads = 0
|
|
h.api.fetchImMessages = () => { reads++; return pending.promise }
|
|
await h.advance(5000)
|
|
void h.store.getState().syncLatestMessages()
|
|
await h.advance(15000)
|
|
assert.equal(reads, 1)
|
|
h.store.getState().disconnectSocket()
|
|
pending.resolve(page([message(99, 1)]))
|
|
await flush()
|
|
await h.advance(15000)
|
|
assert.equal(reads, 1)
|
|
assert.equal(h.store.getState().messages.length, 0)
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
|
|
for (const change of ["conversation", "identity", "round-trip selection"]) {
|
|
test(`late fallback cannot paint into a changed ${change}`, async () => {
|
|
const pending = deferred()
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
h.api.fetchImMessages = () => pending.promise
|
|
await h.advance(5000)
|
|
h.api.fetchImMessages = async () => page()
|
|
if (change === "identity") h.config.externalId = "visitor-2"
|
|
else {
|
|
await h.store.getState().startNewConversation()
|
|
if (change === "round-trip selection") await h.store.getState().selectConversation(1)
|
|
}
|
|
pending.resolve(page([message(99, 1)]))
|
|
await flush()
|
|
assert.equal(h.store.getState().messages.length, 0)
|
|
h.store.getState().disconnectSocket()
|
|
})
|
|
}
|
|
|
|
test("bootstrap and retry attempt realtime even when loading history fails", async () => {
|
|
const h = harness({ fetchImMessages: async () => { throw new Error("history unavailable") } })
|
|
h.store.setState({ initialized: false, conversation: null, activeConversationId: 0 })
|
|
h.store.getState().bootstrap()
|
|
await flush()
|
|
assert.ok(h.connects > 0)
|
|
const before = h.connects
|
|
await h.store.getState().retry()
|
|
assert.ok(h.connects > before)
|
|
assert.equal(h.store.getState().status, "connecting")
|
|
h.store.getState().disconnectSocket()
|
|
})
|
|
|
|
test("loading initial history does not erase a newer realtime reply", async () => {
|
|
const pending = deferred()
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
h.api.fetchImMessages = () => pending.promise
|
|
const loading = h.store.getState().refreshMessages()
|
|
h.emit("message.created", { conversationId: 1, message: message(20, 1) })
|
|
pending.resolve(page([message(10, 1)]))
|
|
await loading
|
|
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [10, 20])
|
|
})
|
|
|
|
test("late close for previous request does not close or disconnect the new chat", async () => {
|
|
const pending = deferred()
|
|
const h = harness({ closeImConversation: () => pending.promise })
|
|
const closing = h.store.getState().closeConversation()
|
|
await h.store.getState().startNewConversation()
|
|
const disconnects = h.disconnects
|
|
pending.resolve()
|
|
await closing
|
|
assert.equal(h.store.getState().conversation.id, 2)
|
|
assert.equal(h.store.getState().conversation.status, 3)
|
|
assert.equal(h.disconnects, disconnects)
|
|
assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4)
|
|
})
|
|
|
|
test("a successful pending send cannot reconnect or start polling after unmount", async () => {
|
|
const pending = deferred()
|
|
const h = harness({ sendImMessage: () => pending.promise })
|
|
const sending = h.store.getState().handleSendMessage("hello")
|
|
h.store.getState().disconnectSocket()
|
|
pending.resolve({ ...message(10, 1), senderType: "customer" })
|
|
await sending
|
|
await h.advance(15000)
|
|
assert.equal(h.connects, 0)
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
|
|
test("closing the active conversation stops degraded polling", async () => {
|
|
let reads = 0
|
|
const h = harness({ fetchImMessages: async () => { reads++; return page() } })
|
|
await h.store.getState().retry()
|
|
await h.store.getState().closeConversation()
|
|
const before = reads
|
|
await h.advance(15000)
|
|
assert.equal(reads, before)
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
|
|
test("connection opening during fallback queues one fresh catch-up after its older snapshot", async () => {
|
|
const pending = deferred()
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
h.api.fetchImMessages = () => pending.promise
|
|
await h.advance(5000)
|
|
h.open()
|
|
h.api.fetchImMessages = async () => page([message(50, 1)])
|
|
pending.resolve(page())
|
|
await flush()
|
|
assert.equal(h.store.getState().messages[0].id, 50)
|
|
assert.equal(h.store.getState().status, "connected")
|
|
assert.equal(h.timers.size, 0)
|
|
})
|
|
|
|
test("switching aborts a stalled previous-view fallback without blocking new replies", async () => {
|
|
const pending = deferred()
|
|
let previousSignal
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
h.api.fetchImMessages = (_query, options) => { previousSignal = options?.signal; return pending.promise }
|
|
await h.advance(5000)
|
|
let replied = false
|
|
h.api.fetchImMessages = async ({ conversationId }) => page(replied ? [message(70, conversationId)] : [])
|
|
await h.store.getState().startNewConversation()
|
|
assert.equal(previousSignal.aborted, true)
|
|
replied = true
|
|
await h.advance(5000)
|
|
assert.equal(h.store.getState().messages[0].conversationId, 2)
|
|
pending.resolve(page([message(99, 1)]))
|
|
await flush()
|
|
assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [70])
|
|
})
|
|
|
|
test("a stalled fallback is aborted at its deadline and can retry", async () => {
|
|
const h = harness()
|
|
await h.store.getState().retry()
|
|
let signal, reads = 0
|
|
h.api.fetchImMessages = (_query, options) => {
|
|
reads++
|
|
signal = options.signal
|
|
return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }))
|
|
}
|
|
await h.advance(5000)
|
|
await h.advance(15000)
|
|
assert.equal(signal.aborted, true)
|
|
h.api.fetchImMessages = async () => { reads++; return page([message(80, 1)]) }
|
|
await h.advance(5000)
|
|
assert.equal(reads, 2)
|
|
assert.equal(h.store.getState().messages[0].id, 80)
|
|
assert.equal(h.store.getState().error, "")
|
|
})
|