summaryrefslogtreecommitdiffhomepage
path: root/src/app/store.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/store.test.ts')
-rw-r--r--src/app/store.test.ts240
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();
+ });
});