generated from nhcarrigan/template
c3a6a2950a
- Set document.body.style.fontFamily directly (belt-and-suspenders alongside the CSS variable) to ensure the font propagates - Use var(--ui-font-family) in .app-container so the main app area respects the custom font (was hardcoded, blocking inheritance) - Rename 'Custom Font' setting to 'Custom Terminal Font' for clarity
1475 lines
54 KiB
TypeScript
1475 lines
54 KiB
TypeScript
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,
|
|
};
|
|
|
|
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,
|
|
};
|
|
|
|
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("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,
|
|
};
|
|
|
|
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<HikariConfig> = {
|
|
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,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|