diff --git a/src/lib/stores/sessions.test.ts b/src/lib/stores/sessions.test.ts new file mode 100644 index 0000000..810c7e5 --- /dev/null +++ b/src/lib/stores/sessions.test.ts @@ -0,0 +1,515 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { get } from "svelte/store"; +import { sessionsStore } from "$lib/stores/sessions"; +import { setMockInvokeResult } from "../../../vitest.setup"; +import type { SavedSession } from "$lib/stores/sessions"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: vi.fn(), + open: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + writeTextFile: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openPath: vi.fn().mockResolvedValue(undefined), +})); + +const makeSavedSession = (overrides: Partial = {}): SavedSession => ({ + id: "session-1", + name: "Test Session", + created_at: "2026-03-03T10:00:00.000Z", + last_activity_at: "2026-03-03T11:00:00.000Z", + working_directory: "/home/naomi/code", + message_count: 2, + preview: "Hello world", + messages: [ + { id: "msg-1", type: "user", content: "Hello world", timestamp: "2026-03-03T10:00:00.000Z" }, + { id: "msg-2", type: "assistant", content: "Hi there!", timestamp: "2026-03-03T10:01:00.000Z" }, + ], + ...overrides, +}); + +const makeConversation = () => ({ + id: "conv-1", + name: "Test Conversation", + workingDirectory: "/home/naomi/code", + createdAt: new Date("2026-03-03T10:00:00.000Z"), + lastActivityAt: new Date("2026-03-03T11:00:00.000Z"), + terminalLines: [ + { + id: "line-1", + type: "user", + content: "Hello", + timestamp: new Date("2026-03-03T10:00:00.000Z"), + toolName: undefined, + }, + { + id: "line-2", + type: "assistant", + content: "Hi", + timestamp: new Date("2026-03-03T10:01:00.000Z"), + toolName: undefined, + }, + ], +}); + +describe("sessionsStore - loadSessions", () => { + it("loads sessions from backend and updates the store", async () => { + const sessionList = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }]; + setMockInvokeResult("list_sessions", sessionList); + await sessionsStore.loadSessions(); + expect(get(sessionsStore.sessions)).toEqual(sessionList); + }); + + it("handles errors gracefully", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("list_sessions", new Error("Backend error")); + await sessionsStore.loadSessions(); + expect(spy).toHaveBeenCalledWith("Failed to load sessions:", expect.any(Error)); + spy.mockRestore(); + }); + + it("sets isLoading to false after completion", async () => { + setMockInvokeResult("list_sessions", []); + await sessionsStore.loadSessions(); + expect(get(sessionsStore.isLoading)).toBe(false); + }); +}); + +describe("sessionsStore - loadSession", () => { + it("returns session data on success", async () => { + const session = makeSavedSession(); + setMockInvokeResult("load_session", session); + const result = await sessionsStore.loadSession("session-1"); + expect(result).toEqual(session); + }); + + it("returns null on error", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", new Error("Not found")); + const result = await sessionsStore.loadSession("session-1"); + expect(result).toBeNull(); + spy.mockRestore(); + }); +}); + +describe("sessionsStore - deleteSession", () => { + it("deletes session and reloads the session list", async () => { + setMockInvokeResult("delete_session", undefined); + setMockInvokeResult("list_sessions", []); + await sessionsStore.deleteSession("session-1"); + expect(get(sessionsStore.sessions)).toEqual([]); + }); + + it("handles errors gracefully", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("delete_session", new Error("Failed")); + await sessionsStore.deleteSession("session-1"); + expect(spy).toHaveBeenCalledWith("Failed to delete session:", expect.any(Error)); + spy.mockRestore(); + }); +}); + +describe("sessionsStore - searchSessions", () => { + it("calls loadSessions when query is empty", async () => { + setMockInvokeResult("list_sessions", []); + await sessionsStore.searchSessions(""); + expect(get(sessionsStore.sessions)).toEqual([]); + }); + + it("calls loadSessions when query is whitespace-only", async () => { + setMockInvokeResult("list_sessions", []); + await sessionsStore.searchSessions(" "); + expect(get(sessionsStore.sessions)).toEqual([]); + }); + + it("searches with the given query", async () => { + const results = [{ id: "session-1", name: "Test", message_count: 1, preview: "..." }]; + setMockInvokeResult("search_sessions", results); + await sessionsStore.searchSessions("test"); + expect(get(sessionsStore.sessions)).toEqual(results); + }); + + it("updates searchQuery store", async () => { + setMockInvokeResult("search_sessions", []); + await sessionsStore.searchSessions("hello"); + expect(get(sessionsStore.searchQuery)).toBe("hello"); + await sessionsStore.searchSessions(""); + }); + + it("handles search errors gracefully", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("search_sessions", new Error("Search failed")); + await sessionsStore.searchSessions("error-query"); + expect(spy).toHaveBeenCalledWith("Failed to search sessions:", expect.any(Error)); + spy.mockRestore(); + }); +}); + +describe("sessionsStore - clearAllSessions", () => { + it("clears all sessions", async () => { + setMockInvokeResult("clear_all_sessions", undefined); + await sessionsStore.clearAllSessions(); + expect(get(sessionsStore.sessions)).toEqual([]); + }); + + it("handles errors gracefully", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("clear_all_sessions", new Error("Failed")); + await sessionsStore.clearAllSessions(); + expect(spy).toHaveBeenCalledWith("Failed to clear sessions:", expect.any(Error)); + spy.mockRestore(); + }); +}); + +describe("sessionsStore - saveConversation", () => { + it("saves conversation to backend", async () => { + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + await sessionsStore.saveConversation(makeConversation() as never); + }); + + it("handles save errors gracefully", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("save_session", new Error("Save failed")); + await sessionsStore.saveConversation(makeConversation() as never); + expect(spy).toHaveBeenCalledWith("Failed to save session:", expect.any(Error)); + spy.mockRestore(); + }); + + it("handles empty conversation", async () => { + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + const conv = { ...makeConversation(), terminalLines: [] }; + await sessionsStore.saveConversation(conv as never); + }); +}); + +describe("sessionsStore - scheduleAutoSave and cancelAutoSave", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("schedules an auto-save after the debounce delay", async () => { + setMockInvokeResult("save_session", undefined); + setMockInvokeResult("list_sessions", []); + sessionsStore.scheduleAutoSave(makeConversation() as never); + await vi.advanceTimersByTimeAsync(2001); + }); + + it("does not auto-save empty conversations", async () => { + const conv = { ...makeConversation(), terminalLines: [] }; + sessionsStore.scheduleAutoSave(conv as never); + await vi.advanceTimersByTimeAsync(3000); + }); + + it("cancels a pending auto-save", () => { + const conv = makeConversation(); + sessionsStore.scheduleAutoSave(conv as never); + sessionsStore.cancelAutoSave(conv.id); + }); + + it("handles cancel when no auto-save is pending", () => { + sessionsStore.cancelAutoSave("non-existent-id"); + }); +}); + +describe("sessionsStore - exportSessionAsJson", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true on successful export", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue("/output/session.json"); + expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(true); + }); + + it("returns false when session not found", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", null); + expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false); + spy.mockRestore(); + }); + + it("returns false when user cancels save dialog", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue(null); + expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false); + }); + + it("returns false on error", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", new Error("Load failed")); + expect(await sessionsStore.exportSessionAsJson("session-1")).toBe(false); + spy.mockRestore(); + }); +}); + +describe("sessionsStore - exportSessionAsMarkdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("generates markdown with all message types", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const session = makeSavedSession({ + messages: [ + { id: "1", type: "user", content: "User message", timestamp: "2026-03-03T10:00:00Z" }, + { + id: "2", + type: "assistant", + content: "Assistant reply", + timestamp: "2026-03-03T10:01:00Z", + }, + { + id: "3", + type: "tool_use", + content: "Tool input", + timestamp: "2026-03-03T10:02:00Z", + tool_name: "bash", + }, + { id: "4", type: "tool_result", content: "Tool output", timestamp: "2026-03-03T10:03:00Z" }, + { id: "5", type: "system", content: "System event", timestamp: "2026-03-03T10:04:00Z" }, + { id: "6", type: "error", content: "Error message", timestamp: "2026-03-03T10:05:00Z" }, + ], + }); + setMockInvokeResult("load_session", session); + vi.mocked(save).mockResolvedValue("/output/session.md"); + expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(true); + const content = vi.mocked(writeTextFile).mock.calls[0][1] as string; + expect(content).toContain("User message"); + expect(content).toContain("Assistant reply"); + expect(content).toContain("Tool: bash"); + expect(content).toContain("Tool input"); + expect(content).toContain("Tool output"); + expect(content).toContain("System event"); + expect(content).toContain("Error message"); + }); + + it("returns false when session not found", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", null); + expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false); + spy.mockRestore(); + }); + + it("returns false when user cancels dialog", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue(null); + expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false); + }); + + it("returns false on error", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + setMockInvokeResult("load_session", new Error("Load failed")); + expect(await sessionsStore.exportSessionAsMarkdown("session-1")).toBe(false); + spy.mockRestore(); + }); +}); + +describe("sessionsStore - exportSessionAsHtml (tests escapeHtml + generateHtmlExport)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("generates HTML and returns true on success", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + setMockInvokeResult("load_session", makeSavedSession()); + vi.mocked(save).mockResolvedValue("/output/session.html"); + expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(true); + const content = vi.mocked(writeTextFile).mock.calls[0][1] as string; + expect(content).toContain(""); + expect(content).toContain("Test Session"); + }); + + it("escapes HTML characters in session name", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + setMockInvokeResult("load_session", makeSavedSession({ name: "" })); + vi.mocked(save).mockResolvedValue("/output/session.html"); + await sessionsStore.exportSessionAsHtml("session-1"); + const content = vi.mocked(writeTextFile).mock.calls[0][1] as string; + expect(content).toContain("<Test & 'Session'>"); + expect(content).not.toContain(""); + }); + + it("escapes HTML characters in message content", async () => { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + setMockInvokeResult( + "load_session", + makeSavedSession({ + messages: [ + { + id: "1", + type: "user", + content: '', + timestamp: "2026-03-03T10:00:00Z", + }, + ], + }) + ); + vi.mocked(save).mockResolvedValue("/output/session.html"); + await sessionsStore.exportSessionAsHtml("session-1"); + const content = vi.mocked(writeTextFile).mock.calls[0][1] as string; + expect(content).toContain("<script>alert("xss")</script>"); + expect(content).not.toContain("