import { describe, expect, it } from "vitest"; import { QueryClient } from "@tanstack/react-query"; import { APP_SETTINGS_KEY, APP_SETTINGS_QUERY_KEY, DEFAULT_APP_SETTINGS, DEFAULT_CLIENT_SETTINGS, DEFAULT_CODE_FONT_SIZE, DEFAULT_CONTENT_FONT_SIZE, DEFAULT_UI_BASE_FONT_SIZE, defaultUiBaseFontSize, defaultContentFontSize, loadAppSettingsFromStorage, loadSettingsFromStorage, parseClampedFontSize, parseTerminalScrollbackLines, resolveContentMaxWidth, sanitizeFontFamily, saveAppSettings, type SettingsDeps, } from "./storage"; import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes"; import { DEFAULT_SIDEBAR_ROW_ITEMS, SIDEBAR_ROW_ITEMS, } from "@/components/sidebar/display-preferences/row-items"; import { DEFAULT_CONTENT_MAX_WIDTH, THEME_OPTIONS } from "@/styles/theme"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; function makeDeps( overrides: { storage?: ReturnType; desktop?: ReturnType; } = {}, ): SettingsDeps & { storage: ReturnType; desktop: ReturnType; } { return { storage: overrides.storage ?? createInMemoryKeyValueStorage(), desktop: overrides.desktop ?? createFakeDesktopBridge(), }; } describe("loadAppSettingsFromStorage", () => { it("persists file panel associations, including panels absent from this host", async () => { const deps = makeDeps(); const queryClient = new QueryClient(); const filePanelAssociations = { "ext:md": "builtin:markdown", "name:Dockerfile": "plugin:absent/container", }; await saveAppSettings({ queryClient, deps, updates: { filePanelAssociations } }); expect((await loadAppSettingsFromStorage(deps)).filePanelAssociations).toEqual( filePanelAssociations, ); }); it.each([undefined, null, [], "invalid", { "ext:md": 42 }])( "defaults malformed file panel associations without discarding other settings: %j", async (filePanelAssociations) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ filePanelAssociations, vimKeybindings: true }), }), }); const settings = await loadAppSettingsFromStorage(deps); expect(settings.filePanelAssociations).toEqual({}); expect(settings.vimKeybindings).toBe(true); }, ); it("preserves a persisted steer send behavior", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ "@paseo:app-settings": JSON.stringify({ sendBehavior: "steer" }), }), }); expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer"); }); it("keeps valid settings when another build wrote unknown fields or enum values", async () => { const stored = { theme: "dark", contentFontSize: 16, sendBehavior: "future-mode", futureSetting: { enabled: true }, sidebarRowItems: { host: false, futureRowItem: true }, }; const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify(stored), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.theme).toBe("dark"); expect(result.sendBehavior).toBe(DEFAULT_CLIENT_SETTINGS.sendBehavior); expect(result.sidebarRowItems.host).toBe(false); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(stored); }); it("migrates a stored interrupt to steer and persists it", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sendBehavior: "interrupt" }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.sendBehavior).toBe("steer"); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}").sendBehavior).toBe( "steer", ); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}")).not.toHaveProperty( "needsWrite", ); }); it("keeps an explicit services choice over the legacy scripts fallback", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: 16, sidebarRowItems: { scripts: false, services: true }, }), }), }); expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true); await saveAppSettings({ queryClient: new QueryClient(), updates: { sidebarRowItems: { ...DEFAULT_SIDEBAR_ROW_ITEMS, services: true }, }, deps, }); expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true); }); it("keeps an interrupt the user picked after the migration ran", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sendBehavior: "interrupt" }), }), }); await loadAppSettingsFromStorage(deps); await saveAppSettings({ queryClient: new QueryClient(), updates: { sendBehavior: "interrupt" }, deps, }); expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("interrupt"); }); it("defaults theme to auto when storage is empty", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result.theme).toBe("auto"); }); it.each(THEME_OPTIONS)("loads the persisted $name theme", async ({ name }) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ theme: name }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.theme).toBe(name); }); it("seeds storage with the client defaults when nothing is persisted", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result).toEqual(DEFAULT_CLIENT_SETTINGS); expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system"); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual( DEFAULT_CLIENT_SETTINGS, ); }); it("defaults language to system when storage is empty", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result.language).toBe("system"); }); it("defaults workspace title source to title when storage is empty", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result.workspaceTitleSource).toBe("title"); }); it("enables the chat outline by default", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result.chatOutlineEnabled).toBe(true); }); it("loads a disabled chat outline preference", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ chatOutlineEnabled: false }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.chatOutlineEnabled).toBe(false); }); it("defaults sidebar navigation items to an empty preference list", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result.sidebarNavItems).toEqual([]); }); it("loads stored sidebar navigation items in order", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sidebarNavItems: [ { key: "history", visible: false }, { key: "new-workspace", visible: true }, ], }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.sidebarNavItems).toEqual([ { key: "history", visible: false }, { key: "new-workspace", visible: true }, ]); }); it("falls back to the default sidebar navigation items when the stored list is malformed", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sidebarNavItems: [{ key: 3 }] }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.sidebarNavItems).toEqual([]); }); it("loads stored sidebar footer items in order and defaults them to empty", async () => { expect((await loadAppSettingsFromStorage(makeDeps())).sidebarFooterItems).toEqual([]); const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sidebarFooterItems: [ // A footer button that was configurable once; the sidebar model skips it. { key: "help", visible: false }, { key: "plugin:sync:status", visible: true }, { key: "usage", visible: false }, ], }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.sidebarFooterItems).toEqual([ { key: "help", visible: false }, { key: "plugin:sync:status", visible: true }, { key: "usage", visible: false }, ]); }); it("loads legacy usage pins in order and defaults a fresh device to source defaults", async () => { expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({ displayAs: "used", pins: null, serverId: null, }); const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ usage: { displayAs: "remaining", pinned: [ { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ], serverId: "server", }, }), }), }); expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ displayAs: "remaining", pins: [ { sourceId: "codex", windowId: "weekly" }, { sourceId: "claude", windowId: "five-hour" }, ], serverId: "server", }); }); it("loads an explicitly empty usage selection without restoring defaults", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ usage: { displayAs: "remaining", pins: [], serverId: "server" }, }), }), }); expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ displayAs: "remaining", pins: [], serverId: "server", }); }); it("keeps valid usage preferences when one field is malformed", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ usage: { displayAs: "percent", pinned: [{ sourceId: "claude", windowId: "weekly" }], serverId: 42, }, }), }), }); expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ displayAs: "used", pins: [{ sourceId: "claude", windowId: "weekly" }], serverId: null, }); }); it("collapses legacy diff destinations into the former Explorer choice", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ openInSidePane: { explorerChanges: true, changesLinks: false }, }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.openInSidePane.diffs).toBe(true); expect(result.openInSidePane).not.toHaveProperty("explorerChanges"); expect(result.openInSidePane).not.toHaveProperty("changesLinks"); }); it("defaults PRs to Explorer and preserves the legacy side choice", async () => { const defaults = await loadAppSettingsFromStorage(makeDeps()); const legacySide = await loadAppSettingsFromStorage( makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ openInSidePane: { pullRequests: true } }), }), }), ); expect(defaults.pullRequestOpenLocation).toBe("explorer"); expect(legacySide.pullRequestOpenLocation).toBe("side"); expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests"); }); it("uses the native terminal renderer by default", async () => { const deps = makeDeps(); const result = await loadAppSettingsFromStorage(deps); expect(result.useLegacyTerminalRenderer).toBe(false); }); it("loads the per-device legacy terminal renderer preference", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.useLegacyTerminalRenderer).toBe(true); }); it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 42_000 }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.terminalScrollbackLines).toBe(42_000); }); it("loads configured workspace title source from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "branch" }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.workspaceTitleSource).toBe("branch"); }); it("drops an unknown workspace title source back to title", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "directory" }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.workspaceTitleSource).toBe("title"); }); it("normalizes terminal scrollback lines from storage", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ terminalScrollbackLines: 1_000_000.9 }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.terminalScrollbackLines).toBe(1_000_000); }); it("migrates the legacy theme key into the new settings object", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [LEGACY_SETTINGS_KEY]: JSON.stringify({ theme: "dark", manageBuiltInDaemon: false, releaseChannel: "beta", }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result).toEqual({ ...DEFAULT_CLIENT_SETTINGS, theme: "dark", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ manageBuiltInDaemon: false, releaseChannel: "beta", ...result, }); }); it("preserves the legacy key's explicit interface size as content size", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [LEGACY_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 17 }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.uiBaseFontSize).toBe(17); expect(result.contentFontSize).toBe(17); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({ uiBaseFontSize: 17, contentFontSize: 17, }); }); it("preserves the legacy interface scale as content size", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [LEGACY_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 17 }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.uiBaseFontSize).toBe(15); expect(result.contentFontSize).toBe(15); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({ uiBaseFontSize: 15, contentFontSize: 15, }); }); it("loads a persisted explicit language", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ language: "zh-CN" }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.language).toBe("zh-CN"); }); it("drops an unknown persisted language back to system", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ language: "klingon" }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.language).toBe("system"); }); }); describe("saveAppSettings", () => { it("applies consecutive functional updates to the latest cached settings", async () => { const deps = makeDeps(); const queryClient = new QueryClient(); queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, DEFAULT_CLIENT_SETTINGS); await Promise.all([ saveAppSettings({ queryClient, updates: (current) => ({ sidebarNavItems: [...current.sidebarNavItems, { key: "history", visible: false }], }), deps, }), saveAppSettings({ queryClient, updates: (current) => ({ sidebarNavItems: [...current.sidebarNavItems, { key: "search", visible: true }], }), deps, }), ]); expect(queryClient.getQueryData(APP_SETTINGS_QUERY_KEY)).toMatchObject({ sidebarNavItems: [ { key: "history", visible: false }, { key: "search", visible: true }, ], }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({ sidebarNavItems: [ { key: "history", visible: false }, { key: "search", visible: true }, ], }); }); }); describe("loadSettingsFromStorage", () => { it("defaults built-in daemon management to enabled when storage is empty", async () => { const deps = makeDeps(); const result = await loadSettingsFromStorage(deps); expect(result).toEqual(DEFAULT_APP_SETTINGS); }); it("defaults release channel to stable when storage is empty", async () => { const deps = makeDeps(); const result = await loadSettingsFromStorage(deps); expect(result.releaseChannel).toBe("stable"); }); it("ignores renderer-owned daemon management state outside Electron", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ theme: "light", manageBuiltInDaemon: false, }), }), }); const result = await loadSettingsFromStorage(deps); expect(result).toEqual({ ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, }); }); it("ignores renderer-owned release channel outside Electron", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ releaseChannel: "beta" }), }), }); const result = await loadSettingsFromStorage(deps); expect(result.releaseChannel).toBe("stable"); }); it("migrates legacy desktop-owned settings through the bridge before reading effective settings", async () => { const desktop = createFakeDesktopBridge({ isElectron: true, settings: { releaseChannel: "beta", notifications: { playSound: true }, daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true }, }, }); const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ theme: "light", manageBuiltInDaemon: false, releaseChannel: "beta", }), }), desktop, }); const result = await loadSettingsFromStorage(deps); expect(desktop.migrationsApplied).toEqual([ { manageBuiltInDaemon: false, releaseChannel: "beta" }, ]); expect(result).toEqual({ ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, manageBuiltInDaemon: false, releaseChannel: "beta", }); }); it("does not call the desktop bridge outside Electron", async () => { const desktop = createFakeDesktopBridge({ isElectron: false }); const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ theme: "light" }), }), desktop, }); const result = await loadSettingsFromStorage(deps); expect(desktop.migrationsApplied).toEqual([]); expect(result).toEqual({ ...DEFAULT_APP_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, }); }); }); describe("saveAppSettings", () => { it("round-trips fields written by a newer build", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ theme: "dark", contentFontSize: 16, sendBehavior: "future-mode", futureSetting: { enabled: true }, sidebarRowItems: { host: false, futureRowItem: true }, }), }), }); await loadAppSettingsFromStorage(deps); await saveAppSettings({ queryClient: new QueryClient(), updates: { theme: "light" }, deps, }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({ theme: "light", futureSetting: { enabled: true }, sidebarRowItems: { host: false, futureRowItem: true, }, }); }); it("saves terminal scrollback through app settings persistence", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify(DEFAULT_CLIENT_SETTINGS), }), }); const queryClient = new QueryClient(); await saveAppSettings({ queryClient, updates: { terminalScrollbackLines: 42_000 }, deps, }); expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe( JSON.stringify({ ...DEFAULT_CLIENT_SETTINGS, terminalScrollbackLines: 42_000, }), ); }); it("normalizes a legacy cached settings shape before saving", async () => { const deps = makeDeps(); const queryClient = new QueryClient(); queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, { theme: "dark", compactToolCalls: true, }); await saveAppSettings({ queryClient, updates: { theme: "light" }, deps, }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({ ...DEFAULT_CLIENT_SETTINGS, theme: "light", contentFontSize: DEFAULT_UI_BASE_FONT_SIZE, toolCallDetailLevel: "overview", }); }); it("persists a selected plugin theme", async () => { const deps = makeDeps(); const queryClient = new QueryClient(); await saveAppSettings({ queryClient, updates: { theme: "plugin", pluginThemeId: "catppuccin/theme/mocha" }, deps, }); const loaded = await loadAppSettingsFromStorage(deps); expect(loaded.theme).toBe("plugin"); expect(loaded.pluginThemeId).toBe("catppuccin/theme/mocha"); }); // The row items are written as one object through one strict schema, so an item the schema // does not know does not just fail to persist itself — it takes every sibling toggle with it. it.each(SIDEBAR_ROW_ITEMS)("persists the %s row item being switched off", async (item) => { const deps = makeDeps(); const queryClient = new QueryClient(); const sidebarRowItems = { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false }; await saveAppSettings({ queryClient, updates: { sidebarRowItems }, deps }); expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems).toEqual(sidebarRowItems); }); }); describe("parseTerminalScrollbackLines", () => { it("clamps negative values to the minimum and rejects non-numeric strings", () => { expect(parseTerminalScrollbackLines("-10")).toBe(0); expect(parseTerminalScrollbackLines("abc")).toBeNull(); }); }); describe("appearance settings", () => { it("defaults the appearance fields when an old blob omits them", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ theme: "dark" }), }), }); const result = await loadAppSettingsFromStorage(deps); expect(result.uiFontFamily).toBe(""); expect(result.monoFontFamily).toBe(""); expect(result.uiBaseFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE); expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE); expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); expect(result.syntaxTheme).toBe("one"); expect(result.toolCallDetailLevel).toBe("detailed"); }); it("migrates the enabled compact tool call preference to overview", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }), }), }); expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); it("clears settings with an unrecognized tool call detail level", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), }), }); expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed"); }); it("migrates a switched-off checks row item to the hidden checks display", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sidebarRowItems: { checks: false } }), }), }); expect((await loadAppSettingsFromStorage(deps)).sidebarChecksDisplay).toBe("none"); }); it("lets a stored checks display win over the row item it replaced", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ sidebarChecksDisplay: "icon", sidebarRowItems: { checks: false }, }), }), }); expect((await loadAppSettingsFromStorage(deps)).sidebarChecksDisplay).toBe("icon"); }); it("uses a 15px mobile base and a 14px web base", () => { expect(defaultUiBaseFontSize(true)).toBe(15); expect(defaultUiBaseFontSize(false)).toBe(14); }); it("uses a 16px content default on mobile and a 15px default on web", () => { expect(defaultContentFontSize(true)).toBe(16); expect(defaultContentFontSize(false)).toBe(15); expect(DEFAULT_CONTENT_FONT_SIZE).toBe(defaultContentFontSize(false)); }); it("derives and persists content size from an existing interface-size preference", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 17 }), }), }); const result = await loadAppSettingsFromStorage(deps); const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null"); expect(result.contentFontSize).toBe(17); expect(persisted).toMatchObject({ uiBaseFontSize: 17, contentFontSize: 17 }); }); it("clamps the content font size into range and rejects non-numeric values", async () => { const high = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: 999 }), }), }); expect((await loadAppSettingsFromStorage(high)).contentFontSize).toBe(21); const low = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: 8 }), }), }); expect((await loadAppSettingsFromStorage(low)).contentFontSize).toBe(10); const bogus = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ contentFontSize: "abc" }), }), }); expect((await loadAppSettingsFromStorage(bogus)).contentFontSize).toBe( DEFAULT_CONTENT_FONT_SIZE, ); }); it.each([ { legacySize: 16, baseSize: 14 }, { legacySize: 17, baseSize: 15 }, { legacySize: 18, baseSize: 16 }, ])( "migrates legacy interface size $legacySize to base size $baseSize", async ({ legacySize, baseSize }) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: legacySize }), }), }); const result = await loadAppSettingsFromStorage(deps); const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null"); expect(result.uiBaseFontSize).toBe(baseSize); expect(persisted).toMatchObject({ uiBaseFontSize: baseSize }); expect(persisted.uiFontSize).toBe(legacySize); }, ); it("lets an explicit base size win over the legacy interface scale", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 16, uiFontSize: 17 }), }), }); expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(16); }); it("falls back to a valid legacy interface scale when the explicit base size is invalid", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: "abc", uiFontSize: 17 }), }), }); const result = await loadAppSettingsFromStorage(deps); const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null"); expect(result.uiBaseFontSize).toBe(15); expect(persisted).toMatchObject({ uiBaseFontSize: 15, uiFontSize: 17 }); }); it("clamps the UI base font size into range and rejects non-numeric values", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 999 }), }), }); expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(21); const low = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: 8 }), }), }); expect((await loadAppSettingsFromStorage(low)).uiBaseFontSize).toBe(10); const bogus = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: "abc" }), }), }); expect((await loadAppSettingsFromStorage(bogus)).uiBaseFontSize).toBe( DEFAULT_UI_BASE_FONT_SIZE, ); }); it("clamps the code font size into range and rejects non-numeric values", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 999 }), }), }); expect((await loadAppSettingsFromStorage(deps)).codeFontSize).toBe(22); const low = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 8 }), }), }); expect((await loadAppSettingsFromStorage(low)).codeFontSize).toBe(9); const bogus = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: "abc" }), }), }); expect((await loadAppSettingsFromStorage(bogus)).codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE); }); it("trims an accepted font family", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: " Menlo " }), }), }); expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("Menlo"); }); it("keeps an explicit empty font family as the default sentinel", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "" }), }), }); expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe(""); }); it("rejects a font family containing CSS-breaking characters", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a;b{c}" }), }), }); expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe(""); }); it("rejects an over-length font family", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a".repeat(201) }), }), }); expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe(""); }); it("accepts a known syntax theme id", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "dracula" }), }), }); expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("dracula"); }); it("drops a removed syntax theme id back to the default", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "auto" }), }), }); expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("one"); }); it("drops an unknown syntax theme id back to the default", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "bogus" }), }), }); expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("one"); }); }); describe("parseClampedFontSize", () => { it("clamps to the bounds and rejects non-numeric strings", () => { expect(parseClampedFontSize(999, { min: 11, max: 24 })).toBe(24); expect(parseClampedFontSize(8, { min: 11, max: 24 })).toBe(11); expect(parseClampedFontSize("15", { min: 11, max: 24 })).toBe(15); expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull(); }); }); describe("content max width", () => { it("follows the current default until the user picks a width", async () => { const deps = makeDeps(); const queryClient = new QueryClient(); await saveAppSettings({ queryClient, updates: { codeFontSize: 14 }, deps }); const stored = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null"); expect(stored.contentMaxWidth).toBeNull(); expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe( DEFAULT_CONTENT_MAX_WIDTH, ); }); it("keeps a picked width and returns to the default when reset", async () => { const deps = makeDeps(); const queryClient = new QueryClient(); await saveAppSettings({ queryClient, updates: { contentMaxWidth: 1600 }, deps }); expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(1600); await saveAppSettings({ queryClient, updates: { contentMaxWidth: null }, deps }); expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null").contentMaxWidth).toBe( null, ); expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe( DEFAULT_CONTENT_MAX_WIDTH, ); }); it("clamps stored widths and drops malformed ones to the default", async () => { const load = (contentMaxWidth: unknown) => loadAppSettingsFromStorage( makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ contentMaxWidth }), }), }), ); expect((await load(100_000)).contentMaxWidth).toBe(4000); expect((await load(10)).contentMaxWidth).toBe(600); expect((await load("wide")).contentMaxWidth).toBeNull(); }); }); describe("font family CSS safety", () => { it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])( "rejects unfinished CSS syntax: %s", (value) => expect(sanitizeFontFamily(value)).toBeNull(), ); it.each([ "Menlo", "'Fira Code', monospace", '"Fira Code", monospace', "'Font\\'s name'", "Font\\ Name", "'Font (Mono)'", "'Font [Mono]'", ])("preserves complete font families: %s", (value) => expect(sanitizeFontFamily(value)).toBe(value), ); it.each(["uiFontFamily", "monoFontFamily"])( "recovers a persisted unmatched quote in %s", async (field) => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }), }), }); expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" }); }, ); });