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: "...", last_activity_at: "2026-03-03T11:00:00.000Z", }, ]; setMockInvokeResult("list_sessions", sessionList); await sessionsStore.loadSessions(); expect(get(sessionsStore.sessions)).toEqual(sessionList); }); it("sorts sessions by last_activity_at descending", async () => { const sessionList = [ { id: "older", name: "Older", message_count: 1, preview: "...", last_activity_at: "2026-03-01T10:00:00.000Z", }, { id: "newest", name: "Newest", message_count: 1, preview: "...", last_activity_at: "2026-03-03T12:00:00.000Z", }, { id: "middle", name: "Middle", message_count: 1, preview: "...", last_activity_at: "2026-03-02T10:00:00.000Z", }, ]; setMockInvokeResult("list_sessions", sessionList); await sessionsStore.loadSessions(); const sorted = get(sessionsStore.sessions); expect(sorted[0].id).toBe("newest"); expect(sorted[1].id).toBe("middle"); expect(sorted[2].id).toBe("older"); }); 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: "...", last_activity_at: "2026-03-03T11:00:00.000Z", }, ]; setMockInvokeResult("search_sessions", results); await sessionsStore.searchSessions("test"); expect(get(sessionsStore.sessions)).toEqual(results); }); it("sorts search results by last_activity_at descending", async () => { const results = [ { id: "older", name: "Older", message_count: 1, preview: "...", last_activity_at: "2026-03-01T10:00:00.000Z", }, { id: "newest", name: "Newest", message_count: 1, preview: "...", last_activity_at: "2026-03-03T12:00:00.000Z", }, ]; setMockInvokeResult("search_sessions", results); await sessionsStore.searchSessions("query"); const sorted = get(sessionsStore.sessions); expect(sorted[0].id).toBe("newest"); expect(sorted[1].id).toBe("older"); }); 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); }); it("uses the most recent user message as the preview", async () => { const { invoke } = await import("@tauri-apps/api/core"); setMockInvokeResult("save_session", undefined); setMockInvokeResult("list_sessions", []); const conv = { ...makeConversation(), terminalLines: [ { id: "1", type: "user", content: "First message", timestamp: new Date(), toolName: undefined, }, { id: "2", type: "assistant", content: "Reply one", timestamp: new Date(), toolName: undefined, }, { id: "3", type: "user", content: "Most recent prompt", timestamp: new Date(), toolName: undefined, }, { id: "4", type: "assistant", content: "Reply two", timestamp: new Date(), toolName: undefined, }, ], }; await sessionsStore.saveConversation(conv as never); const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session"); const capturedSession = (saveCall![1] as { session: SavedSession }).session; expect(capturedSession.preview).toBe("Most recent prompt"); }); it("truncates long preview text at 150 characters", async () => { const { invoke } = await import("@tauri-apps/api/core"); setMockInvokeResult("save_session", undefined); setMockInvokeResult("list_sessions", []); const longContent = "A".repeat(200); const conv = { ...makeConversation(), terminalLines: [ { id: "1", type: "user", content: longContent, timestamp: new Date(), toolName: undefined }, ], }; await sessionsStore.saveConversation(conv as never); const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session"); const capturedSession = (saveCall![1] as { session: SavedSession }).session; expect(capturedSession.preview).toBe("A".repeat(150) + "..."); }); it("uses 'Empty conversation' as preview when there are no user messages", async () => { const { invoke } = await import("@tauri-apps/api/core"); setMockInvokeResult("save_session", undefined); setMockInvokeResult("list_sessions", []); const conv = { ...makeConversation(), terminalLines: [ { id: "1", type: "assistant", content: "Only assistant message", timestamp: new Date(), toolName: undefined, }, ], }; await sessionsStore.saveConversation(conv as never); const saveCall = vi.mocked(invoke).mock.calls.find((call) => call[0] === "save_session"); const capturedSession = (saveCall![1] as { session: SavedSession }).session; expect(capturedSession.preview).toBe("Empty conversation"); }); }); 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"); }); it("clears an existing pending auto-save when rescheduled for the same conversation", async () => { setMockInvokeResult("save_session", undefined); setMockInvokeResult("list_sessions", []); const conv = makeConversation(); sessionsStore.scheduleAutoSave(conv as never); // Schedule again before timer fires — hits clearTimeout branch (line 487) sessionsStore.scheduleAutoSave(conv as never); await vi.advanceTimersByTimeAsync(2001); }); }); 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(); }); it("returns false when writeTextFile throws", async () => { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const spy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("load_session", makeSavedSession()); vi.mocked(save).mockResolvedValue("/output/session.json"); vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full")); 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(); }); it("returns false when writeTextFile throws", async () => { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const spy = vi.spyOn(console, "error").mockImplementation(() => {}); setMockInvokeResult("load_session", makeSavedSession()); vi.mocked(save).mockResolvedValue("/output/session.md"); vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full")); 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("