Skip to content

feat(devex): component fixtures with headless screenshots for fast UI checks - #33406

Merged
nwparker merged 7 commits into
mainfrom
nwparker/component-fixtures
Oct 25, 2026
Merged

nwparker merged 7 commits into
mainfrom
nwparker/component-fixtures

Conversation

@nwparker

@nwparker nwparker commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 0 0 0 0
Prod 26 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1537 $\color{#cf222e}{\Huge{\mathbf{−}}}$​34 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1503

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.tsx file next to the component that says how to render it, then run pnpm fixtures:shot <id>.

What Changed

  • Fixture convention. Colocated *.fixture.tsx files export named fixtures built with defineFixture({ render, store?, setup?, interact?, width? }). Ids are the path under src/renderer/src/ with components/ and .fixture.tsx removed, plus the export name. For example, sidebar/DeleteWorktreeDirtyChangeHint:dirty.
  • Explorer entry. src/renderer/fixtures.html and src/renderer/src/component-fixtures/ are served by vite.fixtures.config.ts, which mergeConfigs vite.web.config.ts. Each fixture renders inside I18nProvider, TooltipProvider, the document theme class, and the default-seeded app store (getDefaultSettings()). window.api is the existing web client bridge (installWebPreloadApi + withFallback), so no new mock layer was added. Routes: /fixtures is the index, and /fixtures?id=<id>&theme=dark|light renders one fixture. Open popovers, menus, and dialogs are captured too: the screenshot clip covers the frame plus everything portaled to <body>.
  • CLI. 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.
    • One warm page re-renders in place. It reloads only after a watched source change, so screenshots never show stale code.
    • The server restarts itself when its own scripts change and exits after 20 idle minutes.
    • Browser lookup order: ORCA_FIXTURES_BROWSER, then the Playwright-managed chromium-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.
  • MCP. config/scripts/component-fixtures/fixtures-mcp-server.mjs is a dependency-free stdio JSON-RPC server, registered in a new root .mcp.json. It exposes list_fixtures and screenshot_fixture, which returns up to 6 inline PNGs plus their paths. @modelcontextprotocol/sdk is only a transitive dependency, so I did not add a direct dependency.
  • Fixtures. Five fixture files, 15 fixtures: AgentStateDot (all states, md, with progress), ui/button (variants, sizes), DetachedHeadBadge, sidebar/DeleteWorktreeDirtyChangeHint (checking, clean, unavailable, dirty, and dirty with the paths popover open via interact), and settings/AppearanceAdvancedDisclosure (collapsed, expanded, and forced open by a seeded settingsSearchQuery).
  • Guards.
    • Fixtures load only through import.meta.glob in the fixture entry, which is not an input of any production build. build:web output contains no fixture code; I checked by grepping out/web.
    • .oxlintrc.json rejects static imports of *.fixture modules from non-test files.
    • knip gets the explorer and *.fixture.tsx as entries.
    • .cache/fixtures/ is gitignored.
  • Docs. New 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:dirtyWithPathsOpen writes .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):

Scenario Time
Cold first screenshot, no Vite dep cache and no server ~13 s
Cold server, Vite cache already warm ~10.5 s
Warm single screenshot, CLI wall clock ~0.3 s (~100 ms render + capture)
Warm batch: 15 fixtures × 2 themes ~4.3 s total, median 114 ms per shot
First shot after editing a component ~1.7 s (page reload)
  • 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-wide oxlint pass. 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

  • Fixtures suit mostly presentational components. Components that need a paired runtime, terminals, Monaco, or deep store graphs still need $electron.
  • window.api is the web client bridge with no runtime paired. Runtime-backed calls reject, and namespaces without a web implementation return the web fallback defaults. Use setup() to stub what a fixture needs.
  • Module-level state outside the zustand store (i18n readiness, caches) persists across fixtures inside the warm page. If the first in-place render fails, the runner retries once on a fresh page load.
  • The first shot after an edit pays a full page reload (about 1–2 s). HMR is off in the screenshot server to avoid racing reloads.
  • .mcp.json is read by Claude Code. Other agent CLIs need their own config pointing at the same script.
  • Windows and Linux paths are handled (detached spawn with windowsHide, path normalization), but I only ran this on macOS.

… 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.
@nwparker
nwparker marked this pull request as ready for review October 25, 2026 05:53
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Walkthrough

The change adds a renderer component-fixture format, explorer, and sample fixtures. A browser-backed server lists fixtures and captures screenshots. Developers can use a CLI or MCP tools to list fixtures and request screenshots. The change also adds fixture-specific Vite settings, repository configuration, and documentation.

Priority: Normal

Merge Risk: Low · up to 23871

