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.ts363
1 files changed, 317 insertions, 46 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 7b00d42..dabc80d 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -51,11 +51,21 @@ function fakeSocket(): FakeSocket {
return ws;
}
-function fakeFetchImpl(responses: Record<string, unknown> = {}): typeof fetch {
+interface FakeFetchOptions {
+ models?: readonly string[];
+ history?: Record<string, ConversationHistoryResponse>;
+}
+
+function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
+ const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const history = opts?.history ?? {};
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models }), { status: 200 });
+ }
const body =
- responses[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
+ history[url] ?? ({ chunks: [], latestSeq: 0 } satisfies ConversationHistoryResponse);
return new Response(JSON.stringify(body), { status: 200 });
};
}
@@ -64,6 +74,36 @@ function parseSent(ws: FakeSocket): unknown[] {
return ws.sent.map((s) => JSON.parse(s));
}
+function createFakeStorage(): Storage {
+ const map = new Map<string, string>();
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem(key: string): string | null {
+ return map.get(key) ?? null;
+ },
+ key(_index: number): string | null {
+ return null;
+ },
+ removeItem(key: string) {
+ map.delete(key);
+ },
+ setItem(key: string, value: string) {
+ map.set(key, value);
+ },
+ };
+}
+
+function activeConversationId(store: ReturnType<typeof createAppStore>): string {
+ const id = store.activeConversationId;
+ expect(id).not.toBeNull();
+ return id as string;
+}
+
describe("createAppStore", () => {
it("starts with empty catalog and no selection", () => {
const ws = fakeSocket();
@@ -71,6 +111,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -88,6 +129,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -112,6 +154,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -139,6 +182,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -175,6 +219,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -208,6 +253,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -234,6 +280,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -261,6 +308,7 @@ describe("createAppStore", () => {
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
@@ -268,41 +316,45 @@ describe("createAppStore", () => {
expect(closeSpy.called).toBe(true);
});
- it("exposes chat store with empty initial messages", () => {
+ it("exposes activeChat with empty initial messages", () => {
const ws = fakeSocket();
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
- expect(store.chat).toBeDefined();
- expect(store.chat.messages).toEqual([]);
- expect(store.chat.chunks).toEqual([]);
- expect(store.chat.error).toBeNull();
+ expect(store.activeChat).toBeDefined();
+ expect(store.activeChat.messages).toEqual([]);
+ expect(store.activeChat.chunks).toEqual([]);
+ expect(store.activeChat.error).toBeNull();
store.dispose();
});
- it("sending a message posts a chat.send on the socket", () => {
+ it("sending a message from draft creates a tab and posts chat.send", () => {
const ws = fakeSocket();
+ const storage = createFakeStorage();
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
+ localStorage: storage,
});
ws.resolveOpen();
ws.sent.length = 0;
- store.chat.send("hello world");
+ store.send("hello world");
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.title).toBe("hello world");
+ expect(store.activeConversationId).not.toBeNull();
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();
@@ -313,41 +365,30 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
+ store.send("test");
+ const convId = activeConversationId(store);
+
ws.feedServerMessage({
type: "chat.delta",
- event: {
- type: "turn-start",
- conversationId: "test-conv",
- turnId: "turn-1",
- },
+ event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
});
ws.feedServerMessage({
type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: "test-conv",
- turnId: "turn-1",
- delta: "Hello ",
- },
+ event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "Hello " },
});
ws.feedServerMessage({
type: "chat.delta",
- event: {
- type: "text-delta",
- conversationId: "test-conv",
- turnId: "turn-1",
- delta: "world",
- },
+ event: { type: "text-delta", conversationId: convId, turnId: "turn-1", delta: "world" },
});
- expect(store.chat.chunks.length).toBeGreaterThan(0);
- const textChunks = store.chat.chunks.filter((c) => c.chunk.type === "text");
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+ const textChunks = store.activeChat.chunks.filter((c) => c.chunk.type === "text");
expect(textChunks).toHaveLength(1);
expect((textChunks[0]?.chunk as { type: "text"; text: string }).text).toBe("Hello world");
@@ -359,16 +400,20 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl(),
- conversationId: "test-conv",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
+ store.send("test");
+ const convId = activeConversationId(store);
+
ws.feedServerMessage({
type: "chat.error",
+ conversationId: convId,
message: "bad request",
});
- expect(store.chat.error).toBe("bad request");
+ expect(store.activeChat.error).toBe("bad request");
store.dispose();
});
@@ -385,6 +430,11 @@ describe("createAppStore", () => {
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);
+ if (url.endsWith("/models")) {
+ return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ status: 200,
+ });
+ }
return new Response(JSON.stringify(historyResponse), { status: 200 });
};
@@ -392,36 +442,257 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl,
- conversationId: "test-conv",
httpUrl: "http://localhost:24203",
+ localStorage: createFakeStorage(),
});
ws.resolveOpen();
+ store.send("hi");
+ const convId = activeConversationId(store);
+
ws.feedServerMessage({
type: "chat.delta",
- event: {
- type: "turn-start",
- conversationId: "test-conv",
- turnId: "turn-1",
- },
+ event: { type: "turn-start", conversationId: convId, turnId: "turn-1" },
+ });
+
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-sealed", conversationId: convId, turnId: "turn-1" },
+ });
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fetchedUrls.some((u) => u.includes(`/conversations/${convId}?sinceSeq=`))).toBe(true);
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.activeChat.chunks.length).toBeGreaterThan(0);
+
+ store.dispose();
+ });
+
+ it("fetches and exposes the model catalog", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({
+ models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.models).toEqual([
+ "opencode/deepseek-v4-flash",
+ "openai/gpt-4o",
+ "anthropic/claude-3",
+ ]);
+
+ store.dispose();
+ });
+
+ it("default model is flash", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("draft: sending the first message creates a tab titled from the message", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+
+ store.send("What is the meaning of life?");
+
+ expect(store.tabs).toHaveLength(1);
+ expect(store.tabs[0]?.title).toBe("What is the meaning of life?");
+ expect(store.activeConversationId).toBe(store.tabs[0]?.conversationId);
+
+ store.dispose();
+ });
+
+ it("selecting a model updates the active tab", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("hello");
+
+ store.selectModel("openai/gpt-4o");
+
+ expect(store.activeModel).toBe("openai/gpt-4o");
+ expect(store.tabs[0]?.model).toBe("openai/gpt-4o");
+
+ store.dispose();
+ });
+
+ it("chat.delta routes to the matching tab only", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
});
+ ws.resolveOpen();
+
+ store.send("first message");
+ const convId1 = activeConversationId(store);
+
+ store.newDraft();
+ store.send("second message");
+ const convId2 = activeConversationId(store);
+
+ expect(convId1).not.toBe(convId2);
ws.feedServerMessage({
type: "chat.delta",
+ event: { type: "turn-start", conversationId: convId1, turnId: "turn-1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
event: {
- type: "turn-sealed",
- conversationId: "test-conv",
+ type: "text-delta",
+ conversationId: convId1,
turnId: "turn-1",
+ delta: "response to first",
},
});
- await new Promise((r) => setTimeout(r, 50));
+ store.selectTab(convId1);
+ const textChunks1 = store.activeChat.chunks.filter((c) => c.chunk.type === "text");
+ expect(textChunks1).toHaveLength(1);
+ expect((textChunks1[0]?.chunk as { type: "text"; text: string }).text).toBe(
+ "response to first",
+ );
- expect(fetchedUrls.some((u) => u.includes("/conversations/test-conv?sinceSeq="))).toBe(true);
+ store.selectTab(convId2);
+ expect(store.activeChat.chunks).toEqual([]);
- await new Promise((r) => setTimeout(r, 50));
+ store.dispose();
+ });
+
+ it("closing a tab evicts its cache and drops the tab", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ expect(store.tabs).toHaveLength(1);
+
+ store.closeTab(convId);
+
+ expect(store.tabs).toHaveLength(0);
+ expect(store.activeConversationId).toBeNull();
+
+ store.dispose();
+ });
+
+ it("tabs persist to the injected storage and restore on a new store", () => {
+ const ws = fakeSocket();
+ const storage = createFakeStorage();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+
+ store.send("persist me");
+ const convId = store.tabs[0]?.conversationId;
+ const title = store.tabs[0]?.title;
+ expect(convId).toBeDefined();
+ expect(title).toBeDefined();
+
+ const raw = storage.getItem("dispatch.tabs");
+ expect(raw).not.toBeNull();
+ const parsed = JSON.parse(raw as string);
+ expect(parsed.tabs).toHaveLength(1);
+ expect(parsed.tabs[0].conversationId).toBe(convId);
+ expect(parsed.tabs[0].title).toBe(title);
+
+ const ws2 = fakeSocket();
+ const store2 = createAppStore({
+ socketFactory: () => ws2,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws2.resolveOpen();
+
+ expect(store2.tabs).toHaveLength(1);
+ expect(store2.tabs[0]?.conversationId).toBe(convId);
+ expect(store2.tabs[0]?.title).toBe(title);
+ expect(store2.activeConversationId).toBe(convId);
+
+ store.dispose();
+ store2.dispose();
+ });
+
+ it("newDraft resets to draft mode", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ expect(store.tabs).toHaveLength(1);
+
+ store.newDraft();
+ expect(store.activeConversationId).toBeNull();
+
+ store.dispose();
+ });
+
+ it("selectTab switches active tab", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId1 = activeConversationId(store);
+
+ store.newDraft();
+ store.send("second");
+ const convId2 = activeConversationId(store);
+
+ store.selectTab(convId1);
+ expect(store.activeConversationId).toBe(convId1);
- expect(store.chat.chunks.length).toBeGreaterThan(0);
+ store.selectTab(convId2);
+ expect(store.activeConversationId).toBe(convId2);
store.dispose();
});