import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Message } from "../types"; import { useChatHistory } from "./useChatHistory"; const PROJECT = "/tmp/test-project"; const STORAGE_KEY = `storykit-chat-history:${PROJECT}`; const LIMIT_KEY = `storykit-chat-history-limit:${PROJECT}`; const sampleMessages: Message[] = [ { role: "user", content: "Hello" }, { role: "assistant", content: "Hi there!" }, ]; function makeMessages(count: number): Message[] { return Array.from({ length: count }, (_, i) => ({ role: "user" as const, content: `Message ${i + 1}`, })); } describe("useChatHistory", () => { beforeEach(() => { localStorage.clear(); }); afterEach(() => { localStorage.clear(); }); it("AC1: restores messages from localStorage on mount", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual(sampleMessages); }); it("AC1: returns empty array when localStorage has no data", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual([]); }); it("AC1: returns empty array when localStorage contains invalid JSON", () => { localStorage.setItem(STORAGE_KEY, "not-json{{{"); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual([]); }); it("AC1: returns empty array when localStorage contains a non-array", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: "array" })); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual([]); }); it("AC2: saves messages to localStorage when setMessages is called with an array", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); act(() => { result.current.setMessages(sampleMessages); }); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(stored).toEqual(sampleMessages); }); it("AC2: saves messages to localStorage when setMessages is called with updater function", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); act(() => { result.current.setMessages(() => sampleMessages); }); const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(stored).toEqual(sampleMessages); }); it("AC3: clearMessages removes messages from state and localStorage", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.messages).toEqual(sampleMessages); act(() => { result.current.clearMessages(); }); expect(result.current.messages).toEqual([]); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); it("AC4: handles localStorage quota errors gracefully", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const setItemSpy = vi .spyOn(Storage.prototype, "setItem") .mockImplementation(() => { throw new DOMException("QuotaExceededError"); }); const { result } = renderHook(() => useChatHistory(PROJECT)); // Should not throw act(() => { result.current.setMessages(sampleMessages); }); // State should still update even though storage failed expect(result.current.messages).toEqual(sampleMessages); expect(warnSpy).toHaveBeenCalledWith( "Failed to persist chat history to localStorage:", expect.any(DOMException), ); warnSpy.mockRestore(); setItemSpy.mockRestore(); }); it("AC5: scopes storage key to project path", () => { const projectA = "/projects/a"; const projectB = "/projects/b"; const keyA = `storykit-chat-history:${projectA}`; const keyB = `storykit-chat-history:${projectB}`; const messagesA: Message[] = [{ role: "user", content: "From project A" }]; const messagesB: Message[] = [{ role: "user", content: "From project B" }]; localStorage.setItem(keyA, JSON.stringify(messagesA)); localStorage.setItem(keyB, JSON.stringify(messagesB)); const { result: resultA } = renderHook(() => useChatHistory(projectA)); const { result: resultB } = renderHook(() => useChatHistory(projectB)); expect(resultA.current.messages).toEqual(messagesA); expect(resultB.current.messages).toEqual(messagesB); }); it("AC2: removes localStorage key when messages are set to empty array", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(sampleMessages)); const { result } = renderHook(() => useChatHistory(PROJECT)); act(() => { result.current.setMessages([]); }); expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); }); // --- Story 179: Chat history pruning tests --- it("S179: default limit of 200 is applied when saving to localStorage", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.maxMessages).toBe(200); }); it("S179: messages are pruned from the front when exceeding the limit", () => { // Set a small limit to make testing practical localStorage.setItem(LIMIT_KEY, "3"); const { result } = renderHook(() => useChatHistory(PROJECT)); const fiveMessages = makeMessages(5); act(() => { result.current.setMessages(fiveMessages); }); // localStorage should contain only the last 3 messages const stored: Message[] = JSON.parse( localStorage.getItem(STORAGE_KEY) ?? "[]", ); expect(stored).toEqual(fiveMessages.slice(-3)); expect(stored).toHaveLength(3); expect(stored[0].content).toBe("Message 3"); }); it("S179: messages under the limit are not pruned", () => { localStorage.setItem(LIMIT_KEY, "10"); const { result } = renderHook(() => useChatHistory(PROJECT)); const threeMessages = makeMessages(3); act(() => { result.current.setMessages(threeMessages); }); const stored: Message[] = JSON.parse( localStorage.getItem(STORAGE_KEY) ?? "[]", ); expect(stored).toEqual(threeMessages); expect(stored).toHaveLength(3); }); it("S179: limit is configurable via localStorage key", () => { localStorage.setItem(LIMIT_KEY, "5"); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.maxMessages).toBe(5); }); it("S179: setMaxMessages updates the limit and persists it", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); act(() => { result.current.setMaxMessages(50); }); expect(result.current.maxMessages).toBe(50); expect(localStorage.getItem(LIMIT_KEY)).toBe("50"); }); it("S179: a limit of 0 means unlimited (no pruning)", () => { localStorage.setItem(LIMIT_KEY, "0"); const { result } = renderHook(() => useChatHistory(PROJECT)); const manyMessages = makeMessages(500); act(() => { result.current.setMessages(manyMessages); }); const stored: Message[] = JSON.parse( localStorage.getItem(STORAGE_KEY) ?? "[]", ); expect(stored).toHaveLength(500); expect(stored).toEqual(manyMessages); }); it("S179: changing the limit re-prunes messages on next save", () => { const { result } = renderHook(() => useChatHistory(PROJECT)); const tenMessages = makeMessages(10); act(() => { result.current.setMessages(tenMessages); }); // All 10 saved (default limit 200 > 10) let stored: Message[] = JSON.parse( localStorage.getItem(STORAGE_KEY) ?? "[]", ); expect(stored).toHaveLength(10); // Now lower the limit — the effect re-runs and prunes act(() => { result.current.setMaxMessages(3); }); stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); expect(stored).toHaveLength(3); expect(stored[0].content).toBe("Message 8"); }); it("S179: invalid limit in localStorage falls back to default", () => { localStorage.setItem(LIMIT_KEY, "not-a-number"); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.maxMessages).toBe(200); }); it("S179: negative limit in localStorage falls back to default", () => { localStorage.setItem(LIMIT_KEY, "-5"); const { result } = renderHook(() => useChatHistory(PROJECT)); expect(result.current.maxMessages).toBe(200); }); });