diff options
Diffstat (limited to 'src/app/store.test.ts')
| -rw-r--r-- | src/app/store.test.ts | 240 |
1 files changed, 224 insertions, 16 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts index b521975..7b00d42 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -1,3 +1,4 @@ +import type { ConversationHistoryResponse, WsServerMessage } from "@dispatch/transport-contract"; import type { SurfaceServerMessage } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; import type { WebSocketLike } from "../adapters/ws"; @@ -6,7 +7,8 @@ import { createAppStore } from "./store.svelte"; interface FakeSocket extends WebSocketLike { sent: string[]; resolveOpen(): void; - feedMessage(data: SurfaceServerMessage): void; + feedServerMessage(data: WsServerMessage): void; + feedSurfaceMessage(data: SurfaceServerMessage): void; } function fakeSocket(): FakeSocket { @@ -38,7 +40,10 @@ function fakeSocket(): FakeSocket { resolveOpen() { onopen?.(); }, - feedMessage(msg: SurfaceServerMessage) { + feedServerMessage(msg: WsServerMessage) { + onmessage?.({ data: JSON.stringify(msg) }); + }, + feedSurfaceMessage(msg: SurfaceServerMessage) { onmessage?.({ data: JSON.stringify(msg) }); }, sent, @@ -46,10 +51,27 @@ function fakeSocket(): FakeSocket { return ws; } +function fakeFetchImpl(responses: Record<string, unknown> = {}): typeof fetch { + return async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const body = + responses[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse); + return new Response(JSON.stringify(body), { status: 200 }); + }; +} + +function parseSent(ws: FakeSocket): unknown[] { + return ws.sent.map((s) => JSON.parse(s)); +} + describe("createAppStore", () => { it("starts with empty catalog and no selection", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); expect(store.catalog).toEqual([]); @@ -62,10 +84,14 @@ describe("createAppStore", () => { it("updates catalog when catalog message arrives", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -82,10 +108,14 @@ describe("createAppStore", () => { it("select sends subscribe and sets selectedId", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); @@ -105,10 +135,14 @@ describe("createAppStore", () => { it("selecting a different surface unsubscribes from previous", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [ { id: "s1", region: "sidebar", title: "Surface One" }, @@ -137,17 +171,21 @@ describe("createAppStore", () => { it("surface message updates selectedSpec", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "catalog", catalog: [{ id: "s1", region: "sidebar", title: "Surface One" }], }); store.select("s1"); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "surface", spec: { id: "s1", @@ -166,7 +204,11 @@ describe("createAppStore", () => { it("invoke sends an invoke message", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); ws.sent.length = 0; @@ -188,10 +230,14 @@ describe("createAppStore", () => { it("error message updates lastError", () => { const ws = fakeSocket(); - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); - ws.feedMessage({ + ws.feedSurfaceMessage({ type: "error", message: "Something went wrong", }); @@ -211,10 +257,172 @@ describe("createAppStore", () => { origClose(); }; - const store = createAppStore({ socketFactory: () => ws }); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); ws.resolveOpen(); store.dispose(); expect(closeSpy.called).toBe(true); }); + + it("exposes chat store with empty initial messages", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + expect(store.chat).toBeDefined(); + expect(store.chat.messages).toEqual([]); + expect(store.chat.chunks).toEqual([]); + expect(store.chat.error).toBeNull(); + + store.dispose(); + }); + + it("sending a message posts a chat.send on the socket", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + ws.sent.length = 0; + store.chat.send("hello world"); + + const msgs = parseSent(ws); + const chatSend = msgs.find((m) => (m as { type: string }).type === "chat.send") as + | { type: string; conversationId: string; message: string } + | undefined; + expect(chatSend).toBeTruthy(); + expect(chatSend?.conversationId).toBe("test-conv"); + expect(chatSend?.message).toBe("hello world"); + + store.dispose(); + }); + + it("an incoming chat.delta renders in the transcript", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "test-conv", + turnId: "turn-1", + delta: "Hello ", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "text-delta", + conversationId: "test-conv", + turnId: "turn-1", + delta: "world", + }, + }); + + expect(store.chat.chunks.length).toBeGreaterThan(0); + const textChunks = store.chat.chunks.filter((c) => c.chunk.type === "text"); + expect(textChunks).toHaveLength(1); + expect((textChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world"); + + store.dispose(); + }); + + it("chat.error sets the chat error", () => { + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl: fakeFetchImpl(), + conversationId: "test-conv", + }); + ws.resolveOpen(); + + ws.feedServerMessage({ + type: "chat.error", + message: "bad request", + }); + + expect(store.chat.error).toBe("bad request"); + + store.dispose(); + }); + + it("turn-sealed triggers a history fetch and synced chunks render", async () => { + const fetchedUrls: string[] = []; + const historyResponse: ConversationHistoryResponse = { + chunks: [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" } }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "hello!" } }, + ], + latestSeq: 2, + }; + const fetchImpl: typeof fetch = async (input: string | URL | Request): Promise<Response> => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + fetchedUrls.push(url); + return new Response(JSON.stringify(historyResponse), { status: 200 }); + }; + + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + conversationId: "test-conv", + httpUrl: "http://localhost:24203", + }); + ws.resolveOpen(); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-start", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + ws.feedServerMessage({ + type: "chat.delta", + event: { + type: "turn-sealed", + conversationId: "test-conv", + turnId: "turn-1", + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchedUrls.some((u) => u.includes("/conversations/test-conv?sinceSeq="))).toBe(true); + + await new Promise((r) => setTimeout(r, 50)); + + expect(store.chat.chunks.length).toBeGreaterThan(0); + + store.dispose(); + }); }); |
