huskies: merge 804
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
// 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(
|
||||
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
|
||||
);
|
||||
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
// 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(
|
||||
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
|
||||
);
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
// 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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
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(
|
||||
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
|
||||
);
|
||||
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||
unmount();
|
||||
|
||||
// Step 2: Remount (simulates page reload)
|
||||
capturedWsHandlers = null;
|
||||
lastSendChatArgs = null;
|
||||
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
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<string, unknown>
|
||||
).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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
|
||||
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(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
|
||||
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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
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<typeof lastSendChatArgs>
|
||||
).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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||
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<typeof lastSendChatArgs>
|
||||
).messages;
|
||||
const userMsg = sentMessages[sentMessages.length - 1];
|
||||
expect(userMsg.content).toBe("hello world");
|
||||
expect(mockedApi.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user