story-kit: accept 145_story_persist_chat_history_to_localstorage_across_rebuilds

This commit is contained in:
Dave
2026-02-24 17:03:04 +00:00
parent aef022c74c
commit 6f7338dfdb
5 changed files with 324 additions and 6 deletions

View File

@@ -0,0 +1,146 @@
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 sampleMessages: Message[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
];
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();
});
});