mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Unfinished CSS strings in font preferences consumed the shared theme stylesheet. Reject incomplete syntax at the settings boundary, recover persisted malformed values through the existing default path, and restore rejected edits in the font field. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
1129 lines
36 KiB
TypeScript
1129 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("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]: "" });
|
|
},
|
|
);
|
|
});
|