Repository navigation
feat(devex): component fixtures with headless screenshots for fast UI checks - #33406
Conversation
… checks Colocated *.fixture.tsx files export named fixtures (render + optional store seed, setup, interact, width). A dev-only Vite entry (vite.fixtures.config.ts, extending vite.web.config.ts) renders one fixture in isolation with the real I18nProvider, TooltipProvider, theme class, default-seeded store, and the web client's preload bridge (installWebPreloadApi) as window.api. `pnpm fixtures:shot <id|glob> [--theme dark|light|both]` starts or reuses a detached server (Vite + one headless Chromium) and writes PNGs to .cache/fixtures/. A warm page re-renders in place (~100ms/shot) and reloads only after source changes. A stdio MCP server (registered in .mcp.json) exposes list_fixtures and screenshot_fixture. Fixtures load only through import.meta.glob in the fixture entry, which is not a production build input; oxlint rejects static imports of *.fixture.
Walkthrough
Priority: Merge Risk:
|
There was a problem hiding this comment.
Actionable comments posted: 4

Review info
-
Run configuration-
- Source mode: Auto
- Repository: Unavailable
- Branch: Unavailable
- File: No configuration file from this repository applied
- Commit: Unavailable
- Layers: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
4540327a-b474-416b-b8f7-81188e191f97
Files selected for processing: 24-
.gitignore.mcp.json.oxlintrc.jsonAGENTS.mdconfig/knip.jsonconfig/scripts/component-fixtures/fixture-page-runner.mjsconfig/scripts/component-fixtures/fixture-paths.mjsconfig/scripts/component-fixtures/fixture-server-client.mjsconfig/scripts/component-fixtures/fixture-server.mjsconfig/scripts/component-fixtures/fixtures-mcp-server.mjsconfig/scripts/component-fixtures/fixtures-shot.mjsdocs/reference/component-fixtures.mdpackage.jsonsrc/renderer/fixtures.htmlsrc/renderer/src/component-fixtures/ComponentFixtureFrame.tsxsrc/renderer/src/component-fixtures/component-fixture-explorer.tsxsrc/renderer/src/component-fixtures/component-fixture-registry.tssrc/renderer/src/component-fixtures/component-fixture.tssrc/renderer/src/components/AgentStateDot.fixture.tsxsrc/renderer/src/components/DetachedHeadBadge.fixture.tsxsrc/renderer/src/components/settings/AppearanceAdvancedDisclosure.fixture.tsxsrc/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.fixture.tsxsrc/renderer/src/components/ui/button.fixture.tsxvite.fixtures.config.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
-
| if (values.json) { | ||
| console.log(JSON.stringify({ ...payload, totalMs: Math.round(performance.now() - startedAt) })) | ||
| return | ||
| } |
There was a problem hiding this comment.
Set a non-zero exit code in --json mode when a shot fails.
The --json branch prints the payload and returns before any error check. An unknown fixture ID (payload.error) or a render failure (result.error) therefore exits with status 0.
The PR states that unknown IDs and render errors exit with status 1. That holds only without --json. Scripts and agents that use --json will treat failed captures as success.
Set process.exitCode before the JSON branch.
Proposed fix
-
--- "a/config/scripts/component-fixtures/fixtures-shot.mjs" +++ "b/config/scripts/component-fixtures/fixtures-shot.mjs" @@ -52,10 +52,13 @@ scale: Number(values.scale), outDir: values.out ? path.resolve(values.out) : undefined }) + if (payload.error || payload.results?.some((result) => result.error)) { + process.exitCode = 1 + } if (values.json) { console.log(JSON.stringify({ ...payload, totalMs: Math.round(performance.now() - startedAt) })) return } if (payload.error) { console.error(payload.error) console.error(`Known fixtures: ${payload.knownIds?.join(', ')}`)
Committable suggestion
-
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.Suggested changeif (values.json) { console.log(JSON.stringify({ ...payload, totalMs: Math.round(performance.now() - startedAt) })) return } if (payload.error || payload.results?.some((result) => result.error)) { process.exitCode = 1 } if (values.json) { console.log(JSON.stringify({ ...payload, totalMs: Math.round(performance.now() - startedAt) })) return }
| // Why replace: each fixture starts from the same seeded store, even in a reused page. | ||
| useAppStore.setState(baseStoreState, true) |
There was a problem hiding this comment.
Reinstall the window.api bridge for each fixture. A warm page leaks setup() stubs.
The comment says each fixture starts from the same seeded store. The code restores only the store. installWebPreloadApi() runs once at module load (Line 34).
ComponentFixture.setup is documented as the place to stub window.api methods. The worker page is reused across shots. A stub from one fixture therefore stays active for later fixtures, until the next page reload. Screenshot output then depends on the order of earlier shots.
No current fixture uses setup, so the defect is latent. Move the bridge install into a helper. Call the helper at module load and at the start of renderFixture.
Proposed fix
-
--- "a/src/renderer/src/component-fixtures/component-fixture-explorer.tsx" +++ "b/src/renderer/src/component-fixtures/component-fixture-explorer.tsx" @@ -92,11 +92,18 @@ ) } +function installFixtureBridge(): void { + installWebPreloadApi() + // Fixtures preview the desktop renderer; keep web-only component branches off. + Reflect.deleteProperty(window, '__ORCA_WEB_CLIENT__') +} + async function renderFixture(id: string, theme: ComponentFixtureTheme): Promise<void> { markComponentFixtureState('loading') const target = freshRoot() - // Why replace: each fixture starts from the same seeded store, even in a reused page. + // Why replace: each fixture starts from the same seeded store and a fresh `window.api`, even in a reused page. + installFixtureBridge() useAppStore.setState(baseStoreState, true) useAppStore.setState({ settings: { ...defaultSettings, theme } }) applyTheme(theme) try {
Replace the module-level
installWebPreloadApi()andReflect.deleteProperty(...)calls (Lines 34-36) withinstallFixtureBridge().
There was a problem hiding this comment.
Actionable comments posted: 3

Review info
-
Run configuration-
- Source mode: Auto
- Repository: Unavailable
- Branch: Unavailable
- File: No configuration file from this repository applied
- Commit: Unavailable
- Layers: Repository UI
- Review profile: CHILL
- Plan: Advanced
- Run ID:
a878d1a7-bd34-417d-afec-1a1d71e61fbf
Files selected for processing: 9-
.gitignoreAGENTS.mdconfig/focus-setting-read-baseline.txtconfig/localization-coverage-allowlist.jsonconfig/scripts/component-fixtures/fixture-paths.mjsconfig/scripts/component-fixtures/fixture-server.mjsconfig/vite.fixtures.config.tsdocs/reference/component-fixtures.mdpackage.json
Files skipped from review as they are similar to previous changes: 1-
- .gitignore
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.
-
| const fileStem = id | ||
| .replaceAll('/', '__') | ||
| .replaceAll(':', '--') | ||
| .replace(/[^\w.-]/g, '_') | ||
| return path.join(outDir, `${fileStem}.${theme}.png`) |
There was a problem hiding this comment.
Give distinct fixture IDs distinct screenshot paths.
If fixtures have IDs a/b:default and a__b:default, this replacement gives both the same filename. A request for both fixtures overwrites the first PNG and reports the same path twice. Use a reversible filename encoding or add a stable ID hash to the filename.
| const themes = | ||
| body.theme === 'both' ? ['dark', 'light'] : [body.theme === 'light' ? 'light' : 'dark'] |
There was a problem hiding this comment.
Reject unsupported themes instead of capturing dark.
If a user passes --theme ligth, the CLI forwards that string and handleShot silently captures dark. The command succeeds with the wrong screenshot. Validate dark, light, and both before rendering, and return an error for any other value.
| pnpm fixtures:shot --stop | ||
| ``` | ||
|
|
||
| PNGs go to `.cache/fixtures/` (override with `--out`), and the command prints their paths. Read them with your image viewer tool. Render errors and page console errors are printed for each fixture, and any failure makes the command exit non-zero. |
There was a problem hiding this comment.
Make the documented failure exit status hold in JSON mode.
If pnpm fixtures:shot --json receives an unmatched ID or a failed render, config/scripts/component-fixtures/fixtures-shot.mjs prints the JSON payload and returns before setting process.exitCode. The command exits successfully despite this guarantee. Set the failure exit code before the JSON branch returns.
ELI5
Agents (and people) can now take a picture of a single UI component in about a second, without starting the whole desktop app. You write a small
*.fixture.tsxfile next to the component that says how to render it, then runpnpm fixtures:shot <id>.What Changed
*.fixture.tsxfiles export named fixtures built withdefineFixture({ render, store?, setup?, interact?, width? }). Ids are the path undersrc/renderer/src/withcomponents/and.fixture.tsxremoved, plus the export name. For example,sidebar/DeleteWorktreeDirtyChangeHint:dirty.src/renderer/fixtures.htmlandsrc/renderer/src/component-fixtures/are served byvite.fixtures.config.ts, whichmergeConfigsvite.web.config.ts. Each fixture renders insideI18nProvider,TooltipProvider, the document theme class, and the default-seeded app store (getDefaultSettings()).window.apiis the existing web client bridge (installWebPreloadApi+withFallback), so no new mock layer was added. Routes:/fixturesis the index, and/fixtures?id=<id>&theme=dark|lightrenders one fixture. Open popovers, menus, and dialogs are captured too: the screenshot clip covers the frame plus everything portaled to<body>.pnpm fixtures:shot <id|component|glob>... [--theme dark|light|both] [--scale 2] [--out dir] [--list] [--stop] [--json]starts or reuses a detached server (Vite + one headless Chromium) and writes PNGs to.cache/fixtures/. Render and page errors are printed, and any failure exits non-zero.ORCA_FIXTURES_BROWSER, then the Playwright-managedchromium-headless-shell(already in the local Playwright cache for playwright-core 1.64.1), then an installed Chrome or Edge. Every option is headless, so no windows open and focus is never taken.config/scripts/component-fixtures/fixtures-mcp-server.mjsis a dependency-free stdio JSON-RPC server, registered in a new root.mcp.json. It exposeslist_fixturesandscreenshot_fixture, which returns up to 6 inline PNGs plus their paths.@modelcontextprotocol/sdkis only a transitive dependency, so I did not add a direct dependency.AgentStateDot(all states, md, with progress),ui/button(variants, sizes),DetachedHeadBadge,sidebar/DeleteWorktreeDirtyChangeHint(checking, clean, unavailable, dirty, and dirty with the paths popover open viainteract), andsettings/AppearanceAdvancedDisclosure(collapsed, expanded, and forced open by a seededsettingsSearchQuery).import.meta.globin the fixture entry, which is not an input of any production build.build:weboutput contains no fixture code; I checked by greppingout/web..oxlintrc.jsonrejects static imports of*.fixturemodules from non-test files.*.fixture.tsxas entries..cache/fixtures/is gitignored.docs/reference/component-fixtures.md, plus one line in AGENTS.md under Electron UI Validation.Why
Booting Electron to check a badge or button state takes tens of seconds and requires careful window and focus isolation. A fixture screenshot takes about 100 ms once warm, so it can sit in an agent's edit loop. The design reuses the web build's Vite settings and preload shims instead of a parallel mock layer. I chose a long-lived server with a warm page over a fresh page per shot because a fresh page costs about 1 s or more per screenshot in unbundled dev mode.
Linked Issue
N/A (maintainer devex change)
Visual Proof
No product UI changes. Example output:
pnpm fixtures:shot sidebar/DeleteWorktreeDirtyChangeHint:dirtyWithPathsOpenwrites.cache/fixtures/sidebar__DeleteWorktreeDirtyChangeHint--dirtyWithPathsOpen.dark.png(about 360×250 at 1x). It shows the red "17 uncommitted or untracked changes ›" trigger, and below it the open popover: the "Loaded changed paths" heading, the "may be incomplete" note, and five monospace paths with colored M/M/A/U/D status letters. The second path carries an "Unresolved conflict" tag, and the list ends with "and 12 more loaded paths". It renders in the dark theme with the app's real fonts.Testing
Measured on macOS (Apple Silicon):
Verified an edited component shows up in the next shot, and a fixture file added while the server is running shows up in
--list.Verified that an unknown id and a render error both report clearly and exit 1.
MCP checked end to end over stdio: initialize, tools/list, list_fixtures, screenshot_fixture with inline images, and the error path.
pnpm tc,pnpm run check:code-quality:changed, and repo-wideoxlintpass. knip reports no findings for the new files; its existing findings are unchanged.I manually tested these changes locally
Automated tests added/updated, or explained why not below: this is dev tooling exercised by running it, and the fixtures themselves are the regression surface.
Notes / Limitations
$electron.window.apiis the web client bridge with no runtime paired. Runtime-backed calls reject, and namespaces without a web implementation return the web fallback defaults. Usesetup()to stub what a fixture needs..mcp.jsonis read by Claude Code. Other agent CLIs need their own config pointing at the same script.windowsHide, path normalization), but I only ran this on macOS.