mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(memory): auto-memory store with chat-protocol-aware extraction
Markdown memory store at <dataDir>/memory/ with two extractors —
heuristic regex for explicit "remember:" / "我是 X" markers, and a
small-model LLM pass after each turn — folded into the system prompt
so cross-chat preferences, role, and ongoing-work context survive
restarts.
Settings UI:
- Memory tab lists entries, exposes a hand-edited MEMORY.md index, and
shows an extraction history with per-attempt phase/skip/failure rows.
- Memory model picker is inline next to the chat model picker (CLI and
BYOK) so the choice "which fast model mines facts each turn?" sits
next to the chat-model decision instead of a separate panel. The
picker reuses the same SUGGESTED_MODELS table and "Custom..." pattern
the chat picker uses.
LLM extractor supports all four protocols (anthropic / openai / azure /
google); pickProvider takes the chat agent id from the chat handler
and constrains its auto-pick to the chat's protocol family — Claude
Code chats no longer surprise users by silently extracting on whatever
OpenAI key happens to be in media-config. When no matching key is
configured the attempt records as 'skipped: no-provider' instead of
quietly switching vendors.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memory): keep hint outside <label> and disambiguate Model selectors
The inline Memory model picker wrapped its hint paragraph inside the
<label>, which made the hint's "API key" / "model" wording bleed into
the <select>'s accessible name and broke Playwright's getByLabel('API
key') / getByLabel('Model') strict-mode matching in the existing
settings-api-protocol e2e suite.
- Move the hint <p> out of the <label> in MemoryModelInline so the
select's accessible name is just "Memory model".
- Switch the chat-Model selectors in settings-api-protocol.test.ts from
getByLabel('Model') to getByRole('combobox', { name: 'Model', exact:
true }) so they no longer collide with the new "Memory model" select
that sits next to the chat Model picker.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memory): address review changes — BYOK wiring, MEMORY.md index, /v1, label wrapper
Addresses the four blocking review threads on PR #999.
1. MemoryModelInline accessibility (mrcfps)
The inline picker still wrapped its select + custom input + flash +
hint inside a single <label>, which made the select's accessible
name absorb every text descendant — including the "API key" / "model"
hint copy. The previous fix moved only the hint outside; the
reviewer asked for a non-label wrapper. Switch to <div className="field">
and associate just the short title with the controls via
`aria-labelledby` / `aria-label`. The select's accessible name is
now exactly "Memory model" so `getByLabel` strict-mode locators
on the surrounding chat form stop cross-matching the memory copy.
2. Respect the hand-edited MEMORY.md index (mrcfps + codex)
`composeMemoryBody()` was reading every *.md file in the memory
dir, ignoring the index. Removing a `- [Name](id.md)` line had no
effect on future prompts. Parse the index's `INDEX_LINK_RE` bullets
and filter `listMemoryEntries()` to the linked id set, so the
editor's "delete this line to disable injection" promise actually
holds.
3. Versioned OpenAI-compatible base URLs (codex)
`callOpenAI` and `callAnthropic` hard-coded `/v1` onto
`provider.baseUrl`, breaking custom endpoints whose saved URL
already includes `/v1` (`/v1/v1/chat/completions`). Apply the same
conditional `appendVersionedApiPath` helper the chat proxy and
connection-test routes already use.
4. Wire memory into BYOK / API-mode chats (mrcfps + codex)
The previous PR's daemon-only memory hook never fired for BYOK,
leaving the Memory tab + model picker as a no-op for that mode.
Add the missing surface and wire it through ProjectView:
- contracts: extend `composeSystemPrompt` with `memoryBody`,
mirroring the daemon's local composer; add
`MemorySystemPromptResponse` and the `attemptedLLM` flag on
`ExtractMemoryResponse`.
- daemon: expose `GET /api/memory/system-prompt` (returns the
composed body) and turn `POST /api/memory/extract` into a
two-phase endpoint — heuristic-only when only userMessage is
supplied (pre-turn), LLM-only when assistantMessage is also
supplied (post-turn), so the extraction-history doesn't double
up.
- web: ProjectView's BYOK branch now fetches the memory body
before composing the system prompt, runs the heuristic
extractor before the run (so "remember:" markers in this turn
reach this turn's prompt), accumulates assistant text during
streaming, and queues the LLM extractor on `onDone` — fire-and-
forget so it never blocks the chat round-trip.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memory): re-sync BYOK memory override when chat config drifts
The inline memory-model picker captured `apiProtocol` / `chatApiKey` /
`chatBaseUrl` / `chatApiVersion` into the saved override only at the
moment the user clicked a model. If they later swapped the BYOK
protocol tab, rotated the API key, or edited the base URL in the same
settings flow, the daemon's background extractor kept calling the
*old* vendor / credential — directly contradicting the picker's
"borrows the surrounding chat picker's protocol, key, base URL, and
api-version automatically" promise.
Add a debounced effect that compares the persisted (masked) shape
against the live chat props and re-PATCHes /api/memory/config when
they drift. The masked config exposes `apiKeyTail` (last 4 chars), so
key rotation is detectable without ever round-tripping the secret
back to the browser. The 300 ms debounce coalesces the keystroke-
granularity prop updates the parent settings dialog streams during
its autosave loop, so a user editing the base URL doesn't trigger one
PATCH per character. Background re-syncs are silent — the "Saved!"
flash only fires for explicit user clicks, so the picker doesn't feel
like it's fighting them as they edit unrelated chat fields.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memory): thread BYOK chat config through /api/memory/extract default path
Leaving the BYOK memory picker on "Same as chat" still broke the
default LLM extraction path: `MemoryModelInline` clears the override
for that option, both `/api/memory/extract` calls in `ProjectView`
only sent the messages, and the daemon never persists BYOK creds, so
`extractWithLLM(..., { chatAgentId: null })` always reached
`pickProvider()` with no chat context and fell through to env /
media-config — the wrong vendor for a BYOK chat that works for
inference.
Thread the live BYOK chat config through the extract endpoint as a
per-call snapshot:
- contracts: extend `ExtractMemoryRequest` with an optional
`chatProvider` (provider/apiKey/baseUrl/apiVersion/model) and add
`'chat-byok'` to the credentialSource enum.
- daemon: parse + validate `chatProvider` on `/api/memory/extract`
(provider must be one of the five known shapes) and forward to
`extractWithLLM` as a new option. `pickProvider()` gets a new
path 2 that uses the snapshot directly with the per-protocol
fast-model default — so a memory pass on `gpt-4o` / `claude-sonnet-4-5`
silently turns into a cheap `gpt-4o-mini` / `claude-haiku-4-5` call
instead of paying chat-tier rates for sediment work. Override and
CLI-agent-constrained paths still win when they apply.
- web: `ProjectView` snapshots `apiProtocol` / `apiKey` / `baseUrl` /
`apiVersion` from the live `AppConfig` on each BYOK extract call
(both pre-turn heuristic-only and post-turn LLM phases). The
picker's existing drift-resync effect already covers explicit
overrides; this snapshot covers the implicit "Same as chat"
default that the override flow can't reach.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(memory): treat empty apiKey on PATCH as a real clear
MemoryModelInline silently re-PATCHes /api/memory/config whenever the
surrounding BYOK chat creds drift. The previous reuse branch lumped
`apiKey === ''` together with `apiKey === undefined`, so clearing the
chat API key from the picker quietly preserved the old daemon-side
secret and kept calling the provider on a stale credential.
Distinguish four states for the apiKey field:
- absent -> preserve stored secret (form re-save without re-typing)
- '' -> clear stored secret (user removed it from the picker)
- 'sk-...' -> replace
- new provider -> ignore stored secret entirely
Add tests/memory-config-route.test.ts covering all four cases.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2133 lines
70 KiB
TypeScript
2133 lines
70 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { en } from '../../src/i18n/locales/en';
|
||
|
||
const {
|
||
playSoundMock,
|
||
requestNotificationPermissionMock,
|
||
showCompletionNotificationMock,
|
||
notificationPermissionMock,
|
||
fetchCodexPetsMock,
|
||
syncCommunityPetsMock,
|
||
fetchSkillsMock,
|
||
fetchDesignSystemsMock,
|
||
fetchSkillMock,
|
||
fetchDesignSystemMock,
|
||
fetchProviderModelsMock,
|
||
} = vi.hoisted(() => ({
|
||
playSoundMock: vi.fn(),
|
||
requestNotificationPermissionMock: vi.fn(),
|
||
showCompletionNotificationMock: vi.fn(),
|
||
notificationPermissionMock: vi.fn(),
|
||
fetchCodexPetsMock: vi.fn(),
|
||
syncCommunityPetsMock: vi.fn(),
|
||
fetchSkillsMock: vi.fn(),
|
||
fetchDesignSystemsMock: vi.fn(),
|
||
fetchSkillMock: vi.fn(),
|
||
fetchDesignSystemMock: vi.fn(),
|
||
fetchProviderModelsMock: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../../src/utils/notifications', async () => {
|
||
const actual = await vi.importActual<typeof import('../../src/utils/notifications')>(
|
||
'../../src/utils/notifications',
|
||
);
|
||
return {
|
||
...actual,
|
||
playSound: playSoundMock,
|
||
requestNotificationPermission: requestNotificationPermissionMock,
|
||
showCompletionNotification: showCompletionNotificationMock,
|
||
notificationPermission: notificationPermissionMock,
|
||
};
|
||
});
|
||
|
||
vi.mock('../../src/providers/registry', async () => {
|
||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||
'../../src/providers/registry',
|
||
);
|
||
return {
|
||
...actual,
|
||
fetchCodexPets: fetchCodexPetsMock,
|
||
syncCommunityPets: syncCommunityPetsMock,
|
||
fetchSkills: fetchSkillsMock,
|
||
fetchDesignSystems: fetchDesignSystemsMock,
|
||
fetchSkill: fetchSkillMock,
|
||
fetchDesignSystem: fetchDesignSystemMock,
|
||
codexPetSpritesheetUrl: (pet: { spritesheetUrl: string }) => pet.spritesheetUrl,
|
||
};
|
||
});
|
||
|
||
vi.mock('../../src/providers/provider-models', () => ({
|
||
fetchProviderModels: fetchProviderModelsMock,
|
||
}));
|
||
|
||
import { SettingsDialog } from '../../src/components/SettingsDialog';
|
||
import type { SettingsSection } from '../../src/components/SettingsDialog';
|
||
import { I18nProvider } from '../../src/i18n';
|
||
import { LOCALES } from '../../src/i18n/types';
|
||
import type { AgentInfo, AppConfig, AppVersionInfo } from '../../src/types';
|
||
|
||
const baseConfig: AppConfig = {
|
||
mode: 'api',
|
||
apiKey: '',
|
||
apiProtocol: 'anthropic',
|
||
apiVersion: '',
|
||
baseUrl: 'https://api.anthropic.com',
|
||
model: 'claude-sonnet-4-5',
|
||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||
apiProtocolConfigs: {},
|
||
agentId: null,
|
||
skillId: null,
|
||
designSystemId: null,
|
||
onboardingCompleted: true,
|
||
mediaProviders: {},
|
||
agentModels: {},
|
||
agentCliEnv: {},
|
||
};
|
||
|
||
const availableAgents: AgentInfo[] = [
|
||
{
|
||
id: 'codex',
|
||
name: 'Codex CLI',
|
||
bin: 'codex',
|
||
available: true,
|
||
version: '0.80.0',
|
||
models: [{ id: 'default', label: 'Default' }],
|
||
},
|
||
];
|
||
|
||
const sampleBundledPets = [
|
||
{
|
||
id: 'dario',
|
||
displayName: 'Dario',
|
||
description: 'A tiny frustrated companion.',
|
||
spritesheetUrl: '/api/codex-pets/dario.webp',
|
||
spritesheetExt: 'webp',
|
||
hatchedAt: 1710000000000,
|
||
bundled: true,
|
||
},
|
||
{
|
||
id: 'nyako',
|
||
displayName: 'Nyako',
|
||
description: 'A warm companion.',
|
||
spritesheetUrl: '/api/codex-pets/nyako.webp',
|
||
spritesheetExt: 'webp',
|
||
hatchedAt: 1710000001000,
|
||
bundled: true,
|
||
},
|
||
];
|
||
|
||
const sampleCommunityPets = [
|
||
{
|
||
id: 'jade',
|
||
displayName: 'Jade',
|
||
description: 'A cheerful explorer.',
|
||
spritesheetUrl: '/api/codex-pets/jade.webp',
|
||
spritesheetExt: 'webp',
|
||
hatchedAt: 1710000010000,
|
||
},
|
||
{
|
||
id: 'voidling',
|
||
displayName: 'Voidling',
|
||
description: 'A tiny grim companion.',
|
||
spritesheetUrl: '/api/codex-pets/voidling.webp',
|
||
spritesheetExt: 'webp',
|
||
hatchedAt: 1710000020000,
|
||
},
|
||
];
|
||
|
||
const sampleSkills = [
|
||
{
|
||
id: 'blog-post',
|
||
name: 'blog-post',
|
||
description: 'A long-form article / blog post.',
|
||
mode: 'prototype',
|
||
previewType: 'HTML',
|
||
},
|
||
{
|
||
id: 'dashboard',
|
||
name: 'dashboard',
|
||
description: 'Admin / analytics dashboard.',
|
||
mode: 'prototype',
|
||
previewType: 'HTML',
|
||
},
|
||
{
|
||
id: 'sales-deck',
|
||
name: 'sales-deck',
|
||
description: 'A narrative sales presentation.',
|
||
mode: 'deck',
|
||
previewType: 'PPTX',
|
||
},
|
||
];
|
||
|
||
const sampleDesignSystems = [
|
||
{
|
||
id: 'neutral-modern',
|
||
title: 'Neutral Modern',
|
||
summary: 'Calm editorial neutrals.',
|
||
category: 'Default',
|
||
swatches: ['#111827', '#f5f5f4'],
|
||
},
|
||
{
|
||
id: 'signal-green',
|
||
title: 'Signal Green',
|
||
summary: 'Brighter utility system.',
|
||
category: 'Experimental',
|
||
swatches: ['#14532d', '#86efac'],
|
||
},
|
||
];
|
||
|
||
function renderSettingsDialog(
|
||
initial: Partial<AppConfig> = {},
|
||
options: {
|
||
agents?: AgentInfo[];
|
||
daemonLive?: boolean;
|
||
onRefreshAgents?: ReturnType<typeof vi.fn>;
|
||
initialSection?: SettingsSection;
|
||
appVersionInfo?: AppVersionInfo | null;
|
||
} = {},
|
||
) {
|
||
const onPersist = vi.fn();
|
||
const onPersistComposioKey = vi.fn();
|
||
const onClose = vi.fn();
|
||
const onRefreshAgents = options.onRefreshAgents ?? vi.fn();
|
||
|
||
const view = render(
|
||
<SettingsDialog
|
||
initial={{ ...baseConfig, ...initial }}
|
||
agents={options.agents ?? availableAgents}
|
||
daemonLive={options.daemonLive ?? true}
|
||
appVersionInfo={options.appVersionInfo ?? null}
|
||
initialSection={options.initialSection ?? 'execution'}
|
||
onPersist={onPersist}
|
||
onPersistComposioKey={onPersistComposioKey}
|
||
onClose={onClose}
|
||
onRefreshAgents={onRefreshAgents}
|
||
/>,
|
||
);
|
||
|
||
return { onPersist, onPersistComposioKey, onClose, onRefreshAgents, ...view };
|
||
}
|
||
|
||
function renderLanguageSettingsDialog(initialLocale: Parameters<typeof I18nProvider>[0]['initial'] = 'en') {
|
||
const onPersist = vi.fn();
|
||
const onClose = vi.fn();
|
||
|
||
render(
|
||
<I18nProvider initial={initialLocale}>
|
||
<SettingsDialog
|
||
initial={baseConfig}
|
||
agents={availableAgents}
|
||
daemonLive={true}
|
||
appVersionInfo={null}
|
||
initialSection="language"
|
||
onPersist={onPersist}
|
||
onPersistComposioKey={vi.fn()}
|
||
onClose={onClose}
|
||
onRefreshAgents={vi.fn()}
|
||
/>
|
||
</I18nProvider>,
|
||
);
|
||
|
||
return { onPersist, onClose };
|
||
}
|
||
|
||
async function waitForPersist(
|
||
onPersist: ReturnType<typeof vi.fn>,
|
||
expectedConfig: unknown,
|
||
expectedOptions: { forceMediaProviderSync?: boolean } = { forceMediaProviderSync: false },
|
||
) {
|
||
await waitFor(() => {
|
||
expect(onPersist).toHaveBeenCalledWith(
|
||
expectedConfig,
|
||
expect.objectContaining(expectedOptions),
|
||
);
|
||
});
|
||
}
|
||
|
||
function deferred<T>() {
|
||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||
let reject!: (reason?: unknown) => void;
|
||
const promise = new Promise<T>((res, rej) => {
|
||
resolve = res;
|
||
reject = rej;
|
||
});
|
||
return { promise, resolve, reject };
|
||
}
|
||
|
||
beforeEach(() => {
|
||
playSoundMock.mockReset();
|
||
requestNotificationPermissionMock.mockReset();
|
||
showCompletionNotificationMock.mockReset();
|
||
notificationPermissionMock.mockReset();
|
||
fetchCodexPetsMock.mockReset();
|
||
syncCommunityPetsMock.mockReset();
|
||
fetchSkillsMock.mockReset();
|
||
fetchDesignSystemsMock.mockReset();
|
||
fetchSkillMock.mockReset();
|
||
fetchDesignSystemMock.mockReset();
|
||
fetchProviderModelsMock.mockReset();
|
||
notificationPermissionMock.mockReturnValue('default');
|
||
requestNotificationPermissionMock.mockResolvedValue('granted');
|
||
showCompletionNotificationMock.mockResolvedValue('shown');
|
||
fetchCodexPetsMock.mockResolvedValue({
|
||
pets: [],
|
||
rootDir: '/Users/test/.codex/pets',
|
||
});
|
||
syncCommunityPetsMock.mockResolvedValue({
|
||
wrote: 0,
|
||
skipped: 0,
|
||
failed: 0,
|
||
total: 0,
|
||
rootDir: '/Users/test/.codex/pets',
|
||
errors: [],
|
||
});
|
||
fetchSkillsMock.mockResolvedValue(sampleSkills);
|
||
fetchDesignSystemsMock.mockResolvedValue(sampleDesignSystems);
|
||
fetchSkillMock.mockImplementation(async (id: string) => ({
|
||
id,
|
||
body: `skill body for ${id}`,
|
||
}));
|
||
fetchDesignSystemMock.mockImplementation(async (id: string) => ({
|
||
id,
|
||
body: `design system body for ${id}`,
|
||
}));
|
||
fetchProviderModelsMock.mockResolvedValue({
|
||
ok: true,
|
||
kind: 'success',
|
||
latencyMs: 1,
|
||
models: [],
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog execution settings BYOK interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('renders BYOK protocol tabs and toggles API key visibility', () => {
|
||
renderSettingsDialog();
|
||
|
||
expect(screen.getByRole('tab', { name: 'Anthropic' }).getAttribute('aria-selected')).toBe('true');
|
||
expect(screen.getByRole('tab', { name: 'OpenAI' })).toBeTruthy();
|
||
expect(screen.getByRole('tab', { name: 'Azure OpenAI' })).toBeTruthy();
|
||
expect(screen.getByRole('tab', { name: 'Google Gemini' })).toBeTruthy();
|
||
expect(screen.getByLabelText('Quick fill provider')).toBeTruthy();
|
||
expect(screen.getByLabelText('Model')).toBeTruthy();
|
||
expect(screen.getByLabelText('Base URL')).toBeTruthy();
|
||
|
||
const apiKeyInput = screen.getByLabelText('API key') as HTMLInputElement;
|
||
expect(apiKeyInput.type).toBe('password');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Show' }));
|
||
expect(apiKeyInput.type).toBe('text');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Hide' }));
|
||
expect(apiKeyInput.type).toBe('password');
|
||
});
|
||
|
||
it('updates model and base URL when quick fill provider changes', () => {
|
||
renderSettingsDialog({ apiProtocol: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiProviderBaseUrl: 'https://api.openai.com/v1' });
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
fireEvent.change(screen.getByLabelText('Quick fill provider'), {
|
||
target: { value: '1' },
|
||
});
|
||
|
||
expect((screen.getByLabelText('Model') as HTMLSelectElement).value).toBe('deepseek-chat');
|
||
expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe('https://api.deepseek.com');
|
||
});
|
||
|
||
it('treats a manually edited base URL as a custom provider', () => {
|
||
renderSettingsDialog({ apiProtocol: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiProviderBaseUrl: 'https://api.openai.com/v1' });
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
const providerSelect = screen.getByLabelText('Quick fill provider') as HTMLSelectElement;
|
||
expect(providerSelect.value).toBe('0');
|
||
|
||
fireEvent.change(screen.getByLabelText('Base URL'), {
|
||
target: { value: 'https://my-proxy.example.com/v1' },
|
||
});
|
||
|
||
expect(providerSelect.value).toBe('');
|
||
expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe(
|
||
'https://my-proxy.example.com/v1',
|
||
);
|
||
});
|
||
|
||
it('keeps protocol drafts isolated without leaking API keys between tabs', () => {
|
||
renderSettingsDialog({ apiKey: 'anthropic-key' });
|
||
|
||
const apiKeyInput = screen.getByLabelText('API key') as HTMLInputElement;
|
||
expect(apiKeyInput.value).toBe('anthropic-key');
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('');
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'openai-key' },
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Anthropic' }));
|
||
expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('anthropic-key');
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('openai-key');
|
||
});
|
||
|
||
it('autosaves BYOK edits once required fields are valid', async () => {
|
||
const { onPersist } = renderSettingsDialog();
|
||
|
||
const baseUrlInput = screen.getByLabelText('Base URL') as HTMLInputElement;
|
||
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-test' },
|
||
});
|
||
|
||
fireEvent.change(baseUrlInput, {
|
||
target: { value: 'http://10.0.0.5:11434/v1' },
|
||
});
|
||
expect(screen.getByRole('alert').textContent).toContain(
|
||
'Enter a valid public http:// or https:// URL.',
|
||
);
|
||
|
||
fireEvent.change(baseUrlInput, {
|
||
target: { value: 'http://localhost:11434/v1' },
|
||
});
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mode: 'api',
|
||
apiProtocol: 'anthropic',
|
||
apiKey: 'sk-test',
|
||
baseUrl: 'http://localhost:11434/v1',
|
||
model: 'claude-sonnet-4-5',
|
||
apiProviderBaseUrl: null,
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('surfaces autosave progress, success, and failure states in the modal chrome', async () => {
|
||
const first = renderSettingsDialog();
|
||
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-saved' },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Saving…')).toBeTruthy();
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByText('All changes saved')).toBeTruthy();
|
||
});
|
||
expect(first.onPersist).toHaveBeenCalledWith(
|
||
expect.objectContaining({ apiKey: 'sk-saved' }),
|
||
expect.any(Object),
|
||
);
|
||
|
||
cleanup();
|
||
|
||
const second = renderSettingsDialog();
|
||
second.onPersist.mockRejectedValueOnce(new Error('daemon offline'));
|
||
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-error' },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Saving…')).toBeTruthy();
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Couldn’t save changes/i)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('closes BYOK via the close button or backdrop', () => {
|
||
const first = renderSettingsDialog();
|
||
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-unsaved' },
|
||
});
|
||
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
|
||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||
|
||
cleanup();
|
||
|
||
const second = renderSettingsDialog();
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-unsaved-2' },
|
||
});
|
||
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
|
||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('shows Azure-specific fields and autosaves an Azure config', async () => {
|
||
const { onPersist } = renderSettingsDialog();
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' }));
|
||
|
||
expect(screen.getByRole('heading', { name: 'Azure OpenAI' })).toBeTruthy();
|
||
expect(screen.getByLabelText('Deployment name')).toBeTruthy();
|
||
expect(screen.getByLabelText('API version')).toBeTruthy();
|
||
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'azure-key' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Deployment name'), {
|
||
target: { value: '__custom__' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Custom model id'), {
|
||
target: { value: 'deployment-one' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Base URL'), {
|
||
target: { value: 'https://example.openai.azure.com' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('API version'), {
|
||
target: { value: '2024-10-21' },
|
||
});
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mode: 'api',
|
||
apiProtocol: 'azure',
|
||
apiKey: 'azure-key',
|
||
model: 'deployment-one',
|
||
baseUrl: 'https://example.openai.azure.com',
|
||
apiVersion: '2024-10-21',
|
||
apiProviderBaseUrl: null,
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('enables model fetching only for supported BYOK provider drafts', () => {
|
||
renderSettingsDialog({
|
||
apiProtocol: 'openai',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-4o',
|
||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
const fetchButton = screen.getByRole('button', { name: 'Fetch models' }) as HTMLButtonElement;
|
||
expect(fetchButton.disabled).toBe(true);
|
||
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-openai' },
|
||
});
|
||
expect(fetchButton.disabled).toBe(false);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' }));
|
||
expect((screen.getByRole('button', { name: 'Fetch models' }) as HTMLButtonElement).disabled).toBe(true);
|
||
expect(screen.getByText(/Automatic deployment discovery is not available/)).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Ollama Cloud' }));
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'ollama-key' },
|
||
});
|
||
expect((screen.getByRole('button', { name: 'Fetch models' }) as HTMLButtonElement).disabled).toBe(true);
|
||
expect(screen.getByText('Model discovery is not available for this protocol.')).toBeTruthy();
|
||
});
|
||
|
||
it('fetches provider models, merges them into the picker, and preserves a custom current model', async () => {
|
||
fetchProviderModelsMock.mockResolvedValueOnce({
|
||
ok: true,
|
||
kind: 'success',
|
||
latencyMs: 12,
|
||
models: [
|
||
{ id: 'remote-alpha', label: 'Remote Alpha' },
|
||
{ id: 'gpt-4o', label: 'gpt-4o' },
|
||
],
|
||
});
|
||
renderSettingsDialog({
|
||
apiProtocol: 'openai',
|
||
apiKey: 'sk-openai',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'custom-still-here',
|
||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
expect((screen.getByLabelText('Custom model id') as HTMLInputElement).value).toBe('custom-still-here');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Fetch models' }));
|
||
|
||
expect(await screen.findByText('Fetched 2 models.')).toBeTruthy();
|
||
expect(fetchProviderModelsMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
protocol: 'openai',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
apiKey: 'sk-openai',
|
||
}),
|
||
expect.any(AbortSignal),
|
||
);
|
||
const select = screen.getByLabelText('Model') as HTMLSelectElement;
|
||
expect(Array.from(select.options).map((option) => option.value)).toEqual(
|
||
expect.arrayContaining(['remote-alpha', 'gpt-4o', '__custom__']),
|
||
);
|
||
expect(
|
||
Array.from(select.options).some((option) => option.textContent === 'Remote Alpha (remote-alpha)'),
|
||
).toBe(true);
|
||
expect((screen.getByLabelText('Custom model id') as HTMLInputElement).value).toBe('custom-still-here');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Fetch models' }));
|
||
expect(fetchProviderModelsMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('clears stale fetched-model status when provider fields change', async () => {
|
||
fetchProviderModelsMock.mockResolvedValueOnce({
|
||
ok: true,
|
||
kind: 'success',
|
||
latencyMs: 12,
|
||
models: [{ id: 'remote-alpha', label: 'Remote Alpha' }],
|
||
});
|
||
renderSettingsDialog({
|
||
apiProtocol: 'openai',
|
||
apiKey: 'sk-openai',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-4o',
|
||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
fireEvent.click(screen.getByRole('button', { name: 'Fetch models' }));
|
||
expect(await screen.findByText('Fetched 1 models.')).toBeTruthy();
|
||
|
||
fireEvent.change(screen.getByLabelText('Base URL'), {
|
||
target: { value: 'https://proxy.example.com/v1' },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByText('Fetched 1 models.')).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('renders provider model fetch failures inline', async () => {
|
||
fetchProviderModelsMock.mockResolvedValueOnce({
|
||
ok: false,
|
||
kind: 'auth_failed',
|
||
latencyMs: 12,
|
||
status: 401,
|
||
detail: 'bad key',
|
||
});
|
||
renderSettingsDialog({
|
||
apiProtocol: 'openai',
|
||
apiKey: 'sk-openai',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-4o',
|
||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
fireEvent.click(screen.getByRole('button', { name: 'Fetch models' }));
|
||
|
||
expect(await screen.findByText('Authentication failed. Check your API key.')).toBeTruthy();
|
||
});
|
||
|
||
it('supports custom model entry in BYOK mode', async () => {
|
||
const { onPersist } = renderSettingsDialog({ apiProtocol: 'openai', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiProviderBaseUrl: 'https://api.openai.com/v1' });
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
|
||
fireEvent.change(screen.getByLabelText('API key'), {
|
||
target: { value: 'sk-openai' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Model'), {
|
||
target: { value: '__custom__' },
|
||
});
|
||
|
||
const customModelInput = screen.getByLabelText('Custom model id') as HTMLInputElement;
|
||
expect(customModelInput).toBeTruthy();
|
||
fireEvent.change(customModelInput, {
|
||
target: { value: 'gpt-4.1-custom' },
|
||
});
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
apiProtocol: 'openai',
|
||
apiKey: 'sk-openai',
|
||
model: 'gpt-4.1-custom',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('runs the BYOK connection test only after required fields are present', async () => {
|
||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const url = input.toString();
|
||
// MemoryModelInline mounts inside the BYOK section and reads the
|
||
// current extraction override from /api/memory on mount. Swallow
|
||
// it here so the assertion below only counts the test-connection
|
||
// POST the user actually triggered.
|
||
if (url === '/api/memory') {
|
||
return new Response(
|
||
JSON.stringify({ enabled: true, memories: [], extraction: null }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
expect(url).toBe('/api/test/connection');
|
||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||
mode: 'provider',
|
||
protocol: 'anthropic',
|
||
apiKey: 'sk-test-provider',
|
||
baseUrl: 'https://api.anthropic.com',
|
||
model: 'claude-sonnet-4-5',
|
||
});
|
||
return new Response(
|
||
JSON.stringify({
|
||
ok: true,
|
||
kind: 'ok',
|
||
latencyMs: 42,
|
||
model: 'claude-sonnet-4-5',
|
||
sample: 'pong',
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
renderSettingsDialog({ apiKey: 'sk-test-provider' });
|
||
|
||
const testButton = screen.getByRole('button', { name: 'Test' }) as HTMLButtonElement;
|
||
expect(testButton.disabled).toBe(false);
|
||
|
||
fireEvent.click(testButton);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Testing connection…')).toBeTruthy();
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Connected\. Replied in 42 ms/)).toBeTruthy();
|
||
});
|
||
const testConnectionCalls = fetchMock.mock.calls.filter(
|
||
([input]) => input.toString() === '/api/test/connection',
|
||
);
|
||
expect(testConnectionCalls).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog execution settings Local CLI interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('lets users switch to Local CLI, select an installed agent, and autosave', async () => {
|
||
const installed = availableAgents[0]!;
|
||
const unavailable: AgentInfo = {
|
||
id: 'gemini',
|
||
name: 'Gemini CLI',
|
||
bin: 'gemini',
|
||
available: false,
|
||
version: null,
|
||
models: [],
|
||
installUrl: 'https://github.com/google-gemini/gemini-cli',
|
||
docsUrl: 'https://github.com/google-gemini/gemini-cli/blob/main/README.md',
|
||
};
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: null },
|
||
{ agents: [installed, unavailable] },
|
||
);
|
||
|
||
const localCliTab = screen.getByRole('tab', { name: /Local CLI.*1 installed/i });
|
||
fireEvent.click(localCliTab);
|
||
|
||
const codexCard = screen.getByRole('button', { name: /Codex CLI/i }) as HTMLButtonElement;
|
||
const geminiGroup = screen.getByRole('group', { name: /Gemini CLI/i });
|
||
expect(
|
||
(within(geminiGroup).getByRole('link', { name: en['settings.agentInstall.install'] }) as HTMLAnchorElement).getAttribute('href'),
|
||
).toBe(
|
||
'https://github.com/google-gemini/gemini-cli',
|
||
);
|
||
expect(
|
||
screen.getByText(en['settings.agentInstall.stepAuth']),
|
||
).toBeTruthy();
|
||
expect(
|
||
screen.getByText(en['settings.agentInstall.stepSelect']),
|
||
).toBeTruthy();
|
||
expect(screen.getByText(en['settings.agentInstall.pathHint'])).toBeTruthy();
|
||
|
||
fireEvent.click(codexCard);
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('shows an empty state when no local CLI agents are detected', () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: null },
|
||
{ agents: [] },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*0 installed/i }));
|
||
expect(screen.getByText(/No agents detected yet/i)).toBeTruthy();
|
||
});
|
||
|
||
it('shows rescan loading, avoids duplicate rescans, and renders the success notice', async () => {
|
||
const nextAgents: AgentInfo[] = [
|
||
availableAgents[0]!,
|
||
{
|
||
id: 'claude',
|
||
name: 'Claude Code',
|
||
bin: 'claude',
|
||
available: true,
|
||
version: '1.2.3',
|
||
models: [{ id: 'default', label: 'Default' }],
|
||
},
|
||
];
|
||
const pending = deferred<AgentInfo[]>();
|
||
const onRefreshAgents = vi.fn(() => pending.promise);
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ agents: availableAgents, onRefreshAgents },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
|
||
const rescanButton = screen.getByRole('button', { name: /Rescan|Scanning/i }) as HTMLButtonElement;
|
||
|
||
fireEvent.click(rescanButton);
|
||
expect(onRefreshAgents).toHaveBeenCalledTimes(1);
|
||
expect(onRefreshAgents).toHaveBeenCalledWith({
|
||
throwOnError: true,
|
||
agentCliEnv: {},
|
||
});
|
||
expect(rescanButton.disabled).toBe(true);
|
||
expect(screen.getByText('Scanning...')).toBeTruthy();
|
||
|
||
fireEvent.click(rescanButton);
|
||
expect(onRefreshAgents).toHaveBeenCalledTimes(1);
|
||
|
||
pending.resolve(nextAgents);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Scan complete. 2 available.')).toBeTruthy();
|
||
expect((screen.getByRole('button', { name: /Rescan/i }) as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
});
|
||
|
||
it('renders an error notice when rescan fails', async () => {
|
||
const onRefreshAgents = vi.fn(async () => {
|
||
throw new Error('boom');
|
||
});
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ agents: availableAgents, onRefreshAgents },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
|
||
fireEvent.click(screen.getByRole('button', { name: /Rescan/i }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Scan failed. Check the daemon and try again.')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('autosaves CLI config locations from the execution form', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ agents: availableAgents },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
|
||
|
||
fireEvent.change(screen.getByLabelText('Claude Code config directory'), {
|
||
target: { value: ' ~/.claude-qa ' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Codex home'), {
|
||
target: { value: ' ~/.codex-team ' },
|
||
});
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
agentCliEnv: {
|
||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-qa' },
|
||
codex: { CODEX_HOME: '~/.codex-team' },
|
||
},
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('disables Local CLI mode when the daemon is offline', () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'api' },
|
||
{ agents: availableAgents, daemonLive: false },
|
||
);
|
||
|
||
const localCliTab = screen.getByRole('tab', { name: /Local CLI.*daemon offline/i }) as HTMLButtonElement;
|
||
expect(localCliTab.disabled).toBe(true);
|
||
expect(localCliTab.getAttribute('title')).toBe('Daemon is not running');
|
||
expect(screen.getByRole('tab', { name: /BYOK.*API provider/i }).getAttribute('aria-selected')).toBe('true');
|
||
});
|
||
|
||
it('runs the Local CLI connection test for the selected installed agent', async () => {
|
||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
const url = input.toString();
|
||
// MemoryModelInline mounts inside the Local CLI section and reads
|
||
// the current extraction override from /api/memory on mount.
|
||
// Swallow it here so the assertion below only counts the
|
||
// test-connection POST the user actually triggered.
|
||
if (url === '/api/memory') {
|
||
return new Response(
|
||
JSON.stringify({ enabled: true, memories: [], extraction: null }),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
}
|
||
expect(url).toBe('/api/test/connection');
|
||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||
mode: 'agent',
|
||
agentId: 'codex',
|
||
agentCliEnv: {},
|
||
});
|
||
return new Response(
|
||
JSON.stringify({
|
||
ok: true,
|
||
kind: 'ok',
|
||
latencyMs: 31,
|
||
agentName: 'Codex CLI',
|
||
model: 'default',
|
||
sample: 'ready',
|
||
}),
|
||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||
);
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ agents: availableAgents },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
|
||
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Testing connection…')).toBeTruthy();
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Codex CLI replied in 31 ms/)).toBeTruthy();
|
||
});
|
||
const testConnectionCalls = fetchMock.mock.calls.filter(
|
||
([input]) => input.toString() === '/api/test/connection',
|
||
);
|
||
expect(testConnectionCalls).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog media providers interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('sorts configured providers ahead of unconfigured ones and shows configured badges', () => {
|
||
renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
mediaProviders: {
|
||
openai: { apiKey: 'sk-media', baseUrl: 'https://custom.openai.example/v1' },
|
||
minimax: { apiKey: 'mini-key', baseUrl: 'https://api.minimaxi.chat/v1' },
|
||
},
|
||
},
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
const names = Array.from(document.querySelectorAll('.media-provider-name')).map((node) =>
|
||
node.textContent?.trim(),
|
||
);
|
||
expect(names.slice(0, 2)).toEqual(['MiniMax', 'OpenAI']);
|
||
expect(screen.getAllByText('Configured').length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('renders unsupported providers as disabled rows', () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
expect(screen.getAllByText('Unsupported').length).toBeGreaterThan(0);
|
||
const bflApiKey = screen.getByLabelText('Black Forest Labs API key') as HTMLInputElement;
|
||
const bflBaseUrl = screen.getByLabelText('Black Forest Labs Base URL') as HTMLInputElement;
|
||
expect(bflApiKey.disabled).toBe(true);
|
||
expect(bflBaseUrl.disabled).toBe(true);
|
||
});
|
||
|
||
it('clears an existing provider config and removes it from the persisted payload', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
mediaProviders: {
|
||
openai: { apiKey: 'sk-media', baseUrl: 'https://custom.openai.example/v1' },
|
||
},
|
||
},
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
// Issue #737 added a window.confirm guard on the Clear button so a
|
||
// stray click cannot wipe a saved API key. Auto-accept the prompt
|
||
// here so the test still exercises the cleared-payload path.
|
||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||
|
||
const clearButtons = screen.getAllByRole('button', { name: 'Clear' });
|
||
fireEvent.click(clearButtons[0]!);
|
||
|
||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||
expect((screen.getByLabelText('OpenAI API key') as HTMLInputElement).value).toBe('');
|
||
expect((screen.getByLabelText('OpenAI Base URL') as HTMLInputElement).value).toBe('');
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mediaProviders: {},
|
||
}),
|
||
{ forceMediaProviderSync: true },
|
||
);
|
||
|
||
confirmSpy.mockRestore();
|
||
});
|
||
|
||
it('cancels Clear when the confirmation is dismissed (issue #737)', () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
mediaProviders: {
|
||
openai: { apiKey: 'sk-media', baseUrl: 'https://custom.openai.example/v1' },
|
||
},
|
||
},
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||
const clearButtons = screen.getAllByRole('button', { name: 'Clear' });
|
||
fireEvent.click(clearButtons[0]!);
|
||
|
||
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
||
// Saved key + base URL must stay intact when the user dismisses
|
||
// the confirmation; without this guard a fat-fingered click on
|
||
// Clear would silently wipe the key. Autosave should never fire
|
||
// because nothing changed.
|
||
expect((screen.getByLabelText('OpenAI API key') as HTMLInputElement).value).toBe('sk-media');
|
||
expect((screen.getByLabelText('OpenAI Base URL') as HTMLInputElement).value).toBe(
|
||
'https://custom.openai.example/v1',
|
||
);
|
||
expect(onPersist).not.toHaveBeenCalled();
|
||
|
||
confirmSpy.mockRestore();
|
||
});
|
||
|
||
it('supports persisting provider API key and base URL edits', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText('FishAudio API key'), {
|
||
target: { value: 'fish-key' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('FishAudio Base URL'), {
|
||
target: { value: 'https://fish.example.com' },
|
||
});
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mediaProviders: expect.objectContaining({
|
||
fishaudio: {
|
||
apiKey: 'fish-key',
|
||
baseUrl: 'https://fish.example.com',
|
||
model: '',
|
||
},
|
||
}),
|
||
}),
|
||
{ forceMediaProviderSync: true },
|
||
);
|
||
});
|
||
|
||
it('re-masks a replacement media provider API key until reveal is used again', () => {
|
||
renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
mediaProviders: {
|
||
openai: { apiKey: 'sk-media', baseUrl: 'https://api.openai.com/v1' },
|
||
},
|
||
},
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
const apiKeyInput = screen.getByLabelText('OpenAI API key') as HTMLInputElement;
|
||
expect(apiKeyInput.type).toBe('password');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'OpenAI Show key' }));
|
||
expect(apiKeyInput.type).toBe('text');
|
||
|
||
// Issue #737 added a window.confirm guard on Clear; jsdom's
|
||
// unimplemented confirm() returns undefined, which would cancel
|
||
// the clear and leave this test asserting the wrong reveal state.
|
||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||
fireEvent.click(screen.getAllByRole('button', { name: 'Clear' })[0]!);
|
||
expect(apiKeyInput.type).toBe('password');
|
||
|
||
fireEvent.change(apiKeyInput, { target: { value: 'sk-replacement' } });
|
||
expect(apiKeyInput.type).toBe('password');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'OpenAI Show key' }));
|
||
expect(apiKeyInput.type).toBe('text');
|
||
|
||
confirmSpy.mockRestore();
|
||
});
|
||
|
||
it('supports providers with a custom model override field', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText('Nano Banana API key'), {
|
||
target: { value: 'banana-key' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Nano Banana Base URL'), {
|
||
target: { value: 'https://gateway.example.com' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('Nano Banana model'), {
|
||
target: { value: 'gemini-3.1-flash-image-preview' },
|
||
});
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
mediaProviders: expect.objectContaining({
|
||
nanobanana: {
|
||
apiKey: 'banana-key',
|
||
baseUrl: 'https://gateway.example.com',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
},
|
||
}),
|
||
}),
|
||
{ forceMediaProviderSync: true },
|
||
);
|
||
});
|
||
|
||
it('catches unmount flush failures for pending media-provider autosaves', async () => {
|
||
const rejection = new Error('daemon unavailable');
|
||
const handleUnhandledRejection = vi.fn((event: PromiseRejectionEvent) => {
|
||
event.preventDefault();
|
||
});
|
||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||
|
||
try {
|
||
const { onPersist, unmount } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'media' },
|
||
);
|
||
onPersist.mockRejectedValueOnce(rejection);
|
||
|
||
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
|
||
target: { value: 'sk-unmount-media' },
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Saving…')).toBeTruthy();
|
||
});
|
||
unmount();
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
|
||
expect(onPersist).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
mediaProviders: expect.objectContaining({
|
||
openai: expect.objectContaining({ apiKey: 'sk-unmount-media' }),
|
||
}),
|
||
}),
|
||
expect.objectContaining({ forceMediaProviderSync: true }),
|
||
);
|
||
expect(handleUnhandledRejection).not.toHaveBeenCalled();
|
||
} finally {
|
||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||
}
|
||
});
|
||
|
||
it('closes media settings via the close button or backdrop', () => {
|
||
const first = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'media' },
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
|
||
target: { value: 'sk-unsaved-media' },
|
||
});
|
||
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
|
||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||
|
||
cleanup();
|
||
|
||
const second = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'media' },
|
||
);
|
||
fireEvent.change(screen.getByLabelText('OpenAI API key'), {
|
||
target: { value: 'sk-unsaved-media-2' },
|
||
});
|
||
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
|
||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog connectors interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('renders a saved Composio key state with masked tail and replacement guidance', () => {
|
||
renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
composio: {
|
||
apiKey: '',
|
||
apiKeyConfigured: true,
|
||
apiKeyTail: 'uQEg',
|
||
},
|
||
},
|
||
{ initialSection: 'composio' },
|
||
);
|
||
|
||
expect(screen.getAllByRole('heading', { name: 'Connectors' }).length).toBeGreaterThan(0);
|
||
expect(screen.getByText('Saved · ••••uQEg')).toBeTruthy();
|
||
expect((screen.getByPlaceholderText('Paste a new key to replace the saved one') as HTMLInputElement).value).toBe('');
|
||
expect(screen.getByText(/your key is saved in the local daemon/i)).toBeTruthy();
|
||
expect((screen.getByRole('button', { name: 'Clear' }) as HTMLButtonElement).disabled).toBe(false);
|
||
|
||
const getApiKeyLink = screen.getByRole('link', { name: /Get API Key/i }) as HTMLAnchorElement;
|
||
expect(getApiKeyLink.href).toBe('https://app.composio.dev/');
|
||
});
|
||
|
||
it('supports replacing a saved Composio key and saving the pending edit', async () => {
|
||
const { onPersistComposioKey } = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
composio: {
|
||
apiKey: '',
|
||
apiKeyConfigured: true,
|
||
apiKeyTail: 'uQEg',
|
||
},
|
||
},
|
||
{ initialSection: 'composio' },
|
||
);
|
||
|
||
fireEvent.change(screen.getByPlaceholderText('Paste a new key to replace the saved one'), {
|
||
target: { value: 'cmp_replacement_secret' },
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Save key' }));
|
||
await waitFor(() => {
|
||
expect(onPersistComposioKey).toHaveBeenCalledWith({
|
||
apiKey: 'cmp_replacement_secret',
|
||
apiKeyConfigured: true,
|
||
apiKeyTail: 'uQEg',
|
||
});
|
||
});
|
||
});
|
||
|
||
it('clears a saved Composio key from the payload', async () => {
|
||
const { onPersistComposioKey } = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
composio: {
|
||
apiKey: '',
|
||
apiKeyConfigured: true,
|
||
apiKeyTail: 'uQEg',
|
||
},
|
||
},
|
||
{ initialSection: 'composio' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
|
||
await waitFor(() => {
|
||
expect((screen.getByRole('button', { name: /hold on|disconnect/i }) as HTMLButtonElement).disabled).toBe(false);
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: /hold on|disconnect/i }));
|
||
|
||
await waitFor(() => {
|
||
expect(onPersistComposioKey).toHaveBeenCalledWith({
|
||
apiKey: '',
|
||
apiKeyConfigured: false,
|
||
apiKeyTail: '',
|
||
});
|
||
});
|
||
expect(screen.getByText(/keys are stored locally in the daemon/i)).toBeTruthy();
|
||
});
|
||
|
||
it('closes Composio settings via the close button or backdrop', () => {
|
||
const first = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
composio: {
|
||
apiKey: '',
|
||
apiKeyConfigured: true,
|
||
apiKeyTail: 'uQEg',
|
||
},
|
||
},
|
||
{ initialSection: 'composio' },
|
||
);
|
||
|
||
fireEvent.change(screen.getByPlaceholderText('Paste a new key to replace the saved one'), {
|
||
target: { value: 'cmp_unsaved_secret' },
|
||
});
|
||
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
|
||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||
|
||
cleanup();
|
||
|
||
const second = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
composio: {
|
||
apiKey: '',
|
||
apiKeyConfigured: true,
|
||
apiKeyTail: 'uQEg',
|
||
},
|
||
},
|
||
{ initialSection: 'composio' },
|
||
);
|
||
fireEvent.change(screen.getByPlaceholderText('Paste a new key to replace the saved one'), {
|
||
target: { value: 'cmp_unsaved_secret_2' },
|
||
});
|
||
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
|
||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog MCP server interactions', () => {
|
||
const installInfo = {
|
||
command: '/Applications/Open Design.app/Contents/Resources/open-design/bin/node',
|
||
args: [
|
||
'/Applications/Open Design.app/Contents/Resources/app/node_modules/@open-design/daemon/dist/cli.js',
|
||
'mcp',
|
||
'--daemon-url',
|
||
'http://127.0.0.1:51706',
|
||
],
|
||
daemonUrl: 'http://127.0.0.1:51706',
|
||
platform: 'darwin',
|
||
cliExists: true,
|
||
nodeExists: true,
|
||
buildHint: null,
|
||
};
|
||
|
||
let fetchMock: ReturnType<typeof vi.fn>;
|
||
let writeTextMock: ReturnType<typeof vi.fn>;
|
||
let originalClipboard: PropertyDescriptor | undefined;
|
||
|
||
beforeEach(() => {
|
||
fetchMock = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
json: async () => installInfo,
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||
writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: {
|
||
writeText: writeTextMock,
|
||
},
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.unstubAllGlobals();
|
||
if (originalClipboard) {
|
||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||
} else {
|
||
delete (navigator as { clipboard?: Clipboard }).clipboard;
|
||
}
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('renders the default Claude Code install snippet after fetching daemon install info', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'integrations' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(fetchMock).toHaveBeenCalledWith('/api/mcp/install-info');
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('heading', { level: 3, name: 'MCP server' })).toBeTruthy();
|
||
});
|
||
|
||
expect(screen.getByText(/Run this in your terminal/i)).toBeTruthy();
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/claude mcp add-json --scope user open-design/i)).toBeTruthy();
|
||
});
|
||
expect(screen.getByText(/Restart your client to pick up the new server/i)).toBeTruthy();
|
||
expect(screen.getByText(/Open Design must be running for MCP tool calls to succeed/i)).toBeTruthy();
|
||
});
|
||
|
||
it('switches client instructions and snippet content when a different MCP client is selected', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'integrations' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/claude mcp add-json --scope user open-design/i)).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /Claude Code/i }));
|
||
fireEvent.click(screen.getByRole('option', { name: /Codex/i }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/Append this table to ~\/\.codex\/config\.toml/i)).toBeTruthy();
|
||
});
|
||
expect(screen.getByText(/\[mcp_servers\.open-design\]/i)).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /Codex/i }));
|
||
fireEvent.click(screen.getByRole('option', { name: /Cursor/i }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('button', { name: /Install in Cursor/i })).toBeTruthy();
|
||
});
|
||
expect(screen.getByText(/merge this JSON into ~\/\.cursor\/mcp\.json/i)).toBeTruthy();
|
||
expect(screen.getByText(/"mcpServers"/i)).toBeTruthy();
|
||
});
|
||
|
||
it('copies the currently selected MCP snippet to the clipboard', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'integrations' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText(/claude mcp add-json --scope user open-design/i)).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Copy MCP configuration snippet' }));
|
||
|
||
await waitFor(() => {
|
||
expect(writeTextMock).toHaveBeenCalledWith(
|
||
expect.stringContaining("claude mcp add-json --scope user open-design"),
|
||
);
|
||
});
|
||
expect(screen.getByText('Copied')).toBeTruthy();
|
||
});
|
||
|
||
it('shows a daemon error state when install paths cannot be resolved', async () => {
|
||
fetchMock.mockRejectedValueOnce(new Error('network down'));
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'integrations' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
const errorCard = document.querySelector('.empty-card');
|
||
expect(errorCard?.textContent).toContain('reach the local daemon to resolve install paths');
|
||
});
|
||
expect(screen.getByText(/# resolving paths failed, see the error above/i)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog language interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
window.localStorage.removeItem('open-design:locale');
|
||
document.documentElement.removeAttribute('lang');
|
||
document.documentElement.removeAttribute('dir');
|
||
});
|
||
|
||
it('opens the language menu and marks the current locale as selected', async () => {
|
||
renderLanguageSettingsDialog('en');
|
||
|
||
const trigger = screen.getByRole('button', { name: /English/i });
|
||
fireEvent.click(trigger);
|
||
|
||
const options = await screen.findAllByRole('menuitemradio');
|
||
expect(options).toHaveLength(LOCALES.length);
|
||
expect(screen.getByRole('menuitemradio', { name: /English/i }).getAttribute('aria-checked')).toBe('true');
|
||
expect(screen.getByRole('menuitemradio', { name: /简体中文/i }).getAttribute('aria-checked')).toBe('false');
|
||
});
|
||
|
||
it('switches locale immediately, updates localStorage, and closes the menu', async () => {
|
||
renderLanguageSettingsDialog('en');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /简体中文/i }));
|
||
|
||
expect(screen.queryByRole('menu')).toBeNull();
|
||
expect(screen.getByRole('button', { name: /简体中文/i })).toBeTruthy();
|
||
expect(window.localStorage.getItem('open-design:locale')).toBe('zh-CN');
|
||
expect(document.documentElement.getAttribute('lang')).toBe('zh-CN');
|
||
expect(document.documentElement.getAttribute('dir')).toBe('ltr');
|
||
});
|
||
|
||
it('sets rtl direction for rtl locales and closes the menu on escape', async () => {
|
||
renderLanguageSettingsDialog('en');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||
fireEvent.keyDown(document, { key: 'Escape' });
|
||
await waitFor(() => {
|
||
expect(screen.queryByRole('menu')).toBeNull();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /فارسی/i }));
|
||
|
||
expect(window.localStorage.getItem('open-design:locale')).toBe('fa');
|
||
expect(document.documentElement.getAttribute('lang')).toBe('fa');
|
||
expect(document.documentElement.getAttribute('dir')).toBe('rtl');
|
||
});
|
||
|
||
it('does not route language changes through autosave and closing does not revert an applied locale', async () => {
|
||
const { onPersist, onClose } = renderLanguageSettingsDialog('en');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /Deutsch/i }));
|
||
|
||
expect(window.localStorage.getItem('open-design:locale')).toBe('de');
|
||
expect(document.documentElement.getAttribute('lang')).toBe('de');
|
||
|
||
fireEvent.click(screen.getByTitle(/close|schließen/i));
|
||
expect(onPersist).not.toHaveBeenCalled();
|
||
expect(onClose).toHaveBeenCalledTimes(1);
|
||
expect(window.localStorage.getItem('open-design:locale')).toBe('de');
|
||
expect(document.documentElement.getAttribute('lang')).toBe('de');
|
||
expect(document.documentElement.getAttribute('dir')).toBe('ltr');
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog notifications interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('renders notifications offline by default and only reveals sound pickers when enabled', () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'notifications' },
|
||
);
|
||
|
||
expect(screen.getByRole('group', { name: 'Completion sound' })).toBeTruthy();
|
||
expect(screen.getAllByRole('button', { name: 'offline' })[0]?.getAttribute('aria-pressed')).toBe('false');
|
||
expect(screen.queryByRole('group', { name: 'Success sound' })).toBeNull();
|
||
expect(screen.queryByRole('group', { name: 'Failure sound' })).toBeNull();
|
||
|
||
fireEvent.click(screen.getAllByRole('button', { name: 'offline' })[0] as HTMLButtonElement);
|
||
expect(playSoundMock).toHaveBeenCalledWith('ding');
|
||
expect(screen.getByRole('group', { name: 'Success sound' })).toBeTruthy();
|
||
expect(screen.getByRole('group', { name: 'Failure sound' })).toBeTruthy();
|
||
});
|
||
|
||
it('updates completion success and failure sounds and autosaves the edited notification config', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
notifications: {
|
||
soundEnabled: true,
|
||
successSoundId: 'chime',
|
||
failureSoundId: 'two-tone-down',
|
||
desktopEnabled: false,
|
||
},
|
||
},
|
||
{ initialSection: 'notifications' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Pluck' }));
|
||
fireEvent.click(screen.getByRole('button', { name: 'Thud' }));
|
||
|
||
expect(playSoundMock).toHaveBeenNthCalledWith(1, 'pluck');
|
||
expect(playSoundMock).toHaveBeenNthCalledWith(2, 'thud');
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
notifications: {
|
||
soundEnabled: true,
|
||
successSoundId: 'pluck',
|
||
failureSoundId: 'thud',
|
||
desktopEnabled: false,
|
||
},
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('enables desktop notifications after permission is granted and sends a test notification', async () => {
|
||
notificationPermissionMock.mockReturnValueOnce('default').mockReturnValue('granted');
|
||
requestNotificationPermissionMock.mockResolvedValue('granted');
|
||
showCompletionNotificationMock.mockResolvedValue('shown');
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'notifications' },
|
||
);
|
||
|
||
const desktopToggle = screen.getAllByRole('button', { name: 'offline' })[1] as HTMLButtonElement;
|
||
fireEvent.click(desktopToggle);
|
||
|
||
await waitFor(() => {
|
||
expect(requestNotificationPermissionMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
expect(screen.getByRole('button', { name: 'active' }).getAttribute('aria-pressed')).toBe('true');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Send test' }));
|
||
await waitFor(() => {
|
||
expect(showCompletionNotificationMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ status: 'succeeded' }),
|
||
);
|
||
});
|
||
expect(screen.getByText(/Test notification sent/i)).toBeTruthy();
|
||
});
|
||
|
||
it('shows a blocked hint and keeps desktop notifications disabled when permission is denied', async () => {
|
||
notificationPermissionMock.mockReturnValueOnce('default').mockReturnValue('denied');
|
||
requestNotificationPermissionMock.mockResolvedValue('denied');
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'notifications' },
|
||
);
|
||
|
||
const desktopToggle = screen.getAllByRole('button', { name: 'offline' })[1] as HTMLButtonElement;
|
||
fireEvent.click(desktopToggle);
|
||
|
||
await waitFor(() => {
|
||
expect(requestNotificationPermissionMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
expect(screen.getByText(/Notifications blocked by the browser/i)).toBeTruthy();
|
||
expect(screen.queryByRole('button', { name: 'Send test' })).toBeNull();
|
||
});
|
||
|
||
it('closes notification settings via the close button or backdrop', () => {
|
||
const first = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'notifications' },
|
||
);
|
||
|
||
fireEvent.click(screen.getAllByRole('button', { name: 'offline' })[0] as HTMLButtonElement);
|
||
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
|
||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||
|
||
cleanup();
|
||
|
||
const second = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'notifications' },
|
||
);
|
||
fireEvent.click(screen.getAllByRole('button', { name: 'offline' })[0] as HTMLButtonElement);
|
||
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
|
||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog appearance interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
document.documentElement.removeAttribute('data-theme');
|
||
document.documentElement.style.removeProperty('--accent');
|
||
document.documentElement.style.removeProperty('--accent-strong');
|
||
document.documentElement.style.removeProperty('--accent-soft');
|
||
document.documentElement.style.removeProperty('--accent-tint');
|
||
document.documentElement.style.removeProperty('--accent-hover');
|
||
});
|
||
|
||
it('treats System as the selected appearance mode when theme is unset or system', () => {
|
||
renderSettingsDialog(
|
||
{ theme: 'system' },
|
||
{ initialSection: 'appearance' },
|
||
);
|
||
|
||
expect(screen.getByRole('button', { name: 'System' }).getAttribute('aria-pressed')).toBe('true');
|
||
expect(screen.getByRole('button', { name: 'Light' }).getAttribute('aria-pressed')).toBe('false');
|
||
expect(screen.getByRole('button', { name: 'Dark' }).getAttribute('aria-pressed')).toBe('false');
|
||
});
|
||
|
||
it('live previews explicit themes and removes the explicit document theme when switching back to System', () => {
|
||
renderSettingsDialog(
|
||
{ theme: 'dark' },
|
||
{ initialSection: 'appearance' },
|
||
);
|
||
|
||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Light' }));
|
||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'System' }));
|
||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||
});
|
||
|
||
it('reverts an unsaved appearance preview back to the saved theme when the dialog closes', () => {
|
||
const first = renderSettingsDialog(
|
||
{ theme: 'dark' },
|
||
{ initialSection: 'appearance' },
|
||
);
|
||
|
||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Light' }));
|
||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
|
||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||
|
||
first.unmount();
|
||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||
});
|
||
|
||
it('persists System mode explicitly and preserves accent variables without an explicit document theme', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex', theme: 'dark', accentColor: '#2563eb' },
|
||
{ initialSection: 'appearance' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'System' }));
|
||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#2563eb');
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
theme: 'system',
|
||
accentColor: '#2563eb',
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('live previews and autosaves preset and custom accent colors', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex', theme: 'light' },
|
||
{ initialSection: 'appearance' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('radio', { name: '#059669' }));
|
||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#059669');
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
accentColor: '#059669',
|
||
}),
|
||
{},
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText('Custom accent color'), {
|
||
target: { value: '#123456' },
|
||
});
|
||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#123456');
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
accentColor: '#123456',
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog pets interactions', () => {
|
||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(window.navigator, 'clipboard');
|
||
|
||
afterEach(() => {
|
||
if (clipboardDescriptor) {
|
||
Object.defineProperty(window.navigator, 'clipboard', clipboardDescriptor);
|
||
} else {
|
||
Reflect.deleteProperty(window.navigator, 'clipboard');
|
||
}
|
||
cleanup();
|
||
});
|
||
|
||
it('renders bundled pets by default and exposes community pets in a separate tab', async () => {
|
||
fetchCodexPetsMock.mockResolvedValue({
|
||
pets: [...sampleBundledPets, ...sampleCommunityPets],
|
||
rootDir: '/Users/test/.codex/pets',
|
||
});
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'pet' },
|
||
);
|
||
|
||
expect((screen.getByRole('button', { name: 'Wake' }) as HTMLButtonElement).disabled).toBe(true);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Dario')).toBeTruthy();
|
||
expect(screen.getByText('Nyako')).toBeTruthy();
|
||
});
|
||
expect(screen.queryByText('Jade')).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Community' }));
|
||
expect(screen.getByText('Recently hatched')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: 'Download community pets' })).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeTruthy();
|
||
expect(screen.getByText('Jade')).toBeTruthy();
|
||
expect(screen.getByText('Voidling')).toBeTruthy();
|
||
});
|
||
|
||
it('supports editing and persisting a custom pet', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'pet' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Custom' }));
|
||
|
||
fireEvent.change(screen.getByDisplayValue('Buddy'), {
|
||
target: { value: 'Scout' },
|
||
});
|
||
fireEvent.change(screen.getByDisplayValue('🦄'), {
|
||
target: { value: '🤖' },
|
||
});
|
||
fireEvent.change(screen.getByDisplayValue('Hi! I am here whenever you need me.'), {
|
||
target: { value: 'Hi there, builder.' },
|
||
});
|
||
fireEvent.click(document.querySelector('.pet-swatch[title="#2348b8"]') as HTMLElement);
|
||
|
||
expect(screen.getAllByText('Scout').length).toBeGreaterThan(0);
|
||
expect(screen.getByText('Hi there, builder.')).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Use my pet' }));
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
pet: expect.objectContaining({
|
||
adopted: true,
|
||
enabled: true,
|
||
petId: 'custom',
|
||
custom: expect.objectContaining({
|
||
name: 'Scout',
|
||
glyph: '🤖',
|
||
greeting: 'Hi there, builder.',
|
||
accent: '#2348b8',
|
||
}),
|
||
}),
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('toggles an adopted pet between tucked and awake states', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{
|
||
mode: 'daemon',
|
||
agentId: 'codex',
|
||
pet: {
|
||
adopted: true,
|
||
enabled: true,
|
||
petId: 'custom',
|
||
custom: {
|
||
name: 'Buddy',
|
||
glyph: '🦄',
|
||
accent: '#c96442',
|
||
greeting: 'Hi! I am here whenever you need me.',
|
||
},
|
||
},
|
||
},
|
||
{ initialSection: 'pet' },
|
||
);
|
||
|
||
const toggle = screen.getByRole('button', { name: 'Tuck away' });
|
||
fireEvent.click(toggle);
|
||
expect(screen.getByRole('button', { name: 'Wake' })).toBeTruthy();
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
pet: expect.objectContaining({
|
||
adopted: true,
|
||
enabled: false,
|
||
}),
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('refreshes and syncs community pets with inline status feedback', async () => {
|
||
fetchCodexPetsMock.mockResolvedValue({
|
||
pets: sampleCommunityPets,
|
||
rootDir: '/Users/test/.codex/pets',
|
||
});
|
||
syncCommunityPetsMock.mockResolvedValue({
|
||
wrote: 2,
|
||
skipped: 1,
|
||
failed: 0,
|
||
total: 5,
|
||
rootDir: '/Users/test/.codex/pets',
|
||
errors: [],
|
||
});
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'pet' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Community' }));
|
||
await waitFor(() => {
|
||
expect(fetchCodexPetsMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
||
await waitFor(() => {
|
||
expect(fetchCodexPetsMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Download community pets' }));
|
||
await waitFor(() => {
|
||
expect(syncCommunityPetsMock).toHaveBeenCalledTimes(1);
|
||
expect(fetchCodexPetsMock).toHaveBeenCalledTimes(3);
|
||
expect(screen.getByText('Synced 2 new pets (5 total).')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('copies the hatch prompt with the current concept', async () => {
|
||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||
Object.defineProperty(window.navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: { writeText },
|
||
});
|
||
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'pet' },
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: 'Community' }));
|
||
fireEvent.change(screen.getByLabelText('Pet concept (optional)'), {
|
||
target: { value: 'a tiny pixel-art bee in a cozy sweater' },
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: 'Copy prompt' }));
|
||
|
||
await waitFor(() => {
|
||
expect(writeText).toHaveBeenCalledWith(
|
||
expect.stringContaining('Concept: a tiny pixel-art bee in a cozy sweater.'),
|
||
);
|
||
expect(writeText).toHaveBeenCalledWith(
|
||
expect.stringContaining('Use the @hatch-pet skill end-to-end:'),
|
||
);
|
||
expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy();
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog skills and design systems interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('renders the skills library by default and filters by mode and search', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'library' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('tab', { name: /Skills3/i })).toBeTruthy();
|
||
expect(screen.getByText('blog-post')).toBeTruthy();
|
||
expect(screen.getByText('sales-deck')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /deck1/i }));
|
||
expect(screen.queryByText('blog-post')).toBeNull();
|
||
expect(screen.getByText('sales-deck')).toBeTruthy();
|
||
|
||
fireEvent.change(screen.getByPlaceholderText('Search...'), {
|
||
target: { value: 'sales' },
|
||
});
|
||
expect(screen.getByText('sales-deck')).toBeTruthy();
|
||
expect(screen.queryByText('dashboard')).toBeNull();
|
||
});
|
||
|
||
it('opens a skill preview and persists disabled skills from toggle switches', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'library' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('blog-post')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getAllByTitle('Preview')[0] as HTMLElement);
|
||
await waitFor(() => {
|
||
expect(fetchSkillMock).toHaveBeenCalledWith('blog-post');
|
||
expect(screen.getByText('skill body for blog-post')).toBeTruthy();
|
||
});
|
||
|
||
const toggles = screen.getAllByTitle('Toggle');
|
||
fireEvent.click(toggles[0] as HTMLElement);
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
disabledSkills: ['blog-post'],
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('switches to design systems, previews details, and persists disabled design systems', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'library' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('tab', { name: /Design Systems2/i })).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('tab', { name: /Design Systems2/i }));
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Neutral Modern')).toBeTruthy();
|
||
expect(screen.getByText('Signal Green')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: /Experimental1/i }));
|
||
expect(screen.queryByText('Neutral Modern')).toBeNull();
|
||
expect(screen.getByText('Signal Green')).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByText('Signal Green'));
|
||
await waitFor(() => {
|
||
expect(fetchDesignSystemMock).toHaveBeenCalledWith('signal-green');
|
||
expect(screen.getByText('design system body for signal-green')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getAllByTitle('Toggle')[0] as HTMLElement);
|
||
|
||
await waitForPersist(
|
||
onPersist,
|
||
expect.objectContaining({
|
||
disabledDesignSystems: ['signal-green'],
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
|
||
it('shows an empty state when library search returns no results', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'library' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('blog-post')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.change(screen.getByPlaceholderText('Search...'), {
|
||
target: { value: 'zzz-no-match' },
|
||
});
|
||
expect(screen.getByText('No items match your search.')).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe('SettingsDialog about interactions', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('renders app version and runtime details when version info is available', () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{
|
||
initialSection: 'about',
|
||
appVersionInfo: {
|
||
version: '0.4.1',
|
||
channel: 'beta',
|
||
packaged: true,
|
||
platform: 'darwin',
|
||
arch: 'arm64',
|
||
},
|
||
},
|
||
);
|
||
|
||
expect(screen.getByRole('heading', { level: 3, name: 'About' })).toBeTruthy();
|
||
expect(screen.getByText('Version')).toBeTruthy();
|
||
expect(screen.getByText('0.4.1')).toBeTruthy();
|
||
expect(screen.getByText('Channel')).toBeTruthy();
|
||
expect(screen.getByText('beta')).toBeTruthy();
|
||
expect(screen.getByText('Runtime')).toBeTruthy();
|
||
expect(screen.getByText('Packaged app')).toBeTruthy();
|
||
expect(screen.getByText('Platform')).toBeTruthy();
|
||
expect(screen.getByText('darwin')).toBeTruthy();
|
||
expect(screen.getByText('Architecture')).toBeTruthy();
|
||
expect(screen.getByText('arm64')).toBeTruthy();
|
||
});
|
||
|
||
it('renders the unavailable fallback when app version info is missing', () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'about', appVersionInfo: null },
|
||
);
|
||
|
||
expect(
|
||
screen.getByText(/Version details are unavailable while the daemon is offline\./i),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('does not create dirty state on the about page', () => {
|
||
const first = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{
|
||
initialSection: 'about',
|
||
appVersionInfo: {
|
||
version: '0.4.1',
|
||
channel: 'beta',
|
||
packaged: false,
|
||
platform: 'linux',
|
||
arch: 'x64',
|
||
},
|
||
},
|
||
);
|
||
|
||
fireEvent.click(first.container.querySelector('.settings-close') as HTMLElement);
|
||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||
|
||
cleanup();
|
||
|
||
const second = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{
|
||
initialSection: 'about',
|
||
appVersionInfo: {
|
||
version: '0.4.1',
|
||
channel: 'beta',
|
||
packaged: false,
|
||
platform: 'linux',
|
||
arch: 'x64',
|
||
},
|
||
},
|
||
);
|
||
|
||
fireEvent.click(document.querySelector('.modal-backdrop') as HTMLElement);
|
||
expect(second.onClose).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|