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, reject const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } const conversation = (id, extra = {}) => ({ id, channelId: 1, customerName: "Visitor", status: 3, serviceMode: 1, priority: 0, currentAssigneeId: 0, lastMessageId: 0, customerUnreadCount: 0, agentUnreadCount: 0, customerLastReadMessageId: 0, agentLastReadMessageId: 0, ...extra }) const message = (id, conversationId, extra = {}) => ({ id, conversationId, senderType: "customer", senderId: 1, messageType: "html", content: `
${id}
`, sendStatus: 1, customerRead: false, agentRead: false, ...extra }) const page = (results, hasMore = false) => ({ results, hasMore }) // Exercise the actual store, including its asynchronous continuations, without // connecting a socket, browser, database, or service. function harness(overrides = {}) { let identity = "visitor-1", uuid = 0, state, realtimeOptions let connectCount = 0, disconnectCount = 0 const timers = new Map() const api = { getCustomerIdentityScope: () => identity, getCustomerEntryScope: () => identity, ensureCustomerSession: async () => ({}), fetchImWidgetConfig: async () => ({}), fetchImConversationList: async () => page([conversation(1), conversation(2)]), createOrMatchImConversation: async () => conversation(1), fetchImMessages: async () => page([]), createImConversation: async () => conversation(3), markImMessageRead: async () => {}, closeImConversation: async () => {}, applyCustomerSessionRefresh: () => {}, ...overrides, } const modules = new Map() const mocks = { zustand: { create: (initializer) => { const set = (patch) => { state = { ...state, ...(typeof patch === "function" ? patch(state) : patch) } } state = initializer(set, () => state) return { getState: () => state, setState: set } } }, "@/lib/api/im": api, "@/lib/message-file-identity": { getMessageFileIdentity: async (file) => file.content }, "@/lib/im-realtime": { createImRealtimeConnection: () => {} }, "@/lib/im-message": { summarizeIMMessage: (item) => item.content, parseMessageAssetPayload: () => null }, "@/lib/utils": { generateUUID: () => `uuid-${++uuid}` }, "@/i18n/messages": { translateCurrentMessage: (key) => key }, "@/lib/sdk/runtime-config": { readSupportChatRuntimeConfig: () => ({}), setSupportChatRuntimeConfig: () => {} }, "@/lib/realtime-connection": { createRealtimeConnectionManager: (options) => { realtimeOptions = options return { connect: async () => { connectCount++ }, disconnect: () => { disconnectCount++ } } } }, } const 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 moduleContext = { exports: {} } modules.set(name, moduleContext.exports) vm.runInNewContext(compiled.outputText, { module: moduleContext, exports: moduleContext.exports, require: load, console, AbortController, setTimeout: (callback) => { const id = timers.size + 1; timers.set(id, callback); return id }, clearTimeout: (id) => timers.delete(id), WebSocket: { OPEN: 1, CONNECTING: 0 } }) return moduleContext.exports } const store = load("./support-chat.ts").useSupportChatStore const seed = (extra = {}) => store.setState({ identityScope: identity, initialized: true, conversations: [conversation(1), conversation(2)], activeConversationId: 1, conversation: conversation(1), ...extra }) seed() return { store, api, seed, timers, setIdentity: (value) => { identity = value }, emit: (type, data) => realtimeOptions.onMessage({ data: JSON.stringify({ type, data }) }), get connectCount() { return connectCount }, get disconnectCount() { return disconnectCount } } } for (const loader of ["refreshMessages", "syncLatestMessages", "loadOlderMessages"]) { test(`${loader} cannot paint a late response into a newly selected request`, async () => { const late = deferred() const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 ? late.promise : Promise.resolve(page([message(20, 2)])) }) h.seed({ messages: [message(10, 1)], messagesHasMore: true, messagesCursor: "10" }) const pending = h.store.getState()[loader]() await h.store.getState().selectConversation(2) late.resolve(page([message(5, 1)], true)) await pending assert.equal(h.store.getState().conversation.id, 2) assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.conversationId), [2]) assert.equal(h.store.getState().messagesLoadingMore, false) }) } test("A to B to A selection invalidates the first A response", async () => { const late = deferred() let calls = 0 const h = harness({ fetchImMessages: ({ conversationId }) => conversationId === 1 && calls++ === 0 ? late.promise : Promise.resolve(page([message(30, conversationId)])) }) const pending = h.store.getState().refreshMessages() await h.store.getState().selectConversation(2) await h.store.getState().selectConversation(1) late.resolve(page([message(10, 1)])) await pending assert.deepEqual(Array.from(h.store.getState().messages, (item) => item.id), [30]) }) test("newer latest request wins and stale failure cannot replace its error state", async () => { const old = deferred(), current = deferred() let calls = 0 const h = harness({ fetchImMessages: () => ++calls === 1 ? old.promise : current.promise }) const first = h.store.getState().refreshMessages() const second = h.store.getState().refreshMessages() current.resolve(page([message(30, 1)])) await second old.reject(new Error("stale error")) await first assert.equal(h.store.getState().error, "") assert.equal(h.store.getState().messages[0].id, 30) }) test("read acknowledgment updates its original list row without marking the new view", async () => { const read = deferred() const h = harness({ markImMessageRead: () => read.promise, fetchImMessages: async () => page([message(20, 2)]) }) const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 }) h.seed({ conversations: [first, conversation(2, { customerUnreadCount: 2 })], conversation: first, messages: [message(10, 1)] }) const pending = h.store.getState().markConversationRead() await h.store.getState().selectConversation(2) read.resolve() await pending const state = h.store.getState() assert.equal(state.conversations.find((item) => item.id === 1).customerUnreadCount, 0) assert.equal(state.conversation.customerUnreadCount, 2) assert.equal(state.messages[0].customerRead, false) }) test("read acknowledgment does not erase a newer unread reply", async () => { const read = deferred() const h = harness({ markImMessageRead: () => read.promise }) const first = conversation(1, { lastMessageId: 10, customerUnreadCount: 1 }) h.seed({ conversations: [first], conversation: first, messages: [message(10, 1)] }) const pending = h.store.getState().markConversationRead() h.emit("conversation.updated", { conversationId: 1, lastMessageId: 11, customerUnreadCount: 2 }) read.resolve() await pending assert.equal(h.store.getState().conversation.customerUnreadCount, 2) assert.equal(h.store.getState().conversation.customerLastReadMessageId, 10) }) test("send is single-flight and failed logical send keeps its idempotency key", async () => { const first = deferred(), sent = [] const h = harness({ sendImMessage: (payload) => { sent.push(payload); return sent.length === 1 ? first.promise : Promise.resolve(message(10, 1)) } }) const pending = h.store.getState().handleSendMessage("Hello
") await assert.rejects(h.store.getState().handleSendMessage("Hello
"), /sendInProgress/) assert.equal(sent.length, 1) first.reject(new Error("response lost")) await assert.rejects(pending, /response lost/) await h.store.getState().handleSendMessage("Hello
") assert.equal(sent[0].clientMsgId, sent[1].clientMsgId) await h.store.getState().handleSendMessage("Hello
") assert.notEqual(sent[1].clientMsgId, sent[2].clientMsgId) }) test("delivered send after switching updates only original request summary", async () => { const send = deferred() const h = harness({ sendImMessage: () => send.promise, fetchImMessages: async () => page([message(20, 2)]) }) const pending = h.store.getState().handleSendMessage("hello") await h.store.getState().selectConversation(2) assert.equal(h.store.getState().sending, false) send.resolve(message(10, 1)) await pending const state = h.store.getState() assert.equal(state.conversations.find((item) => item.id === 1).lastMessageId, 10) assert.equal(state.conversation.lastMessageId, 0) assert.equal(state.messages[0].conversationId, 2) }) test("close response for old selection does not close or disconnect current request", async () => { const close = deferred() const h = harness({ closeImConversation: () => close.promise }) const pending = h.store.getState().closeConversation() await h.store.getState().selectConversation(2) close.resolve() await pending assert.equal(h.store.getState().conversation.status, 3) assert.equal(h.store.getState().conversations.find((item) => item.id === 1).status, 4) assert.equal(h.disconnectCount, 0) }) test("identity change invalidates old delivered result even for same numeric conversation", async () => { const send = deferred() const h = harness({ sendImMessage: () => send.promise }) const pending = h.store.getState().handleSendMessage("private") h.setIdentity("visitor-2") h.seed() send.resolve(message(10, 1)) await pending assert.equal(h.store.getState().messages.length, 0) assert.equal(h.store.getState().conversation.lastMessageId, 0) }) test("unmount invalidates pending bootstrap and prevents sockets from reopening", async () => { const config = deferred() const h = harness({ fetchImWidgetConfig: () => config.promise }) h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 }) const pending = h.store.getState().bootstrap() h.store.getState().disconnectSocket() config.resolve({ title: "stale" }) await pending assert.equal(h.store.getState().conversation, null) assert.equal(h.connectCount, 0) }) test("retry reboots a failed initial load, and concurrent bootstrap is single-flight", async () => { let listCalls = 0 const config = deferred() const h = harness({ fetchImWidgetConfig: () => config.promise, fetchImConversationList: async () => { if (++listCalls === 1) throw new Error("offline"); return page([conversation(1)]) } }) h.seed({ initialized: false, isOpen: true, conversation: null, activeConversationId: 0 }) const first = h.store.getState().bootstrap() assert.equal(h.store.getState().bootstrap(), first) config.resolve({}) await first assert.equal(h.store.getState().conversation, null) await h.store.getState().retry() assert.equal(h.store.getState().conversation.id, 1) assert.equal(h.store.getState().error, "") }) test("new request and request switching reconnect after transport was disconnected", async () => { const h = harness() h.seed({ isOpen: true, socket: null }) await h.store.getState().startNewConversation() assert.equal(h.store.getState().conversation.id, 3) assert.ok(h.connectCount > 0) const connects = h.connectCount await h.store.getState().selectConversation(2) assert.ok(h.connectCount > connects) }) test("list refresh cannot overwrite a delivered send that happened after its snapshot", async () => { const list = deferred() const h = harness({ fetchImConversationList: () => list.promise, sendImMessage: async () => message(10, 1) }) const pending = h.store.getState().refreshConversations() await h.store.getState().handleSendMessage("hello") list.resolve(page([conversation(1), conversation(2)])) await pending assert.equal(h.store.getState().conversation.lastMessageId, 10) }) test("image upload completes into captured draft even after selecting another request", async () => { const upload = deferred() const h = harness({ uploadImImage: () => upload.promise }) const pending = h.store.getState().uploadMessageImage({}) await h.store.getState().selectConversation(2) const asset = { assetId: "image-1" } upload.resolve(asset) assert.equal(await pending, asset) }) test("changed identity clears private state while closed, before reopening", async () => { const h = harness() h.seed({ messages: [message(10, 1)], isOpen: false }) h.setIdentity("visitor-2") await h.store.getState().bootstrap() assert.equal(h.store.getState().conversation, null) assert.equal(h.store.getState().conversations.length, 0) assert.equal(h.store.getState().messages.length, 0) assert.equal(h.store.getState().identityScope, "visitor-2") }) test("reselected attachment reuses upload and logical send key after uncertain failure", async () => { const sent = [] let uploads = 0 const h = harness({ uploadImAttachment: async () => { uploads++; return { assetId: "asset-1", filename: "attachment.txt" } }, sendImMessage: async (payload) => { sent.push(payload); if (sent.length === 1) throw new Error("ack lost"); return message(10, 1) } }) await assert.rejects(h.store.getState().sendAttachment({ content: "same file" }), /ack lost/) await h.store.getState().sendAttachment({ content: "same file" }) assert.equal(uploads, 1) assert.equal(sent[0].clientMsgId, sent[1].clientMsgId) })