mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat: general-purpose skills with @-mention composition and user import
Lift skills from "one mode-bound skill per project" to a generic capability
the user can compose per turn:
- Daemon: scan multiple skill roots (user-skills under runtime data, then
the bundled `skills/`); user-imported skills can shadow built-ins by id.
- New `POST /api/skills/import` and `DELETE /api/skills/:id` endpoints,
with CONFLICT/BAD_REQUEST/NOT_FOUND error codes and built-in delete
protection.
- ChatRequest gains `skillIds: string[]`; the chat run concatenates each
picked skill's body (and merges craftRequires) into the system prompt
for that turn only — the project's persistent `skillId` is untouched.
- Web composer: `@` popover now lists skills alongside project files;
picks render as removable chips above the textarea and ride along with
the request as `skillIds`.
- Settings → Library: import form (name/description/triggers/body),
per-card delete for user skills, "user" origin badge.
* chore(web): drop welcome pet teaser + add ds→prompt-template mapping util
- SettingsDialog: remove the inline pet adoption teaser from the welcome
panel so the first-run modal stays focused on configuration.
- New `inferPromptTemplateCategoriesForDs(ds)` helper that maps a design
system's authored metadata to prompt-template gallery categories.
Imported by the design-system gallery wiring on a sibling branch; no
callers in this branch yet.
* feat: split skills/design-templates and add finalize-design API
Phase 0 of the skills/design-templates refactor (specs/current/
skills-and-design-templates.md):
- Move ~104 rendering catalogue entries from skills/ to design-templates/
and keep skills/ for the small set of functional skills that *do work*
on user input (utilities, briefs, packagers).
- Add design-templates/AGENTS.md and skills/AGENTS.md describing the
contract, and a brand-agnostic craft/ surface for opt-in craft rules.
- Daemon: add DESIGN_TEMPLATES_DIR / USER_DESIGN_TEMPLATES_DIR roots and
an /api/design-templates surface mirroring /api/skills. Asset/example
routes still span both registries so existing srcdoc URLs keep
resolving across the rename.
- Web: split LibrarySection into SkillsSection + DesignSystemsSection,
rename the EntryView "Examples" tab to "Templates", and update locales
+ the New-project picker accordingly.
Adds the finalize-design endpoint:
- New apps/daemon/src/finalize-design.ts and packages/contracts/src/api/
finalize.ts — one-shot synthesis of a project's transcript + active
design system + current artifact into <projectDir>/DESIGN.md via the
Anthropic Messages API. Per-project .finalize.lock mirrors the
transcript-export hygiene from PR #493; provider credentials are not
persisted by the daemon.
Other supporting changes:
- README + AGENTS.md updates to document the new directory split and
craft/ surface, plus i18n strings across 13 locales.
- Test refactors and new coverage (finalize-design, runs, sidecar
server, plus refreshed daemon integration tests).
- .gitignore: scope the *.exe ignore to /OpenDesign.exe so legitimate
vendor binaries are no longer hidden.
* fix(merge): move clinical-case-report to design-templates/
Origin/main added the clinical-case-report skill under skills/ before
the skills/design-templates split landed. Its od.mode is prototype, so
per specs/current/skills-and-design-templates.md it is a design template
and belongs alongside the other rendering catalogue entries — not under
the slimmed-down functional skills/ root. Moving it keeps the EntryView
Templates tab consistent with origin/main's intent.
* feat(skills): curated design/creative catalogue + collapsible Settings rows
Seed ~100 curated design/creative skill stubs under skills/ sourced from
awesome-claude-skills (ComposioHQ) and awesome-agent-skills (VoltAgent).
Each stub carries an od.category tag so the new filter pill row in
Settings -> Skills can group them. The seed script
(scripts/seed-curated-design-skills.ts, pnpm seed:curated-design-skills)
is idempotent: it only creates folders that don't already exist, so
hand-edited stubs are never overwritten.
- Daemon: parse and surface od.category on SkillInfo with a strict slug
normaliser; mirror the field on SkillSummary in @open-design/contracts.
Category is purely a UI hint — system-prompt composition is unchanged.
- Web: rewrite SkillsSection from a left-list / right-detail grid into a
vertical stack of collapsible rows mirroring the External MCP panel
(header always visible with name + mode/source/category pills + per-row
enable toggle; SKILL.md preview, file tree and inline edit form expand
on demand). Add a Category filter row above the list. Reorder Settings
nav so Skills + External MCP sit above the Composio/MCP cluster. Update
composer placeholder/hint across 17 locales to advertise '@ files or
skills · / for commands'.
- Docs: extend skills/AGENTS.md with the curated catalogue rules
(idempotency, category vocabulary, no upstream vendoring).
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(skills): teach localized-content + system-prompt tests about the skills/design-templates split
mrcfps blocking review on PR #955: the skills/design-templates split
(b5993385) moved ~110 SKILL.md entries out of `skills/` and into
`design-templates/`, but two repo-level tests still hard-coded the
single-root layout, so CI gates went red on the merged branch:
- `e2e/tests/localized-content.test.ts` only scanned `<repo>/skills`
while the locale `skillCopy` map keeps id-keyed entries spanning
both roots (ExamplesTab/Templates uses one lookup regardless of
origin). Teach the helper to read both `skills/` and
`design-templates/`, deduplicating ids so the union matches the
localized claim.
- `apps/daemon/tests/prompts/system.test.ts` read
`skills/live-artifact/SKILL.md`, which now lives under
`design-templates/live-artifact/`. Update the absolute path so
composeSystemPrompt's coverage of the live-artifact preamble is
exercised again.
Also enroll the curated design/creative catalogue (PR #955, ~91
stubs sourced from awesome-claude-skills / awesome-agent-skills) in
the DE / FR / RU `_SKILL_IDS_WITH_EN_FALLBACK` lists. The stubs are
English-only by design (frontmatter advertises an upstream URL); the
fallback list is exactly the place to acknowledge "we know this id
exists, English copy is fine here" so the localized-content coverage
gate passes without forcing a translation task per locale.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(skills): always quote frontmatter name so importUserSkill round-trips numeric / boolean ids
mrcfps PR #955 review: `buildSkillMarkdown` emitted `name:
${escapeYamlString(name)}` without quotes, so YAML coerced names
like `123`, `true`, `false`, or `null` into non-string scalars on
re-parse. listSkills() then read `data.name` as a number/boolean
and the import flow's follow-up `findSkillById(skills, result.id)`
missed it, falling into `/api/skills/import`'s "imported skill
could not be re-read" 500 path for those ids.
Switch the emitter to a quoted scalar (`name: "..."`) — the
double-escape already in `escapeYamlString` makes the quoted form
safe — and add a round-trip test covering `123`, `true`, `false`,
`null`, and `0` to lock in the contract.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): drop staged-skill chips when the matching @<id> token leaves the draft
mrcfps PR #955 review: `submit()` always forwarded every id in
`stagedSkills`, but that state was only mutated on picker click and
chip removal. Hand-deleting an `@<id>` token from the textarea left
the chip staged, so the request still carried `skillIds: [<id>]` and
the daemon composed a skill the prompt no longer referenced.
Sync the chips with the draft inside `handleChange()` by pruning
`stagedSkills` whenever the new value no longer contains the
`@<id>` token (using the same whitespace boundary as
`removeStagedSkill`'s strip regex). Comment explains why this
prune does not run for `staged` file attachments — users frequently
add files via the upload button without leaving an `@<path>` token,
so a symmetric prune there would erase legitimate uploads.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(daemon): stage @-composed skills' side files alongside the active skill
codex PR #955 review: composing a per-turn `@`-picked skill into the
system prompt appended its body (with the `withSkillRootPreamble`
guidance pointing at relative paths under `<cwd>/.od-skills/<folder>/`)
but never staged the actual folder. `startChatRun` only copied
`activeSkillDir`, so when the project's primary skill was different
(or absent) the composed skill's references/, examples/, and scripts/
files lived only at their absolute repo path — agents that honour
the cwd-relative form (or that don't get `--add-dir`, e.g. Codex with
allowlisted gpt-image projects) couldn't reach them.
Thread the composed skills' dirs out of `composeDaemonSystemPrompt`
as `extraSkillDirs` and stage each one through the same
`stageActiveSkill` API used for the primary skill. Dedupe by folder
basename so a project whose primary skill is also `@`-composed isn't
copied twice. Each preamble already advertises its own folder, so the
prompt and the staged tree stay aligned without further changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): respect the Library disable toggle in the project @-mention picker
codex PR #955 review: only `EntryView` received `enabledSkills`
(filtered against `config.disabledSkills`); active projects still
got `skills={skills}` raw, so a skill the user disabled in Settings
kept appearing in the project's `@`-mention popover and could ride
along to the daemon via `skillIds`. That broke the Library toggle
for any project opened on the post-split branch.
Compute a functional-skills-only enabled subset
(`enabledFunctionalSkills`) and pass it into `<ProjectView>` instead.
Templates stay separate — design-templates are filtered through their
own `enabledDesignTemplates` memo for the Templates gallery — so
ProjectView's chat composer still only sees skills, never templates,
matching the pre-split prop surface.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(e2e): mock /api/design-templates for example-use-prompt flow
The Templates tab in EntryView fetches from /api/design-templates after
the skills/design-templates split (specs/current/skills-and-design-templates.md).
The example-use-prompt Playwright scenario only mocked /api/skills, so the
gallery card never appeared and the test timed out waiting on
example-card-warm-utility-example. Serve the same fixture summary on both
endpoints so the templates gallery renders the card the test clicks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(tools-pack): create design-templates fixture for resources test
The packaging resources copy now bundles the new design-templates tree
alongside skills (see resources.ts BUNDLED_RESOURCE_TREES). The
copyBundledResourceTrees fixture only created skills, design-systems,
craft, etc., so the recursive copy crashed with ENOENT on
design-templates before it could check the prompt-templates assertion.
Add the missing fixture directory so the test exercises the same set
of resource trees the packaged build does.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(skills): clone built-in side files into the shadow on first edit
mrcfps PR #955 review: editing a built-in skill wrote a USER_SKILLS_DIR
shadow folder that contained only a new SKILL.md. The next listSkills()
pass surfaced the shadow as the active dir, but every side-file resolver
(/api/skills/:id/files, /example, /assets/*, the system-prompt preamble,
and the per-turn cwd staging) reads through skill.dir. With nothing but
SKILL.md in the shadow, the bundled assets/, references/, scripts/, and
examples/ disappeared the moment the user hit save — a built-in like
last30days or live-artifact would break immediately after edit instead
of just having its body overridden.
Teach updateUserSkill() to take a `sourceDir` and clone every entry
except SKILL.md / dotfiles into the shadow on the very first edit. The
shadow stays self-contained, so all the resolvers keep working without
fallback bookkeeping. Subsequent edits detect the existing shadow and
skip the clone, so user tweaks under the side tree survive a re-save.
Wire `sourceDir: skill.dir` from server.ts's PUT /api/skills/:id handler
and add two regression tests:
- 'clones built-in side files into the shadow on the first edit' walks
the file tree after save and asserts assets/template.html, references/
notes.md, and scripts/helper.sh all round-trip from the built-in.
- 'preserves user-edited side files on subsequent edits' edits the
staged assets/template.html, re-saves, and confirms the user content
is still there.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(e2e): rename home tab from Examples to Templates
The Examples tab was renamed to Templates in EntryView (b5993385's
skills/design-templates split — entry.tabExamples became entry.tabTemplates
and the tab value moved from 'examples' to 'templates'), but
entry-chrome-flows still asserted the old label and testId. Update both.
* fix(skills+web): preserve template body in API mode and dir-based skill delete
Two follow-ups from PR #955 review:
1. ProjectView only received `enabledFunctionalSkills`, but
`composedSystemPrompt()` still resolved `project.skillId` through that
prop and `fetchSkill()`. Projects created from the new
`/api/design-templates` surface keep a template id in `project.skillId`,
so opening one in API mode dropped the template body from the system
prompt and the upstream request ran without the project's primary
template instructions. Now ProjectView takes a separate
`designTemplates` prop (the unfiltered template list, so a
later-disabled template still loads for projects already created from
it) and `composedSystemPrompt()` plus the metadata / `isDeck` lookups
fall back to that list, with `fetchDesignTemplate()` as the body-fetch
fallback to `fetchSkill()`. The chat composer's `@`-picker keeps
receiving only the enabled functional skills.
2. `DELETE /api/skills/:id` used `deleteUserSkill(USER_SKILLS_DIR, skill.id)`
which re-slugified the frontmatter id and removed
`<userSkillsDir>/<slug>/`. That matched the import shape but missed the
install shape — `installFromTarget` writes the folder at
`sanitizeRepoName(url)` (GitHub) or `path.basename(realpath)` (local
symlink), neither of which is guaranteed to equal the slugified
frontmatter `name`. A duplicate `app.delete('/api/skills/:id', ...)`
handler at the install routes never fired because Express resolved the
earlier registration first, leaving the install/uninstall path without
working teardown. The handler now removes `skill.dir` (the absolute
path listSkills already discovered) under a USER_SKILLS_DIR safety
check, using `lstat` + `unlinkSync` so symlinked local installs unlink
cleanly without recursing into the user's source tree. The dead
duplicate handler is removed; `deleteUserSkill` is dropped from the
server.ts import set (still exported and unit-tested in skills.ts).
Regression coverage in `apps/daemon/tests/skills-delete-route.test.ts`
pins both shapes plus the symlink-preserves-source case.
* test(daemon): point hyperframes system-prompt test at design-templates
The merge with main brought in a hyperframes system-prompt test that
reads `skills/hyperframes/SKILL.md`, but this branch's split moved
`hyperframes` into `design-templates/` (same migration as `live-artifact`
already handled above in this file). CI was failing with ENOENT on the
old path.
---------
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 section', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('lists functional skills and filters them by mode + search', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'skills' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
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 detail panel and persists disabled skills from toggle switches', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'skills' },
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('blog-post')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByText('blog-post'));
|
||
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('shows an empty state when search matches nothing', async () => {
|
||
renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'skills' },
|
||
);
|
||
|
||
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 design systems section', () => {
|
||
afterEach(() => {
|
||
cleanup();
|
||
});
|
||
|
||
it('lists design systems and persists disabled selections from toggle switches', async () => {
|
||
const { onPersist } = renderSettingsDialog(
|
||
{ mode: 'daemon', agentId: 'codex' },
|
||
{ initialSection: 'designSystems' },
|
||
);
|
||
|
||
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'],
|
||
}),
|
||
{},
|
||
);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|