open-design/apps/web/tests/components/SettingsDialog.execution.test.tsx
lefarcen df8a0faff6
feat(runtimes): register AMR (vela) as an ACP stdio agent (#2355)
* feat(runtimes): register AMR (vela) as an ACP stdio agent

AMR is the vela CLI's ACP runtime mode. `vela agent run --runtime opencode`
speaks ACP JSON-RPC over stdio (see vela's
`specs/current/runtime/manual-agent-run-openrouter.md`); per
`docs/new-agent-runtime-acp.md` we expose it through the same `streamFormat:
'acp-json-rpc'` transport that already powers Hermes, Devin, Kimi, etc.

The new `defs/amr.ts` is the entire wiring — `buildArgs` returns
`['agent', 'run', '--runtime', 'opencode']`, `fetchModels` reuses
`detectAcpModels`, and the fallback list seeds the OpenRouter ids vela's
e2e baseline uses. `executables.ts`/`app-config.ts`/`metadata.ts` get the
matching `VELA_BIN`/`VELA_LINK_URL`/`VELA_RUNTIME_KEY`/`VELA_OPENCODE_BIN`
allowlist + install/docs URLs, so users can configure the per-agent env in
Settings without leaking into other adapters.

Coverage: `tests/fixtures/fake-vela.mjs` is a minimal ACP stub that returns
the documented `initialize` / `session/new` / `session/set_model` /
`session/prompt` shapes; `tests/amr-acp-integration.test.ts` spawns it via
`child_process.spawn` and drives a full turn through `attachAcpSession` and
`detectAcpModels`, so the ACP transport contract for AMR is end-to-end
verified locally even before a real `vela` binary is installed.

Validated:
- pnpm guard
- pnpm typecheck (all workspace projects)
- pnpm --filter @open-design/daemon test (2881/2881)

Deferred: real OpenRouter-backed turn through a built `vela` binary —
the runtime def needs no changes for that path, only `VELA_RUNTIME_KEY`
and `VELA_LINK_URL` in env (or Settings).

* fix(runtimes/amr): pin a concrete default model and bare openai ids

End-to-end validation against a freshly-built `vela` (nexu-io/vela@main)
+ OpenRouter surfaced two contract details the first AMR runtime def
got wrong:

1. vela rejects `session/prompt` with `session/set_model must be called
   before session/prompt`. attachAcpSession in apps/daemon/src/acp.ts
   skips set_model whenever the picked model is the synthetic 'default'
   id, so AMR's fallback list must NOT include DEFAULT_MODEL_OPTION. The
   def now ships a concrete `gpt-5.4-mini` as both `fetchModels`'
   default option and `fallbackModels[0]`, which makes attachAcpSession
   always send a real `session/set_model` for AMR turns.

2. `vela --runtime opencode` auto-prepends `openai/` to whatever modelId
   it forwards to opencode's openai provider. With OpenRouter-style ids
   like `openai/gpt-5.4-mini`, opencode receives the double-prefixed
   `openai/openai/gpt-5.4-mini` and replies `ProviderModelNotFoundError`.
   The new fallback list ships the bare ids opencode's openai registry
   actually knows about (gpt-5.4, gpt-5.4-mini, gpt-5.4-fast, etc.).

Stub + tests:
- tests/fixtures/fake-vela.mjs now enforces the set_model gate the same
  way real vela does, so a regression that silently goes back to
  model: 'default' would surface as a fatal error in tests instead of a
  hidden production failure.
- tests/amr-acp-integration.test.ts pins both contracts: no 'default' /
  no 'openai/' prefix in fallbackModels, and a negative case that
  asserts session/prompt fails when no model is set.

Adds `apps/daemon/scripts/verify-amr-real-vela.mjs` — a small dev-time
runner that drives `attachAcpSession` against a real `vela` binary and
prints the daemon's chat events, so future protocol drift can be checked
against an actual OpenRouter call.

Verified locally: `vela agent run --runtime opencode` + OpenRouter
returns the prompted string ("AMR-E2E-PASS") through the full daemon
pipeline; daemon test suite stays 2883/2883.

* fix(runtimes/amr): substitute concrete model when chat run sends 'default'

A plugin-driven AMR run from the UI surfaced a real-world hole in the
prior commit:

  json-rpc id 3: session/set_model must be called before session/prompt

The Default-design-router plugin (and any caller that doesn't pin a
real model) sends `model: 'default'` straight through, which the AMR
runtime def cannot accept — vela rejects `session/prompt` without
`session/set_model` and attachAcpSession skips set_model whenever
model === 'default'. Just leaving DEFAULT_MODEL_OPTION out of the
adapter's `fallbackModels` is not enough: the chat-run handler in
server.ts still forwarded 'default' verbatim.

This adds `resolveModelForAgent(def, resolved, env?)` as the
single source of truth for the substitution:

  1. If the caller picked a real id, pass it through.
  2. Else, if `def.defaultModelEnvVar` is set and the daemon process
     env has a non-empty value for it, return that (operator escape
     hatch — see below).
  3. Else, if the def's `fallbackModels` does NOT contain a 'default'
     id, return `fallbackModels[0].id`.
  4. Else, return the original value (the historic shape — defs that
     list 'default' themselves are untouched).

AMR sets `defaultModelEnvVar: 'VELA_DEFAULT_MODEL'`, so when
opencode's openai-provider registry deprecates `gpt-5.4-mini`
upstream, an operator can swap the fallback id without a code change
by exporting `VELA_DEFAULT_MODEL=gpt-5.5` before launching tools-dev
/ od. Worth noting the env var must live in the daemon's `process.env`
(Settings-UI per-agent env values only reach the spawned child, not
the daemon's resolver) — the new field's docblock spells this out.

Coverage:
- `tests/runtimes/resolve-model.test.ts` — 8 unit tests covering all
  four resolver branches plus the env-override happy path / fallback /
  ignore-when-user-picked-a-real-id case.
- `pnpm --filter @open-design/daemon typecheck` clean.

* chore(runtimes/amr): move AMR to the top of the base agent list

So `AMR (vela)` shows up first in the agent picker / status views,
ahead of claude / codex. Pure ordering change; no behavior delta.

* feat(amr): Sign-in / Sign-out button on the AMR Settings card

The first half of the AMR work assumed the operator would set
VELA_RUNTIME_KEY / VELA_LINK_URL on the daemon process and never
surfaced login state to users. This adds the missing UX so a fresh
install can drive the full path from Settings:

  - GET  /api/integrations/vela/status   reads ~/.vela/config.json
    for the active profile and returns { loggedIn, profile, user }
    (without leaking the runtime/control keys themselves).
  - POST /api/integrations/vela/login    spawns `vela login` once
    (409 if one is already in flight). The vela CLI opens the user's
    browser to the device-authorization page itself — Open Design
    only needs to kick the subprocess off.
  - POST /api/integrations/vela/logout   removes ~/.vela/config.json
    so the next status read returns logged-out.

`AmrAgentCard` is a dedicated agent-card component for AMR because
the existing `<button>` row can't host an interactive sub-control
(nested interactive elements). It polls /status after a login click
until the daemon reports loggedIn=true (or 5 minutes elapse), and
exposes a Sign-out action on hover. Other adapters (claude, codex,
hermes, …) keep their existing `<button>` card.

i18n: 8 new keys (settings.amrLogin / Logout / LoggingIn / etc.)
added to en + zh-CN. Other locales spread `en` and inherit the
English copy until translations land.

Coverage:
- `tests/integrations/vela.test.ts` pins the config.json reader
  against a tmp HOME — including the negative case where a profile
  has user info but no runtimeKey (still logged-out), and the
  secret-leak guard ("rt-secret-*" must not appear in the projection
  payload).
- `tests/components/AmrAgentCard.test.tsx` covers all four UI
  states (logged-out, logging-in, logged-in, logging-out) plus the
  click-propagation invariant the divergent card was built to keep.

`pnpm --filter @open-design/daemon test` 2901 / 2901 passing.
`pnpm --filter @open-design/web test` 1719 / 1719 passing.
`pnpm typecheck` + `pnpm guard` clean.

Dev script side-effects: `apps/daemon/scripts/verify-amr-real-vela.mjs`
no longer requires both VELA_RUNTIME_KEY and VELA_LINK_URL — if
VELA_PROFILE is set, the vela CLI is allowed to resolve credentials
from `~/.vela/config.json`. Added the two AMR `.mjs` fixtures to
`scripts/guard.ts` allowlist with the executable-fixture / dev-runner
rationale.

* fix(connection-test): substitute model for AMR before attachAcpSession

The chat-run path in server.ts already routes the requested model through
`resolveModelForAgent` so AMR / vela (whose CLI demands an explicit
`session/set_model` before `session/prompt`) gets the def's first
concrete fallback id when the chat run ships `model: 'default'`.
`connectionTest.ts` was wiring `attachAcpSession({ ..., model: model ?? null })`
directly, which made the Test Connection button on the AMR Settings
card deadlock with the same `session/set_model must be called before
session/prompt` error the chat-run path already handles — surfaced as a
permanent "Testing connection…" spinner in the UI.

Reuse the same helper here so Test Connection mirrors chat-run behavior.

* test(amr): three-layer end-to-end coverage for the AMR login + turn flow

The PR up to this point shipped runtime + UI code with unit-level Vitest
coverage. This commit adds the cross-layer regression net the live demo
relied on:

1. apps/daemon/tests/integrations/vela.routes.test.ts (HTTP, Vitest)
   Spins up the real daemon Express app via `startServer({port:0,...})`,
   persists `agentCliEnv.amr.VELA_BIN = <fake>` into app-config.json,
   and exercises every /api/integrations/vela/* endpoint against the
   extended fake-vela stub:
     - status reads ~/.vela/config.json under various states
     - login spawns the fake, waits for config.json to appear, returns
       pid + startedAt + profile
     - 409 already-running guard with the stub's delay knob
     - logout removes the file (idempotent)
     - secrets (runtimeKey / controlKey) never leak in the projection
     - login → status round-trip flips loggedIn=false → true

2. e2e/tests/amr/turn.test.ts (tools-dev orchestrated, Vitest)
   Boots a namespaced daemon + web pair through `createSmokeSuite`,
   inlines a self-contained fake `vela` binary that handles BOTH
   `vela login` (writes ~/.vela/config.json) and
   `vela agent run --runtime opencode` (ACP stdio with the
   `session/set_model must precede session/prompt` gate the real binary
   enforces), then drives a complete /api/runs lifecycle for
   `agentId: 'amr', model: 'default'` and asserts the assistant message
   captures the fake's streamed text. This is the test that would have
   surfaced today's plugin-default-model regression (the `set_model
   before prompt` error) at PR time instead of demo time.

3. e2e/ui/amr-login-pill.test.ts (Playwright)
   Mocks /api/agents + /api/integrations/vela/{status,login,logout}
   to drive the Settings AMR card through the full Sign in → Signed in
   → Sign out cycle. Pins the AmrLoginPill polling contract and the
   aria-label semantics (the pill's accessible name is "Sign out" once
   logged in, regardless of which label the hover-state text shows).

fake-vela.mjs extensions:
   - Handles `vela login` argv by writing
     ~/.vela/config.json for the active VELA_PROFILE and exiting 0 —
     mirrors real vela's on-disk side-effect without the device-auth
     loop.
   - FAKE_VELA_LOGIN_DELAY_MS knob so route tests can observe the
     in-flight state of the spawn lifecycle.
   - FAKE_VELA_LOGIN_USER_EMAIL / _USER_PLAN to assert the surfaced
     user fields end-to-end.

Validated:
   - `pnpm guard` + `pnpm typecheck` (all workspace projects)
   - `pnpm --filter @open-design/daemon test`: 2998 / 2998 passing,
     including the new 8-test integration suite.
   - `cd e2e && pnpm test tests/amr`: 1 / 1 passing.
   - `cd e2e && pnpm exec playwright test ui/amr-login-pill.test.ts`:
     1 / 1 passing (6.7s).

* feat(amr): package native cli and refine login ui

* feat(amr): wire vela cli beta packaging

* docs(amr): document vela ci packaging review

* docs(amr): refine vela ci integration review

* fix(ci): refresh nix pnpm dependency hashes

* fix(pack): clean up Vela CLI packaging

* fix(pack): bundle Vela CLI support files

* fix(amr): recover login attempts from stale auth state

* test: expand AMR and automations coverage

* fix(amr): address review follow-ups

* test(web): align tasks fixtures with contracts

* fix(daemon): type wildcard route params

* fix(ci): refresh PR merge validation

* fix(amr): clear env credentials on logout

* feat(settings): inline local CLI model configuration

* fix(amr): recognize daemon env credentials

* [codex] Fix Vela companion packaging (#2979)

* Fix Vela companion packaging

* Update Nix pnpm dependency hashes

* [codex] Surface AMR account failures (#2980)

* fix: surface AMR account failures

* fix: cover AMR recovery error guidance

* chore: bump beta base version to 0.8.1 (#2990)

* Fix AMR profile and packaged runtime review issues

* Detect packaged AMR OpenCode companion tree

* feat(web): polish AMR frontend flows

* Polish AMR onboarding card

* fix: read AMR login state from dot-amr config (#3048)

* test: tighten AMR credential and packaging coverage

* test: restore AMR executable test env helper

* [codex] Fix packaged mac Dock identity and AMR label (#3076)

* Fix packaged mac sidecar Dock identity

* Rename AMR assistant label

* Fix AMR live models and dot-amr login state (#3073)

* fix: read AMR login state from dot-amr config

* fix: load live AMR models before runs

* fix: point AMR onboarding link to production wallet

* fix: address AMR model review feedback

* fix: persist live AMR model fallback

* [codex] Fix AMR link catalog model ids (#3088)

* Fix packaged mac sidecar Dock identity

* Rename AMR assistant label

* Fix AMR link catalog model ids

* Fix AMR model normalization typecheck

* Use live AMR model for default runs

* fix: polish AMR runtime settings UI

* Accelerate AMR startup defaults (#3092)

* Surface AMR insufficient balance wallet URL (#3099)

* fix(web): polish onboarding controls (#3112)

* fix(web): show CLI scan loading state

* Avoid duplicate AMR wallet recharge links (#3117)

* Avoid duplicate AMR wallet recharge links

* Use Vela CLI 0.0.3 test package

* chore(nix): refresh pnpm deps hash

* Fix AMR wallet guidance display

---------

Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>

* chore(pack): pin Vela CLI 0.0.3-test.1 (#3127)

* chore(nix): refresh pnpm deps hash

* chore(pack): pin Vela CLI 0.0.3

* chore(nix): refresh pnpm deps hash

* fix(web): suppress AMR exit 130 fallback (#3136)

* feat(web): nudge users to hosted AMR on model/auth/quota failures (#3083)

* feat(web): nudge users to hosted AMR on model/auth/quota failures

When a non-AMR agent run fails with an auth / quota / upstream model
error, surface an inline nudge under the error pill linking to Open
Design's hosted AMR gateway (https://open-design.ai/amr). The nudge
fires `surface_view` (element=run_failed_toast) on impression and
`ui_click` (element=go_amr) on the link.

Also teach the daemon to classify CLI-agent auth/quota/upstream failures
(Claude Code, codex, ...) into specific API error codes
(AGENT_AUTH_REQUIRED / RATE_LIMITED / UPSTREAM_UNAVAILABLE) instead of
the generic AGENT_EXECUTION_FAILED, so both the error message and the
nudge key off accurate codes. AMR's own runs are excluded from the
nudge — they keep the dedicated sign-in / recharge affordances.

* feat(web): rework failed-run AMR guidance into per-case error UI

Replace the single inline nudge with a per-case failed-run experience
driven by the run's error code + agent:

- The error card is now neutral gray (was red) and always carries a
  retry button; it is driven by the persisted per-message error event so
  it survives a reload.
- Non-AMR agent hitting a model/auth/quota wall: a theme-color promotion
  card under the error card offers "switch to AMR & retry" — switches the
  run to AMR, opens Settings on the AMR card, and auto-retries once the
  account signs in (ProjectView polls vela login status, independent of
  the Settings pill lifecycle, with success / 5-min-timeout / unmount
  exits).
- AMR agent unauthorized: clearer copy + an "authorize & retry" button.
- AMR agent out of balance: clearer copy + a "top up" button to the AMR
  wallet, with manual retry.
- Settings AMR card: when opened from the nudge, it scrolls into view and
  pulses, and an authorize-button coachmark (a fake hand cursor that
  rises in and dismisses on hover) points at the sign-in control when not
  yet authorized.

analytics: surface_view (run_failed_toast) on the promotion card and
ui_click (go_amr) on its action are retained. i18n adds chat.amrCard.*
and chat.amrError.* (en / zh-CN / zh-TW translated; other locales fall
back to en) and drops the old chat.amrErrorGuidance keys.

* fix(daemon): require status context for numeric service-failure codes

Per review on #3083: the model-service classifier matched bare HTTP
status numbers (`500`, `502`, `429`, `401`), so ordinary CLI output like
`line 500`, `read 502 bytes`, or `exit code 401` could be misclassified
as a provider outage / auth wall and wrongly surface the AMR nudge. Now
a status number only counts when it carries explicit context (`HTTP 500`,
`status 503`, `code: 401`, `502 Bad Gateway`); textual provider phrases
(overloaded, bad gateway, service unavailable, rate limit, …) are
unchanged. Adds fixtures proving unrelated numeric output stays null.

* fix(web): keep error pill for failed runs ChatPane's card doesn't cover

Per review on #3083: the per-message gray error pill was suppressed for
every persisted error status event, but ChatPane only renders the
replacement top-level error card for `retryableAssistantMessage` (the
last failed assistant). So a failed turn that is no longer last (after a
follow-up) or an older failed run in history showed neither the pill nor
the card — its error detail vanished, undercutting reload/history
survival. ChatPane now passes `errorCardOwnerId` (the assistant id whose
error the card represents); AssistantMessage suppresses only that one
pill and keeps rendering StatusPill for all other error events.

* fix(daemon): don't treat a process exit code as an HTTP status

Follow-up to review on #3083: the status-context helper accepted a bare
`code` prefix, so `exit code 401` / `process exited with code 429` still
matched and got classified as AGENT_AUTH_REQUIRED / RATE_LIMITED (the
very `exit code 401` case the comment calls out as noise). `code` now
only counts when qualified (`status code` / `error code` / `response
code`) or punctuation-bound (`code: 401`); bare `exit code N` no longer
matches. Adds fixtures for exit-code lines returning null.

* chore(web): translate AMR card / error keys for 16 remaining locales

PR #3083 added 10 new `chat.amrCard.*` / `chat.amrError.*` keys but only
provided en/zh-CN/zh-TW translations; the other 16 locales fell back to
English. Translate the card title/body, three chips, primary CTA, and
the AMR self-error (auth / balance) messages and buttons for ar, de,
es-ES, fa, fr, hu, id, it, ja, ko, pl, pt-BR, ru, th, tr, uk.

* fix(amr): address review feedback on #2355

Targeted fixes for the unresolved review threads on #2355. Each fix
includes / updates a focused test.

- runtimes/executables.ts: `packagedVelaOpenCodeCompanionTree` now
  verifies the inner `opencode` executable exists + is runnable, not
  just the directory. This closes the false-positive availability path
  that let `detectAgents()` surface AMR as available even when the
  packaged companion was empty / partially copied (mrcfps, 4 threads).

- runtimes/executables.ts: `resolveAmrOpenCodeExecutable` now prefers
  the bundled `<OD_RESOURCE_ROOT>/bin/libexec/opencode/opencode` over a
  stale `opencode` on the user's PATH, so packaged AMR builds can't be
  hijacked by a global installation.

- web/EntryShell.tsx: when the Local CLI scan returns an available
  agent and the previously-selected agent is AMR, switch the selection
  to the first available local agent so the runtime and persisted
  agent agree before Continue.

- server.ts (model-probe branch): for AMR, check `readVelaLoginStatus`
  BEFORE rejecting on an empty live-model catalog — a signed-out user
  was getting `AMR_MODEL_UNAVAILABLE` ("choose a model") instead of
  the correct `AMR_AUTH_REQUIRED` (sign-in affordance).

- server.ts (default model fallback): if the user asked for the AMR
  agent default and the cached id is no longer in the FRESH catalog,
  fall back to `liveModels[0]` from the probe instead of rejecting the
  run as `AMR_MODEL_UNAVAILABLE`.

- integrations/vela.ts: route `vela login` through
  `createCommandInvocation` so an npm/Node-style `vela.cmd` / `.bat`
  shim on Windows gets the correct `cmd.exe /d /s /c …` wrapping with
  verbatim args (matches `execAgentFile` / chat-run spawning).

- tools/pack/src/linux.ts: in containerized Linux builds, bind-mount
  the host directory of `OPEN_DESIGN_VELA_CLI_BIN` and rewrite the env
  to the container-side path. The host path was being passed in as-is
  even though the default container only mounts /project, /tools-pack
  and cache/home — `copyOptionalVelaCliBinary` saw a missing path.

Deferred (out of scope for this PR):
- `od amr status/login/logout/cancel` CLI subcommands (AGENTS.md
  UI/CLI dual-track rule, server.ts:5763) — sizable surface; tracked
  for a separate focused PR.
- Strict `--require-vela-cli` for Windows + mac-x64 beta builds:
  prematurely blocked — `@powerformer/vela-cli` only publishes the
  `darwin-arm64` platform binary today; adding the flag elsewhere
  would fail the builds. Revisit once win/x64/linux binaries ship.

* fix(amr): hoist sendAmrAccountFailure above the AMR catalog preflight (TDZ)

The new signed-out AMR branch in the catalog preflight at server.ts:10875
calls `sendAmrAccountFailure(...)` to emit AMR_AUTH_REQUIRED, but the
const declaration sat ~100 lines below at the outer function scope. Because
`const` is TDZ-aware, that branch would have thrown `ReferenceError:
Cannot access 'sendAmrAccountFailure' before initialization` for the
exact users it tries to help — defeating the original intent.

Hoist the helper to just above the AMR preflight block so it's available
to every AMR code path in this function. Behavior elsewhere is unchanged.

Also rerun the daemon test suite: `launch.test.ts > resolveAgentLaunch
uses packaged built-in Vela for AMR` was creating the
`<resourceRoot>/bin/libexec/opencode/` companion *directory* only, but
this PR's earlier tightening of `packagedVelaOpenCodeCompanionTree`
also requires the inner `opencode` executable. Add it to that fixture
to match the new contract; the test was a sibling of the executables /
env-and-detection fixtures already updated in 13fc4f4.

Addresses #2355 review (mrcfps, 2026-05-28).

* feat(web): add hover cancel for AMR login (#3158)

* feat(web): add hover cancel for AMR login

* fix(web): don't bounce AmrLoginPill back to 'Signing in…' after local cancel

Both codex-connector (P2) and looper (CHANGES_REQUESTED) on this PR
flagged the same race in the new local-cancel path: `handleCancelLogin`
dispatches `notifyAmrLoginStatusChanged('login-canceled')` immediately
after `/login/cancel` returns, but the `AMR_LOGIN_STATUS_EVENT` listener
unconditionally re-enters `refresh()` and then restarts polling
whenever `/api/integrations/vela/status` still reports
`loginInFlight: true`.

That is a real race because the daemon's `cancelVelaLogin()` only sends
SIGTERM (escalating to SIGKILL after `LOGIN_CANCEL_KILL_GRACE_MS` =
2000 ms) and keeps the child in `activeLoginProcs` until it actually
exits — so the first `/status` read after a successful cancel can
legally still come back as in-flight. Under that window the pill flips
back to 'Signing in…' and can later surface the timeout/error path even
though the user already canceled, defeating the behavior promised in
the PR description.

Fix the listener instead of every dispatch site: in the
`login-canceled` branch, after the local reset (stopPolling +
setPending(null) + clear refs), optimistically mark every subscribed
pill instance as not-in-flight (`setStatus((c) => c ? { ...c,
loginInFlight: false } : c)`) and `return` — skip the
refresh-and-reconcile branch below entirely. The next explicit refresh
(component mount, user interaction, or a `status-changed` event) will
pick up the daemon's confirmed state once the child has actually
exited.

Add a focused regression test that holds `/api/integrations/vela/status`
at `loginInFlight: true` even after a successful `/login/cancel`,
asserting that the pill stays at the Canceled → Authorize sequence and
never bounces back to 'Signing in…'. This test fails on the pre-fix
listener and passes on the new behavior; existing
'cancels an in-flight AMR sign-in…' and 'reconciles late AMR browser
completion to Signed in after local cancel' tests continue to pass.

Addresses review feedback on #3158 (chatgpt-codex-connector, nettee).

---------

Co-authored-by: lefarcen <935902669@qq.com>

---------

Co-authored-by: a1chzt <chizblank@gmail.com>
Co-authored-by: Amy <1184569493@qq.com>
Co-authored-by: Mason <jinmeihong0201@gmail.com>
Co-authored-by: Caprika <56862773+alchemistklk@users.noreply.github.com>
Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
2026-05-28 05:09:55 +00:00

3269 lines
111 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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,
importLocalDesignSystemMock,
importGitHubDesignSystemMock,
fetchProviderModelsMock,
fetchLatestGithubReleaseInfoMock,
} = 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(),
importLocalDesignSystemMock: vi.fn(),
importGitHubDesignSystemMock: vi.fn(),
fetchProviderModelsMock: vi.fn(),
fetchLatestGithubReleaseInfoMock: 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,
importLocalDesignSystem: importLocalDesignSystemMock,
importGitHubDesignSystem: importGitHubDesignSystemMock,
fetchLatestGithubReleaseInfo: fetchLatestGithubReleaseInfoMock,
codexPetSpritesheetUrl: (pet: { spritesheetUrl: string }) => pet.spritesheetUrl,
};
});
vi.mock('../../src/providers/provider-models', () => ({
fetchProviderModels: fetchProviderModelsMock,
}));
import { SettingsDialog } from '../../src/components/SettingsDialog';
import type { AgentRefreshOptions, 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 amrAgent: AgentInfo = {
id: 'amr',
name: 'AMR (vela)',
bin: 'amr',
available: true,
version: '1.0.0',
models: [{ id: 'default', label: 'Default' }],
};
type OnRefreshAgents = (
options?: AgentRefreshOptions,
) => void | AgentInfo[] | Promise<void | AgentInfo[]>;
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?: OnRefreshAgents;
initialSection?: SettingsSection;
appVersionInfo?: AppVersionInfo | null;
welcome?: boolean;
} = {},
) {
const onPersist = vi.fn();
const onPersistComposioKey = vi.fn();
const onClose = vi.fn();
const onRefreshAgents = options.onRefreshAgents ?? vi.fn<OnRefreshAgents>();
const view = render(
<SettingsDialog
initial={{ ...baseConfig, ...initial }}
agents={options.agents ?? availableAgents}
daemonLive={options.daemonLive ?? true}
appVersionInfo={options.appVersionInfo ?? null}
initialSection={options.initialSection ?? 'execution'}
welcome={options.welcome}
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();
importLocalDesignSystemMock.mockReset();
importGitHubDesignSystemMock.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}`,
}));
fetchLatestGithubReleaseInfoMock.mockReset();
fetchLatestGithubReleaseInfoMock.mockResolvedValue(null);
importLocalDesignSystemMock.mockResolvedValue({
designSystem: {
id: 'imported-system',
title: 'Imported System',
summary: 'A newly imported system.',
category: 'Imported',
swatches: ['#0f766e', '#ccfbf1'],
},
});
importGitHubDesignSystemMock.mockResolvedValue({
designSystem: {
id: 'github-system',
title: 'GitHub System',
summary: 'A GitHub imported system.',
category: 'Imported',
swatches: ['#1d4ed8', '#bfdbfe'],
},
});
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();
const baseUrlInput = screen.getByLabelText('Base URL') as HTMLInputElement;
expect(baseUrlInput.value).toBe('https://api.anthropic.com');
expect(baseUrlInput.readOnly).toBe(true);
expect(screen.getByText('Default endpoint. Usually no need to change this.')).toBeTruthy();
const memoryModelDetails = screen
.getAllByText('Memory model')
.find((node) => node.closest('summary'))
?.closest('details');
expect(memoryModelDetails?.hasAttribute('open')).toBe(false);
expect(within(memoryModelDetails!).queryByLabelText('Base URL')).toBeNull();
expect(
screen
.getByRole('link', { name: 'Get your key at console.anthropic.com ↗' })
.getAttribute('href'),
).toBe('https://console.anthropic.com/settings/keys');
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');
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
expect(screen.getByLabelText('Quick fill provider')).toBeTruthy();
expect(screen.getByLabelText('Base URL').closest('details')).toBeNull();
expect(
screen
.getByRole('link', { name: 'Get your key at platform.openai.com ↗' })
.getAttribute('href'),
).toBe('https://platform.openai.com/api-keys');
});
it('warns BYOK users that API mode cannot edit project files (issue #1106)', () => {
// Regression cover: switching from Local CLI to BYOK previously gave no
// signal that file-editing tools (`Read`/`Write`/`Edit`) are absent on the
// API path. Users typed "continue adjusting the design" expecting edits
// and got an HTML monologue back. The notice must be visible on every
// BYOK protocol tab so the missing tool surface is discoverable before
// the user wastes a turn.
renderSettingsDialog();
const notice = screen.getByTestId('settings-byok-no-file-tools-notice');
expect(notice.textContent).toContain('BYOK mode');
expect(notice.textContent).toContain("can't read, write, or edit files");
expect(notice.textContent).toContain('Local CLI mode');
fireEvent.click(screen.getByRole('tab', { name: 'OpenAI' }));
expect(screen.getByTestId('settings-byok-no-file-tools-notice')).toBeTruthy();
fireEvent.click(screen.getByRole('tab', { name: 'Google Gemini' }));
expect(screen.getByTestId('settings-byok-no-file-tools-notice')).toBeTruthy();
});
it('hides the BYOK no-file-tools notice when Local CLI mode is selected', () => {
renderSettingsDialog({ mode: 'daemon' });
expect(screen.queryByTestId('settings-byok-no-file-tools-notice')).toBeNull();
});
it('lets Anthropic and Google users customize the default base URL', () => {
renderSettingsDialog();
expect((screen.getByLabelText('Base URL') as HTMLInputElement).readOnly).toBe(true);
fireEvent.click(screen.getByRole('button', { name: 'Customize' }));
expect((screen.getByLabelText('Base URL') as HTMLInputElement).readOnly).toBe(false);
cleanup();
renderSettingsDialog();
fireEvent.click(screen.getByRole('tab', { name: 'Google Gemini' }));
expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe(
'https://generativelanguage.googleapis.com',
);
expect((screen.getByLabelText('Base URL') as HTMLInputElement).readOnly).toBe(true);
});
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('keeps Anthropic-compatible presets selectable from quick fill', () => {
renderSettingsDialog();
const providerSelect = screen.getByLabelText('Quick fill provider') as HTMLSelectElement;
expect(Array.from(providerSelect.options).map((option) => option.textContent)).toEqual(
expect.arrayContaining([
'Anthropic (Claude)',
'DeepSeek — Anthropic',
'MiniMax — Anthropic',
'MiMo (Xiaomi) — Anthropic',
]),
);
fireEvent.change(providerSelect, { 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/anthropic',
);
});
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('offers managed and self-hosted Ollama presets with editable base URLs', () => {
renderSettingsDialog();
fireEvent.click(screen.getByRole('tab', { name: 'Ollama Cloud' }));
const providerSelect = screen.getByLabelText('Quick fill provider') as HTMLSelectElement;
expect(providerSelect.options[0]?.textContent).toBe('Custom provider');
expect(providerSelect.options[1]?.textContent).toBe('Ollama Cloud (managed)');
expect(providerSelect.options[2]?.textContent).toBe('Ollama Self-hosted (local)');
expect((screen.getByLabelText('Base URL') as HTMLInputElement).readOnly).toBe(false);
fireEvent.change(providerSelect, { target: { value: '1' } });
expect((screen.getByLabelText('Base URL') as HTMLInputElement).value).toBe(
'http://localhost:11434',
);
expect(screen.queryByRole('link', { name: /Get your key/i })).toBeNull();
expect(screen.getByRole('button', { name: 'Test' })).toBeTruthy();
});
it('saves and tests the self-hosted Ollama preset without an API key', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
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: 'ollama',
apiKey: '',
baseUrl: 'http://localhost:11434',
model: 'gemma3:4b',
});
return new Response(
JSON.stringify({
ok: true,
kind: 'ok',
latencyMs: 28,
model: 'gemma3:4b',
sample: 'pong',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
const { onPersist } = renderSettingsDialog();
fireEvent.click(screen.getByRole('tab', { name: 'Ollama Cloud' }));
fireEvent.change(screen.getByLabelText('Quick fill provider'), {
target: { value: '1' },
});
await waitForPersist(
onPersist,
expect.objectContaining({
apiProtocol: 'ollama',
apiKey: '',
baseUrl: 'http://localhost:11434',
model: 'gemma3:4b',
apiProviderBaseUrl: 'http://localhost:11434',
}),
{},
);
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
expect(screen.getByText(/Connected\. Replied in 28 ms/)).toBeTruthy();
});
const testConnectionCalls = fetchMock.mock.calls.filter(
([input]) => input.toString() === '/api/test/connection',
);
expect(testConnectionCalls).toHaveLength(1);
});
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();
fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'sk-test' },
});
fireEvent.click(screen.getByRole('button', { name: 'Customize' }));
fireEvent.change(screen.getByLabelText('Base URL'), {
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(screen.getByLabelText('Base URL'), {
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(/Couldnt 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();
expect((screen.getByLabelText('Base URL') as HTMLInputElement).placeholder).toBe(
'https://my-resource.openai.azure.com',
);
expect(
screen.getByText('Find this in Azure portal → your resource → Endpoint.'),
).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('does not fetch provider models while the API key edit is still uncommitted', async () => {
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-partial' },
});
await new Promise((resolve) => window.setTimeout(resolve, 350));
expect(fetchProviderModelsMock).not.toHaveBeenCalled();
});
it('loads provider models automatically only after required fields are committed', async () => {
fetchProviderModelsMock.mockResolvedValueOnce({
ok: true,
kind: 'success',
latencyMs: 12,
models: [{ id: 'gpt-account', label: 'Account Model' }],
});
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' }));
expect(screen.queryByRole('button', { name: 'Fetch models' })).toBeNull();
expect(fetchProviderModelsMock).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'sk-openai' },
});
fireEvent.blur(screen.getByLabelText('API key'));
expect(await screen.findByText('✓ Loaded 1 models from your account.')).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(['gpt-account', 'gpt-4o', '__custom__']),
);
fireEvent.click(screen.getByRole('tab', { name: 'Azure OpenAI' }));
expect(screen.queryByRole('button', { name: 'Fetch models' })).toBeNull();
expect(screen.getByText(/Automatic deployment discovery is not available/)).toBeTruthy();
fireEvent.click(screen.getByRole('tab', { name: 'Ollama Cloud' }));
expect(screen.queryByRole('button', { name: 'Fetch models' })).toBeNull();
expect(screen.getByText('Model discovery is not available for this protocol.')).toBeTruthy();
});
it('does not show a BYOK Test button or nag when the API key is still missing', () => {
renderSettingsDialog({
apiProtocol: 'anthropic',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
});
expect(screen.queryByRole('button', { name: 'Test' })).toBeNull();
fireEvent.blur(screen.getByLabelText('API key'));
expect(screen.queryByText('Fill API key to test the connection.')).toBeNull();
});
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');
expect(await screen.findByText('✓ Loaded 2 models from your account.')).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');
});
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' }));
expect(await screen.findByText('✓ Loaded 1 models from your account.')).toBeTruthy();
fireEvent.change(screen.getByLabelText('Base URL'), {
target: { value: 'https://proxy.example.com/v1' },
});
await waitFor(() => {
expect(screen.queryByText('✓ Loaded 1 models from your account.')).toBeNull();
});
});
it('renders automatic provider auth failures under the API key field', 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' }));
expect(await screen.findByText('Invalid API key.')).toBeTruthy();
});
it('renders non-auth provider model discovery failures explicitly', async () => {
fetchProviderModelsMock.mockResolvedValueOnce({
ok: false,
kind: 'upstream_unavailable',
latencyMs: 12,
status: 503,
detail: 'provider unavailable',
});
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' }));
expect(await screen.findByText('Could not fetch models: provider unavailable')).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' });
expect(screen.getByRole('button', { name: 'Test' })).toBeTruthy();
fireEvent.blur(screen.getByLabelText('API key'));
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);
});
it('lets users retry a failed BYOK connection test without editing the API key', async () => {
let attempt = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
attempt += 1;
return new Response(
JSON.stringify(
attempt === 1
? {
ok: false,
kind: 'timeout',
latencyMs: 30000,
model: 'claude-sonnet-4-5',
}
: {
ok: true,
kind: 'ok',
latencyMs: 18,
model: 'claude-sonnet-4-5',
sample: 'pong',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog({ apiKey: 'sk-test-provider' });
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
expect(await screen.findByRole('button', { name: 'Retry test' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Retry test' }));
expect(await screen.findByText(/Connected\. Replied in 18 ms/)).toBeTruthy();
const testConnectionCalls = fetchMock.mock.calls.filter(
([input]) => input.toString() === '/api/test/connection',
);
expect(testConnectionCalls).toHaveLength(2);
});
});
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);
expect(screen.getByText('Your CLIs (1)')).toBeTruthy();
const installGroupSummary = screen.getByText('Available to install (1)');
expect(installGroupSummary.closest('details')?.hasAttribute('open')).toBe(false);
const codexCard = screen.getByRole('button', { name: /Codex CLI/i }) as HTMLButtonElement;
fireEvent.click(installGroupSummary);
const geminiGroup = screen.getByRole('group', { name: /Gemini CLI/i });
expect(within(geminiGroup).getByText('Google official CLI')).toBeTruthy();
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);
const selectedCard = codexCard.closest('.agent-card') as HTMLElement;
expect(
within(selectedCard).getByRole('combobox', {
name: en['settings.modelPicker'],
}),
).toBeTruthy();
expect(
selectedCard.compareDocumentPosition(installGroupSummary) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await waitForPersist(
onPersist,
expect.objectContaining({
mode: 'daemon',
agentId: 'codex',
}),
{},
);
});
it('labels live CLI model metadata in the model picker', () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{
agents: [
{
...availableAgents[0]!,
modelsSource: 'live',
models: [
{ id: 'default', label: 'Default' },
{ id: 'gpt-6-codex', label: 'GPT-6 Codex' },
],
},
],
},
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i }));
expect(screen.getByText('Live from CLI')).toBeTruthy();
expect(
screen.getByText(/Model list comes from this CLI/i),
).toBeTruthy();
});
it('labels fallback CLI model metadata in the model picker', () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{
agents: [
{
...availableAgents[0]!,
modelsSource: 'fallback',
},
],
},
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i }));
expect(screen.getByText('Built-in list')).toBeTruthy();
expect(
screen.getByText(/Showing built-in defaults/i),
).toBeTruthy();
});
it('uses the existing Settings card picker for AMR without exposing custom stale models', () => {
renderSettingsDialog(
{
mode: 'daemon',
agentId: 'amr',
agentModels: { amr: { model: 'gpt-5.4-mini', reasoning: 'default' } },
},
{
agents: [
{
...amrAgent,
modelsSource: 'live',
models: [
{ id: 'glm-5', label: 'GLM 5' },
{ id: 'glm-5.1', label: 'GLM 5.1' },
],
},
],
},
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i }));
fireEvent.click(screen.getByRole('button', { name: /^Open Design AMR\b/ }));
const modelPickers = screen.getAllByRole('combobox', {
name: en['settings.modelPicker'],
}) as HTMLSelectElement[];
expect(modelPickers).toHaveLength(1);
expect(modelPickers[0]?.value).toBe('glm-5');
expect(
Array.from(modelPickers[0]?.options ?? []).map((option) => option.value),
).toEqual(['glm-5', 'glm-5.1']);
expect(screen.queryByLabelText(en['settings.modelCustomLabel'])).toBeNull();
});
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('labels the memory model default with the selected Local CLI', async () => {
const agents: AgentInfo[] = [
...availableAgents,
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '1.2.3',
models: [{ id: 'default', label: 'Default (CLI config)' }],
},
];
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response(JSON.stringify({}), { status: 404 });
}));
renderSettingsDialog(
{ mode: 'daemon', agentId: 'claude' },
{ agents },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*2 installed/i }));
const memoryModel = await screen.findByRole('combobox', { name: 'Memory model' }) as HTMLSelectElement;
expect(memoryModel.options[memoryModel.selectedIndex]?.textContent).toBe('Same as chat (Claude Code)');
expect(screen.getByText(/anthropic is only the fallback provider family/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('rescans automatically when returning after opening an install link', async () => {
const unavailable: AgentInfo = {
id: 'gemini',
name: 'Gemini CLI',
bin: 'gemini',
available: false,
version: null,
models: [],
installUrl: 'https://github.com/google-gemini/gemini-cli',
};
const onRefreshAgents = vi.fn(async () => availableAgents);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ agents: [availableAgents[0]!, unavailable], onRefreshAgents },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByText('Available to install (1)'));
fireEvent.click(screen.getByRole('link', { name: en['settings.agentInstall.install'] }));
expect(onRefreshAgents).not.toHaveBeenCalled();
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => {
expect(onRefreshAgents).toHaveBeenCalledWith({
throwOnError: true,
agentCliEnv: {},
});
});
});
it('autosaves CLI config locations from the execution form', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ agents: availableAgents },
);
expect(
screen.getByLabelText('Codex/OpenAI proxy API key (CODEX_API_KEY)'),
).toBeTruthy();
expect(
screen.getByLabelText('Codex/OpenAI proxy API key (OPENAI_API_KEY · proxy/legacy)'),
).toBeTruthy();
fireEvent.change(screen.getByLabelText('Codex home'), {
target: { value: ' ~/.codex-team ' },
});
await waitForPersist(
onPersist,
expect.objectContaining({
mode: 'daemon',
agentId: 'codex',
agentCliEnv: {
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('renders a Local CLI connection test for selected installed agents', () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ agents: availableAgents },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(screen.getByRole('button', { name: /Codex CLI/i })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Test' })).toBeTruthy();
});
it('renders the AMR local agent without vela branding and with the Local CLI test action', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.queryByText('1.0.0')).toBeNull();
expect(screen.queryByText(/AMR \(vela\)/i)).toBeNull();
expect(screen.queryByText(/vela/i)).toBeNull();
expect(screen.queryByText(/Not signed in/i)).toBeNull();
expect(screen.getByText('Official')).toBeTruthy();
expect(screen.getByText('Lower cost')).toBeTruthy();
expect(screen.getByText('Many models')).toBeTruthy();
expect(screen.queryByText('Limited bonus: +100%')).toBeNull();
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Test' })).toBeNull();
});
it('only shows the AMR authorization action after selecting the AMR card', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({ loggedIn: false, profile: 'local', user: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const codexAgent = availableAgents.find((agent) => agent.id === 'codex');
expect(codexAgent).toBeTruthy();
if (!codexAgent) throw new Error('missing codex test agent');
renderSettingsDialog(
{ mode: 'daemon', agentId: codexAgent.id },
{ agents: [amrAgent, codexAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*2 installed/i }));
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Authorize' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /^Open Design AMR\b/ }));
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
});
it('reveals AMR cancel only while hovering the active card during sign-in', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCardButton = screen.getByRole('button', { name: /^Open Design AMR\b/ });
const amrCard = amrCardButton.closest('.agent-card') as HTMLElement;
expect(amrCard).toBeTruthy();
expect(await screen.findByText('Signing in…')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
fireEvent.mouseEnter(amrCard);
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeTruthy();
fireEvent.mouseLeave(amrCard);
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
});
});
it('cancels an in-flight AMR sign-in and returns to Authorize after a brief canceled state', async () => {
let statusStage: 'pending' | 'signed-out' = 'pending';
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify(
statusStage === 'pending'
? {
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
loginInFlight: false,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
statusStage = 'signed-out';
return new Response(JSON.stringify({ canceled: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCard = screen.getByRole('button', { name: /^Open Design AMR\b/ }).closest('.agent-card') as HTMLElement;
expect(await screen.findByText('Signing in…')).toBeTruthy();
fireEvent.mouseEnter(amrCard);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Canceled')).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login/cancel', { method: 'POST' });
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Authorize' })).toBeTruthy();
}, { timeout: 3000 });
expect(screen.queryByText('Canceled')).toBeNull();
});
// Regression for the race called out on #3158 by both codex-connector and
// looper: the daemon's `cancelVelaLogin()` only SIGTERMs the vela child
// and keeps it in `activeLoginProcs` until it actually exits, so a
// `/api/integrations/vela/status` read right after a successful cancel
// can legally still report `loginInFlight: true`. If the AmrLoginPill
// listener self-refreshed on the local cancel path it would bounce back
// into `Signing in…` polling and surface the timeout/error path even
// though the user already canceled.
it('does not bounce back to Signing in… when daemon /status still reports loginInFlight after a local cancel (#3158)', async () => {
let cancelReceived = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
// Keep reporting in-flight even *after* the cancel API succeeds —
// this is the SIGTERM-to-exit window where the daemon hasn't reaped
// the vela child yet.
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
cancelReceived = true;
return new Response(JSON.stringify({ canceled: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCard = screen.getByRole('button', { name: /^Open Design AMR\b/ }).closest('.agent-card') as HTMLElement;
expect(await screen.findByText('Signing in…')).toBeTruthy();
fireEvent.mouseEnter(amrCard);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Canceled')).toBeTruthy();
expect(cancelReceived).toBe(true);
// Give the listener event handler — plus any rogue polling tick — a
// generous window to misfire. Under the buggy code path the pill would
// call /status again, see loginInFlight:true, setPending('login'), and
// restart polling, flipping the UI back to 'Signing in…'.
await new Promise((resolve) => setTimeout(resolve, 400));
expect(screen.queryByText('Signing in…')).toBeNull();
// Eventually the Canceled UI window times out and Authorize re-appears.
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Authorize' })).toBeTruthy();
}, { timeout: 3000 });
// And still no bounce back to Signing in…
expect(screen.queryByText('Signing in…')).toBeNull();
});
it('reconciles late AMR browser completion to Signed in after local cancel', async () => {
let statusStage: 'pending' | 'signed-out' | 'signed-in' = 'pending';
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
const body =
statusStage === 'pending'
? {
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}
: statusStage === 'signed-in'
? {
loggedIn: true,
loginInFlight: false,
profile: 'local',
user: { id: 'user-1', email: 'late@example.com' },
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
loginInFlight: false,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
statusStage = 'signed-out';
return new Response(JSON.stringify({ canceled: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCard = screen.getByRole('button', { name: /^Open Design AMR\b/ }).closest('.agent-card') as HTMLElement;
expect(await screen.findByText('Signing in…')).toBeTruthy();
fireEvent.mouseEnter(amrCard);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Canceled')).toBeTruthy();
statusStage = 'signed-in';
window.dispatchEvent(
new CustomEvent('od:amr-login-status-change', {
detail: { reason: 'status-changed' },
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy();
});
expect(screen.getByText('late@example.com')).toBeTruthy();
});
it('renders the signed-in AMR account state inside Settings without leaking vela branding', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'local',
user: {
id: 'user-1',
email: 'signed-in@example.com',
name: 'Signed In User',
},
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.getByText('signed-in@example.com')).toBeTruthy();
expect(screen.queryByText(/AMR \(vela\)/i)).toBeNull();
expect(screen.queryByText(/^vela$/i)).toBeNull();
});
it('renders env-backed AMR login inside Settings without fabricating account details', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.queryByText(/@/i)).toBeNull();
expect(screen.queryByText(/AMR \(vela\)/i)).toBeNull();
});
it('does not keep a stale signed-in AMR state after a later Settings reopen reads loggedOut', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify(
statusCalls === 1
? {
loggedIn: true,
profile: 'local',
user: { id: 'user-1', email: 'signed-in@example.com' },
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const first = renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
first.unmount();
const second = renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull();
second.unmount();
});
it('keeps AMR selected in Settings after local logout instead of silently switching agents', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify({
loggedIn: statusCalls === 1,
profile: 'local',
user: statusCalls === 1 ? { id: 'user-1', email: 'signed-in@example.com' } : null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/logout' && init?.method === 'POST') {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(
onPersist.mock.calls.some(
([nextConfig]) =>
typeof nextConfig === 'object' &&
nextConfig !== null &&
'agentId' in (nextConfig as Record<string, unknown>) &&
(nextConfig as Record<string, unknown>).agentId !== 'amr',
),
).toBe(false);
});
});
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']);
});
it('renders non-integrated providers in the coming-soon section without input fields', () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'media' },
);
// Non-integrated providers (e.g. Fal.ai, Black Forest Labs) are shown in
// a separate "Coming soon" disclosure without editable inputs.
expect(screen.queryByLabelText('Black Forest Labs API key')).toBeNull();
expect(screen.queryByLabelText('Black Forest Labs Base URL')).toBeNull();
expect(document.querySelector('.media-provider-coming-soon')).toBeTruthy();
expect(screen.getByText('ComfyUI')).toBeTruthy();
});
it('renders ElevenLabs as an integrated media provider with enabled inputs', () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{ initialSection: 'media' },
);
const apiKeyInput = screen.getByLabelText('ElevenLabs API key') as HTMLInputElement;
const baseUrlInput = screen.getByLabelText('ElevenLabs Base URL') as HTMLInputElement;
expect(apiKeyInput.disabled).toBe(false);
expect(baseUrlInput.disabled).toBe(false);
});
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 and never shared/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');
});
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('shows every locale as a tile and marks the current locale as selected', async () => {
renderLanguageSettingsDialog('en');
const tiles = await screen.findAllByRole('radio');
expect(tiles).toHaveLength(LOCALES.length);
expect(screen.getByRole('radio', { name: /English/i }).getAttribute('aria-checked')).toBe('true');
expect(screen.getByRole('radio', { name: /简体中文/i }).getAttribute('aria-checked')).toBe('false');
});
it('switches locale immediately and updates localStorage', async () => {
renderLanguageSettingsDialog('en');
fireEvent.click(screen.getByRole('radio', { name: /简体中文/i }));
expect(screen.getByRole('radio', { name: /简体中文/i }).getAttribute('aria-checked')).toBe('true');
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', async () => {
renderLanguageSettingsDialog('en');
fireEvent.click(screen.getByRole('radio', { 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('radio', { 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('applies the first accent color as the default appearance color', () => {
renderSettingsDialog(
{ theme: 'system' },
{ initialSection: 'appearance' },
);
expect(screen.getByRole('radio', { name: 'Default accent color' }).getAttribute('aria-checked')).toBe('true');
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#c96442');
});
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('switches back to the default accent color and persists it explicitly', async () => {
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex', theme: 'light', accentColor: '#2563eb' },
{ initialSection: 'appearance' },
);
fireEvent.click(screen.getByRole('radio', { name: 'Default accent color' }));
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#c96442');
await waitForPersist(
onPersist,
expect.objectContaining({
accentColor: '#c96442',
}),
{},
);
});
it('keeps an autosaved accent color applied after the dialog closes', async () => {
const view = renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex', theme: 'light', accentColor: '#2563eb' },
{ initialSection: 'appearance' },
);
fireEvent.click(screen.getByRole('radio', { name: '#059669' }));
await waitForPersist(
view.onPersist,
expect.objectContaining({
accentColor: '#059669',
}),
{},
);
fireEvent.click(view.container.querySelector('.settings-close') as HTMLElement);
expect(view.onClose).toHaveBeenCalledTimes(1);
view.unmount();
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#059669');
});
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 color'), {
target: { value: '#123456' },
});
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#123456');
await waitForPersist(
onPersist,
expect.objectContaining({
accentColor: '#123456',
}),
{},
);
});
it('localizes the accent color controls in Chinese', () => {
render(
<I18nProvider initial="zh-CN">
<SettingsDialog
initial={{ ...baseConfig, theme: 'light' }}
agents={availableAgents}
daemonLive={true}
appVersionInfo={null}
initialSection="appearance"
onPersist={vi.fn()}
onPersistComposioKey={vi.fn()}
onClose={vi.fn()}
onRefreshAgents={vi.fn()}
/>
</I18nProvider>,
);
expect(screen.getByText('主题色')).toBeTruthy();
expect(screen.getByRole('radiogroup', { name: '主题色' })).toBeTruthy();
expect(screen.getByRole('radio', { name: '默认主题色' })).toBeTruthy();
expect(screen.getByLabelText('自定义颜色')).toBeTruthy();
});
});
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: 'Show pet' }) as HTMLButtonElement).disabled).toBe(false);
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: 'Hide pet' });
fireEvent.click(toggle);
expect(screen.getByRole('button', { name: 'Show pet' })).toBeTruthy();
expect(screen.getByText('Hide pet')).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.change(screen.getByRole('combobox', { name: 'Type' }), {
target: { value: 'deck' },
});
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.change(screen.getByLabelText('Category'), {
target: { value: 'Experimental' },
});
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.getAllByLabelText('Show in home gallery')[0] as HTMLElement);
await waitForPersist(
onPersist,
expect.objectContaining({
disabledDesignSystems: ['signal-green'],
}),
{},
);
});
it('shows an imported design system from the hidden-only import CTA', async () => {
renderSettingsDialog(
{
mode: 'daemon',
agentId: 'codex',
disabledDesignSystems: ['neutral-modern'],
},
{ initialSection: 'designSystems' },
);
await waitFor(() => {
expect(screen.getByText('Neutral Modern')).toBeTruthy();
expect(screen.getByText('Signal Green')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: 'Show hidden' }));
expect(screen.getByText('Neutral Modern')).toBeTruthy();
expect(screen.queryByText('Signal Green')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Add design system' }));
fireEvent.change(screen.getByPlaceholderText('/path/to/project'), {
target: { value: '/tmp/imported-system' },
});
fireEvent.click(screen.getByRole('button', { name: 'Import from project' }));
await waitFor(() => {
expect(importLocalDesignSystemMock).toHaveBeenCalledWith({
baseDir: '/tmp/imported-system',
importMode: 'hybrid',
craftApplies: [],
});
expect(screen.getByText('Imported Imported System')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: 'View imported design system' }));
await waitFor(() => {
expect(screen.getByText('Imported System')).toBeTruthy();
});
expect(screen.queryByText('No items match your search.')).toBeNull();
});
});
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.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);
});
it('opens the releases page when the latest release info is stale', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
fetchLatestGithubReleaseInfoMock.mockResolvedValue({
tagName: 'v0.4.1',
htmlUrl: 'https://github.com/nexu-io/open-design/releases/tag/v0.4.1',
stale: true,
});
renderSettingsDialog(
{ mode: 'daemon', agentId: 'codex' },
{
initialSection: 'about',
appVersionInfo: {
version: '0.4.1',
channel: 'beta',
packaged: true,
platform: 'darwin',
arch: 'arm64',
},
},
);
fireEvent.click(screen.getByRole('button', { name: 'Install latest' }));
await waitFor(() => {
expect(openSpy).toHaveBeenCalledWith(
'https://github.com/nexu-io/open-design/releases',
'_blank',
'noopener,noreferrer',
);
});
expect(screen.queryByText(en['settings.alreadyLatest'])).toBeNull();
});
});