Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
276 lines
8.0 KiB
TypeScript
276 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|