diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 19:39:47 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 19:39:47 +0900 |
| commit | 7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a (patch) | |
| tree | 29bbfa52183b2a6639d96ad21d66806fb8ccea30 /src/app/store.test.ts | |
| parent | ac43e92d174ed1f081c387944e9044643ee9efc2 (diff) | |
| download | dispatch-web-7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a.tar.gz dispatch-web-7b7d6d01cf94ecad1dcaaa4b87670d50fe15790a.zip | |
fix(chat): bound live subscriptions and preserve unconfirmed input
Diffstat (limited to 'src/app/store.test.ts')
| -rw-r--r-- | src/app/store.test.ts | 87 |
1 files changed, 78 insertions, 9 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 33f93bb..90c11b3 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -270,8 +270,8 @@ function createFakeStorage(): Storage { getItem(key: string): string | null { return map.get(key) ?? null; }, - key(_index: number): string | null { - return null; + key(index: number): string | null { + return [...map.keys()][index] ?? null; }, removeItem(key: string) { map.delete(key); @@ -2711,13 +2711,14 @@ describe("conversation load-on-demand", () => { ws: WebSocketLike, count = 3, activeConversationId: string | null = "lazy-0", + workspaceCount = 1, ) { const storage = createFakeStorage(); const conversations = Array.from({ length: count }, (_, i) => ({ id: `lazy-${i}`, title: `Conversation ${i}`, model: "openai/gpt-4o", - workspaceId: "default", + workspaceId: i % workspaceCount === 0 ? "default" : `workspace-${i % workspaceCount}`, status: i === 1 ? "active" : "idle", lastActivityAt: 1234, })); @@ -2763,7 +2764,7 @@ describe("conversation load-on-demand", () => { fetchImpl.mock.calls .map(([url]) => new URL(String(url)).pathname + new URL(String(url)).search) .filter((url) => /\/conversations\/[^/?]+(?:\?sinceSeq=|\/metrics$)/.test(url)); - await vi.waitFor(() => expect(store.tabs).toHaveLength(count)); + await vi.waitFor(() => expect(store.tabs).toHaveLength(Math.ceil(count / workspaceCount))); if (activeConversationId !== null) { await vi.waitFor(() => expect(requests()).toContain(`/conversations/${activeConversationId}/metrics`), @@ -2785,7 +2786,7 @@ describe("conversation load-on-demand", () => { ]); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(300); + ).toHaveLength(1); expect(store.tabs[299]?.title).toBe("Conversation 299"); expect(store.conversationStatus("lazy-1")).toBe("active"); ws.feedServerMessage({ @@ -2801,6 +2802,24 @@ describe("conversation load-on-demand", () => { } }, 20_000); + it("discovers 339 conversations across 24 workspaces without subscribing to all of them", async () => { + const ws = fakeSocket(); + const { store } = await setup(ws, 339, "lazy-0", 24); + try { + ws.resolveOpen(); + const subscriptions = parseSent(ws).filter( + (message) => (message as { type: string }).type === "chat.subscribe", + ); + expect(subscriptions).toEqual([{ type: "chat.subscribe", conversationId: "lazy-0" }]); + store.setActiveWorkspace("workspace-1"); + store.selectTab("lazy-1"); + expect(parseSent(ws)).toContainEqual({ type: "chat.unsubscribe", conversationId: "lazy-0" }); + expect(parseSent(ws)).toContainEqual({ type: "chat.subscribe", conversationId: "lazy-1" }); + } finally { + store.dispose(); + } + }, 20_000); + it("does not load any history while the active slot is a draft", async () => { const { store, requests } = await setup(fakeSocket(), 300, null); try { @@ -2813,13 +2832,13 @@ describe("conversation load-on-demand", () => { } }, 20_000); - it("loads a tab once, including repeated activation while its load is pending", async () => { + it("loads a tab once while pending and catches up a previously loaded tab on reactivation", async () => { const { store, requests } = await setup(fakeSocket()); try { store.selectTab("lazy-2"); store.selectTab("lazy-0"); store.selectTab("lazy-2"); - await vi.waitFor(() => expect(requests()).toHaveLength(4)); + await vi.waitFor(() => expect(requests()).toHaveLength(6)); expect(requests().filter((u) => u.includes("lazy-2"))).toHaveLength(2); expect(store.activeChat.cacheTimer.running?.startedAt).toBe(1234); } finally { @@ -2827,7 +2846,7 @@ describe("conversation load-on-demand", () => { } }); - it("re-subscribes all tabs on reconnect but resyncs only loaded tabs and watches", async () => { + it("re-subscribes and resyncs only focused tabs and watches on reconnect", async () => { const ws = reconnectableSocket(); const { store, requests } = await setup(ws); try { @@ -2847,7 +2866,7 @@ describe("conversation load-on-demand", () => { ).toBe(true); expect( parseSent(ws).filter((m) => (m as { type: string }).type === "chat.subscribe"), - ).toHaveLength(4); + ).toHaveLength(2); } finally { store.dispose(); } @@ -3129,3 +3148,53 @@ it("routes omitted replay only to the matching watch/tab without WS resend or an store.dispose(); } }); + +describe("unconfirmed first-message recovery", () => { + it("retains a local-only tab and input after reload and authoritative discovery, without resending", async () => { + const storage = createFakeStorage(); + const base = fakeFetchImpl(); + const fetchImpl: typeof fetch = async (input, init) => { + if (String(input).endsWith("/conversations?status=active,idle")) + return Response.json({ conversations: [] }); + return base(input, init); + }; + const ws = fakeSocket(); + const first = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => ws, + }); + ws.resolveOpen(); + first.send("must survive", [{ url: "data:image/png;base64,AAAA" }]); + const id = first.activeConversationId; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(first.activeChat.pendingInputs).toHaveLength(1); + first.dispose(); + const nextSocket = fakeSocket(); + const next = createAppStore({ + localStorage: storage, + indexedDB: new IDBFactory(), + fetchImpl, + socketFactory: () => nextSocket, + }); + try { + nextSocket.resolveOpen(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next.tabs.some((tab) => tab.conversationId === id)).toBe(true); + expect(next.activeChat.pendingInputs).toMatchObject([ + { request: { message: "must survive" } }, + ]); + const requestId = next.activeChat.pendingInputs[0]?.request.requestId ?? ""; + nextSocket.feedServerMessage({ type: "chat.accepted", conversationId: id ?? "", requestId }); + expect(next.activeChat.pendingInputs).toEqual([]); + expect( + parseSent(nextSocket).some((message) => + ["chat.send", "chat.queue"].includes((message as { type: string }).type), + ), + ).toBe(false); + } finally { + next.dispose(); + } + }); +}); |
