import { act, fireEvent, render, screen, waitFor, } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../api/client"; import type { Message } from "../types"; import { Chat } from "./Chat"; // Module-level store for the WebSocket handlers captured during connect(). type WsHandlers = { onToken: (content: string) => void; onUpdate: (history: Message[]) => void; onSessionId: (sessionId: string) => void; onError: (message: string) => void; onActivity: (toolName: string) => void; onReconciliationProgress: ( storyId: string, status: string, message: string, ) => void; }; let capturedWsHandlers: WsHandlers | null = null; // Captures the last sendChat call's arguments for assertion. let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null; vi.mock("../api/client", () => { const api = { getOllamaModels: vi.fn(), getAnthropicApiKeyExists: vi.fn(), getAnthropicModels: vi.fn(), getModelPreference: vi.fn(), setModelPreference: vi.fn(), cancelChat: vi.fn(), setAnthropicApiKey: vi.fn(), readFile: vi.fn(), listProjectFiles: vi.fn(), botCommand: vi.fn(), }; class ChatWebSocket { connect(handlers: WsHandlers) { capturedWsHandlers = handlers; } close() {} sendChat(messages: Message[], config: unknown) { lastSendChatArgs = { messages, config }; } cancel() {} } return { api, ChatWebSocket }; }); const mockedApi = { getOllamaModels: vi.mocked(api.getOllamaModels), getAnthropicApiKeyExists: vi.mocked(api.getAnthropicApiKeyExists), getAnthropicModels: vi.mocked(api.getAnthropicModels), getModelPreference: vi.mocked(api.getModelPreference), setModelPreference: vi.mocked(api.setModelPreference), cancelChat: vi.mocked(api.cancelChat), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), readFile: vi.mocked(api.readFile), listProjectFiles: vi.mocked(api.listProjectFiles), botCommand: vi.mocked(api.botCommand), }; function setupMocks() { mockedApi.getOllamaModels.mockResolvedValue(["llama3.1"]); mockedApi.getAnthropicApiKeyExists.mockResolvedValue(true); mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.readFile.mockResolvedValue(""); mockedApi.listProjectFiles.mockResolvedValue([]); mockedApi.cancelChat.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true); mockedApi.botCommand.mockResolvedValue({ response: "Bot response" }); } describe("Chat localStorage persistence (Story 145)", () => { const PROJECT_PATH = "/tmp/project"; const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; beforeEach(() => { capturedWsHandlers = null; localStorage.clear(); setupMocks(); }); afterEach(() => { localStorage.clear(); }); it("AC1: restores persisted messages on mount", async () => { const saved: Message[] = [ { role: "user", content: "Previously saved question" }, { role: "assistant", content: "Previously saved answer" }, ]; localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); render(); expect( await screen.findByText("Previously saved question"), ).toBeInTheDocument(); expect( await screen.findByText("Previously saved answer"), ).toBeInTheDocument(); }); it("AC2: persists messages when WebSocket onUpdate fires", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const history: Message[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ]; await act(async () => { capturedWsHandlers?.onUpdate(history); }); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(stored).toEqual(history); }); it("AC3: clears localStorage when New Session is clicked", async () => { const saved: Message[] = [ { role: "user", content: "Old message" }, { role: "assistant", content: "Old reply" }, ]; localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); // Stub window.confirm to auto-approve the clear dialog const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); render(); // Wait for the persisted message to appear expect(await screen.findByText("Old message")).toBeInTheDocument(); // Click "New Session" button const newSessionBtn = screen.getByText(/New Session/); await act(async () => { fireEvent.click(newSessionBtn); }); // localStorage should be cleared expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); // Messages should be gone from the UI expect(screen.queryByText("Old message")).not.toBeInTheDocument(); confirmSpy.mockRestore(); }); it("Bug 245: messages survive unmount/remount cycle (page refresh)", async () => { // Step 1: Render Chat and populate messages via WebSocket onUpdate const { unmount } = render( , ); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const history: Message[] = [ { role: "user", content: "Persist me across refresh" }, { role: "assistant", content: "I should survive a reload" }, ]; await act(async () => { capturedWsHandlers?.onUpdate(history); }); // Verify messages are persisted to localStorage expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); const storedBefore = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(storedBefore).toEqual(history); // Step 2: Unmount the Chat component (simulates page unload) unmount(); // Verify localStorage was NOT cleared by unmount expect(localStorage.getItem(STORAGE_KEY)).not.toBeNull(); const storedAfterUnmount = JSON.parse( localStorage.getItem(STORAGE_KEY) ?? "[]", ); expect(storedAfterUnmount).toEqual(history); // Step 3: Remount the Chat component (simulates page reload) capturedWsHandlers = null; render(); // Verify messages are restored from localStorage expect( await screen.findByText("Persist me across refresh"), ).toBeInTheDocument(); expect( await screen.findByText("I should survive a reload"), ).toBeInTheDocument(); // Verify localStorage still has the messages const storedAfterRemount = JSON.parse( localStorage.getItem(STORAGE_KEY) ?? "[]", ); expect(storedAfterRemount).toEqual(history); }); it("Bug 245: after refresh, sendChat includes full prior history", async () => { // Step 1: Render, populate messages via onUpdate, then unmount (simulate refresh) const { unmount } = render( , ); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const priorHistory: Message[] = [ { role: "user", content: "What is Rust?" }, { role: "assistant", content: "Rust is a systems programming language." }, ]; await act(async () => { capturedWsHandlers?.onUpdate(priorHistory); }); // Verify localStorage has the prior history const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(stored).toEqual(priorHistory); unmount(); // Step 2: Remount (simulates page reload) — messages load from localStorage capturedWsHandlers = null; lastSendChatArgs = null; render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Verify prior messages are displayed expect(await screen.findByText("What is Rust?")).toBeInTheDocument(); // Step 3: Send a new message — sendChat should include the full prior history const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Tell me more" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); // Verify sendChat was called with ALL prior messages + the new one expect(lastSendChatArgs).not.toBeNull(); const args = lastSendChatArgs as unknown as { messages: Message[]; config: unknown; }; expect(args.messages).toHaveLength(3); expect(args.messages[0]).toEqual({ role: "user", content: "What is Rust?", }); expect(args.messages[1]).toEqual({ role: "assistant", content: "Rust is a systems programming language.", }); expect(args.messages[2]).toEqual({ role: "user", content: "Tell me more", }); }); it("AC5: uses project-scoped storage key", async () => { const otherKey = "storykit-chat-history:/other/project"; localStorage.setItem( otherKey, JSON.stringify([{ role: "user", content: "Other project msg" }]), ); render(); // Should NOT show the other project's messages await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); expect(screen.queryByText("Other project msg")).not.toBeInTheDocument(); // Other project's data should still be in storage expect(localStorage.getItem(otherKey)).not.toBeNull(); }); }); describe("Bug 264: Claude Code session ID persisted across browser refresh", () => { const PROJECT_PATH = "/tmp/project"; const SESSION_KEY = `storykit-claude-session-id:${PROJECT_PATH}`; const STORAGE_KEY = `storykit-chat-history:${PROJECT_PATH}`; beforeEach(() => { capturedWsHandlers = null; lastSendChatArgs = null; localStorage.clear(); setupMocks(); }); afterEach(() => { localStorage.clear(); }); it("AC1: session_id is persisted to localStorage when onSessionId fires", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); await act(async () => { capturedWsHandlers?.onSessionId("test-session-abc"); }); await waitFor(() => { expect(localStorage.getItem(SESSION_KEY)).toBe("test-session-abc"); }); }); it("AC2: after remount, next sendChat includes session_id from localStorage", async () => { // Step 1: Render, receive a session ID, then unmount (simulate refresh) localStorage.setItem(SESSION_KEY, "persisted-session-xyz"); localStorage.setItem( STORAGE_KEY, JSON.stringify([ { role: "user", content: "Prior message" }, { role: "assistant", content: "Prior reply" }, ]), ); const { unmount } = render( , ); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); unmount(); // Step 2: Remount (simulates page reload) capturedWsHandlers = null; lastSendChatArgs = null; render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); // Prior messages should be visible expect(await screen.findByText("Prior message")).toBeInTheDocument(); // Step 3: Send a new message — config should include session_id const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "Continue" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); expect(lastSendChatArgs).not.toBeNull(); expect( ( ( lastSendChatArgs as unknown as { messages: Message[]; config: unknown; } )?.config as Record ).session_id, ).toBe("persisted-session-xyz"); }); it("AC3: clearing the session also clears the persisted session_id", async () => { localStorage.setItem(SESSION_KEY, "session-to-clear"); const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const newSessionBtn = screen.getByText(/New Session/); await act(async () => { fireEvent.click(newSessionBtn); }); expect(localStorage.getItem(SESSION_KEY)).toBeNull(); confirmSpy.mockRestore(); }); it("AC1: storage key is scoped to project path", async () => { const otherPath = "/other/project"; const otherKey = `storykit-claude-session-id:${otherPath}`; localStorage.setItem(otherKey, "other-session"); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); await act(async () => { capturedWsHandlers?.onSessionId("my-session"); }); await waitFor(() => { expect(localStorage.getItem(SESSION_KEY)).toBe("my-session"); }); // Other project's session should be untouched expect(localStorage.getItem(otherKey)).toBe("other-session"); }); }); describe("File reference expansion (Story 269 AC4)", () => { beforeEach(() => { vi.clearAllMocks(); capturedWsHandlers = null; lastSendChatArgs = null; setupMocks(); }); it("includes file contents as context when message contains @file reference", async () => { mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }'); render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "explain @src/main.rs" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); const sentMessages = ( lastSendChatArgs as NonNullable ).messages; const userMsg = sentMessages[sentMessages.length - 1]; expect(userMsg.content).toContain("explain @src/main.rs"); expect(userMsg.content).toContain("[File: src/main.rs]"); expect(userMsg.content).toContain("fn main()"); }); it("sends message without modification when no @file references are present", async () => { render(); await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); const input = screen.getByPlaceholderText("Send a message..."); await act(async () => { fireEvent.change(input, { target: { value: "hello world" } }); }); await act(async () => { fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); }); await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); const sentMessages = ( lastSendChatArgs as NonNullable ).messages; const userMsg = sentMessages[sentMessages.length - 1]; expect(userMsg.content).toBe("hello world"); expect(mockedApi.readFile).not.toHaveBeenCalled(); }); });