generated from nhcarrigan/template
452fe185df
## Summary This PR brings Hikari Desktop up to full compatibility with Claude Code CLI versions v2.1.68 through v2.1.74, implementing all changelog items audited in issues #200–#218. ## Changes ### Bug Fixes - Remove deprecated Claude Opus 4.0 and 4.1 models from the model selector - Auto-migrate users pinned to deprecated models to Opus 4.6 ### New Features - Add cron tool support (`CronCreate`, `CronDelete`, `CronList`) with character state mapping and `CLAUDE_CODE_DISABLE_CRON` settings toggle - Handle `EnterWorktree` and `ExitWorktree` tools in character state mapping and tool display - Add CLI update check with npm registry indicator in the version bar - Add `agent_type` field and support the Agent tool rename from CLI v2.1.69 - Consume `worktree` field from status line hook events - Display per-agent model override in the agent monitor tree - Expose Claude Code CLI built-in slash commands (`/simplify`, `/loop`, `/batch`, `/memory`, `/context`) in the command menu with CLI badges - Add `includeGitInstructions` toggle in settings - Add `ENABLE_CLAUDEAI_MCP_SERVERS` opt-out setting - Linkify MCP binary file paths (PDFs, audio, Office docs) in markdown output - Add auto-memory panel, `/memory` slash command shortcut, and unified toast notification system - Toast notifications for `WorktreeCreate` and `WorktreeRemove` hook events - Sort session resume list by most recent activity, with most recent user message as preview - Convert WSL Linux paths to Windows UNC paths when opening binary files via `open_binary_file` command - Expose `autoMemoryDirectory` setting in ConfigSidebar (Agent Settings section) - Add `/context` as a CLI built-in in the slash command menu - Expose `modelOverrides` setting as a JSON textarea in ConfigSidebar (for AWS Bedrock, Google Vertex, etc.) > **Note:** The CLI update check commit does not have a corresponding issue — it was a bonus addition during the audit sprint. ## Closes Closes #200 Closes #201 Closes #202 Closes #205 Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 Closes #211 Closes #212 Closes #213 Closes #214 Closes #215 Closes #216 Closes #217 Closes #218 Reviewed-on: #221 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
730 lines
27 KiB
TypeScript
730 lines
27 KiB
TypeScript
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> = {}): 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("<!DOCTYPE html>");
|
|
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: "<Test & 'Session'>" }));
|
|
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("<Test & 'Session'>");
|
|
});
|
|
|
|
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: '<script>alert("xss")</script>',
|
|
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("<script>");
|
|
});
|
|
|
|
it("escapes HTML in tool names", 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: "tool_use",
|
|
content: "input",
|
|
timestamp: "2026-03-03T10:00:00Z",
|
|
tool_name: "<dangerous>",
|
|
},
|
|
],
|
|
})
|
|
);
|
|
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("<dangerous>");
|
|
expect(content).not.toContain("<dangerous>");
|
|
});
|
|
|
|
it("excludes metadata when includeMetadata is false", 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");
|
|
await sessionsStore.exportSessionAsHtml("session-1", false);
|
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
expect(content).not.toContain('class="metadata"');
|
|
});
|
|
|
|
it("returns false when session not found", async () => {
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("load_session", null);
|
|
expect(await sessionsStore.exportSessionAsHtml("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.exportSessionAsHtml("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.exportSessionAsHtml("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.html");
|
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
|
expect(await sessionsStore.exportSessionAsHtml("session-1")).toBe(false);
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("sessionsStore - exportSessionAsPdf (tests generatePrintableHtml)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("generates printable HTML and opens it in the browser", async () => {
|
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
|
const { openPath } = await import("@tauri-apps/plugin-opener");
|
|
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
|
setMockInvokeResult("load_session", makeSavedSession());
|
|
vi.mocked(save).mockResolvedValue("/output/session-print.html");
|
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(true);
|
|
expect(openPath).toHaveBeenCalledWith("/output/session-print.html");
|
|
const content = vi.mocked(writeTextFile).mock.calls[0][1] as string;
|
|
expect(content).toContain("<!DOCTYPE html>");
|
|
});
|
|
|
|
it("excludes metadata when includeMetadata is false", 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-print.html");
|
|
await sessionsStore.exportSessionAsPdf("session-1", false);
|
|
expect(vi.mocked(writeTextFile).mock.calls[0][1]).toBeDefined();
|
|
});
|
|
|
|
it("returns false when session not found", async () => {
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
setMockInvokeResult("load_session", null);
|
|
expect(await sessionsStore.exportSessionAsPdf("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.exportSessionAsPdf("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.exportSessionAsPdf("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-print.html");
|
|
vi.mocked(writeTextFile).mockRejectedValueOnce(new Error("Disk full"));
|
|
expect(await sessionsStore.exportSessionAsPdf("session-1")).toBe(false);
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("sessionsStore - importSession", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("imports session from JSON file and returns true", async () => {
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
|
vi.mocked(open).mockResolvedValue("/input/session.json");
|
|
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ session: makeSavedSession() }));
|
|
setMockInvokeResult("save_session", undefined);
|
|
setMockInvokeResult("list_sessions", []);
|
|
expect(await sessionsStore.importSession()).toBe(true);
|
|
});
|
|
|
|
it("returns false when user cancels file dialog", async () => {
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
vi.mocked(open).mockResolvedValue(null);
|
|
expect(await sessionsStore.importSession()).toBe(false);
|
|
});
|
|
|
|
it("returns false when file has invalid format", async () => {
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
|
vi.mocked(open).mockResolvedValue("/input/bad.json");
|
|
vi.mocked(readTextFile).mockResolvedValue(JSON.stringify({ not_a_session: true }));
|
|
expect(await sessionsStore.importSession()).toBe(false);
|
|
spy.mockRestore();
|
|
});
|
|
|
|
it("returns false on error", async () => {
|
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const { open } = await import("@tauri-apps/plugin-dialog");
|
|
vi.mocked(open).mockRejectedValue(new Error("Dialog failed"));
|
|
expect(await sessionsStore.importSession()).toBe(false);
|
|
spy.mockRestore();
|
|
});
|
|
});
|