open-design/apps/web/tests/components/SettingsDialog.test.ts
Marc Chan e14b8092ea
feat: add Orbit activity summaries (#681)
* feat: add Orbit activity summaries

* fix(orbit): make runs navigable while agent continues

* fix(web): widen minimum chat panel

* feat: support Orbit template selection

* fix(daemon): avoid bogus skill side-file preflight

* fix(web): collapse orbit artifact project cards

* fix(web): preserve orbit project card titles

* fix: improve Orbit run daily briefing

* fix: handle Orbit digest data failures

* fix: load Orbit templates and connector tools reliably

* fix: keep Orbit summary counts consistent

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: apply Orbit template skill context

* fix: cache and curate connector tools for Orbit

* fix: align Orbit defaults and connector discovery

* fix: simplify Orbit template settings

* fix: move connectors into settings

* fix: compact connector settings catalog

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: prevent connector action button from stretching into pill

The icon-only connect/disconnect buttons in the embedded connectors
catalog inherited min-width: 92px / 106px from the non-embedded pill
rules, overriding the 24px square sizing and causing the buttons to
overlap the card head text. Reset min-width to 0 in the embedded
icon-only rule so the compact square layout holds.

* fix(web): align live artifact file rows

* fix: clean up Orbit connector settings lifecycle

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address Orbit review regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* feat(web): localize Orbit and connector settings

* feat(web): gate Orbit runs without connectors

* feat(web): refine connector settings UX

* feat(web): safeguard Composio key clearing

* fix(web): refresh Composio tool badges

* feat(web): show connector logos

* feat(daemon): localize Orbit prompt window

* fix(daemon): clarify blocked connector callback closes

* test(daemon): harden flaky async probes

* fix(web): align Indonesian connector locale keys

* test(web): align connector browser props

* fix(web): preserve explicit credential clears

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): time out Composio logo proxy fetches

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Indonesian connector settings copy

Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve discovered connector tools

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve onboarding autosave completion

Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): defer Composio catalog cache hydration

Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): treat discovery completion independently

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve latest settings draft on close

Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): avoid syncing draft Composio key on Orbit run

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Orbit settings copy

Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): prefer fresh connector catalog state

Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): declare Indonesian locale fallback keys explicitly

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): inline Indonesian fallback strings for CI

Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI.

Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): restrict proxied connector logos to image MIME types

Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* test(e2e): align settings dialog regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): decouple Orbit runs from media sync failures

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): keep SPA catch-all export-compatible

Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve Orbit config and workspace routes

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): block SVG in connector logo proxy

Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): fall back to static catalog for empty cache

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): disable Orbit run before connector gate resolves

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(desktop): export shipped desktop types

Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore persisted question form selections

Render historical submitted answers directly so reloaded question forms keep their locked selections visible.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): retry forced media sync autosave

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep Composio logo timeout through body read

Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): refresh Orbit gate after connector auth

Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep connector detail tool lists intact

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): ignore malformed Orbit summaries

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(e2e): stabilize design-system multi-select flow

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): cap Composio logo cache growth

Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): bound proxied Composio logo payloads

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): align autosave settings tests

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): remove stray CSS conflict marker

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fixer: address PR #681 follow-up items

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore restart routes and connector flows

* fix(web): keep SPA export route static

* fix(web): stabilize chat scroll tests

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 14:27:46 +08:00

