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, "") })