2026-09-05 10:19:03 -06:00
|
|
|
import { execFileSync } from 'node:child_process';
|
2026-09-07 17:50:55 +03:00
|
|
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
2026-09-05 10:19:03 -06:00
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { dirname, join } from 'node:path';
|
|
|
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
2026-04-20 15:41:15 +03:00
|
|
|
import { describe, expect, it } from 'vitest';
|
2026-04-17 16:07:26 +08:00
|
|
|
|
2026-09-05 10:19:03 -06:00
|
|
|
import {
|
|
|
|
|
DEFAULT_INPUT_HISTORY_LIMIT,
|
|
|
|
|
} from './input-history-scope.js';
|
2026-04-17 16:07:26 +08:00
|
|
|
import { createSettingsHelpers } from './settings-helpers.js';
|
2026-06-23 21:46:23 +03:00
|
|
|
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
|
2026-04-17 16:07:26 +08:00
|
|
|
|
2026-09-05 10:19:03 -06:00
|
|
|
const testFilePath = fileURLToPath(import.meta.url);
|
|
|
|
|
const packagesWebDir = join(dirname(testFilePath), '..', '..', '..');
|
|
|
|
|
|
2026-04-17 16:07:26 +08:00
|
|
|
const createTestHelpers = () => createSettingsHelpers({
|
|
|
|
|
normalizePathForPersistence: (value) => value,
|
|
|
|
|
normalizeDirectoryPath: (value) => value,
|
|
|
|
|
normalizeTunnelBootstrapTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelSessionTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelProvider: (value) => value,
|
|
|
|
|
normalizeTunnelMode: (value) => value,
|
|
|
|
|
normalizeOptionalPath: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelPresets: () => undefined,
|
|
|
|
|
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
|
|
|
|
sanitizeTypographySizesPartial: () => undefined,
|
|
|
|
|
normalizeStringArray: (input) => input,
|
|
|
|
|
sanitizeModelRefs: () => undefined,
|
|
|
|
|
sanitizeSkillCatalogs: () => undefined,
|
|
|
|
|
sanitizeProjects: () => undefined,
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-23 21:46:23 +03:00
|
|
|
const createTestHelpersWithRealSanitizers = () => {
|
|
|
|
|
const runtime = createSettingsNormalizationRuntime({
|
|
|
|
|
os: { homedir: () => '/home/testuser' },
|
|
|
|
|
path: {
|
|
|
|
|
resolve: (...args) => args[args.length - 1],
|
|
|
|
|
sep: '/',
|
|
|
|
|
dirname: (p) => p.split('/').slice(0, -1).join('/') || '/',
|
|
|
|
|
},
|
|
|
|
|
processLike: { platform: 'linux', env: {} },
|
|
|
|
|
realpathSync: (p) => p,
|
|
|
|
|
tunnelBootstrapTtlDefaultMs: 600000,
|
|
|
|
|
tunnelBootstrapTtlMinMs: 60000,
|
|
|
|
|
tunnelBootstrapTtlMaxMs: 3600000,
|
|
|
|
|
tunnelSessionTtlDefaultMs: 86400000,
|
|
|
|
|
tunnelSessionTtlMinMs: 3600000,
|
|
|
|
|
tunnelSessionTtlMaxMs: 604800000,
|
|
|
|
|
});
|
|
|
|
|
return createSettingsHelpers({
|
|
|
|
|
normalizePathForPersistence: (value) => value,
|
|
|
|
|
normalizeDirectoryPath: (value) => value,
|
|
|
|
|
normalizeTunnelBootstrapTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelSessionTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelProvider: (value) => value,
|
|
|
|
|
normalizeTunnelMode: (value) => value,
|
|
|
|
|
normalizeOptionalPath: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelPresets: () => undefined,
|
|
|
|
|
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
|
|
|
|
sanitizeTypographySizesPartial: () => undefined,
|
|
|
|
|
normalizeStringArray: runtime.normalizeStringArray,
|
|
|
|
|
sanitizeModelRefs: runtime.sanitizeModelRefs,
|
|
|
|
|
sanitizeSkillCatalogs: () => undefined,
|
|
|
|
|
sanitizeProjects: () => undefined,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-17 16:07:26 +08:00
|
|
|
describe('settings helpers', () => {
|
2026-09-07 05:56:27 +10:00
|
|
|
it('round-trips telemetry opt-in with the hidden list and preserves it across unrelated writes', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
const legacy = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [] });
|
|
|
|
|
expect(legacy.workStatusHiddenSectionsExplicit).toBeUndefined();
|
|
|
|
|
const changes = helpers.sanitizeSettingsUpdate({ workStatusHiddenSections: [], workStatusHiddenSectionsExplicit: true });
|
|
|
|
|
const saved = helpers.mergePersistedSettings(legacy, changes);
|
|
|
|
|
const reloaded = helpers.formatSettingsResponse(JSON.parse(JSON.stringify(saved)));
|
|
|
|
|
expect(reloaded.workStatusHiddenSections).toEqual([]);
|
|
|
|
|
expect(reloaded.workStatusHiddenSectionsExplicit).toBe(true);
|
|
|
|
|
const next = helpers.mergePersistedSettings(reloaded, helpers.sanitizeSettingsUpdate({ workStatusPanelEnabled: false }));
|
|
|
|
|
expect(helpers.formatSettingsResponse(next).workStatusHiddenSectionsExplicit).toBe(true);
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ workStatusHiddenSectionsExplicit: 'true' }).workStatusHiddenSectionsExplicit).toBeUndefined();
|
|
|
|
|
});
|
2026-09-05 10:19:03 -06:00
|
|
|
it('imports from the packed @openchamber/web tarball without escaping the published package', async () => {
|
|
|
|
|
const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-'));
|
|
|
|
|
const packDir = join(tempRoot, 'pack');
|
|
|
|
|
const extractDir = join(tempRoot, 'extract');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
mkdirSync(packDir);
|
|
|
|
|
mkdirSync(extractDir);
|
|
|
|
|
execFileSync('npm', ['pack', '--silent', '--pack-destination', packDir], {
|
|
|
|
|
cwd: packagesWebDir,
|
|
|
|
|
stdio: 'pipe',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const tarballName = readdirSync(packDir).find((entry) => entry.endsWith('.tgz'));
|
|
|
|
|
expect(tarballName).toBeTruthy();
|
|
|
|
|
|
|
|
|
|
execFileSync('tar', ['-xzf', join(packDir, tarballName), '-C', extractDir], {
|
|
|
|
|
stdio: 'pipe',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const extractedModule = await import(
|
|
|
|
|
pathToFileURL(join(extractDir, 'package', 'server', 'lib', 'opencode', 'settings-helpers.js')).href
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const helpers = extractedModule.createSettingsHelpers({
|
|
|
|
|
normalizePathForPersistence: (value) => value,
|
|
|
|
|
normalizeDirectoryPath: (value) => value,
|
|
|
|
|
normalizeTunnelBootstrapTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelSessionTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelProvider: (value) => value,
|
|
|
|
|
normalizeTunnelMode: (value) => value,
|
|
|
|
|
normalizeOptionalPath: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelPresets: () => undefined,
|
|
|
|
|
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
|
|
|
|
sanitizeTypographySizesPartial: () => undefined,
|
|
|
|
|
normalizeStringArray: (input) => input,
|
|
|
|
|
sanitizeModelRefs: () => undefined,
|
|
|
|
|
sanitizeSkillCatalogs: () => undefined,
|
|
|
|
|
sanitizeProjects: () => undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({
|
|
|
|
|
inputHistoryScope: 'global',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({
|
|
|
|
|
inputHistoryScope: 'session',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({
|
|
|
|
|
inputHistoryLimit: 40,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
2026-09-05 20:13:35 +03:00
|
|
|
inputHistoryScope: 'session',
|
2026-09-05 10:19:03 -06:00
|
|
|
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
|
|
|
|
rmSync(tempRoot, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-24 23:07:39 +03:00
|
|
|
it('accepts only booleans for draft starter visibility', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: true })).toEqual({ draftStartersVisible: true });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: false })).toEqual({ draftStartersVisible: false });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-06 00:07:28 +08:00
|
|
|
it('sanitizes both Enter settings independently', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: true })).toEqual({ enterToSend: true });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: false })).toEqual({ enterToSendConfigured: false });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true', enterToSendConfigured: 1 })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-21 16:25:51 +03:00
|
|
|
it('sanitizes shared sidebar display preferences', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
sidebarProjectDisplayMode: 'single',
|
|
|
|
|
sidebarSessionGroupingMode: 'flat',
|
|
|
|
|
sidebarProjectSortOrder: 'z-a',
|
|
|
|
|
sidebarShowRecentSection: false,
|
|
|
|
|
})).toEqual({
|
|
|
|
|
sidebarProjectDisplayMode: 'single',
|
|
|
|
|
sidebarSessionGroupingMode: 'flat',
|
|
|
|
|
sidebarProjectSortOrder: 'z-a',
|
|
|
|
|
sidebarShowRecentSection: false,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
sidebarProjectDisplayMode: 'grid',
|
|
|
|
|
sidebarSessionGroupingMode: 'project',
|
|
|
|
|
sidebarProjectSortOrder: 'random',
|
|
|
|
|
sidebarShowRecentSection: 'false',
|
|
|
|
|
})).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 17:27:38 +02:00
|
|
|
it('persists valid tool JSON view modes', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'summary' })).toEqual({ toolJsonViewMode: 'summary' });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'formatted' })).toEqual({ toolJsonViewMode: 'formatted' });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'raw' })).toEqual({ toolJsonViewMode: 'raw' });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ toolJsonViewMode: 'unknown' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-25 11:21:21 +03:00
|
|
|
it('accepts only booleans for wide chat layout', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: true })).toEqual({ wideChatLayoutEnabled: true });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: false })).toEqual({ wideChatLayoutEnabled: false });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-26 17:53:19 +02:00
|
|
|
it('accepts only booleans for collapsible user messages', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: true })).toEqual({ collapsibleUserMessages: true });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: false })).toEqual({ collapsibleUserMessages: false });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-21 11:57:59 +02:00
|
|
|
it('sanitizes and returns the persisted editor font size', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 20.6 })).toEqual({ editorFontSize: 21 });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 8 })).toEqual({ editorFontSize: 9 });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: 33 })).toEqual({ editorFontSize: 32 });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ editorFontSize: Number.NaN })).toEqual({});
|
|
|
|
|
expect(helpers.formatSettingsResponse({ editorFontSize: 20 })).toMatchObject({ editorFontSize: 20 });
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 16:07:26 +08:00
|
|
|
it('accepts messageStreamTransport as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'ws' })).toEqual({
|
|
|
|
|
messageStreamTransport: 'ws',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'sse' })).toEqual({
|
|
|
|
|
messageStreamTransport: 'sse',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'auto' })).toEqual({
|
|
|
|
|
messageStreamTransport: 'auto',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid messageStreamTransport values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
|
|
|
|
|
});
|
2026-04-17 18:05:53 +03:00
|
|
|
|
2026-09-05 10:19:03 -06:00
|
|
|
it('accepts inputHistoryScope as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({
|
|
|
|
|
inputHistoryScope: 'global',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({
|
|
|
|
|
inputHistoryScope: 'session',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts valid inputHistoryLimit values as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1 })).toEqual({
|
|
|
|
|
inputHistoryLimit: 1,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({
|
|
|
|
|
inputHistoryLimit: 40,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 100 })).toEqual({
|
|
|
|
|
inputHistoryLimit: 100,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid inputHistoryLimit values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 0 })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 101 })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1.5 })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: '40' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NaN })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.POSITIVE_INFINITY })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NEGATIVE_INFINITY })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid inputHistoryScope values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-05 20:13:35 +03:00
|
|
|
it('defaults inputHistoryScope to session in formatted settings responses', () => {
|
2026-09-05 10:19:03 -06:00
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
2026-09-05 20:13:35 +03:00
|
|
|
expect(helpers.formatSettingsResponse({ inputHistoryScope: 'global' })).toMatchObject({
|
|
|
|
|
inputHistoryScope: 'global',
|
2026-09-05 10:19:03 -06:00
|
|
|
});
|
|
|
|
|
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
2026-09-05 20:13:35 +03:00
|
|
|
inputHistoryScope: 'session',
|
2026-09-05 10:19:03 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defaults missing inputHistoryLimit to 40 in formatted settings responses and preserves valid values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
|
|
|
|
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.formatSettingsResponse({ inputHistoryLimit: 1 })).toMatchObject({
|
|
|
|
|
inputHistoryLimit: 1,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.formatSettingsResponse({ inputHistoryLimit: 100 })).toMatchObject({
|
|
|
|
|
inputHistoryLimit: 100,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
it('sanitizes the persisted terminal shell', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ terminalShell: ' ZSH ' })).toEqual({ terminalShell: 'zsh' });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'auto' })).toEqual({ terminalShell: 'auto' });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ terminalShell: '/bin/zsh' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ terminalShell: 'zsh -c whoami' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [' ZSH ', 'bash', 'zsh', '/bin/fish', 42] })).toEqual({
|
|
|
|
|
terminalLoginShells: ['zsh', 'bash'],
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ terminalLoginShells: [] })).toEqual({ terminalLoginShells: [] });
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 18:05:53 +03:00
|
|
|
it('accepts desktopLanAccessEnabled as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopLanAccessEnabled: true })).toEqual({
|
|
|
|
|
desktopLanAccessEnabled: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopLanAccessEnabled: false })).toEqual({
|
|
|
|
|
desktopLanAccessEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-05-05 16:26:34 +07:00
|
|
|
|
2026-07-01 19:07:20 +03:00
|
|
|
it('accepts desktopKeepAwakeEnabled as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: true })).toEqual({
|
|
|
|
|
desktopKeepAwakeEnabled: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopKeepAwakeEnabled: false })).toEqual({
|
|
|
|
|
desktopKeepAwakeEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 17:40:48 +08:00
|
|
|
it('accepts desktopMinimizeToTrayEnabled as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopMinimizeToTrayEnabled: true })).toEqual({
|
|
|
|
|
desktopMinimizeToTrayEnabled: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopMinimizeToTrayEnabled: false })).toEqual({
|
|
|
|
|
desktopMinimizeToTrayEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-22 10:08:58 +03:00
|
|
|
it('accepts desktopMacMenuBarEnabled as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopMacMenuBarEnabled: true })).toEqual({
|
|
|
|
|
desktopMacMenuBarEnabled: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopMacMenuBarEnabled: false })).toEqual({
|
|
|
|
|
desktopMacMenuBarEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.formatSettingsResponse({ desktopMacMenuBarEnabled: false })).toMatchObject({
|
|
|
|
|
desktopMacMenuBarEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-26 13:39:46 +03:00
|
|
|
it('normalizes desktopWindowControlsPosition and maps legacy auto to right', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'left' })).toEqual({
|
|
|
|
|
desktopWindowControlsPosition: 'left',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'right' })).toEqual({
|
|
|
|
|
desktopWindowControlsPosition: 'right',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'auto' })).toEqual({
|
|
|
|
|
desktopWindowControlsPosition: 'right',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'center' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-29 00:07:04 +02:00
|
|
|
it('sanitizes desktopWindowControlsStyle and rejects unknown values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'classic' })).toEqual({
|
|
|
|
|
desktopWindowControlsStyle: 'classic',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'traffic-lights' })).toEqual({
|
|
|
|
|
desktopWindowControlsStyle: 'traffic-lights',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'macos' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'auto' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 15:03:16 +03:00
|
|
|
it('sanitizes the persisted permission auto-accept policy', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
permissionAutoAccept: {
|
|
|
|
|
sessions: { root: true, child: false, invalid: 'true' },
|
|
|
|
|
},
|
|
|
|
|
})).toEqual({
|
|
|
|
|
permissionAutoAccept: {
|
|
|
|
|
sessions: { root: true, child: false },
|
2026-07-21 20:52:20 +03:00
|
|
|
revision: 0,
|
2026-07-12 15:03:16 +03:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-25 15:20:56 +03:00
|
|
|
it('accepts desktopUiPassword as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: ' secret ' })).toEqual({
|
|
|
|
|
desktopUiPassword: 'secret',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ desktopUiPassword: '' })).toEqual({
|
|
|
|
|
desktopUiPassword: '',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-05 16:26:34 +07:00
|
|
|
it('accepts mobileKeyboardMode as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'native' })).toEqual({
|
|
|
|
|
mobileKeyboardMode: 'native',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'resize-content' })).toEqual({
|
|
|
|
|
mobileKeyboardMode: 'resize-content',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: ' resize-content ' })).toEqual({
|
|
|
|
|
mobileKeyboardMode: 'resize-content',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid mobileKeyboardMode values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'fixed-layout' })).toEqual({});
|
|
|
|
|
});
|
2026-05-16 17:20:16 +03:00
|
|
|
|
|
|
|
|
it('accepts collapsibleThinkingBlocks as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: true })).toEqual({
|
|
|
|
|
collapsibleThinkingBlocks: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: false })).toEqual({
|
|
|
|
|
collapsibleThinkingBlocks: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-03 16:00:32 +03:00
|
|
|
it('accepts shortcut overrides as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
shortcutOverrides: {
|
|
|
|
|
open_settings: 'mod+comma',
|
|
|
|
|
new_chat: '__unassigned__',
|
|
|
|
|
invalid: 123,
|
|
|
|
|
empty: '',
|
|
|
|
|
},
|
|
|
|
|
})).toEqual({
|
|
|
|
|
shortcutOverrides: {
|
|
|
|
|
open_settings: 'mod+comma',
|
|
|
|
|
new_chat: '__unassigned__',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves empty shortcut overrides when resetting all shortcuts', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ shortcutOverrides: {} })).toEqual({
|
|
|
|
|
shortcutOverrides: {},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts OpenCode update notification preference as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ showOpenCodeUpdateNotifications: false })).toEqual({
|
|
|
|
|
showOpenCodeUpdateNotifications: false,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ showOpenCodeUpdateNotifications: true })).toEqual({
|
|
|
|
|
showOpenCodeUpdateNotifications: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-06 00:07:28 +08:00
|
|
|
it('accepts and rejects invalid Enter-to-send preference values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: true, enterToSendConfigured: true })).toEqual({
|
|
|
|
|
enterToSend: true,
|
|
|
|
|
enterToSendConfigured: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: true })).toEqual({
|
|
|
|
|
enterToSend: false,
|
|
|
|
|
enterToSendConfigured: true,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: false })).toEqual({
|
|
|
|
|
enterToSend: false,
|
|
|
|
|
enterToSendConfigured: false,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSend: 1 })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 'true' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 1 })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-03 16:00:32 +03:00
|
|
|
it('accepts dismissed OpenCode update toast version as a persisted shared setting', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ openCodeUpdateToastDismissedVersion: ' 1.16.0 ' })).toEqual({
|
|
|
|
|
openCodeUpdateToastDismissedVersion: '1.16.0',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ openCodeUpdateToastDismissedVersion: '' })).toEqual({
|
|
|
|
|
openCodeUpdateToastDismissedVersion: '',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 17:20:16 +03:00
|
|
|
it('rejects non-boolean collapsibleThinkingBlocks values', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: 'true' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsibleThinkingBlocks: 1 })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('includes collapsibleThinkingBlocks in formatSettingsResponse', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
const response = helpers.formatSettingsResponse({ collapsibleThinkingBlocks: false });
|
|
|
|
|
expect(response.collapsibleThinkingBlocks).toBe(false);
|
|
|
|
|
|
|
|
|
|
const responseTrue = helpers.formatSettingsResponse({ collapsibleThinkingBlocks: true });
|
|
|
|
|
expect(responseTrue.collapsibleThinkingBlocks).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defaults collapsibleThinkingBlocks to true in formatSettingsResponse when absent', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
|
|
|
|
|
const response = helpers.formatSettingsResponse({});
|
|
|
|
|
expect(response.collapsibleThinkingBlocks).toBe(true);
|
|
|
|
|
});
|
2026-06-12 18:24:07 +03:00
|
|
|
|
|
|
|
|
it('includes transient desktop LAN access runtime status in desktop settings response', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
const previousRuntime = process.env.OPENCHAMBER_RUNTIME;
|
|
|
|
|
const previousActive = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE;
|
|
|
|
|
const previousReason = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
|
|
|
|
try {
|
|
|
|
|
process.env.OPENCHAMBER_RUNTIME = 'desktop';
|
|
|
|
|
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = 'false';
|
|
|
|
|
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = 'missing-password';
|
|
|
|
|
|
|
|
|
|
const response = helpers.formatSettingsResponse({ desktopLanAccessEnabled: true });
|
|
|
|
|
expect(response.desktopLanAccessActive).toBe(false);
|
|
|
|
|
expect(response.desktopLanAccessBlockedReason).toBe('missing-password');
|
|
|
|
|
} finally {
|
|
|
|
|
if (typeof previousRuntime === 'string') process.env.OPENCHAMBER_RUNTIME = previousRuntime;
|
|
|
|
|
else delete process.env.OPENCHAMBER_RUNTIME;
|
|
|
|
|
if (typeof previousActive === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = previousActive;
|
|
|
|
|
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE;
|
|
|
|
|
if (typeof previousReason === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = previousReason;
|
|
|
|
|
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-06-23 21:46:23 +03:00
|
|
|
|
|
|
|
|
describe('previously-dropped model selector persistence fields', () => {
|
|
|
|
|
it('round-trips hiddenModels through the sanitizer', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
const input = [
|
|
|
|
|
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
|
|
|
|
|
{ providerID: 'openai', modelID: 'gpt-5' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: input })).toEqual({
|
|
|
|
|
hiddenModels: input,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('handles empty hiddenModels the same way as empty favoriteModels', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
const hiddenResult = helpers.sanitizeSettingsUpdate({ hiddenModels: [] });
|
|
|
|
|
const favoriteResult = helpers.sanitizeSettingsUpdate({ favoriteModels: [] });
|
|
|
|
|
|
|
|
|
|
expect(hiddenResult.hiddenModels).toEqual([]);
|
|
|
|
|
expect(favoriteResult.favoriteModels).toEqual([]);
|
|
|
|
|
expect(hiddenResult.hiddenModels).toEqual(favoriteResult.favoriteModels);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('round-trips collapsedModelProviders and recentAgents as string arrays', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: ['anthropic', 'openai'] })).toEqual({
|
|
|
|
|
collapsedModelProviders: ['anthropic', 'openai'],
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentAgents: ['build', 'plan'] })).toEqual({
|
|
|
|
|
recentAgents: ['build', 'plan'],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('round-trips recentEfforts as a Record<string, string[]>', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
const input = {
|
|
|
|
|
'anthropic/claude-opus-4': ['high', 'default'],
|
|
|
|
|
'openai/gpt-5': ['low'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: input })).toEqual({
|
|
|
|
|
recentEfforts: input,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects garbage hiddenModels input the same way sanitizeModelRefs rejects bad refs', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 'not-an-array' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: null })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ hiddenModels: 123 })).toEqual({});
|
|
|
|
|
expect(
|
|
|
|
|
helpers.sanitizeSettingsUpdate({
|
|
|
|
|
hiddenModels: [
|
|
|
|
|
{ providerID: 'anthropic' },
|
|
|
|
|
{ modelID: 'gpt-5' },
|
|
|
|
|
'not-an-object',
|
|
|
|
|
null,
|
|
|
|
|
{ providerID: ' ', modelID: 'x' },
|
|
|
|
|
{ providerID: 'openai', modelID: '' },
|
|
|
|
|
],
|
|
|
|
|
})
|
|
|
|
|
).toEqual({ hiddenModels: [] });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects garbage collapsedModelProviders and recentAgents input', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: 'anthropic' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ collapsedModelProviders: null })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentAgents: 42 })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentAgents: { build: 1 } })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects garbage recentEfforts input', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: 'not-an-object' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: [] })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: null })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': 'high' } })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { '': ['high'] } })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [] } })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ recentEfforts: { 'anthropic/claude-opus-4': [123, ''] } })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-29 01:42:40 +03:00
|
|
|
it('persists only boolean system prompt optimization values', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: true })).toEqual({ optimizeSystemPrompt: true });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: false })).toEqual({ optimizeSystemPrompt: false });
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ optimizeSystemPrompt: 'true' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-23 21:46:23 +03:00
|
|
|
it('survives a full settings.json payload containing all four previously-dropped fields (regression)', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
const payload = {
|
|
|
|
|
themeId: 'default',
|
|
|
|
|
hiddenModels: [
|
|
|
|
|
{ providerID: 'anthropic', modelID: 'claude-opus-4' },
|
|
|
|
|
{ providerID: 'openai', modelID: 'gpt-5' },
|
|
|
|
|
],
|
|
|
|
|
collapsedModelProviders: ['anthropic', 'openai'],
|
|
|
|
|
recentAgents: ['build', 'plan'],
|
|
|
|
|
recentEfforts: {
|
|
|
|
|
'anthropic/claude-opus-4': ['high', 'default'],
|
|
|
|
|
'openai/gpt-5': ['low'],
|
|
|
|
|
},
|
|
|
|
|
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude-haiku-4' }],
|
|
|
|
|
recentModels: [{ providerID: 'openai', modelID: 'gpt-5' }],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sanitized = helpers.sanitizeSettingsUpdate(payload);
|
|
|
|
|
|
|
|
|
|
expect(sanitized.hiddenModels).toEqual(payload.hiddenModels);
|
|
|
|
|
expect(sanitized.collapsedModelProviders).toEqual(payload.collapsedModelProviders);
|
|
|
|
|
expect(sanitized.recentAgents).toEqual(payload.recentAgents);
|
|
|
|
|
expect(sanitized.recentEfforts).toEqual(payload.recentEfforts);
|
|
|
|
|
expect(sanitized.favoriteModels).toEqual(payload.favoriteModels);
|
|
|
|
|
expect(sanitized.recentModels).toEqual(payload.recentModels);
|
|
|
|
|
});
|
2026-08-15 12:26:24 +00:00
|
|
|
|
|
|
|
|
it('round-trips a valid gitProviders payload through sanitizeSettingsUpdate', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
const payload = {
|
|
|
|
|
gitProviders: {
|
|
|
|
|
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
|
|
|
|
gitlab: { apiBaseUrl: 'gitlab.example.com', detectUrls: [] },
|
|
|
|
|
gitea: { apiBaseUrl: '', detectUrls: ['gitea.example.com'] },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate(payload)).toEqual({
|
|
|
|
|
gitProviders: {
|
|
|
|
|
github: { apiBaseUrl: 'https://github.example.com/api/v3', detectUrls: ['github.example.com'] },
|
|
|
|
|
gitlab: { apiBaseUrl: 'https://gitlab.example.com' },
|
|
|
|
|
gitea: { detectUrls: ['gitea.example.com'] },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('drops malformed gitProviders payloads entirely', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ gitProviders: 'not-an-object' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ gitProviders: [] })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ gitProviders: null })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { unknown: { apiBaseUrl: 'https://x.example.com' } } })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { apiBaseUrl: ' ' } } })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ gitProviders: { github: { detectUrls: 'github.example.com' } } })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('normalizes gitProviders apiBaseUrl scheme and strips trailing slashes', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
gitProviders: { github: { apiBaseUrl: 'github.example.com/api/v3/' } },
|
|
|
|
|
})).toEqual({
|
|
|
|
|
gitProviders: { github: { apiBaseUrl: 'https://github.example.com/api/v3' } },
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-23 21:46:23 +03:00
|
|
|
});
|
2026-08-15 00:39:36 +05:30
|
|
|
|
|
|
|
|
describe('session retention settings persistence', () => {
|
|
|
|
|
it('round-trips sessionRetentionAction archive and delete through the sanitizer', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: 'archive' })).toEqual({
|
|
|
|
|
sessionRetentionAction: 'archive',
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: 'delete' })).toEqual({
|
|
|
|
|
sessionRetentionAction: 'delete',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('rejects invalid sessionRetentionAction values', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: 'remove' })).toEqual({});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionAction: true })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('survives a full settings payload containing sessionRetentionAction (regression)', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
const payload = {
|
|
|
|
|
autoDeleteEnabled: true,
|
|
|
|
|
autoDeleteAfterDays: 60,
|
|
|
|
|
sessionRetentionAction: 'delete',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sanitized = helpers.sanitizeSettingsUpdate(payload);
|
|
|
|
|
|
|
|
|
|
expect(sanitized.autoDeleteEnabled).toBe(true);
|
|
|
|
|
expect(sanitized.autoDeleteAfterDays).toBe(60);
|
|
|
|
|
expect(sanitized.sessionRetentionAction).toBe('delete');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-17 16:07:26 +08:00
|
|
|
});
|
2026-09-07 17:50:55 +03:00
|
|
|
|
|
|
|
|
describe('settings registry gate', () => {
|
|
|
|
|
const registryPath = join(dirname(testFilePath), 'settings-registry.json');
|
|
|
|
|
const registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
|
|
|
const persistableKeys = Object.entries(registry.fields)
|
|
|
|
|
.filter(([, field]) => !field.computed && !field.local && field.owner !== 'desktop-shell')
|
|
|
|
|
.map(([key]) => key);
|
|
|
|
|
|
|
|
|
|
// One valid value per persistable registry key. The test below fails when a
|
|
|
|
|
// key is added to the registry without a line here, and when the sanitizer
|
|
|
|
|
// stops accepting a key the registry still lists — that is the drift the
|
|
|
|
|
// registry exists to end.
|
|
|
|
|
const validValues = {
|
|
|
|
|
themeId: 'openchamber-dark', useSystemTheme: true, themeVariant: 'dark', lightThemeId: 'openchamber-light', darkThemeId: 'openchamber-dark',
|
|
|
|
|
splashBgLight: '#fff', splashFgLight: '#000', splashBgDark: '#000', splashFgDark: '#fff',
|
|
|
|
|
lastDirectory: '/home/testuser/project', homeDirectory: '/home/testuser', opencodeBinary: '/usr/local/bin/opencode',
|
|
|
|
|
projects: [{ id: 'p', path: '/home/testuser/project' }], activeProjectId: 'p',
|
|
|
|
|
securityScopedBookmarks: ['bookmark'], pinnedDirectories: ['/home/testuser/project'],
|
|
|
|
|
desktopLanAccessEnabled: true, desktopKeepAwakeEnabled: true, desktopMinimizeToTrayEnabled: true, desktopMacMenuBarEnabled: true,
|
|
|
|
|
desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
|
|
|
|
|
defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
|
|
|
|
|
agentControlToolEnabled: true, agentWebToolEnabled: true, agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
|
|
|
|
|
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
|
|
|
|
|
openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
|
|
|
|
|
tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
|
|
|
|
|
managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
|
|
|
|
|
managedRemoteTunnelSelectedPresetId: 'a', managedRemoteTunnelPresetTokens: { a: 'token' },
|
|
|
|
|
sidebarProjectDisplayMode: 'all', sidebarSessionGroupingMode: 'flat', sidebarProjectSortOrder: 'manual', sidebarShowRecentSection: true,
|
|
|
|
|
workStatusPanelEnabled: true, workStatusHiddenSections: ['mcp'], workStatusHiddenSectionsExplicit: true,
|
|
|
|
|
showReasoningTraces: true, streamingAutoFollowEnabled: true, collapsibleThinkingBlocks: true, showTextJustificationActivity: true,
|
|
|
|
|
chatRenderMode: 'live', activityRenderMode: 'summary', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true,
|
|
|
|
|
stickyUserHeader: true, promptNavigatorEnabled: true, wideChatLayoutEnabled: true, showSplitAssistantMessageActions: true, showToolFileIcons: true,
|
|
|
|
|
codeBlockLineWrap: true, showTurnChangedFiles: true, showExpandedBashTools: true, showExpandedEditTools: true, toolJsonViewMode: 'raw',
|
|
|
|
|
timeFormatPreference: '24h', weekStartPreference: 'monday', messageStreamTransport: 'ws', diffLayoutPreference: 'inline', diffWrapLines: true,
|
|
|
|
|
gitChangesViewMode: 'tree', gitmojiEnabled: true, defaultFileViewerPreview: true, directoryShowHidden: true, filesViewShowGitignored: true,
|
|
|
|
|
fileEditorKeymap: 'vim', autoSaveEnabled: true, autoCreateWorktree: true, sessionTabsEnabled: true, showOpenCodeRestartConfirm: true,
|
|
|
|
|
allowPromptingSubagentSessions: true, inputSpellcheckEnabled: true, enterToSend: true, enterToSendConfigured: true, persistChatDraft: true,
|
|
|
|
|
largeTextPasteBehavior: 'attach', followUpBehavior: 'steer', queueModeEnabled: true, inputHistoryScope: 'global', inputHistoryLimit: 40,
|
|
|
|
|
draftStarters: [{ type: 'command', name: 'plan-feature' }], draftStartersVisible: true, draftStartersCraftGoalAdded: true, draftStartersScheduleTaskAdded: true,
|
|
|
|
|
fontSize: 100, terminalFontSize: 14, editorFontSize: 14, uiFont: 'inter', monoFont: 'jetbrains-mono', padding: 100, cornerRadius: 8,
|
|
|
|
|
shortcutOverrides: { 'chat.send': 'mod+enter' },
|
|
|
|
|
defaultModel: 'anthropic/claude', defaultVariant: 'high', defaultAgent: 'build', smallModelUseDefault: false, smallModelOverride: 'anthropic/haiku',
|
|
|
|
|
walkthroughModelOverride: 'anthropic/claude', zenModel: 'zen/model',
|
|
|
|
|
favoriteModels: [{ providerID: 'anthropic', modelID: 'claude' }], hiddenModels: [{ providerID: 'openai', modelID: 'gpt' }], collapsedModelProviders: ['openai'],
|
|
|
|
|
recentModels: [{ providerID: 'anthropic', modelID: 'claude' }], recentAgents: ['build'], recentEfforts: { 'anthropic/claude': ['high'] }, providerOrder: ['anthropic'],
|
|
|
|
|
sessionRecapEnabled: true, sessionSuggestionEnabled: true, sessionGoalEnabled: true, sessionGoalDefaultBudgetEnabled: true, sessionGoalDefaultBudget: 5,
|
|
|
|
|
summarizeLastMessage: true, summaryThreshold: 100, summaryLength: 50, maxLastMessageLength: 200, showDeletionDialog: true,
|
|
|
|
|
nativeNotificationsEnabled: true, notificationMode: 'always', notifyOnSubtasks: true, notifyOnCompletion: true, notifyOnError: true, notifyOnQuestion: true,
|
|
|
|
|
notificationTemplates: { completion: { title: 't', message: 'm' } }, showOpenCodeUpdateNotifications: true, reportUsage: true,
|
|
|
|
|
usageDisplayMode: 'usage', usageDropdownProviders: ['anthropic'], usageSelectedModels: { anthropic: ['claude'] }, usageCollapsedFamilies: { anthropic: ['f'] },
|
|
|
|
|
usageExpandedFamilies: { anthropic: ['f'] }, usageModelGroups: { anthropic: { customGroups: [{ id: 'g', label: 'G', models: ['claude'], order: 0 }] } },
|
|
|
|
|
globalBehaviorPrompt: 'Be brief.', responseStyleEnabled: true, responseStylePreset: 'concise', responseStyleCustomInstructions: 'x', optimizeSystemPrompt: true,
|
|
|
|
|
pwaAppName: 'OpenChamber', pwaOrientation: 'portrait', mobileKeyboardMode: 'native', desktopWindowControlsPosition: 'left', desktopWindowControlsStyle: 'classic',
|
|
|
|
|
inputBarOffset: 10,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('accepts a valid value for every persistable registry key (no server-side drift)', () => {
|
|
|
|
|
// The shared test helpers stub the injected list sanitizers to `undefined`
|
|
|
|
|
// (they are covered by their own suites); here they must pass values through
|
|
|
|
|
// so a key is judged by the sanitizer's own branch, not by a stub.
|
|
|
|
|
const helpers = createSettingsHelpers({
|
|
|
|
|
normalizePathForPersistence: (value) => value,
|
|
|
|
|
normalizeDirectoryPath: (value) => value,
|
|
|
|
|
normalizeTunnelBootstrapTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelSessionTtlMs: (value) => value,
|
|
|
|
|
normalizeTunnelProvider: (value) => value,
|
|
|
|
|
normalizeTunnelMode: (value) => value,
|
|
|
|
|
normalizeOptionalPath: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelHostname: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelPresets: (value) => value,
|
|
|
|
|
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
|
|
|
|
|
normalizeStringArray: (input) => input,
|
|
|
|
|
sanitizeModelRefs: (value) => value,
|
|
|
|
|
sanitizeSkillCatalogs: (value) => value,
|
|
|
|
|
sanitizeProjects: (value) => value,
|
|
|
|
|
});
|
|
|
|
|
const missingFixture = persistableKeys.filter((key) => !(key in validValues));
|
|
|
|
|
expect(missingFixture).toEqual([]);
|
|
|
|
|
|
|
|
|
|
const rejected = persistableKeys.filter((key) => {
|
|
|
|
|
const result = helpers.sanitizeSettingsUpdate({ [key]: validValues[key] });
|
|
|
|
|
// `queueModeEnabled` is absorbed into `followUpBehavior` on purpose.
|
|
|
|
|
const landedAs = key === 'queueModeEnabled' ? 'followUpBehavior' : key;
|
|
|
|
|
return result[landedAs] === undefined;
|
|
|
|
|
});
|
|
|
|
|
expect(rejected).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('drops keys the registry does not list, computed flags, and desktop-shell-owned keys', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
markdownDisplayMode: 'raw',
|
|
|
|
|
toolCallExpansion: 'collapsed',
|
|
|
|
|
expandedEditorToolbar: true,
|
|
|
|
|
typographySizes: { base: 14 },
|
|
|
|
|
gitProviderId: 'anthropic',
|
|
|
|
|
gitModelId: 'claude',
|
|
|
|
|
messageLimit: 200,
|
|
|
|
|
agentMemoryFeatureAvailable: true,
|
|
|
|
|
desktopHosts: [],
|
|
|
|
|
notARealKey: 1,
|
|
|
|
|
})).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('never returns secret keys from a formatted response', () => {
|
|
|
|
|
const helpers = createTestHelpers();
|
|
|
|
|
const secretKeys = Object.entries(registry.fields).filter(([, field]) => field.secret).map(([key]) => key);
|
|
|
|
|
expect(secretKeys).toContain('managedRemoteTunnelToken');
|
|
|
|
|
expect(secretKeys).toContain('desktopUiPassword');
|
|
|
|
|
expect(secretKeys).toContain('managedRemoteTunnelPresetTokens');
|
|
|
|
|
const response = helpers.formatSettingsResponse({
|
|
|
|
|
managedRemoteTunnelToken: 'token',
|
|
|
|
|
desktopUiPassword: 'pw',
|
|
|
|
|
managedRemoteTunnelPresetTokens: { a: 'tok' },
|
|
|
|
|
themeId: 'x',
|
|
|
|
|
});
|
|
|
|
|
for (const key of secretKeys) {
|
|
|
|
|
expect(response).not.toHaveProperty(key);
|
|
|
|
|
}
|
|
|
|
|
expect(response.hasManagedRemoteTunnelToken).toBe(true);
|
|
|
|
|
expect(response.hasDesktopUiPassword).toBe(true);
|
|
|
|
|
expect(helpers.formatSettingsResponse({ desktopUiPassword: '' }).hasDesktopUiPassword).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('accepts the newly shared profile fields', () => {
|
|
|
|
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({
|
|
|
|
|
providerOrder: ['b', 'a', 'a'],
|
|
|
|
|
diffWrapLines: true,
|
|
|
|
|
persistChatDraft: false,
|
|
|
|
|
largeTextPasteBehavior: 'inline',
|
|
|
|
|
fileEditorKeymap: 'vim',
|
|
|
|
|
allowPromptingSubagentSessions: true,
|
|
|
|
|
showOpenCodeRestartConfirm: false,
|
|
|
|
|
codeBlockLineWrap: true,
|
|
|
|
|
streamingAutoFollowEnabled: false,
|
|
|
|
|
autoSaveEnabled: false,
|
|
|
|
|
})).toEqual({
|
|
|
|
|
providerOrder: ['b', 'a'],
|
|
|
|
|
diffWrapLines: true,
|
|
|
|
|
persistChatDraft: false,
|
|
|
|
|
largeTextPasteBehavior: 'inline',
|
|
|
|
|
fileEditorKeymap: 'vim',
|
|
|
|
|
allowPromptingSubagentSessions: true,
|
|
|
|
|
showOpenCodeRestartConfirm: false,
|
|
|
|
|
codeBlockLineWrap: true,
|
|
|
|
|
streamingAutoFollowEnabled: false,
|
|
|
|
|
autoSaveEnabled: false,
|
|
|
|
|
});
|
|
|
|
|
expect(helpers.sanitizeSettingsUpdate({ largeTextPasteBehavior: 'maybe', fileEditorKeymap: 'emacs' })).toEqual({});
|
|
|
|
|
});
|
|
|
|
|
});
|