847 lines
30 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
agentRefreshOptionsForConfig,
canRunProviderConnectionTest,
deriveComposioCredentialState,
configForManualOrbitRun,
isOrbitRunDisabled,
isValidApiBaseUrl,
sanitizeSettingsSavePayload,
shouldEnableSettingsSave,
shouldShowCustomModelInput,
persistConfigAndRunOrbit,
switchApiProtocolConfig,
testStatusVariant,
updateAgentCliEnvValue,
updateCurrentApiProtocolConfig,
} from '../../src/components/SettingsDialog';
import type { AppConfig, ConnectionTestResponse } from '../../src/types';
const originalFetch = globalThis.fetch;
const baseConfig: AppConfig = {
mode: 'api',
apiKey: 'sk-test',
apiProtocol: 'anthropic',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
agentId: null,
skillId: null,
designSystemId: null,
};
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('SettingsDialog API protocol switching', () => {
it('stores the current custom protocol config while preserving custom endpoint details', () => {
const config: AppConfig = {
...baseConfig,
apiKey: 'anthropic-key',
apiProviderBaseUrl: null,
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
expect(next.apiProtocolConfigs?.anthropic).toMatchObject({
apiKey: 'anthropic-key',
baseUrl: 'https://my-proxy.example.com',
model: 'my-model',
apiProviderBaseUrl: null,
});
});
it('restores each protocol draft instead of leaking shared field values', () => {
const openai = switchApiProtocolConfig(baseConfig, 'openai');
const openaiEdited = updateCurrentApiProtocolConfig(openai, {
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
const google = switchApiProtocolConfig(openaiEdited, 'google');
const googleEdited = updateCurrentApiProtocolConfig(google, {
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
const restoredOpenai = switchApiProtocolConfig(googleEdited, 'openai');
expect(restoredOpenai).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'openai-key',
baseUrl: 'https://openai-proxy.example.com',
model: 'openai-model',
apiProviderBaseUrl: null,
});
expect(restoredOpenai.apiProtocolConfigs?.google).toMatchObject({
apiKey: 'google-key',
baseUrl: 'https://google-proxy.example.com',
model: 'google-model',
apiProviderBaseUrl: null,
});
});
it('loads the new protocol default on first visit', () => {
expect(switchApiProtocolConfig(baseConfig, 'openai')).toMatchObject({
mode: 'api',
apiProtocol: 'openai',
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o',
apiProviderBaseUrl: 'https://api.openai.com/v1',
});
});
it('auto-fills Google defaults when switching from a selected known provider', () => {
expect(switchApiProtocolConfig(baseConfig, 'google')).toMatchObject({
mode: 'api',
apiProtocol: 'google',
apiKey: '',
baseUrl: 'https://generativelanguage.googleapis.com',
model: 'gemini-2.0-flash',
apiProviderBaseUrl: 'https://generativelanguage.googleapis.com',
});
});
it('keeps Azure API version in the Azure draft only', () => {
const config: AppConfig = {
...baseConfig,
apiProtocol: 'azure',
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
};
const next = switchApiProtocolConfig(config, 'openai');
expect(next).toMatchObject({
apiProtocol: 'openai',
apiKey: '',
apiVersion: '',
});
expect(next.apiProtocolConfigs?.azure).toMatchObject({
apiKey: 'azure-key',
model: 'deployment-one',
apiVersion: '2024-10-21',
});
});
});
describe('SettingsDialog test status variant', () => {
const baseResult: ConnectionTestResponse = { ok: false, kind: 'unknown', latencyMs: 0 };
it('returns success for an ok result', () => {
expect(testStatusVariant({ ok: true, kind: 'success', latencyMs: 12 })).toBe(
'success',
);
});
it('returns warn for rate-limit (config still looks valid)', () => {
expect(testStatusVariant({ ...baseResult, kind: 'rate_limited' })).toBe(
'warn',
);
});
it('returns error for the failure kinds', () => {
for (const kind of [
'auth_failed',
'forbidden',
'not_found_model',
'invalid_model_id',
'invalid_base_url',
'upstream_unavailable',
'timeout',
'agent_not_installed',
'agent_spawn_failed',
'unknown',
] as const) {
expect(testStatusVariant({ ...baseResult, kind })).toBe('error');
}
});
});
describe('SettingsDialog provider connection test requirements', () => {
it('allows Azure tests to use the daemon default API version', () => {
expect(
canRunProviderConnectionTest({
apiKey: 'azure-key',
baseUrl: 'https://my-azure.openai.azure.com',
model: 'deployment-one',
}),
).toBe(true);
});
it('still requires the shared provider fields', () => {
expect(
canRunProviderConnectionTest({ ...baseConfig, apiKey: '' }),
).toBe(false);
expect(
canRunProviderConnectionTest({ ...baseConfig, baseUrl: '' }),
).toBe(false);
expect(
canRunProviderConnectionTest({ ...baseConfig, model: '' }),
).toBe(false);
});
});
describe('SettingsDialog custom model picker state', () => {
it('keeps custom input visible while an intermediate value matches a known model', () => {
expect(
shouldShowCustomModelInput('gpt-5', ['gpt-5', 'o3'], true),
).toBe(true);
});
it('uses the dropdown when a known model is selected outside custom mode', () => {
expect(
shouldShowCustomModelInput('gpt-5', ['gpt-5', 'o3'], false),
).toBe(false);
});
it('shows custom input for unknown or empty model values', () => {
expect(
shouldShowCustomModelInput('gpt-5.5', ['gpt-5', 'o3'], false),
).toBe(true);
expect(shouldShowCustomModelInput('', ['gpt-5', 'o3'], false)).toBe(true);
});
});
describe('SettingsDialog API Base URL validation', () => {
it('accepts public http/https URLs and loopback local providers', () => {
expect(isValidApiBaseUrl('https://api.openai.com/v1')).toBe(true);
expect(isValidApiBaseUrl('http://localhost:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://127.0.0.1:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://[::1]:11434/v1')).toBe(true);
expect(isValidApiBaseUrl('http://[::ffff:127.0.0.1]:11434/v1')).toBe(true);
expect(isValidApiBaseUrl(' https://resource.openai.azure.com ')).toBe(true);
expect(isValidApiBaseUrl('ddddd')).toBe(false);
expect(isValidApiBaseUrl('api.openai.com/v1')).toBe(false);
expect(isValidApiBaseUrl('ftp://api.example.com')).toBe(false);
expect(isValidApiBaseUrl('http:api.example.com')).toBe(false);
expect(isValidApiBaseUrl('https://')).toBe(false);
expect(isValidApiBaseUrl('http://10.0.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://169.254.1.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://172.16.0.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://192.168.1.5:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[fd00::1]:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[fe80::1]:11434/v1')).toBe(false);
expect(isValidApiBaseUrl('http://[::ffff:192.168.1.5]:11434/v1')).toBe(false);
});
});
describe('SettingsDialog agent CLI env settings', () => {
it('updates supported per-agent CLI env values without dropping sibling agents', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'claude',
'CLAUDE_CONFIG_DIR',
' ~/.claude-2 ',
);
expect(next.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
});
});
it('updates additional Codex CLI env values without dropping sibling Codex fields', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'codex',
'CODEX_BIN',
' ~/bin/codex-next ',
);
expect(next.agentCliEnv).toEqual({
codex: { CODEX_HOME: '~/.codex-alt', CODEX_BIN: '~/bin/codex-next' },
});
});
it('removes empty per-agent CLI env entries', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
codex: { CODEX_HOME: '~/.codex-alt' },
},
};
const next = updateAgentCliEnvValue(
config,
'claude',
'CLAUDE_CONFIG_DIR',
'',
);
expect(next.agentCliEnv).toEqual({
codex: { CODEX_HOME: '~/.codex-alt' },
});
});
it('passes pending CLI env prefs through agent rescan options', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
},
};
expect(agentRefreshOptionsForConfig(config)).toEqual({
throwOnError: true,
agentCliEnv: {
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
},
});
});
it('passes an empty CLI env object through agent rescan after fields are cleared', () => {
const config: AppConfig = {
...baseConfig,
mode: 'daemon',
agentCliEnv: {},
};
expect(agentRefreshOptionsForConfig(config)).toEqual({
throwOnError: true,
agentCliEnv: {},
});
});
});
describe('deriveComposioCredentialState', () => {
// Issue #741: when a Composio API key is already saved and the user
// starts typing a draft replacement, the saved-key indicator must
// stay visible. The previous code conflated `saved + draft` with
// `draft only` and made the badge vanish on the first keystroke.
it('returns "empty" when nothing is configured and the field is empty', () => {
expect(deriveComposioCredentialState({})).toBe('empty');
expect(deriveComposioCredentialState(null)).toBe('empty');
expect(deriveComposioCredentialState(undefined)).toBe('empty');
expect(deriveComposioCredentialState({ apiKey: '' })).toBe('empty');
expect(deriveComposioCredentialState({ apiKey: ' ' })).toBe('empty');
});
it('returns "saved" when a key is configured and no draft is being typed', () => {
expect(
deriveComposioCredentialState({ apiKeyConfigured: true }),
).toBe('saved');
expect(
deriveComposioCredentialState({ apiKey: '', apiKeyConfigured: true }),
).toBe('saved');
expect(
deriveComposioCredentialState({ apiKey: ' ', apiKeyConfigured: true }),
).toBe('saved');
});
it('returns "pending-new" when only a draft is being typed (no saved key)', () => {
expect(deriveComposioCredentialState({ apiKey: 'sk-draft' })).toBe('pending-new');
expect(
deriveComposioCredentialState({ apiKey: 'sk-draft', apiKeyConfigured: false }),
).toBe('pending-new');
});
it('returns "saved-pending" when a key is saved AND a draft is being typed', () => {
// Regression: this is the state that previously masqueraded as
// "pending-new" and made the saved-key badge disappear.
expect(
deriveComposioCredentialState({ apiKey: 'sk-replacement', apiKeyConfigured: true }),
).toBe('saved-pending');
});
it('treats whitespace-only drafts as no draft so the badge is anchored on "saved"', () => {
expect(
deriveComposioCredentialState({ apiKey: ' \t\n', apiKeyConfigured: true }),
).toBe('saved');
});
});
describe('SettingsDialog Orbit run behavior', () => {
it('keeps manual Orbit runs disabled while connector availability is still loading', () => {
expect(isOrbitRunDisabled(false, null)).toBe(true);
});
it('allows manual Orbit runs once loading finishes and a connector is available', () => {
expect(isOrbitRunDisabled(false, 1)).toBe(false);
});
it('persists the current orbit template config before starting the run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-1' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-1' });
expect(calls).toHaveLength(2);
expect(calls[0]).toMatchObject({
url: '/api/app-config',
method: 'PUT',
});
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
});
expect(calls[1]).toMatchObject({
url: '/api/orbit/run',
method: 'POST',
});
});
it('does not sync an unsaved Composio draft before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-3' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
composio: { apiKey: 'cmp_new_key', apiKeyConfigured: false },
mediaProviders: {
openai: { apiKey: 'media-key', baseUrl: '' },
},
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-3' });
expect(calls.map((call) => call.url)).toEqual([
'/api/media/config',
'/api/app-config',
'/api/orbit/run',
]);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({ force: false });
});
it('does not force an explicit empty media provider map before starting a manual Orbit run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/media/config') {
return new Response(null, { status: 204 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-4' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
mediaProviders: {},
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-template-1',
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-4' });
expect(calls.map((call) => call.url)).toEqual(['/api/media/config', '/api/app-config', '/api/orbit/run']);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
providers: {},
force: false,
});
});
it('does not start a manual Orbit run when saving app config fails', async () => {
const calls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push(url);
if (url === '/api/app-config') {
return new Response(null, { status: 500 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
composio: { apiKey: 'cmp_new_key', apiKeyConfigured: false },
}),
).rejects.toThrow('Failed to sync app config (500)');
expect(calls).toEqual(['/api/app-config']);
});
it('still starts a manual Orbit run when saving media credentials fails', async () => {
const calls: Array<{ url: string; method: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
calls.push({ url, method: init?.method ?? 'GET' });
if (url === '/api/media/config') {
return new Response(null, { status: 500 });
}
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-media-failed' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit({
...baseConfig,
mediaProviders: {
openai: { apiKey: 'media-key', baseUrl: '' },
},
}),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-media-failed' });
expect(calls).toEqual([
{ url: '/api/media/config', method: 'PUT' },
{ url: '/api/app-config', method: 'PUT' },
{ url: '/api/orbit/run', method: 'POST' },
]);
});
it('persists the displayed default template before starting a legacy null-template run', async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
const body = typeof init?.body === 'string' ? init.body : undefined;
calls.push({ url, method, body });
if (url === '/api/app-config') {
return new Response(null, { status: 204 });
}
if (url === '/api/orbit/run') {
return new Response(JSON.stringify({ projectId: 'orbit-project', agentRunId: 'run-2' }), { status: 200 });
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
await expect(
persistConfigAndRunOrbit(configForManualOrbitRun({
...baseConfig,
orbit: {
enabled: true,
time: '09:30',
templateSkillId: null,
},
})),
).resolves.toEqual({ projectId: 'orbit-project', agentRunId: 'run-2' });
expect(calls).toHaveLength(2);
expect(JSON.parse(calls[0]!.body ?? '{}')).toMatchObject({
orbit: {
enabled: true,
time: '09:30',
templateSkillId: 'orbit-general',
},
});
expect(calls[1]).toMatchObject({
url: '/api/orbit/run',
method: 'POST',
});
});
});
describe('shouldEnableSettingsSave', () => {
// Issue #739: when the user toggles BYOK on the execution section without
// filling required fields and then navigates to a different sidebar section
// (language, appearance, ...), the footer Save button must reflect the
// destination section's state, not the execution section's incomplete mode.
const validApiCfg: AppConfig = {
...baseConfig,
mode: 'api',
apiKey: 'sk-x',
model: 'claude-sonnet-4-5',
};
const incompleteApiCfg: AppConfig = {
...baseConfig,
mode: 'api',
apiKey: '', // user toggled BYOK but did not fill in fields
model: '',
};
const validDaemonCfg: AppConfig = {
...baseConfig,
mode: 'daemon',
agentId: 'claude-code',
};
const availableAgent = { id: 'claude-code', available: true };
const unavailableAgent = { id: 'claude-code', available: false };
it('returns true on any non-execution section regardless of mode completeness (the fix for #739)', () => {
// The exact scenario from the issue: incomplete BYOK on execution must
// not block save on language, appearance, composio, etc.
expect(shouldEnableSettingsSave(incompleteApiCfg, 'language', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'appearance', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'composio', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'media', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'integrations', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'notifications', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'pet', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'library', [availableAgent], true)).toBe(true);
expect(shouldEnableSettingsSave(incompleteApiCfg, 'about', [availableAgent], true)).toBe(true);
});
it('on execution + daemon: returns true only when an available agent is selected', () => {
expect(shouldEnableSettingsSave(validDaemonCfg, 'execution', [availableAgent], false)).toBe(true);
expect(
shouldEnableSettingsSave(
{ ...validDaemonCfg, agentId: null },
'execution',
[availableAgent],
false,
),
).toBe(false);
expect(shouldEnableSettingsSave(validDaemonCfg, 'execution', [unavailableAgent], false)).toBe(false);
expect(shouldEnableSettingsSave(validDaemonCfg, 'execution', [], false)).toBe(false);
});
it('on execution + api: returns true only when apiKey, model, and baseUrl are all valid', () => {
expect(shouldEnableSettingsSave(validApiCfg, 'execution', [], true)).toBe(true);
expect(shouldEnableSettingsSave({ ...validApiCfg, apiKey: '' }, 'execution', [], true)).toBe(false);
expect(shouldEnableSettingsSave({ ...validApiCfg, apiKey: ' ' }, 'execution', [], true)).toBe(false);
expect(shouldEnableSettingsSave({ ...validApiCfg, model: '' }, 'execution', [], true)).toBe(false);
expect(shouldEnableSettingsSave(validApiCfg, 'execution', [], false)).toBe(false);
});
it('on execution: incomplete BYOK still disables save (existing behavior preserved)', () => {
// Regression guard so that #739's fix only changes the cross-section
// behavior, not the within-execution-section validity check.
expect(shouldEnableSettingsSave(incompleteApiCfg, 'execution', [availableAgent], true)).toBe(false);
});
});
describe('sanitizeSettingsSavePayload', () => {
// Round-2 review on PR #827 (lefarcen + chatgpt-codex + mrcfps): enabling
// Save on non-execution sections is the right UX, but the click still
// calls onSave(cfg, ...) which writes the entire draft to localStorage.
// If the user toggled BYOK without filling apiKey/model and then saved an
// unrelated Language change, the broken execution mode would persist and
// leave the app unable to run queries. The sanitize helper reverts the
// execution-mode fields to `initial` in that exact case.
const initialDaemon: AppConfig = {
...baseConfig,
mode: 'daemon',
apiKey: 'pre-existing-key',
apiProtocol: 'anthropic',
apiVersion: '2024-01-01',
apiProviderBaseUrl: 'https://api.anthropic.com',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'claude-code',
agentCliEnv: { claude: { CLAUDE_CONFIG_DIR: '~/.claude' } },
maxTokens: 8000,
};
const draftWithIncompleteBYOK: AppConfig = {
...initialDaemon,
mode: 'api',
apiKey: '',
model: '',
// Simulate the user's Appearance change carrying through cfg too.
theme: 'dark',
};
const availableAgent = { id: 'claude-code', available: true };
it('reverts execution-mode fields to initial when saving from a non-execution section with incomplete BYOK', () => {
// The exact P1 from lefarcen + chatgpt-codex + mrcfps: persisting
// mode='api' with empty credentials must NOT happen when the user
// saves from a non-execution section.
const sanitized = sanitizeSettingsSavePayload(
draftWithIncompleteBYOK,
initialDaemon,
'language',
[availableAgent],
true,
);
// Execution-mode fields are restored from initial:
expect(sanitized.mode).toBe('daemon');
expect(sanitized.apiKey).toBe('pre-existing-key');
expect(sanitized.apiProtocol).toBe('anthropic');
expect(sanitized.apiVersion).toBe('2024-01-01');
expect(sanitized.apiProviderBaseUrl).toBe('https://api.anthropic.com');
expect(sanitized.baseUrl).toBe('https://api.anthropic.com');
expect(sanitized.model).toBe('claude-sonnet-4-5');
expect(sanitized.agentId).toBe('claude-code');
expect(sanitized.agentCliEnv).toEqual({ claude: { CLAUDE_CONFIG_DIR: '~/.claude' } });
expect(sanitized.maxTokens).toBe(8000);
// The non-execution change (theme) is preserved:
expect(sanitized.theme).toBe('dark');
});
it('passes the cfg through unchanged when execution config is already valid', () => {
// A user with a valid BYOK setup who navigates to a non-execution
// section and saves expects their pre-existing valid execution config
// AND their non-execution change to land. No reversion.
const validApiInitial: AppConfig = {
...baseConfig,
mode: 'api',
apiKey: 'sk-valid',
model: 'claude-sonnet-4-5',
baseUrl: 'https://api.anthropic.com',
};
const draftWithThemeChange: AppConfig = { ...validApiInitial, theme: 'light' };
const sanitized = sanitizeSettingsSavePayload(
draftWithThemeChange,
validApiInitial,
'appearance',
[availableAgent],
true,
);
expect(sanitized).toEqual(draftWithThemeChange);
});
it('passes the cfg through unchanged on the execution section itself', () => {
// Within the execution section, the canSave gate already blocks
// incomplete-BYOK saves, so we explicitly do NOT sanitize here:
// any draft the user CAN save from execution is one they intend to
// commit as a real execution-config change.
const sanitized = sanitizeSettingsSavePayload(
draftWithIncompleteBYOK,
initialDaemon,
'execution',
[availableAgent],
true,
);
expect(sanitized).toBe(draftWithIncompleteBYOK);
});
it('reverts on every non-execution section, not just language', () => {
// The fix must cover every sidebar section that does not own execution
// fields, otherwise a save from any one of them could leak the
// incomplete BYOK draft.
const sections: Array<Parameters<typeof sanitizeSettingsSavePayload>[2]> = [
'media',
'composio',
'integrations',
'language',
'appearance',
'notifications',
'pet',
'library',
'about',
];
for (const section of sections) {
const sanitized = sanitizeSettingsSavePayload(
draftWithIncompleteBYOK,
initialDaemon,
section,
[availableAgent],
true,
);
expect(sanitized.mode).toBe('daemon');
expect(sanitized.apiKey).toBe('pre-existing-key');
expect(sanitized.agentId).toBe('claude-code');
}
});
it('preserves the non-execution change even when the daemon agent is unavailable in the registry passed in', () => {
// Edge case: user originally had a valid daemon mode with available
// agent. They didn't touch execution. The agent later went unavailable
// (e.g., daemon offline). Saving an Appearance change should still
// preserve the user's existing daemon selection because the revert
// path uses initial as the source of truth, not the live agent registry.
const sanitized = sanitizeSettingsSavePayload(
{ ...initialDaemon, theme: 'system' },
initialDaemon,
'appearance',
[{ id: 'claude-code', available: false }],
true,
);
// Execution fields land equal to initial regardless of revert path,
// and the appearance change survives.
expect(sanitized.mode).toBe('daemon');
expect(sanitized.agentId).toBe('claude-code');
expect(sanitized.theme).toBe('system');
});
});