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();
});
});