Files
paseo/packages/app/src/hooks/use-settings/storage.test.ts
T
Mohamed Boudra 1e0e56c721 refactor(app): unify built-in file panel selection (#7366)
* refactor(app): unify built-in file panel selection

* refactor(app): resolve file panels once per pane
2026-10-19 15:35:41 +07:00

1156 lines
36 KiB
TypeScript

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<typeof createInMemoryKeyValueStorage>;
desktop?: ReturnType<typeof createFakeDesktopBridge>;
} = {},
): SettingsDeps & {
storage: ReturnType<typeof createInMemoryKeyValueStorage>;
desktop: ReturnType<typeof createFakeDesktopBridge>;
} {
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]: "" });
},
);
});