/* @vitest-environment jsdom */ import { createRoot, type Root } from "react-dom/client"; import { act } from "react-dom/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DB } from "../lib/db"; import ChatView from "./ChatView"; import type { Message } from "../lib/types"; function createDbStub(): DB { return { insert: vi.fn(() => 1), query: vi.fn(() => []), get: vi.fn(() => null), run: vi.fn(), persist: vi.fn(), } as unknown as DB; } describe("ChatView", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { // @ts-expect-error React test environment flag globalThis.IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); Element.prototype.scrollIntoView = vi.fn(); }); afterEach(() => { act(() => root.unmount()); container.remove(); vi.restoreAllMocks(); // @ts-expect-error React test environment flag globalThis.IS_REACT_ACT_ENVIRONMENT = false; }); it("keeps the draft in place when Enter is pressed before the runtime is ready", async () => { const db = createDbStub(); await act(async () => { root.render( , ); }); const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; expect(textarea).toBeTruthy(); await act(async () => { textarea!.value = "Keep this draft"; textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { textarea!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); expect(textarea?.value).toBe("Keep this draft"); expect(container.textContent).toContain("Connect an API"); expect(vi.mocked(db.run)).not.toHaveBeenCalled(); }); it("loads messages when switching to a saved chat", async () => { const rowsByChat = new Map([ [1, [{ id: 11, chat_id: 1, role: "user", content: "First saved chat", created_at: 1 }]], [2, [{ id: 21, chat_id: 2, role: "assistant", content: "Second saved chat", created_at: 2 }]], ]); const db = { insert: vi.fn(() => 1), query: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("FROM messages WHERE chat_id = ?")) { const chatId = Number(params?.[0]); return rowsByChat.get(chatId) ?? []; } return []; }), get: vi.fn(() => null), run: vi.fn(), persist: vi.fn(), } as unknown as DB; await act(async () => { root.render( , ); }); expect(container.textContent).toContain("First saved chat"); await act(async () => { root.render( , ); }); expect(container.textContent).toContain("Second saved chat"); expect(container.textContent).not.toContain("First saved chat"); }); it("keeps the selected saved chat visible when a previous request finishes later", async () => { const rowsByChat = new Map([ [1, [{ id: 11, chat_id: 1, role: "user", content: "History from first chat", created_at: 1 }]], [2, [{ id: 21, chat_id: 2, role: "assistant", content: "Saved chat stays visible", created_at: 2 }]], ]); let nextMessageId = 100; const db = { insert: vi.fn(() => 1), query: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("FROM messages WHERE chat_id = ?")) { return [...(rowsByChat.get(Number(params?.[0])) ?? [])]; } return []; }), get: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) { return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 }; } return null; }), run: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) { const chatId = Number(params?.[0]); const content = String(params?.[1] ?? ""); const next = rowsByChat.get(chatId) ?? []; next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId }); rowsByChat.set(chatId, next); return; } if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) { const chatId = Number(params?.[0]); const content = String(params?.[1] ?? ""); const next = rowsByChat.get(chatId) ?? []; next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId }); rowsByChat.set(chatId, next); } }), persist: vi.fn(), } as unknown as DB; let resolveFetch: ((value: Response) => void) | null = null; vi.stubGlobal( "fetch", vi.fn( () => new Promise((resolve) => { resolveFetch = resolve; }), ), ); await act(async () => { root.render( , ); }); const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; expect(textarea).toBeTruthy(); await act(async () => { textarea!.value = "Send from first chat"; textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send")); expect(sendButton).toBeTruthy(); await act(async () => { sendButton?.click(); }); await act(async () => { root.render( , ); }); expect(container.textContent).toContain("Saved chat stays visible"); await act(async () => { resolveFetch?.( new Response( JSON.stringify({ choices: [{ message: { content: "Late answer from first chat" } }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), { status: 200, headers: { "Content-Type": "application/json" }, }, ), ); await Promise.resolve(); }); expect(container.textContent).toContain("Saved chat stays visible"); expect(container.textContent).not.toContain("Late answer from first chat"); }); it("shows assistant reply after generation completes in an existing chat", async () => { const rowsByChat = new Map([ [1, [{ id: 11, chat_id: 1, role: "user", content: "Previous message", created_at: 1 }]], ]); let nextMessageId = 200; const db = { insert: vi.fn(() => 1), query: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("FROM messages WHERE chat_id = ?")) { return [...(rowsByChat.get(Number(params?.[0])) ?? [])]; } if (sql.includes("SELECT role, content FROM messages WHERE chat_id = ?")) { return [...(rowsByChat.get(Number(params?.[0])) ?? [])].map(({ role, content }) => ({ role, content })); } return []; }), get: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) { return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 }; } return null; }), run: vi.fn((sql: string, params?: unknown[]) => { if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) { const chatId = Number(params?.[0]); const content = String(params?.[1] ?? ""); const next = rowsByChat.get(chatId) ?? []; next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId }); rowsByChat.set(chatId, next); } if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) { const chatId = Number(params?.[0]); const content = String(params?.[1] ?? ""); const next = rowsByChat.get(chatId) ?? []; next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId }); rowsByChat.set(chatId, next); } }), persist: vi.fn(), } as unknown as DB; let resolveFetch: ((value: Response) => void) | null = null; vi.stubGlobal( "fetch", vi.fn( () => new Promise((resolve) => { resolveFetch = resolve; }), ), ); await act(async () => { root.render( , ); }); expect(container.textContent).toContain("Previous message"); const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; expect(textarea).toBeTruthy(); const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; await act(async () => { setTextareaValue?.call(textarea, "New question"); textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send")); await act(async () => { sendButton?.click(); }); // During generation, user message should be visible expect(container.textContent).toContain("New question"); // Complete the generation await act(async () => { const encoder = new TextEncoder(); resolveFetch?.( new Response(new ReadableStream({ start(controller) { controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"The answer is 42\"}}]}\n")); controller.enqueue(encoder.encode("data: {\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":5,\"total_tokens\":10}}\n")); controller.enqueue(encoder.encode("data: [DONE]\n")); controller.close(); }, }), { status: 200, headers: { "Content-Type": "text/event-stream" } }), ); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); // After generation completes, both messages should still be visible expect(container.textContent).toContain("New question"); expect(container.textContent).toContain("The answer is 42"); }); it("aborts an in-flight request when switching to another saved chat", async () => { const db = createDbStub(); const signalRef: { current: { aborted: boolean } | null } = { current: null }; vi.stubGlobal( "fetch", vi.fn((_input, init?: RequestInit) => { const signal = init?.signal as AbortSignal; signalRef.current = signal; return new Promise((_resolve, reject) => { signal.addEventListener("abort", () => { reject(new DOMException("Aborted", "AbortError")); }); }); }), ); await act(async () => { root.render( , ); }); const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null; expect(textarea).toBeTruthy(); const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; expect(setTextareaValue).toBeTruthy(); await act(async () => { setTextareaValue?.call(textarea, "Message in progress"); textarea!.dispatchEvent(new Event("input", { bubbles: true })); }); const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send")); expect(sendButton).toBeTruthy(); await act(async () => { sendButton?.click(); }); expect(signalRef.current).toBeTruthy(); if (!signalRef.current) { throw new Error("expected the request signal to be captured"); } const requestSignal = signalRef.current; expect(requestSignal.aborted).toBe(false); await act(async () => { root.render( , ); await Promise.resolve(); }); expect(requestSignal.aborted).toBe(true); }); });