Screenshot commands can report success for a failed JSON request or produce an unintended or overwritten image. Fixture setup can also affect a later capture, though the current samples do not use it. These bounded workflow risks warrant fixes or explicit acceptance before merge.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 12.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 62 functions across 17 files. (6 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title "feat(devex): component fixtures with headless screenshots for fast UI checks" matches the main change. The PR adds colocated *.fixture.tsx files, a fixture explorer, and a `pnpm fixtures:…
Description check Passed The description follows the repository template and covers its main sections. It has an ELI5 section, a detailed What Changed section, a Why section with the design rationale, and a Testing section wi…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title "feat(devex): component fixtures with headless screenshots for fast UI checks" matches the main change. The PR adds colocated *.fixture.tsx files, a fixture explorer, and a pnpm fixtures:shot CLI that renders components in headless Chromium and writes PNGs. The title is specific and follows a conventional-commit style. It is slightly long at 76 characters.

Full details: Description check

Explanation

The description follows the repository template and covers its main sections. It has an ELI5 section, a detailed What Changed section, a Why section with the design rationale, and a Testing section with measured timings and checklist items. The Visual Proof section gives a textual description of the output rather than an attached image. The Linked Issue section is marked N/A with the reason 'maintainer devex change'. The template says a linked issue should always exist for outside contributors, so this should be confirmed. The Review, Agent skill upstream boundary, and AI Disclosure sections are empty or unchecked. The Checklist is only partly ticked, and the 'Self-reviewed' and 'Cross-platform' items are not confirmed. The Notes section lists the cross-platform limitations, but the Windows and Linux paths were not run.

Resolution

Optional: attach the before/after or example screenshot image rather than only describing it. Confirm that no linked issue is required for this author. Complete or mark the AI Disclosure, Agent skill upstream boundary, and Review sections. Tick the remaining checklist items after confirming them, including the Cross-platform and SSH items.

Full details: Docstring Coverage

Explanation

Docstring coverage is 12.90% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 62 functions across 17 files. (6 skipped: 6 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Commits

Reviewing files that changed from the base of the PR and between 62d9706 and 9f2cc08.

Files selected for processing: 24
  • .gitignore
  • .mcp.json
  • .oxlintrc.json
  • AGENTS.md
  • config/knip.json
  • config/scripts/component-fixtures/fixture-page-runner.mjs
  • config/scripts/component-fixtures/fixture-paths.mjs
  • config/scripts/component-fixtures/fixture-server-client.mjs
  • config/scripts/component-fixtures/fixture-server.mjs
  • config/scripts/component-fixtures/fixtures-mcp-server.mjs
  • config/scripts/component-fixtures/fixtures-shot.mjs
  • docs/reference/component-fixtures.md
  • package.json
  • src/renderer/fixtures.html
  • src/renderer/src/component-fixtures/ComponentFixtureFrame.tsx
  • src/renderer/src/component-fixtures/component-fixture-explorer.tsx
  • src/renderer/src/component-fixtures/component-fixture-registry.ts
  • src/renderer/src/component-fixtures/component-fixture.ts
  • src/renderer/src/components/AgentStateDot.fixture.tsx
  • src/renderer/src/components/DetachedHeadBadge.fixture.tsx
  • src/renderer/src/components/settings/AppearanceAdvancedDisclosure.fixture.tsx
  • src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.fixture.tsx
  • src/renderer/src/components/ui/button.fixture.tsx
  • vite.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.

Comment thread .mcp.json Resolved
Comment on lines +55 to +58
if (values.json) {
console.log(JSON.stringify({ ...payload, totalMs: Math.round(performance.now() - startedAt) }))
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

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 change
if (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
}

Comment on lines +98 to +99
// Why replace: each fixture starts from the same seeded store, even in a reused page.
useAppStore.setState(baseStoreState, true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

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() and Reflect.deleteProperty(...) calls (Lines 34-36) with installFixtureBridge().

Comment thread config/vite.fixtures.config.ts Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Commits

Reviewing files that changed from the base of the PR and between 9f2cc08 and 2387171.

Files selected for processing: 9
  • .gitignore
  • AGENTS.md
  • config/focus-setting-read-baseline.txt
  • config/localization-coverage-allowlist.json
  • config/scripts/component-fixtures/fixture-paths.mjs
  • config/scripts/component-fixtures/fixture-server.mjs
  • config/vite.fixtures.config.ts
  • docs/reference/component-fixtures.md
  • package.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.

Comment on lines +26 to +30
const fileStem = id
.replaceAll('/', '__')
.replaceAll(':', '--')
.replace(/[^\w.-]/g, '_')
return path.join(outDir, `${fileStem}.${theme}.png`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

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.

Comment on lines +88 to +89
const themes =
body.theme === 'both' ? ['dark', 'light'] : [body.theme === 'light' ? 'light' : 'dark']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor · Functional Correctness · Quick win

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.

@nwparker
nwparker merged commit a30d4f4 into main Oct 25, 2026
56 checks passed
@nwparker
nwparker deleted the nwparker/component-fixtures branch October 25, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant