import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { get } from "svelte/store"; import { configStore, maskPaths, clampFontSize, applyFontSize, applyCustomFont, applyCustomUiFont, applyTheme, applyCustomThemeColors, clearCustomThemeColors, isDarkTheme, isStreamerMode, isCompactMode, shouldHidePaths, showThinkingBlocks, MIN_FONT_SIZE, MAX_FONT_SIZE, DEFAULT_FONT_SIZE, type HikariConfig, type Theme, type CustomThemeColors, } from "./config"; import { invoke } from "@tauri-apps/api/core"; import { readFile } from "@tauri-apps/plugin-fs"; // Mock Tauri APIs vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); vi.mock("@tauri-apps/plugin-fs", () => ({ readFile: vi.fn(), })); describe("config store", () => { describe("font size constants", () => { it("has correct MIN_FONT_SIZE", () => { expect(MIN_FONT_SIZE).toBe(10); }); it("has correct MAX_FONT_SIZE", () => { expect(MAX_FONT_SIZE).toBe(24); }); it("has correct DEFAULT_FONT_SIZE", () => { expect(DEFAULT_FONT_SIZE).toBe(14); }); }); describe("clampFontSize", () => { it("returns the same value when within range", () => { expect(clampFontSize(14)).toBe(14); expect(clampFontSize(10)).toBe(10); expect(clampFontSize(24)).toBe(24); expect(clampFontSize(18)).toBe(18); }); it("clamps values below minimum", () => { expect(clampFontSize(5)).toBe(MIN_FONT_SIZE); expect(clampFontSize(0)).toBe(MIN_FONT_SIZE); expect(clampFontSize(-10)).toBe(MIN_FONT_SIZE); expect(clampFontSize(9)).toBe(MIN_FONT_SIZE); }); it("clamps values above maximum", () => { expect(clampFontSize(30)).toBe(MAX_FONT_SIZE); expect(clampFontSize(100)).toBe(MAX_FONT_SIZE); expect(clampFontSize(25)).toBe(MAX_FONT_SIZE); }); }); describe("maskPaths", () => { it("returns text unchanged when hidePaths is false", () => { const text = "/home/naomi/code/project/file.ts"; expect(maskPaths(text, false)).toBe(text); }); it("masks Unix home paths", () => { const text = "/home/naomi/code/project/file.ts"; expect(maskPaths(text, true)).toBe("/home/****/code/project/file.ts"); }); it("masks macOS user paths", () => { const text = "/Users/naomi/Documents/project/file.ts"; expect(maskPaths(text, true)).toBe("/Users/****/Documents/project/file.ts"); }); it("masks Windows user paths", () => { const text = "C:\\Users\\naomi\\Documents\\project\\file.ts"; expect(maskPaths(text, true)).toBe("C:\\Users\\****\\Documents\\project\\file.ts"); }); it("masks tilde paths", () => { const text = "~/code/project/file.ts"; expect(maskPaths(text, true)).toBe("****/code/project/file.ts"); }); it("masks multiple paths in the same text", () => { const text = "Editing /home/naomi/file1.ts and /home/naomi/file2.ts"; expect(maskPaths(text, true)).toBe("Editing /home/****/file1.ts and /home/****/file2.ts"); }); it("handles mixed path types", () => { const text = "Unix: /home/user/file, Mac: /Users/user/file, Win: C:\\Users\\user\\file"; const expected = "Unix: /home/****/file, Mac: /Users/****/file, Win: C:\\Users\\****\\file"; expect(maskPaths(text, true)).toBe(expected); }); it("handles paths with special characters in username", () => { const text = "/home/user-name_123/project"; expect(maskPaths(text, true)).toBe("/home/****/project"); }); it("does not mask non-path text", () => { const text = "This is just regular text without any paths"; expect(maskPaths(text, true)).toBe(text); }); it("handles empty string", () => { expect(maskPaths("", true)).toBe(""); expect(maskPaths("", false)).toBe(""); }); }); describe("Theme type", () => { it("accepts valid theme values", () => { const themes: Theme[] = ["dark", "light", "high-contrast", "custom"]; themes.forEach((theme) => { expect(["dark", "light", "high-contrast", "custom"]).toContain(theme); }); }); }); describe("CustomThemeColors interface", () => { it("can create a valid custom theme colors object", () => { const colors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: "#16213e", bg_terminal: "#0f0f23", accent_primary: "#e94560", accent_secondary: "#533483", text_primary: "#eaeaea", text_secondary: "#a0a0a0", border_color: "#333355", }; expect(colors.bg_primary).toBe("#1a1a2e"); expect(colors.accent_primary).toBe("#e94560"); }); it("allows null values for optional colors", () => { const colors: CustomThemeColors = { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: "#e94560", accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; expect(colors.bg_primary).toBeNull(); expect(colors.accent_primary).toBe("#e94560"); }); }); describe("HikariConfig interface", () => { it("can create a valid config object with all fields", () => { const config: HikariConfig = { model: "claude-sonnet-4", api_key: "test-key", custom_instructions: "Be helpful", mcp_servers_json: "{}", auto_granted_tools: ["Read", "Write"], theme: "dark", greeting_enabled: true, greeting_custom_prompt: "Hello!", notifications_enabled: true, notification_volume: 0.7, always_on_top: false, update_checks_enabled: true, character_panel_width: 300, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: "Naomi", profile_avatar_path: "/path/to/avatar.png", profile_bio: "Developer", custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, background_image_opacity: 0.3, custom_font_path: null, custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, model_overrides: null, }; expect(config.model).toBe("claude-sonnet-4"); expect(config.auto_granted_tools).toEqual(["Read", "Write"]); expect(config.theme).toBe("dark"); }); it("allows null values for optional fields", () => { const config: HikariConfig = { model: null, api_key: null, custom_instructions: null, mcp_servers_json: null, auto_granted_tools: [], theme: "dark", greeting_enabled: true, greeting_custom_prompt: null, notifications_enabled: true, notification_volume: 0.7, always_on_top: false, update_checks_enabled: true, character_panel_width: null, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: null, profile_avatar_path: null, profile_bio: null, custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: true, show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, background_image_opacity: 0.3, custom_font_path: null, custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, model_overrides: null, }; expect(config.model).toBeNull(); expect(config.api_key).toBeNull(); expect(config.character_panel_width).toBeNull(); }); }); describe("applyFontSize", () => { beforeEach(() => { // Reset document state if (typeof document !== "undefined") { document.documentElement.style.removeProperty("--terminal-font-size"); } }); it("sets CSS variable for valid font size", () => { applyFontSize(16); const value = document.documentElement.style.getPropertyValue("--terminal-font-size"); expect(value).toBe("16px"); }); it("clamps font size below minimum", () => { applyFontSize(5); const value = document.documentElement.style.getPropertyValue("--terminal-font-size"); expect(value).toBe(`${MIN_FONT_SIZE}px`); }); it("clamps font size above maximum", () => { applyFontSize(50); const value = document.documentElement.style.getPropertyValue("--terminal-font-size"); expect(value).toBe(`${MAX_FONT_SIZE}px`); }); }); describe("applyTheme", () => { beforeEach(() => { // Reset document state if (typeof document !== "undefined") { document.documentElement.removeAttribute("data-theme"); clearCustomThemeColors(); } }); it("sets data-theme attribute for dark theme", () => { applyTheme("dark"); expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); }); it("sets data-theme attribute for light theme", () => { applyTheme("light"); expect(document.documentElement.getAttribute("data-theme")).toBe("light"); }); it("sets data-theme attribute for high-contrast theme", () => { applyTheme("high-contrast"); expect(document.documentElement.getAttribute("data-theme")).toBe("high-contrast"); }); it("uses dark as base for custom theme", () => { applyTheme("custom"); expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); }); it("sets data-theme attribute for dracula theme", () => { applyTheme("dracula"); expect(document.documentElement.getAttribute("data-theme")).toBe("dracula"); }); it("sets data-theme attribute for catppuccin theme", () => { applyTheme("catppuccin"); expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin"); }); it("sets data-theme attribute for nord theme", () => { applyTheme("nord"); expect(document.documentElement.getAttribute("data-theme")).toBe("nord"); }); it("sets data-theme attribute for solarized theme", () => { applyTheme("solarized"); expect(document.documentElement.getAttribute("data-theme")).toBe("solarized"); }); it("sets data-theme attribute for solarized-light theme", () => { applyTheme("solarized-light"); expect(document.documentElement.getAttribute("data-theme")).toBe("solarized-light"); }); it("sets data-theme attribute for catppuccin-latte theme", () => { applyTheme("catppuccin-latte"); expect(document.documentElement.getAttribute("data-theme")).toBe("catppuccin-latte"); }); it("sets data-theme attribute for gruvbox-light theme", () => { applyTheme("gruvbox-light"); expect(document.documentElement.getAttribute("data-theme")).toBe("gruvbox-light"); }); it("sets data-theme attribute for rose-pine-dawn theme", () => { applyTheme("rose-pine-dawn"); expect(document.documentElement.getAttribute("data-theme")).toBe("rose-pine-dawn"); }); it("does not apply custom colors for preset themes", () => { const colors: CustomThemeColors = { bg_primary: "#ff0000", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyTheme("dracula", colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe(""); }); it("applies custom colors when theme is custom", () => { const colors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: null, bg_terminal: null, accent_primary: "#e94560", accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyTheme("custom", colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#1a1a2e"); expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#e94560"); }); it("does not apply custom colors for non-custom themes", () => { const colors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyTheme("dark", colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe(""); }); }); describe("applyCustomThemeColors", () => { beforeEach(() => { clearCustomThemeColors(); }); it("applies all provided colors", () => { const colors: CustomThemeColors = { bg_primary: "#111111", bg_secondary: "#222222", bg_terminal: "#333333", accent_primary: "#444444", accent_secondary: "#555555", text_primary: "#666666", text_secondary: "#777777", border_color: "#888888", }; applyCustomThemeColors(colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111"); expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe("#222222"); expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe("#333333"); expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe("#444444"); expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe("#555555"); expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe("#666666"); expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe("#777777"); expect(document.documentElement.style.getPropertyValue("--border-color")).toBe("#888888"); }); it("skips null values", () => { const colors: CustomThemeColors = { bg_primary: "#111111", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; applyCustomThemeColors(colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#111111"); expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe(""); }); }); describe("clearCustomThemeColors", () => { it("removes all custom theme CSS properties", () => { // First apply some colors const colors: CustomThemeColors = { bg_primary: "#111111", bg_secondary: "#222222", bg_terminal: "#333333", accent_primary: "#444444", accent_secondary: "#555555", text_primary: "#666666", text_secondary: "#777777", border_color: "#888888", }; applyCustomThemeColors(colors); // Then clear them clearCustomThemeColors(); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--bg-secondary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--bg-terminal")).toBe(""); expect(document.documentElement.style.getPropertyValue("--accent-primary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--accent-secondary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--text-primary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--text-secondary")).toBe(""); expect(document.documentElement.style.getPropertyValue("--border-color")).toBe(""); }); }); describe("derived stores", () => { // Note: These tests verify the derived store logic by testing the derivation functions // The actual stores depend on configStore which requires Tauri invoke mocking it("isDarkTheme returns true for dark theme config", () => { // Test the derivation logic const darkConfig = { theme: "dark" as Theme }; expect(darkConfig.theme === "dark").toBe(true); }); it("isDarkTheme returns false for light theme config", () => { const lightConfig = { theme: "light" as Theme }; expect(lightConfig.theme === "dark").toBe(false); }); it("isStreamerMode derives from streamer_mode config", () => { const configWithStreamerMode = { streamer_mode: true }; const configWithoutStreamerMode = { streamer_mode: false }; expect(configWithStreamerMode.streamer_mode).toBe(true); expect(configWithoutStreamerMode.streamer_mode).toBe(false); }); it("isCompactMode derives from compact_mode config", () => { const configWithCompactMode = { compact_mode: true }; const configWithoutCompactMode = { compact_mode: false }; expect(configWithCompactMode.compact_mode).toBe(true); expect(configWithoutCompactMode.compact_mode).toBe(false); }); it("shouldHidePaths requires both streamer_mode and streamer_hide_paths", () => { const config1 = { streamer_mode: true, streamer_hide_paths: true }; const config2 = { streamer_mode: true, streamer_hide_paths: false }; const config3 = { streamer_mode: false, streamer_hide_paths: true }; const config4 = { streamer_mode: false, streamer_hide_paths: false }; expect(config1.streamer_mode && config1.streamer_hide_paths).toBe(true); expect(config2.streamer_mode && config2.streamer_hide_paths).toBe(false); expect(config3.streamer_mode && config3.streamer_hide_paths).toBe(false); expect(config4.streamer_mode && config4.streamer_hide_paths).toBe(false); }); }); describe("configStore methods", () => { it("has all expected methods", () => { expect(typeof configStore.loadConfig).toBe("function"); expect(typeof configStore.saveConfig).toBe("function"); expect(typeof configStore.updateConfig).toBe("function"); expect(typeof configStore.openSidebar).toBe("function"); expect(typeof configStore.closeSidebar).toBe("function"); expect(typeof configStore.toggleSidebar).toBe("function"); expect(typeof configStore.setTheme).toBe("function"); expect(typeof configStore.setCustomThemeColors).toBe("function"); expect(typeof configStore.setFontSize).toBe("function"); expect(typeof configStore.increaseFontSize).toBe("function"); expect(typeof configStore.decreaseFontSize).toBe("function"); expect(typeof configStore.resetFontSize).toBe("function"); expect(typeof configStore.addAutoGrantedTool).toBe("function"); expect(typeof configStore.removeAutoGrantedTool).toBe("function"); expect(typeof configStore.getConfig).toBe("function"); expect(typeof configStore.toggleStreamerMode).toBe("function"); expect(typeof configStore.toggleCompactMode).toBe("function"); expect(typeof configStore.setCompactMode).toBe("function"); }); it("has subscribable stores", () => { expect(typeof configStore.config.subscribe).toBe("function"); expect(typeof configStore.isLoading.subscribe).toBe("function"); expect(typeof configStore.isSidebarOpen.subscribe).toBe("function"); expect(typeof configStore.saveError.subscribe).toBe("function"); }); }); describe("Race Condition Tests", () => { beforeEach(async () => { // Setup mock to return a default config for load_config const mockInvokeImpl = vi.mocked(invoke); mockInvokeImpl.mockResolvedValue({ model: null, api_key: null, custom_instructions: null, mcp_servers_json: null, auto_granted_tools: [], theme: "dark", greeting_enabled: false, greeting_custom_prompt: null, notifications_enabled: false, notification_volume: 0.7, always_on_top: false, update_checks_enabled: false, character_panel_width: null, font_size: 14, streamer_mode: false, streamer_hide_paths: false, compact_mode: false, profile_name: null, profile_avatar_path: null, profile_bio: null, custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: false, session_token_budget: null, session_cost_budget: null, budget_action: "warn", budget_warning_threshold: 0.8, discord_rpc_enabled: false, }); // Load initial config await configStore.loadConfig(); vi.clearAllMocks(); }); it("handles rapid sequential config updates correctly", async () => { // This test validates the fix for the config race condition that caused data loss const mockInvokeImpl = vi.mocked(invoke); const invokeCalls: Array<{ command: string; config: HikariConfig }> = []; mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => { if (command === "save_config" && args && typeof args === "object" && "config" in args) { invokeCalls.push({ command, config: args.config as HikariConfig }); // Simulate small delay in saving await new Promise((resolve) => setTimeout(resolve, 10)); } return null; }); // Perform rapid updates await Promise.all([ configStore.updateConfig({ font_size: 16 }), configStore.updateConfig({ theme: "light" }), configStore.updateConfig({ compact_mode: true }), ]); // All three updates should have been saved expect(invokeCalls.length).toBe(3); // Get final config const finalConfig = configStore.getConfig(); // Final config should have all updates // Note: The last update wins for each field, but all fields should be preserved expect(finalConfig.compact_mode).toBe(true); }); it("preserves previous field values during concurrent updates", async () => { // Set initial values await configStore.updateConfig({ font_size: 16, theme: "dark", compact_mode: false, streamer_mode: false, }); vi.clearAllMocks(); const mockInvokeImpl = vi.mocked(invoke); const invokeCalls: Array<{ command: string; config: HikariConfig }> = []; mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => { if (command === "save_config" && args && typeof args === "object" && "config" in args) { invokeCalls.push({ command, config: args.config as HikariConfig }); await new Promise((resolve) => setTimeout(resolve, 5)); } return null; }); // Update different fields concurrently await Promise.all([ configStore.updateConfig({ font_size: 18 }), configStore.updateConfig({ theme: "light" }), configStore.updateConfig({ compact_mode: true }), ]); // Check that each save included all previous config values invokeCalls.forEach((call) => { // Each save should have a complete config, not just the updated field expect(call.config).toHaveProperty("font_size"); expect(call.config).toHaveProperty("theme"); expect(call.config).toHaveProperty("compact_mode"); expect(call.config).toHaveProperty("streamer_mode"); expect(call.config).toHaveProperty("model"); expect(call.config).toHaveProperty("api_key"); }); }); it("handles update during save operation", async () => { const mockInvokeImpl = vi.mocked(invoke); let firstSaveStarted = false; let firstSaveCompleted = false; mockInvokeImpl.mockImplementation(async (command: string) => { if (command === "save_config") { if (!firstSaveStarted) { firstSaveStarted = true; // Simulate slow save await new Promise((resolve) => setTimeout(resolve, 50)); firstSaveCompleted = true; } else { // Second save starts while first is in progress expect(firstSaveStarted).toBe(true); // First save might not be complete yet (race condition scenario) } } return null; }); // Start first update const firstUpdate = configStore.updateConfig({ font_size: 16 }); // Wait a bit then start second update whilst first is still saving await new Promise((resolve) => setTimeout(resolve, 10)); const secondUpdate = configStore.updateConfig({ theme: "light" }); // Wait for both to complete await Promise.all([firstUpdate, secondUpdate]); // Both should complete successfully without errors expect(firstSaveCompleted).toBe(true); }); it("getConfig returns most recently set configuration", async () => { await configStore.updateConfig({ font_size: 14 }); expect(configStore.getConfig().font_size).toBe(14); await configStore.updateConfig({ font_size: 16 }); expect(configStore.getConfig().font_size).toBe(16); await configStore.updateConfig({ font_size: 18 }); expect(configStore.getConfig().font_size).toBe(18); }); it("updates do not lose data from previous operations", async () => { // Set multiple fields await configStore.updateConfig({ font_size: 16, theme: "dark", compact_mode: true, streamer_mode: true, model: "claude-sonnet-4", }); // Update just one field await configStore.updateConfig({ theme: "light" }); // Other fields should be preserved const config = configStore.getConfig(); expect(config.theme).toBe("light"); expect(config.font_size).toBe(16); expect(config.compact_mode).toBe(true); expect(config.streamer_mode).toBe(true); expect(config.model).toBe("claude-sonnet-4"); }); it("auto granted tools are not lost during other updates", async () => { // Add some tools await configStore.addAutoGrantedTool("Read"); await configStore.addAutoGrantedTool("Write"); expect(configStore.getConfig().auto_granted_tools).toContain("Read"); expect(configStore.getConfig().auto_granted_tools).toContain("Write"); // Update another field await configStore.updateConfig({ theme: "light" }); // Tools should still be there expect(configStore.getConfig().auto_granted_tools).toContain("Read"); expect(configStore.getConfig().auto_granted_tools).toContain("Write"); }); it("custom theme colors persist across other config updates", async () => { const customColors: CustomThemeColors = { bg_primary: "#1a1a2e", bg_secondary: "#16213e", bg_terminal: "#0f0f23", accent_primary: "#e94560", accent_secondary: "#533483", text_primary: "#eaeaea", text_secondary: "#a0a0a0", border_color: "#333355", }; await configStore.setCustomThemeColors(customColors); // Update another field await configStore.updateConfig({ font_size: 18 }); // Colors should still be there const config = configStore.getConfig(); expect(config.custom_theme_colors.bg_primary).toBe("#1a1a2e"); expect(config.custom_theme_colors.accent_primary).toBe("#e94560"); }); it("handles save errors gracefully without losing data", async () => { const mockInvokeImpl = vi.mocked(invoke); // Mock console.error to suppress expected error output const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); // Set initial config await configStore.updateConfig({ font_size: 14 }); // Make next save fail mockInvokeImpl.mockRejectedValueOnce(new Error("Save failed")); // Try to update - should throw await expect(configStore.updateConfig({ theme: "light" })).rejects.toThrow(); // Original config should still be accessible expect(configStore.getConfig().font_size).toBe(14); // Verify error was logged expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error)); // Restore console.error consoleErrorSpy.mockRestore(); }); }); describe("Config Persistence Tests", () => { it("loadConfig retrieves saved configuration", async () => { const mockConfig: HikariConfig = { model: "claude-sonnet-4", api_key: "test-key", custom_instructions: "Be helpful", mcp_servers_json: "{}", auto_granted_tools: ["Read", "Write"], theme: "light", greeting_enabled: false, greeting_custom_prompt: null, notifications_enabled: false, notification_volume: 0.5, always_on_top: true, update_checks_enabled: false, character_panel_width: 400, font_size: 18, streamer_mode: true, streamer_hide_paths: true, compact_mode: true, profile_name: "Test User", profile_avatar_path: "/test/avatar.png", profile_bio: "Test bio", custom_theme_colors: { bg_primary: null, bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }, budget_enabled: true, session_token_budget: 100000, session_cost_budget: 1.5, budget_action: "block", budget_warning_threshold: 0.9, discord_rpc_enabled: false, show_thinking_blocks: true, use_worktree: false, disable_1m_context: false, max_output_tokens: null, trusted_workspaces: [], background_image_path: null, background_image_opacity: 0.3, custom_font_path: null, custom_font_family: null, custom_ui_font_path: null, custom_ui_font_family: null, task_loop_auto_commit: false, task_loop_commit_prefix: "feat", task_loop_include_summary: false, disable_cron: false, include_git_instructions: true, enable_claudeai_mcp_servers: true, auto_memory_directory: null, model_overrides: null, }; const mockInvokeImpl = vi.mocked(invoke); mockInvokeImpl.mockResolvedValueOnce(mockConfig); await configStore.loadConfig(); const loadedConfig = configStore.getConfig(); expect(loadedConfig.model).toBe("claude-sonnet-4"); expect(loadedConfig.theme).toBe("light"); expect(loadedConfig.font_size).toBe(18); expect(loadedConfig.auto_granted_tools).toEqual(["Read", "Write"]); }); it("saveConfig persists configuration to backend", async () => { const mockInvokeImpl = vi.mocked(invoke); const savedConfigs: HikariConfig[] = []; mockInvokeImpl.mockImplementation(async (command: string, args?: unknown) => { if (command === "save_config" && args && typeof args === "object" && "config" in args) { savedConfigs.push(args.config as HikariConfig); } return null; }); const configToSave: Partial = { model: "claude-sonnet-4", theme: "dark", font_size: 16, }; await configStore.updateConfig(configToSave); expect(savedConfigs.length).toBeGreaterThan(0); const lastSaved = savedConfigs[savedConfigs.length - 1]; expect(lastSaved.model).toBe("claude-sonnet-4"); expect(lastSaved.theme).toBe("dark"); expect(lastSaved.font_size).toBe(16); }); }); describe("loadConfig error path", () => { it("resets to default config and logs error when loadConfig fails", async () => { vi.mocked(invoke).mockResolvedValue(null); await configStore.updateConfig({ theme: "light" }); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); vi.mocked(invoke).mockRejectedValue(new Error("Backend unavailable")); await configStore.loadConfig(); expect(configStore.getConfig().theme).toBe("dark"); expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to load config:", expect.any(Error)); consoleErrorSpy.mockRestore(); }); }); describe("configStore sidebar methods", () => { it("openSidebar sets isSidebarOpen to true", () => { configStore.closeSidebar(); configStore.openSidebar(); expect(get(configStore.isSidebarOpen)).toBe(true); }); it("closeSidebar sets isSidebarOpen to false", () => { configStore.openSidebar(); configStore.closeSidebar(); expect(get(configStore.isSidebarOpen)).toBe(false); }); it("toggleSidebar switches from false to true", () => { configStore.closeSidebar(); configStore.toggleSidebar(); expect(get(configStore.isSidebarOpen)).toBe(true); }); it("toggleSidebar switches from true to false", () => { configStore.openSidebar(); configStore.toggleSidebar(); expect(get(configStore.isSidebarOpen)).toBe(false); }); }); describe("configStore setTheme method", () => { beforeEach(async () => { vi.mocked(invoke).mockResolvedValue(null); }); afterEach(() => { vi.resetAllMocks(); }); it("setTheme updates the theme via invoke", async () => { await configStore.setTheme("light"); expect(configStore.getConfig().theme).toBe("light"); }); it("setTheme with custom colors updates custom_theme_colors", async () => { const colors: CustomThemeColors = { bg_primary: "#001122", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; await configStore.setTheme("custom", colors); expect(configStore.getConfig().theme).toBe("custom"); expect(configStore.getConfig().custom_theme_colors.bg_primary).toBe("#001122"); }); }); describe("configStore setCustomThemeColors with custom theme active", () => { beforeEach(async () => { vi.mocked(invoke).mockResolvedValue(null); await configStore.setTheme("custom"); }); afterEach(() => { vi.resetAllMocks(); clearCustomThemeColors(); }); it("applies custom colors to DOM when current theme is custom", async () => { const colors: CustomThemeColors = { bg_primary: "#aabbcc", bg_secondary: null, bg_terminal: null, accent_primary: null, accent_secondary: null, text_primary: null, text_secondary: null, border_color: null, }; await configStore.setCustomThemeColors(colors); expect(document.documentElement.style.getPropertyValue("--bg-primary")).toBe("#aabbcc"); }); }); describe("configStore font size methods", () => { beforeEach(async () => { vi.mocked(invoke).mockResolvedValue(null); await configStore.updateConfig({ font_size: DEFAULT_FONT_SIZE }); vi.resetAllMocks(); vi.mocked(invoke).mockResolvedValue(null); }); afterEach(() => { vi.resetAllMocks(); }); it("setFontSize updates to the given value", async () => { await configStore.setFontSize(18); expect(configStore.getConfig().font_size).toBe(18); }); it("setFontSize clamps to minimum", async () => { await configStore.setFontSize(1); expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE); }); it("setFontSize clamps to maximum", async () => { await configStore.setFontSize(99); expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE); }); it("increaseFontSize increases font size by 2", async () => { await configStore.increaseFontSize(); expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE + 2); }); it("increaseFontSize does not exceed maximum", async () => { await configStore.setFontSize(MAX_FONT_SIZE); await configStore.increaseFontSize(); expect(configStore.getConfig().font_size).toBe(MAX_FONT_SIZE); }); it("decreaseFontSize decreases font size by 2", async () => { await configStore.decreaseFontSize(); expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE - 2); }); it("decreaseFontSize does not go below minimum", async () => { await configStore.setFontSize(MIN_FONT_SIZE); await configStore.decreaseFontSize(); expect(configStore.getConfig().font_size).toBe(MIN_FONT_SIZE); }); it("resetFontSize restores the default font size", async () => { await configStore.setFontSize(20); await configStore.resetFontSize(); expect(configStore.getConfig().font_size).toBe(DEFAULT_FONT_SIZE); }); }); describe("configStore removeAutoGrantedTool", () => { beforeEach(async () => { vi.mocked(invoke).mockResolvedValue(null); await configStore.updateConfig({ auto_granted_tools: [] }); vi.resetAllMocks(); vi.mocked(invoke).mockResolvedValue(null); }); afterEach(() => { vi.resetAllMocks(); }); it("removes an existing tool", async () => { await configStore.addAutoGrantedTool("Bash"); await configStore.removeAutoGrantedTool("Bash"); expect(configStore.getConfig().auto_granted_tools).not.toContain("Bash"); }); it("is a no-op when the tool is not in the list", async () => { await configStore.removeAutoGrantedTool("NonExistentTool"); expect(configStore.getConfig().auto_granted_tools).toEqual([]); }); }); describe("configStore toggle methods", () => { beforeEach(async () => { vi.mocked(invoke).mockResolvedValue(null); await configStore.updateConfig({ streamer_mode: false, compact_mode: false }); vi.resetAllMocks(); vi.mocked(invoke).mockResolvedValue(null); }); afterEach(() => { vi.resetAllMocks(); }); it("toggleStreamerMode flips streamer_mode from false to true", async () => { await configStore.toggleStreamerMode(); expect(configStore.getConfig().streamer_mode).toBe(true); }); it("toggleStreamerMode flips streamer_mode from true to false", async () => { await configStore.updateConfig({ streamer_mode: true }); await configStore.toggleStreamerMode(); expect(configStore.getConfig().streamer_mode).toBe(false); }); it("toggleCompactMode flips compact_mode from false to true", async () => { await configStore.toggleCompactMode(); expect(configStore.getConfig().compact_mode).toBe(true); }); it("toggleCompactMode flips compact_mode from true to false", async () => { await configStore.updateConfig({ compact_mode: true }); await configStore.toggleCompactMode(); expect(configStore.getConfig().compact_mode).toBe(false); }); it("setCompactMode enables compact mode", async () => { await configStore.setCompactMode(true); expect(configStore.getConfig().compact_mode).toBe(true); }); it("setCompactMode disables compact mode", async () => { await configStore.updateConfig({ compact_mode: true }); await configStore.setCompactMode(false); expect(configStore.getConfig().compact_mode).toBe(false); }); }); describe("derived stores (live subscriptions)", () => { beforeEach(async () => { vi.mocked(invoke).mockResolvedValue(null); }); afterEach(() => { vi.resetAllMocks(); }); it("isDarkTheme is true when theme is dark", async () => { await configStore.updateConfig({ theme: "dark" }); expect(get(isDarkTheme)).toBe(true); }); it("isDarkTheme is false when theme is not dark", async () => { await configStore.updateConfig({ theme: "light" }); expect(get(isDarkTheme)).toBe(false); }); it("isStreamerMode reflects streamer_mode config", async () => { await configStore.updateConfig({ streamer_mode: true }); expect(get(isStreamerMode)).toBe(true); await configStore.updateConfig({ streamer_mode: false }); expect(get(isStreamerMode)).toBe(false); }); it("isCompactMode reflects compact_mode config", async () => { await configStore.updateConfig({ compact_mode: true }); expect(get(isCompactMode)).toBe(true); await configStore.updateConfig({ compact_mode: false }); expect(get(isCompactMode)).toBe(false); }); it("shouldHidePaths is true when both streamer flags are enabled", async () => { await configStore.updateConfig({ streamer_mode: true, streamer_hide_paths: true }); expect(get(shouldHidePaths)).toBe(true); }); it("shouldHidePaths is false when streamer_mode is disabled", async () => { await configStore.updateConfig({ streamer_mode: false, streamer_hide_paths: true }); expect(get(shouldHidePaths)).toBe(false); }); it("showThinkingBlocks is true when show_thinking_blocks is enabled", async () => { await configStore.updateConfig({ show_thinking_blocks: true }); expect(get(showThinkingBlocks)).toBe(true); }); it("showThinkingBlocks is false when show_thinking_blocks is disabled", async () => { await configStore.updateConfig({ show_thinking_blocks: false }); expect(get(showThinkingBlocks)).toBe(false); }); }); describe("applyCustomFont", () => { const readFileMock = vi.mocked(readFile); beforeEach(() => { // Remove any style element left by previous tests document.getElementById("hikari-custom-font")?.remove(); document.documentElement.style.removeProperty("--terminal-font-family"); readFileMock.mockReset(); }); it("removes CSS variable when both path and family are null", async () => { document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace"); await applyCustomFont(null, null); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(""); }); it("removes CSS variable when both path and family are empty strings", async () => { document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace"); await applyCustomFont("", ""); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(""); expect(document.getElementById("hikari-custom-font")).toBeNull(); }); it("injects @import for a CSS stylesheet URL", async () => { await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code"); const style = document.getElementById("hikari-custom-font"); expect(style).not.toBeNull(); expect(style?.textContent).toContain( "@import url('https://fonts.googleapis.com/css2?family=Fira+Code')" ); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe( "'Fira Code', monospace" ); }); it("injects @font-face for a direct font file URL (.woff2)", async () => { await applyCustomFont("https://example.com/fonts/myfont.woff2", "MyFont"); const style = document.getElementById("hikari-custom-font"); expect(style).not.toBeNull(); expect(style?.textContent).toContain("@font-face"); expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.woff2')"); expect(style?.textContent).toContain("'MyFont'"); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe( "'MyFont', monospace" ); }); it("injects @font-face for a direct font file URL (.ttf)", async () => { await applyCustomFont("https://example.com/fonts/myfont.ttf", "MyTtfFont"); const style = document.getElementById("hikari-custom-font"); expect(style).not.toBeNull(); expect(style?.textContent).toContain("@font-face"); expect(style?.textContent).toContain("url('https://example.com/fonts/myfont.ttf')"); expect(style?.textContent).toContain("'MyTtfFont'"); }); it("uses HikariCustomFont as fallback family for direct font URLs when family is empty", async () => { await applyCustomFont("https://example.com/fonts/myfont.woff2", ""); const style = document.getElementById("hikari-custom-font"); expect(style?.textContent).toContain("'HikariCustomFont'"); }); it("reads local file and injects @font-face with data URL", async () => { const fakeData = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" readFileMock.mockResolvedValueOnce(fakeData); await applyCustomFont("/home/naomi/.fonts/MyFont.ttf", "MyFont"); expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/MyFont.ttf"); const style = document.getElementById("hikari-custom-font"); expect(style).not.toBeNull(); expect(style?.textContent).toContain("@font-face"); expect(style?.textContent).toContain("data:font/ttf;base64,"); expect(style?.textContent).toContain("'MyFont'"); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe( "'MyFont', monospace" ); }); it("uses HikariCustomFont as fallback family for local files when family is empty", async () => { const fakeData = new Uint8Array([1, 2, 3]); readFileMock.mockResolvedValueOnce(fakeData); await applyCustomFont("/home/naomi/.fonts/MyFont.woff2", ""); const style = document.getElementById("hikari-custom-font"); expect(style?.textContent).toContain("'HikariCustomFont'"); expect(style?.textContent).toContain("font/woff2"); }); it("sets CSS variable when only family is provided (no path)", async () => { await applyCustomFont("", "SystemFont"); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe( "'SystemFont', monospace" ); expect(document.getElementById("hikari-custom-font")).toBeNull(); }); it("replaces a previously injected style element", async () => { await applyCustomFont("https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code"); expect(document.getElementById("hikari-custom-font")).not.toBeNull(); await applyCustomFont("https://fonts.googleapis.com/css2?family=Roboto+Mono", "Roboto Mono"); const styles = document.querySelectorAll("#hikari-custom-font"); expect(styles.length).toBe(1); expect(styles[0].textContent).toContain("Roboto+Mono"); }); it("uses correct MIME type for .otf local files", async () => { readFileMock.mockResolvedValueOnce(new Uint8Array([1])); await applyCustomFont("/fonts/MyFont.otf", "OtfFont"); const style = document.getElementById("hikari-custom-font"); expect(style?.textContent).toContain("font/otf"); }); it("falls back to font/ttf MIME for unknown extension local files", async () => { readFileMock.mockResolvedValueOnce(new Uint8Array([1])); await applyCustomFont("/fonts/MyFont.xyz", "XyzFont"); const style = document.getElementById("hikari-custom-font"); expect(style?.textContent).toContain("font/ttf"); }); }); describe("setCustomFont", () => { const readFileMock = vi.mocked(readFile); const invokeMock = vi.mocked(invoke); beforeEach(() => { document.getElementById("hikari-custom-font")?.remove(); document.documentElement.style.removeProperty("--terminal-font-family"); readFileMock.mockReset(); invokeMock.mockResolvedValue(undefined); }); it("saves config and applies the font", async () => { await configStore.setCustomFont(null, null); await configStore.setCustomFont( "https://fonts.googleapis.com/css2?family=Fira+Code", "Fira Code" ); expect(invokeMock).toHaveBeenCalledWith( "save_config", expect.objectContaining({ config: expect.objectContaining({ custom_font_path: "https://fonts.googleapis.com/css2?family=Fira+Code", custom_font_family: "Fira Code", }), }) ); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe( "'Fira Code', monospace" ); }); it("clears font when called with nulls", async () => { document.documentElement.style.setProperty("--terminal-font-family", "'SomeFont', monospace"); await configStore.setCustomFont(null, null); expect(document.documentElement.style.getPropertyValue("--terminal-font-family")).toBe(""); expect(invokeMock).toHaveBeenCalledWith( "save_config", expect.objectContaining({ config: expect.objectContaining({ custom_font_path: null, custom_font_family: null, }), }) ); }); }); describe("applyCustomUiFont", () => { const readFileMock = vi.mocked(readFile); beforeEach(() => { document.getElementById("hikari-custom-ui-font")?.remove(); document.documentElement.style.removeProperty("--ui-font-family"); document.body.style.removeProperty("font-family"); readFileMock.mockReset(); }); it("removes CSS variable and body font-family when both path and family are null", async () => { document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif"); document.body.style.setProperty("font-family", "'SomeFont', sans-serif"); await applyCustomUiFont(null, null); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); expect(document.body.style.getPropertyValue("font-family")).toBe(""); }); it("removes CSS variable and body font-family when both path and family are empty strings", async () => { document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif"); document.body.style.setProperty("font-family", "'SomeFont', sans-serif"); await applyCustomUiFont("", ""); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); expect(document.body.style.getPropertyValue("font-family")).toBe(""); expect(document.getElementById("hikari-custom-ui-font")).toBeNull(); }); it("injects @import for a CSS stylesheet URL and applies font to body", async () => { await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter"); const style = document.getElementById("hikari-custom-ui-font"); expect(style).not.toBeNull(); expect(style?.textContent).toContain( "@import url('https://fonts.googleapis.com/css2?family=Inter')" ); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( "'Inter', sans-serif" ); expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif'); }); it("injects @font-face for a direct font file URL (.woff2) and applies font to body", async () => { await applyCustomUiFont("https://example.com/fonts/Inter.woff2", "Inter"); const style = document.getElementById("hikari-custom-ui-font"); expect(style).not.toBeNull(); expect(style?.textContent).toContain("@font-face"); expect(style?.textContent).toContain("url('https://example.com/fonts/Inter.woff2')"); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( "'Inter', sans-serif" ); expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif'); }); it("uses HikariCustomUiFont as fallback family and applies it to body", async () => { await applyCustomUiFont("https://example.com/fonts/Inter.woff2", ""); const style = document.getElementById("hikari-custom-ui-font"); expect(style?.textContent).toContain("'HikariCustomUiFont'"); expect(document.body.style.getPropertyValue("font-family")).toBe( '"HikariCustomUiFont", sans-serif' ); }); it("reads local file and embeds as data URL, applies font to body", async () => { const fakeData = new Uint8Array([104, 101, 108, 108, 111]); readFileMock.mockResolvedValueOnce(fakeData); await applyCustomUiFont("/home/naomi/.fonts/Inter.ttf", "Inter"); expect(readFileMock).toHaveBeenCalledWith("/home/naomi/.fonts/Inter.ttf"); const style = document.getElementById("hikari-custom-ui-font"); expect(style?.textContent).toContain("data:font/ttf;base64,"); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( "'Inter', sans-serif" ); expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif'); }); it("sets CSS variable and body font-family when only family is provided (no path)", async () => { await applyCustomUiFont("", "SystemUiFont"); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( "'SystemUiFont', sans-serif" ); expect(document.body.style.getPropertyValue("font-family")).toBe( '"SystemUiFont", sans-serif' ); }); it("replaces a previously injected style element", async () => { await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter"); expect(document.getElementById("hikari-custom-ui-font")).not.toBeNull(); await applyCustomUiFont("https://fonts.googleapis.com/css2?family=Roboto", "Roboto"); const styles = document.querySelectorAll("#hikari-custom-ui-font"); expect(styles.length).toBe(1); expect(styles[0].textContent).toContain("Roboto"); }); }); describe("setCustomUiFont", () => { const readFileMock = vi.mocked(readFile); const invokeMock = vi.mocked(invoke); beforeEach(() => { document.getElementById("hikari-custom-ui-font")?.remove(); document.documentElement.style.removeProperty("--ui-font-family"); document.body.style.removeProperty("font-family"); readFileMock.mockReset(); invokeMock.mockResolvedValue(undefined); }); it("saves config and applies the UI font", async () => { await configStore.setCustomUiFont(null, null); await configStore.setCustomUiFont("https://fonts.googleapis.com/css2?family=Inter", "Inter"); expect(invokeMock).toHaveBeenCalledWith( "save_config", expect.objectContaining({ config: expect.objectContaining({ custom_ui_font_path: "https://fonts.googleapis.com/css2?family=Inter", custom_ui_font_family: "Inter", }), }) ); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe( "'Inter', sans-serif" ); expect(document.body.style.getPropertyValue("font-family")).toBe('"Inter", sans-serif'); }); it("clears UI font when called with nulls", async () => { document.documentElement.style.setProperty("--ui-font-family", "'SomeFont', sans-serif"); document.body.style.setProperty("font-family", "'SomeFont', sans-serif"); await configStore.setCustomUiFont(null, null); expect(document.documentElement.style.getPropertyValue("--ui-font-family")).toBe(""); expect(document.body.style.getPropertyValue("font-family")).toBe(""); expect(invokeMock).toHaveBeenCalledWith( "save_config", expect.objectContaining({ config: expect.objectContaining({ custom_ui_font_path: null, custom_ui_font_family: null, }), }) ); }); }); });