From a30d4f4f287f0d1e8adb85df5ce026db1e757ef5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:23:32 -0700 Subject: [PATCH] feat(devex): component fixtures with headless screenshots for fast UI checks (#33406) * feat(devex): component fixtures with headless screenshots for fast UI 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 [--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. * Keep fixture config and agent config inside reviewed root entries * Baseline the fixture page's default-settings seed in the focus-read ratchet * docs(agents): shorten fixtures pointer * Allowlist the dev-only fixture explorer's labels in localization coverage --- .gitignore | 2 + .mcp.json | 9 + .oxlintrc.json | 4 + AGENTS.md | 1 + config/focus-setting-read-baseline.txt | 69 +++--- config/knip.json | 2 + config/localization-coverage-allowlist.json | 14 ++ .../fixture-page-runner.mjs | 122 ++++++++++ .../component-fixtures/fixture-paths.mjs | 41 ++++ .../fixture-server-client.mjs | 137 +++++++++++ .../component-fixtures/fixture-server.mjs | 216 ++++++++++++++++++ .../fixtures-mcp-server.mjs | 156 +++++++++++++ .../component-fixtures/fixtures-shot.mjs | 85 +++++++ config/vite.fixtures.config.ts | 26 +++ docs/reference/component-fixtures.md | 57 +++++ package.json | 2 + src/renderer/fixtures.html | 12 + .../ComponentFixtureFrame.tsx | 136 +++++++++++ .../component-fixture-explorer.tsx | 138 +++++++++++ .../component-fixture-registry.ts | 70 ++++++ .../component-fixtures/component-fixture.ts | 31 +++ .../src/components/AgentStateDot.fixture.tsx | 44 ++++ .../components/DetachedHeadBadge.fixture.tsx | 34 +++ .../AppearanceAdvancedDisclosure.fixture.tsx | 51 +++++ .../DeleteWorktreeDirtyChangeHint.fixture.tsx | 55 +++++ .../src/components/ui/button.fixture.tsx | 57 +++++ 26 files changed, 1537 insertions(+), 34 deletions(-) create mode 100644 .mcp.json create mode 100644 config/scripts/component-fixtures/fixture-page-runner.mjs create mode 100644 config/scripts/component-fixtures/fixture-paths.mjs create mode 100644 config/scripts/component-fixtures/fixture-server-client.mjs create mode 100644 config/scripts/component-fixtures/fixture-server.mjs create mode 100644 config/scripts/component-fixtures/fixtures-mcp-server.mjs create mode 100644 config/scripts/component-fixtures/fixtures-shot.mjs create mode 100644 config/vite.fixtures.config.ts create mode 100644 docs/reference/component-fixtures.md create mode 100644 src/renderer/fixtures.html create mode 100644 src/renderer/src/component-fixtures/ComponentFixtureFrame.tsx create mode 100644 src/renderer/src/component-fixtures/component-fixture-explorer.tsx create mode 100644 src/renderer/src/component-fixtures/component-fixture-registry.ts create mode 100644 src/renderer/src/component-fixtures/component-fixture.ts create mode 100644 src/renderer/src/components/AgentStateDot.fixture.tsx create mode 100644 src/renderer/src/components/DetachedHeadBadge.fixture.tsx create mode 100644 src/renderer/src/components/settings/AppearanceAdvancedDisclosure.fixture.tsx create mode 100644 src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.fixture.tsx create mode 100644 src/renderer/src/components/ui/button.fixture.tsx diff --git a/.gitignore b/.gitignore index ae80582d7c8..ffa6ea54cde 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ desktop.ini /.cache/vite/ /.cache/vite.lock/ /.cache/typecheck/ +/.cache/fixtures/ /.cache/check-changed.json # Logs @@ -119,6 +120,7 @@ docs/** !docs/reference/cross-platform-support.md !docs/reference/git-workflow.md !docs/reference/test-antipatterns.md +!docs/reference/component-fixtures.md !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..6d7f6de6701 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "orca-component-fixtures": { + "type": "stdio", + "command": "node", + "args": ["config/scripts/component-fixtures/fixtures-mcp-server.mjs"] + } + } +} diff --git a/.oxlintrc.json b/.oxlintrc.json index 52048d08f5b..5957be6e444 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -170,6 +170,10 @@ { "regex": "(?i)(?:^|[/\\\\])(?:web-session-tabs-sync-test-harness|web-runtime-browser-creation-placement-test-rig)(?:\\.[cm]?[jt]sx?)?(?:[?#].*)?$", "message": "This module is unit-test support. Import the production web-session owner instead." + }, + { + "regex": "\\.fixture(?:\\.tsx?)?(?:[?#].*)?$", + "message": "Component fixtures are dev-only and load through the fixture explorer's import.meta.glob; importing one would ship it in a production bundle." } ] } diff --git a/AGENTS.md b/AGENTS.md index 19ac0925ff8..999d910af95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Never steal monitor focus or reveal test windows: no `show()`, `showInactive()`, Rebuild modified launch-policy code before running an app; stale build wrappers are not safe. Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation. +Screenshot presentational components without Electron: `pnpm fixtures:shot ` ([fixtures](docs/reference/component-fixtures.md)). # Code Style diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 7e7f901c675..4082fa759ec 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -3,17 +3,12 @@ # This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read # the default host, through defaultCreationHost. Everything else routes by the owner. # Prune after removing reads: pnpm check:focus-read-ratchet --prune -2 src/renderer/src/App.tsx 3 src/renderer/src/app-shell/use-app-shell-services.ts -1 src/renderer/src/components/JiraIssueWorkspace.tsx -1 src/renderer/src/components/Landing.tsx -2 src/renderer/src/components/LinearIssueTextEditor.tsx -3 src/renderer/src/components/LinearIssueWorkspace.tsx -3 src/renderer/src/components/LinearItemDrawer.tsx -1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx -1 src/renderer/src/components/activity/ActivityPrototypePage.tsx +2 src/renderer/src/App.tsx +1 src/renderer/src/component-fixtures/component-fixture-explorer.tsx # dev-only fixture page seeds default settings 1 src/renderer/src/components/activity/activity-scope-filter-controls.tsx 2 src/renderer/src/components/activity/activity-thread-actions.ts +1 src/renderer/src/components/activity/ActivityPrototypePage.tsx 1 src/renderer/src/components/activity/use-agent-pane-threads.ts 4 src/renderer/src/components/agent/AgentSettingsDialog.tsx 1 src/renderer/src/components/automations/automation-host-client.ts @@ -23,17 +18,17 @@ 2 src/renderer/src/components/automations/use-automations-page-controller.ts 1 src/renderer/src/components/automations/use-automations-page-destination-form.ts 2 src/renderer/src/components/automations/use-automations-page-setup-state.ts -1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx 1 src/renderer/src/components/browser-pane/browser-reopen-on-server.ts +1 src/renderer/src/components/browser-pane/ReopenBrowserPageOnServerButton.tsx 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-stream-activation.ts 2 src/renderer/src/components/cmd-j/quick-action-context.ts +1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +1 src/renderer/src/components/editor/csv/csv-link-routing.ts 2 src/renderer/src/components/editor/EditorContent.tsx 1 src/renderer/src/components/editor/EditorDiffFileSurface.tsx 1 src/renderer/src/components/editor/EditorEditFileSurface.tsx 1 src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx -1 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx -1 src/renderer/src/components/editor/csv/csv-link-routing.ts 1 src/renderer/src/components/editor/markdown-preview-link-actions.ts 1 src/renderer/src/components/editor/markdown-preview-system-link-action.ts 1 src/renderer/src/components/editor/restored-editor-workspace-runtime-owner.ts @@ -51,7 +46,9 @@ 1 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts 1 src/renderer/src/components/jira-connect-dialog.tsx 1 src/renderer/src/components/jira-user-picker.tsx +1 src/renderer/src/components/JiraIssueWorkspace.tsx 1 src/renderer/src/components/landing-preflight-runtime.ts +1 src/renderer/src/components/Landing.tsx 1 src/renderer/src/components/linear-api-key-dialog.tsx 3 src/renderer/src/components/linear-issue-project-selector.tsx 2 src/renderer/src/components/linear-issue-sub-issues.tsx @@ -59,44 +56,47 @@ 2 src/renderer/src/components/linear-item-drawer-comment-footer.tsx 6 src/renderer/src/components/linear-item-drawer-edit-controller.tsx 1 src/renderer/src/components/linear-item-drawer-edit-section.tsx -1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +2 src/renderer/src/components/LinearIssueTextEditor.tsx +3 src/renderer/src/components/LinearIssueWorkspace.tsx +3 src/renderer/src/components/LinearItemDrawer.tsx 2 src/renderer/src/components/native-chat/native-chat-http-link-source-owner.ts 1 src/renderer/src/components/native-chat/native-chat-link-target-actions.ts +1 src/renderer/src/components/native-chat/NativeChatVisualFrame.tsx +1 src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx 1 src/renderer/src/components/new-workspace/use-jira-source-connection.ts 4 src/renderer/src/components/new-workspace/use-linear-source-connection.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-name-field-controller.ts 1 src/renderer/src/components/new-workspace/use-smart-workspace-secondary-searches.ts 2 src/renderer/src/components/new-workspace/use-work-item-source-foundation.ts 1 src/renderer/src/components/new-workspace/use-work-item-source-search.ts -1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx 3 src/renderer/src/components/right-sidebar/checks-panel-hosted-review-click-routing.ts 1 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx +1 src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx 1 src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-context-menu.tsx 1 src/renderer/src/components/right-sidebar/use-create-pull-request-base-ref-discovery.ts 1 src/renderer/src/components/right-sidebar/use-file-explorer-tree-pane-state.ts 1 src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts 1 src/renderer/src/components/settings/BaseRefPicker.tsx +1 src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx +1 src/renderer/src/components/settings/bitbucket-integration-card.tsx 2 src/renderer/src/components/settings/BrowserPane.tsx +3 src/renderer/src/components/settings/cli-source-control-integration-cards.tsx 1 src/renderer/src/components/settings/CliSkillRuntimeSetup.tsx 1 src/renderer/src/components/settings/GeneralPane.tsx 1 src/renderer/src/components/settings/IntegrationsPane.tsx +3 src/renderer/src/components/settings/jira-integration-card.tsx +1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx 3 src/renderer/src/components/settings/LinearAgentSkillPane.tsx +1 src/renderer/src/components/settings/provider-account-scope.ts 4 src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +2 src/renderer/src/components/settings/settings-setup-guide-progress.ts 2 src/renderer/src/components/settings/Settings.tsx 1 src/renderer/src/components/settings/SettingsSetupGuidePane.tsx 1 src/renderer/src/components/settings/SettingsSidebar.tsx 1 src/renderer/src/components/settings/ShortcutsPane.tsx -2 src/renderer/src/components/settings/TasksPane.tsx -1 src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx -1 src/renderer/src/components/settings/bitbucket-credentials-dialog.tsx -1 src/renderer/src/components/settings/bitbucket-integration-card.tsx -3 src/renderer/src/components/settings/cli-source-control-integration-cards.tsx -3 src/renderer/src/components/settings/jira-integration-card.tsx -1 src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx -1 src/renderer/src/components/settings/provider-account-scope.ts -2 src/renderer/src/components/settings/settings-setup-guide-progress.ts 1 src/renderer/src/components/settings/source-control-preflight-card-status.ts 2 src/renderer/src/components/settings/task-tracker-integration-cards.tsx +2 src/renderer/src/components/settings/TasksPane.tsx 2 src/renderer/src/components/settings/token-source-control-integration-cards.tsx 2 src/renderer/src/components/settings/use-integration-provider-status-refresh.ts 1 src/renderer/src/components/settings/use-runtime-environment-mutation-actions.ts @@ -104,27 +104,24 @@ 3 src/renderer/src/components/settings/use-settings-store-model.ts 2 src/renderer/src/components/settings/use-settings-terminal-model.ts 4 src/renderer/src/components/settings/use-task-source-provider-readiness.ts +1 src/renderer/src/components/settings/WorkspaceDirectorySetting.tsx 1 src/renderer/src/components/setup-guide/SetupGuideModal.tsx 1 src/renderer/src/components/setup-guide/SetupGuideTelemetryObserver.tsx 2 src/renderer/src/components/setup-guide/use-setup-guide-progress.ts +2 src/renderer/src/components/sidebar/delete-worktree-flow.ts 1 src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts 3 src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts 1 src/renderer/src/components/sidebar/SavedHostScopeWidenGate.tsx +1 src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts 1 src/renderer/src/components/sidebar/SetupGuideSidebarEntry.tsx 1 src/renderer/src/components/sidebar/SetupScriptPromptCard.tsx +2 src/renderer/src/components/sidebar/sidebar-host-options.ts +1 src/renderer/src/components/sidebar/sidebar-project-drop.ts 1 src/renderer/src/components/sidebar/SidebarAgentsList.tsx 1 src/renderer/src/components/sidebar/SidebarSettingsHelpMenu.tsx 1 src/renderer/src/components/sidebar/SidebarTaskNavButton.tsx -1 src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx -1 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx -1 src/renderer/src/components/sidebar/WorktreeList.tsx -1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx -2 src/renderer/src/components/sidebar/delete-worktree-flow.ts -1 src/renderer/src/components/sidebar/linear-agent-skill-runtime.ts -2 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts -1 src/renderer/src/components/sidebar/setup-script-prompt-render-state.ts -2 src/renderer/src/components/sidebar/sidebar-host-options.ts -1 src/renderer/src/components/sidebar/sidebar-project-drop.ts 2 src/renderer/src/components/sidebar/use-add-repo-host-selection.ts 1 src/renderer/src/components/sidebar/use-delete-worktree-status-hydration.ts 1 src/renderer/src/components/sidebar/use-sidebar-host-scope-options.ts @@ -140,6 +137,8 @@ 1 src/renderer/src/components/sidebar/workspace-options-menu-items.tsx 2 src/renderer/src/components/sidebar/workspace-reference-details.ts 1 src/renderer/src/components/sidebar/workspace-reference-refresh-targets.ts +1 src/renderer/src/components/sidebar/WorkspaceAttachmentSearch.tsx +1 src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx 1 src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx 1 src/renderer/src/components/sidebar/worktree-filter-visibility.ts 1 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -147,12 +146,14 @@ 1 src/renderer/src/components/sidebar/worktree-list/listing/use-window-list-model.ts 1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx 1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts +1 src/renderer/src/components/sidebar/WorktreeList.tsx +1 src/renderer/src/components/sidebar/WorktreeVisibilityDialog.tsx 1 src/renderer/src/components/skills/SkillsPage.tsx 2 src/renderer/src/components/status-bar/ClaudeSwitcherMenu.tsx 1 src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +2 src/renderer/src/components/status-bar/provider-account-sync-key.ts 1 src/renderer/src/components/status-bar/SshStatusSegment.tsx 1 src/renderer/src/components/status-bar/StatusBarSurface.tsx -2 src/renderer/src/components/status-bar/provider-account-sync-key.ts 1 src/renderer/src/components/status-bar/usage-provider-settings-target.ts 2 src/renderer/src/components/status-bar/use-codex-switcher-controller.ts 1 src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -165,7 +166,6 @@ 1 src/renderer/src/components/tab-bar/windows-shell-menu-visibility.ts 3 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts 1 src/renderer/src/components/task-page-jira-status-order.ts -9 src/renderer/src/components/task-page/TaskPage.tsx 2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx 1 src/renderer/src/components/task-page/linear/Filters.tsx 2 src/renderer/src/components/task-page/linear/task-page-linear-issue-model.tsx @@ -173,6 +173,7 @@ 6 src/renderer/src/components/task-page/linear/use-task-page-linear-creation-state.ts 4 src/renderer/src/components/task-page/linear/use-task-page-linear-issue-creation.ts 2 src/renderer/src/components/task-page/linear/use-task-page-linear-project-creation.ts +9 src/renderer/src/components/task-page/TaskPage.tsx 2 src/renderer/src/components/task-page/use-task-page-source-availability.ts 1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts 1 src/renderer/src/components/terminal-pane/retire-unbound-runtime-terminal-pane.ts diff --git a/config/knip.json b/config/knip.json index 7df23412276..f92123a5a85 100644 --- a/config/knip.json +++ b/config/knip.json @@ -19,6 +19,8 @@ "src/renderer/src/main.tsx", "src/renderer/src/popout.tsx", "src/renderer/src/web/main.tsx", + "src/renderer/src/component-fixtures/component-fixture-explorer.tsx", + "src/renderer/src/**/*.fixture.tsx", "src/renderer/src/**/*.worker.ts", "src/cli/index.ts", "src/wsl-guest/wsl-agent-hook-relay.ts", diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index f13ffd0c55f..a9ec45d54dd 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -82,5 +82,19 @@ "text": "Switch language", "dynamic": false, "count": 1 + }, + { + "filePath": "src/renderer/src/component-fixtures/component-fixture-explorer.tsx", + "kind": "jsx-text", + "text": "Component fixtures", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/component-fixtures/component-fixture-explorer.tsx", + "kind": "jsx-text", + "text": "fixtures · files named *.fixture.tsx under src/renderer/src", + "dynamic": false, + "count": 1 } ] diff --git a/config/scripts/component-fixtures/fixture-page-runner.mjs b/config/scripts/component-fixtures/fixture-page-runner.mjs new file mode 100644 index 00000000000..851747bd917 --- /dev/null +++ b/config/scripts/component-fixtures/fixture-page-runner.mjs @@ -0,0 +1,122 @@ +import { fixtureScreenshotPath } from './fixture-paths.mjs' + +const FIXTURE_TIMEOUT_MS = 60_000 + +// One warm page per device scale re-renders fixtures in place. It reloads only +// after a source change, so each shot still reflects the files on disk. +export function createFixturePageRunner(baseUrl, browserPromise) { + const workers = new Map() + let sourceRevision = 0 + let cachedList = null + let queue = Promise.resolve() + + function serialize(task) { + const run = queue.then(task, task) + queue = run.catch(() => {}) + return run + } + + async function createWorker(scale) { + const browser = await browserPromise + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: scale + }) + const page = await context.newPage() + const worker = { page, revision: -1, pageErrors: [] } + page.on('pageerror', (error) => worker.pageErrors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') { + worker.pageErrors.push(message.text()) + } + }) + return worker + } + + async function readyWorker(scale) { + if (!workers.has(scale)) { + workers.set(scale, createWorker(scale)) + } + const worker = await workers.get(scale) + if (worker.revision !== sourceRevision) { + const revision = sourceRevision + await worker.page.goto(`${baseUrl}/fixtures.html?worker=1`) + await worker.page.waitForFunction( + () => Boolean(window.__orcaComponentFixtureHost), + undefined, + { timeout: FIXTURE_TIMEOUT_MS } + ) + worker.revision = revision + } + worker.pageErrors = [] + return worker + } + + async function waitForFixtureOutcome(page) { + await page.waitForFunction( + () => ['ready', 'error'].includes(document.documentElement.dataset.fixtureState ?? ''), + undefined, + { timeout: FIXTURE_TIMEOUT_MS } + ) + return page.evaluate(() => + document.documentElement.dataset.fixtureState === 'error' + ? (document.documentElement.dataset.fixtureError ?? 'Fixture failed.') + : null + ) + } + + function listFixtures() { + return serialize(async () => { + if (cachedList?.revision !== sourceRevision) { + const revision = sourceRevision + const { page } = await readyWorker(1) + const fixtures = await page.evaluate(() => window.__orcaComponentFixtureHost.list()) + cachedList = { revision, fixtures } + } + return cachedList.fixtures + }) + } + + async function renderInWorker(id, theme, scale) { + const worker = await readyWorker(scale) + await worker.page.evaluate( + ([fixtureId, fixtureTheme]) => + window.__orcaComponentFixtureHost.render(fixtureId, fixtureTheme), + [id, theme] + ) + return { worker, failure: await waitForFixtureOutcome(worker.page) } + } + + function screenshotFixture(id, theme, scale, outDir) { + return serialize(async () => { + const startedAt = performance.now() + let { worker, failure } = await renderInWorker(id, theme, scale) + if (failure) { + // Why retry fresh: a reused page can hold stale deps (e.g. after Vite + // re-optimizes), so only a failure on a clean load is reported. + worker.revision = -1 + ;({ worker, failure } = await renderInWorker(id, theme, scale)) + } + const pageErrors = worker.pageErrors + if (failure) { + return { id, theme, error: failure, pageErrors } + } + const path = fixtureScreenshotPath(outDir, id, theme) + const clip = await worker.page.evaluate(() => + JSON.parse(document.documentElement.dataset.fixtureClip ?? 'null') + ) + await (clip + ? worker.page.screenshot({ path, clip, animations: 'disabled' }) + : worker.page + .locator('#component-fixture-frame') + .screenshot({ path, animations: 'disabled' })) + return { id, theme, path, ms: Math.round(performance.now() - startedAt), pageErrors } + }) + } + + function markSourceChanged() { + sourceRevision += 1 + } + + return { listFixtures, screenshotFixture, markSourceChanged } +} diff --git a/config/scripts/component-fixtures/fixture-paths.mjs b/config/scripts/component-fixtures/fixture-paths.mjs new file mode 100644 index 00000000000..16a2a59ae63 --- /dev/null +++ b/config/scripts/component-fixtures/fixture-paths.mjs @@ -0,0 +1,41 @@ +import { readdirSync, statSync } from 'node:fs' +import path from 'node:path' + +export const repoRoot = path.resolve(import.meta.dirname, '../../..') +export const fixtureCacheDir = path.join(repoRoot, '.cache', 'fixtures') +export const serverStatePath = path.join(fixtureCacheDir, 'server.json') +export const serverLockPath = path.join(fixtureCacheDir, 'server.lock') +export const serverLogPath = path.join(fixtureCacheDir, 'server.log') +export const serverScriptPath = path.join( + repoRoot, + 'config', + 'scripts', + 'component-fixtures', + 'fixture-server.mjs' +) + +// Why: a detached server outlives edits to these scripts; clients restart it on mismatch. +export function fixtureServerCodeStamp() { + const scriptDir = path.dirname(serverScriptPath) + const configPath = path.join(repoRoot, 'config', 'vite.fixtures.config.ts') + const files = [...readdirSync(scriptDir).map((name) => path.join(scriptDir, name)), configPath] + return String(Math.max(...files.map((file) => statSync(file).mtimeMs))) +} + +export function fixtureScreenshotPath(outDir, id, theme) { + const fileStem = id + .replaceAll('/', '__') + .replaceAll(':', '--') + .replace(/[^\w.-]/g, '_') + return path.join(outDir, `${fileStem}.${theme}.png`) +} + +// `*` matches within the id; a bare component id (no `:`) matches all of its fixtures. +export function matchFixtureIds(knownIds, pattern) { + if (!pattern.includes('*')) { + return knownIds.filter((id) => id === pattern || id.startsWith(`${pattern}:`)) + } + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*') + const matcher = new RegExp(`^${escaped}$`) + return knownIds.filter((id) => matcher.test(id)) +} diff --git a/config/scripts/component-fixtures/fixture-server-client.mjs b/config/scripts/component-fixtures/fixture-server-client.mjs new file mode 100644 index 00000000000..0cf5e990fba --- /dev/null +++ b/config/scripts/component-fixtures/fixture-server-client.mjs @@ -0,0 +1,137 @@ +import { spawn } from 'node:child_process' +import { closeSync, openSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { setTimeout as delay } from 'node:timers/promises' +import { + fixtureCacheDir, + fixtureServerCodeStamp, + repoRoot, + serverLockPath, + serverLogPath, + serverScriptPath, + serverStatePath +} from './fixture-paths.mjs' + +const STARTUP_TIMEOUT_MS = 120_000 +const STALE_LOCK_MS = 150_000 + +function readServerState() { + try { + return JSON.parse(readFileSync(serverStatePath, 'utf8')) + } catch { + return null + } +} + +async function probeServer(state) { + return Boolean(state) && (await readServerHealth(state))?.pid === state.pid +} + +async function readServerHealth(state) { + if (!state?.url) { + return null + } + try { + const response = await fetch(`${state.url}/__fixtures/health`, { + signal: AbortSignal.timeout(2000) + }) + const body = await response.json() + return body.ok === true ? body : null + } catch { + return null + } +} + +function tryAcquireStartLock() { + try { + closeSync(openSync(serverLockPath, 'wx')) + return true + } catch { + try { + if (Date.now() - statSync(serverLockPath).mtimeMs > STALE_LOCK_MS) { + unlinkSync(serverLockPath) + return tryAcquireStartLock() + } + } catch { + return tryAcquireStartLock() + } + return false + } +} + +function startServerProcess() { + const log = openSync(serverLogPath, 'w') + const child = spawn(process.execPath, [serverScriptPath], { + cwd: repoRoot, + detached: true, + windowsHide: true, + stdio: ['ignore', log, log], + env: process.env + }) + child.unref() + closeSync(log) + return child +} + +function readLogTail() { + try { + return readFileSync(serverLogPath, 'utf8').split('\n').slice(-20).join('\n') + } catch { + return '' + } +} + +/** Returns the running fixture server, starting a detached one if needed. */ +export async function ensureFixtureServer({ onStart } = {}) { + const existing = readServerState() + const health = await readServerHealth(existing) + if (health && health.pid === existing?.pid) { + if (health.codeStamp === fixtureServerCodeStamp()) { + return { url: existing.url, started: false } + } + await callFixtureServer(existing.url, 'stop', {}).catch(() => {}) + await delay(300) + } + await mkdir(fixtureCacheDir, { recursive: true }) + let exited = false + if (tryAcquireStartLock()) { + onStart?.() + startServerProcess().once('exit', () => { + exited = true + }) + } + const deadline = Date.now() + STARTUP_TIMEOUT_MS + while (Date.now() < deadline) { + const state = readServerState() + if (state && state.pid !== existing?.pid && (await probeServer(state))) { + return { url: state.url, started: true } + } + if (exited) { + throw new Error(`Fixture server exited during startup:\n${readLogTail()}`) + } + await delay(150) + } + throw new Error(`Fixture server did not start in time. Log tail:\n${readLogTail()}`) +} + +export async function callFixtureServer(url, endpoint, body) { + const response = await fetch(`${url}/__fixtures/${endpoint}`, { + method: body === undefined ? 'GET' : 'POST', + headers: body === undefined ? undefined : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body) + }) + const payload = await response.json() + if (!response.ok) { + throw new Error(payload.error ?? `Fixture server returned ${response.status}`) + } + return payload +} + +export async function stopFixtureServer() { + const state = readServerState() + if (!(await probeServer(state))) { + return false + } + await callFixtureServer(state.url, 'stop', {}) + return true +} diff --git a/config/scripts/component-fixtures/fixture-server.mjs b/config/scripts/component-fixtures/fixture-server.mjs new file mode 100644 index 00000000000..ef041dc3f25 --- /dev/null +++ b/config/scripts/component-fixtures/fixture-server.mjs @@ -0,0 +1,216 @@ +// Long-lived dev server for component fixtures: Vite + one headless Chromium. +// Started (detached) and reused by fixtures-shot.mjs and the fixtures MCP server. +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { createServer as createNetServer } from 'node:net' +import path from 'node:path' +import { chromium } from 'playwright-core' +import { createServer as createViteServer } from 'vite' +import { createFixturePageRunner } from './fixture-page-runner.mjs' +import { + fixtureCacheDir, + fixtureServerCodeStamp, + matchFixtureIds, + repoRoot, + serverLockPath, + serverStatePath +} from './fixture-paths.mjs' + +const fixtureRegistryPath = path + .join(repoRoot, 'src/renderer/src/component-fixtures/component-fixture-registry.ts') + .replaceAll('\\', '/') +const codeStamp = fixtureServerCodeStamp() +const IDLE_SHUTDOWN_MS = Number(process.env.ORCA_FIXTURES_IDLE_MS) || 20 * 60 * 1000 + +process.chdir(repoRoot) + +function findFreePort() { + return new Promise((resolve, reject) => { + const probe = createNetServer() + probe.once('error', reject) + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + probe.close(() => resolve(typeof address === 'object' && address ? address.port : 0)) + }) + }) +} + +// Prefers an explicit binary, then the Playwright-managed headless shell, then an +// installed Chrome/Edge. Every option runs headless, so no window or focus change. +async function launchHeadlessBrowser() { + const override = process.env.ORCA_FIXTURES_BROWSER + const attempts = [ + ...(override ? [{ label: override, options: { executablePath: override } }] : []), + { label: 'playwright chromium-headless-shell', options: {} }, + { label: 'installed Chrome', options: { channel: 'chrome' } }, + { label: 'installed Edge', options: { channel: 'msedge' } } + ] + const failures = [] + for (const attempt of attempts) { + try { + const browser = await chromium.launch({ headless: true, ...attempt.options }) + console.log(`[fixtures] browser: ${attempt.label}`) + return browser + } catch (error) { + failures.push(`${attempt.label}: ${String(error?.message ?? error).split('\n')[0]}`) + } + } + throw new Error( + `No headless Chromium found. Set ORCA_FIXTURES_BROWSER or run \`pnpm exec playwright install chromium-headless-shell\`.\n${failures.join('\n')}` + ) +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = '' + req.setEncoding('utf8') + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}) + } catch (error) { + reject(error) + } + }) + req.on('error', reject) + }) +} + +function sendJson(res, status, value) { + res.statusCode = status + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify(value)) +} + +async function handleShot(runner, body) { + const patterns = Array.isArray(body.ids) ? body.ids : [body.ids].filter(Boolean) + const themes = + body.theme === 'both' ? ['dark', 'light'] : [body.theme === 'light' ? 'light' : 'dark'] + const scale = Number(body.scale) > 0 ? Number(body.scale) : 1 + const outDir = body.outDir || fixtureCacheDir + // Why: exact ids skip listing, which would import every fixture module after an edit. + const exact = patterns.every((pattern) => pattern.includes(':') && !pattern.includes('*')) + const knownIds = exact ? patterns : (await runner.listFixtures()).map((entry) => entry.id) + const ids = [...new Set(patterns.flatMap((pattern) => matchFixtureIds(knownIds, pattern)))] + if (ids.length === 0) { + return { results: [], error: `No fixture matches ${patterns.join(', ')}.`, knownIds } + } + await mkdir(outDir, { recursive: true }) + const results = [] + for (const id of ids) { + for (const theme of themes) { + results.push(await runner.screenshotFixture(id, theme, scale, outDir)) + } + } + return { results } +} + +async function routeFixtureRequest(req, res, runner, stop) { + if (req.url === '/__fixtures/health') { + sendJson(res, 200, { ok: true, pid: process.pid, codeStamp }) + } else if (req.url === '/__fixtures/list') { + sendJson(res, 200, { fixtures: await runner.listFixtures() }) + } else if (req.url === '/__fixtures/shot' && req.method === 'POST') { + sendJson(res, 200, await handleShot(runner, await readJsonBody(req))) + } else if (req.url === '/__fixtures/stop' && req.method === 'POST') { + sendJson(res, 200, { ok: true }) + setImmediate(stop) + } else { + sendJson(res, 404, { error: `Unknown fixture endpoint ${req.url}` }) + } +} + +// Why: with HMR off Vite never re-expands import.meta.glob, so a new or deleted +// fixture file must invalidate the registry module that owns the glob. +function invalidateFixtureRegistry(server) { + const registryModules = server.moduleGraph.getModulesByFile(fixtureRegistryPath) + for (const module of registryModules ?? []) { + server.moduleGraph.invalidateModule(module) + } +} + +function createFixtureApiPlugin(session) { + return { + name: 'orca-component-fixture-api', + configureServer(server) { + // Any watched source change makes the warm page reload before its next shot. + server.watcher.on('all', (event, file) => { + if ((event === 'add' || event === 'unlink') && file.endsWith('.fixture.tsx')) { + invalidateFixtureRegistry(server) + } + session.runner?.markSourceChanged() + }) + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith('/__fixtures/')) { + next() + return + } + session.lastActivity = Date.now() + if (!session.runner) { + sendJson(res, 503, { error: 'Fixture browser is still starting.' }) + return + } + routeFixtureRequest(req, res, session.runner, session.stop).catch((error) => { + sendJson(res, 500, { error: String(error?.message ?? error) }) + }) + }) + } + } +} + +async function main() { + await mkdir(fixtureCacheDir, { recursive: true }) + const port = await findFreePort() + const baseUrl = `http://127.0.0.1:${port}` + // Why parallel: browser launch and Vite startup are independent cold costs. + const browserPromise = launchHeadlessBrowser() + browserPromise.catch(() => {}) + const session = { runner: null, lastActivity: Date.now(), stop: () => {} } + const vite = await createViteServer({ + configFile: 'config/vite.fixtures.config.ts', + clearScreen: false, + logLevel: 'warn', + // Why no HMR: the runner reloads its page on change, so pushed updates would only race it. + server: { port, strictPort: true, host: '127.0.0.1', open: false, hmr: false }, + plugins: [createFixtureApiPlugin(session)] + }) + await vite.listen() + await browserPromise + + let stopping = false + const stop = async () => { + if (stopping) { + return + } + stopping = true + clearInterval(idleTimer) + await rm(serverStatePath, { force: true }) + await (await browserPromise.catch(() => null))?.close() + await vite.close() + process.exit(0) + } + const idleTimer = setInterval(() => { + if (Date.now() - session.lastActivity > IDLE_SHUTDOWN_MS) { + void stop() + } + }, 30_000) + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => void stop()) + } + session.stop = () => void stop() + session.runner = createFixturePageRunner(baseUrl, browserPromise) + + await writeFile( + serverStatePath, + JSON.stringify({ pid: process.pid, port, url: baseUrl, codeStamp }) + ) + await rm(serverLockPath, { force: true }) + console.log(`[fixtures] ready at ${baseUrl}/fixtures.html`) +} + +main().catch(async (error) => { + console.error(`[fixtures] server failed: ${error?.stack ?? error}`) + await rm(serverLockPath, { force: true }) + process.exit(1) +}) diff --git a/config/scripts/component-fixtures/fixtures-mcp-server.mjs b/config/scripts/component-fixtures/fixtures-mcp-server.mjs new file mode 100644 index 00000000000..77bd6250163 --- /dev/null +++ b/config/scripts/component-fixtures/fixtures-mcp-server.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// Stdio MCP server exposing component fixture listing and screenshots to agents. +// Newline-delimited JSON-RPC 2.0; stdout carries protocol frames only. +import { readFile } from 'node:fs/promises' +import { createInterface } from 'node:readline' +import { callFixtureServer, ensureFixtureServer } from './fixture-server-client.mjs' + +const MAX_INLINE_IMAGES = 6 +const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'] + +const TOOLS = [ + { + name: 'list_fixtures', + description: + 'List Orca renderer component fixtures (*.fixture.tsx). Ids look like "sidebar/DeleteWorktreeDirtyChangeHint:dirty".', + inputSchema: { + type: 'object', + properties: { + filter: { type: 'string', description: 'Optional substring to filter fixture ids.' } + } + } + }, + { + name: 'screenshot_fixture', + description: + 'Render component fixtures headlessly and return PNG screenshots. Accepts an exact id, a component id (all its fixtures), or a glob with *.', + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Fixture id, component id, or glob (e.g. "sidebar/*").' + }, + theme: { type: 'string', enum: ['dark', 'light', 'both'], default: 'dark' }, + scale: { type: 'number', description: 'Device pixel ratio (default 1).', default: 1 } + }, + required: ['id'] + } + } +] + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`) +} + +function textResult(text, isError = false) { + return { content: [{ type: 'text', text }], isError } +} + +async function listFixturesTool(args) { + const { url } = await ensureFixtureServer() + const { fixtures } = await callFixtureServer(url, 'list') + const filter = typeof args.filter === 'string' ? args.filter : '' + const lines = fixtures + .filter((fixture) => fixture.id.includes(filter)) + .map((fixture) => `${fixture.id}\t${fixture.file}`) + return textResult(lines.join('\n') || 'No fixtures match.') +} + +async function screenshotFixtureTool(args) { + if (typeof args.id !== 'string' || !args.id) { + return textResult('`id` is required.', true) + } + const { url } = await ensureFixtureServer() + const payload = await callFixtureServer(url, 'shot', { + ids: [args.id], + theme: args.theme ?? 'dark', + scale: args.scale ?? 1 + }) + if (payload.error) { + return textResult(`${payload.error}\nKnown fixtures: ${payload.knownIds?.join(', ')}`, true) + } + const content = [] + let inlined = 0 + for (const result of payload.results) { + const errors = result.pageErrors?.length + ? `\npage errors:\n${result.pageErrors.join('\n')}` + : '' + if (result.error) { + content.push({ + type: 'text', + text: `FAIL ${result.id} [${result.theme}]: ${result.error}${errors}` + }) + continue + } + content.push({ type: 'text', text: `${result.id} [${result.theme}] ${result.path}${errors}` }) + if (inlined < MAX_INLINE_IMAGES) { + inlined += 1 + const data = (await readFile(result.path)).toString('base64') + content.push({ type: 'image', data, mimeType: 'image/png' }) + } + } + return { content, isError: payload.results.some((result) => result.error) } +} + +async function handleRequest(message) { + switch (message.method) { + case 'initialize': { + const requested = message.params?.protocolVersion + return { + protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requested) + ? requested + : SUPPORTED_PROTOCOL_VERSIONS[0], + capabilities: { tools: {} }, + serverInfo: { name: 'orca-component-fixtures', version: '0.1.0' } + } + } + case 'ping': + return {} + case 'tools/list': + return { tools: TOOLS } + case 'tools/call': { + const args = message.params?.arguments ?? {} + try { + if (message.params?.name === 'list_fixtures') { + return await listFixturesTool(args) + } + if (message.params?.name === 'screenshot_fixture') { + return await screenshotFixtureTool(args) + } + return textResult(`Unknown tool ${message.params?.name}`, true) + } catch (error) { + return textResult(String(error?.message ?? error), true) + } + } + default: + throw Object.assign(new Error(`Method not found: ${message.method}`), { code: -32601 }) + } +} + +const lines = createInterface({ input: process.stdin }) +lines.on('line', (line) => { + if (!line.trim()) { + return + } + let message + try { + message = JSON.parse(line) + } catch { + send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + return + } + // Notifications (no id) such as notifications/initialized need no reply. + if (message.id === undefined || message.id === null) { + return + } + handleRequest(message) + .then((result) => send({ jsonrpc: '2.0', id: message.id, result })) + .catch((error) => + send({ + jsonrpc: '2.0', + id: message.id, + error: { code: error.code ?? -32603, message: String(error?.message ?? error) } + }) + ) +}) diff --git a/config/scripts/component-fixtures/fixtures-shot.mjs b/config/scripts/component-fixtures/fixtures-shot.mjs new file mode 100644 index 00000000000..0515603d8f3 --- /dev/null +++ b/config/scripts/component-fixtures/fixtures-shot.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Usage: pnpm fixtures:shot ... [--theme dark|light|both] [--scale 2] [--out dir] +// pnpm fixtures:shot --list | --stop +import path from 'node:path' +import { parseArgs } from 'node:util' +import { + callFixtureServer, + ensureFixtureServer, + stopFixtureServer +} from './fixture-server-client.mjs' + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + theme: { type: 'string', default: 'dark' }, + scale: { type: 'string', default: '1' }, + out: { type: 'string' }, + list: { type: 'boolean', default: false }, + stop: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false } + } +}) + +async function main() { + if (values.stop) { + console.log( + (await stopFixtureServer()) ? 'Fixture server stopped.' : 'No fixture server running.' + ) + return + } + if (!values.list && positionals.length === 0) { + console.error( + 'Usage: pnpm fixtures:shot ... [--theme dark|light|both] | --list | --stop' + ) + process.exitCode = 2 + return + } + const startedAt = performance.now() + const server = await ensureFixtureServer({ + onStart: () => console.error('[fixtures] starting server (first run takes a few seconds)…') + }) + if (values.list) { + const { fixtures } = await callFixtureServer(server.url, 'list') + for (const fixture of fixtures) { + console.log(values.json ? JSON.stringify(fixture) : `${fixture.id}\t${fixture.file}`) + } + return + } + const payload = await callFixtureServer(server.url, 'shot', { + ids: positionals, + theme: values.theme, + scale: Number(values.scale), + outDir: values.out ? path.resolve(values.out) : undefined + }) + 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(', ')}`) + process.exitCode = 1 + return + } + for (const result of payload.results) { + if (result.error) { + console.error(`FAIL ${result.id} [${result.theme}]: ${result.error}`) + process.exitCode = 1 + } else { + console.log(path.relative(process.cwd(), result.path)) + } + for (const pageError of result.pageErrors ?? []) { + console.error(` page error (${result.id}): ${pageError.split('\n')[0]}`) + } + } + const totalMs = Math.round(performance.now() - startedAt) + console.error( + `[fixtures] ${payload.results.length} shot(s) in ${totalMs}ms${server.started ? ' (cold start)' : ''}` + ) +} + +main().catch((error) => { + console.error(error?.message ?? error) + process.exitCode = 1 +}) diff --git a/config/vite.fixtures.config.ts b/config/vite.fixtures.config.ts new file mode 100644 index 00000000000..18938119a7e --- /dev/null +++ b/config/vite.fixtures.config.ts @@ -0,0 +1,26 @@ +import { resolve } from 'node:path' +import { mergeConfig } from 'vite' +import webConfig from '../vite.web.config' + +// Dev-only component fixture explorer; never part of a production build input. +export default mergeConfig(webConfig, { + cacheDir: resolve('.cache/vite/fixtures'), + base: '/', + server: { + host: '127.0.0.1', + warmup: { + clientFiles: ['./src/component-fixtures/component-fixture-explorer.tsx'] + } + }, + // Why: crawl fixtures up front so a lazily loaded fixture never triggers a dep + // re-optimization, which would leave the warm page with two React copies. + optimizeDeps: { + entries: ['fixtures.html', 'src/**/*.fixture.tsx'] + }, + build: { + outDir: resolve('.cache/fixtures/build'), + rollupOptions: { + input: resolve('src/renderer/fixtures.html') + } + } +}) diff --git a/docs/reference/component-fixtures.md b/docs/reference/component-fixtures.md new file mode 100644 index 00000000000..21136883b38 --- /dev/null +++ b/docs/reference/component-fixtures.md @@ -0,0 +1,57 @@ +# Component Fixtures + +Render one renderer component in isolation and screenshot it headlessly in about a second, without booting Electron. Use it as the fast verify loop for presentational UI changes. Use the `$electron` skill when the change depends on real app state, IPC, terminals, or window chrome. + +## Writing a fixture + +Put a `*.fixture.tsx` file next to the component. Each named export built with `defineFixture` is one fixture: + +```tsx +import { defineFixture } from '../../component-fixtures/component-fixture' +import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' + +export const dirty = defineFixture({ + width: 360, + render: () => +}) +``` + +Optional fields: + +- `store`: partial `AppState` merged into the store before render. The store starts from slice defaults plus `getDefaultSettings()`. +- `setup()`: runs before render, for example to override a `window.api` method. +- `interact(frame)`: runs after first paint, for example clicking a trigger so an open popover is captured. Content portaled to `` (popovers, menus, dialogs) is included in the screenshot. +- `width`: frame width in CSS pixels. The height always fits the content. + +Fixture ids are the file path under `src/renderer/src/` with `components/` and `.fixture.tsx` removed, followed by the export name. For example, `sidebar/DeleteWorktreeDirtyChangeHint:dirty`. + +Every fixture renders inside `I18nProvider`, `TooltipProvider`, and the theme class. `window.api` is the web client's preload bridge (`installWebPreloadApi`) with no runtime paired. Calls that need a runtime reject, and namespaces without a web implementation return the web fallback defaults. The web-client flag is cleared, so components render their desktop branches. + +Fixtures are dev-only. They load through `import.meta.glob` in the fixture explorer, which is not a production build input. Oxlint rejects static imports of `*.fixture` modules. + +## Screenshots + +```bash +pnpm fixtures:shot sidebar/DeleteWorktreeDirtyChangeHint:dirty # one fixture +pnpm fixtures:shot sidebar/DeleteWorktreeDirtyChangeHint --theme both # every fixture in a file, both themes +pnpm fixtures:shot 'ui/*' --scale 2 # glob, at 2x +pnpm fixtures:shot --list +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. + +The first call starts a detached server: Vite (`config/vite.fixtures.config.ts`, which extends `vite.web.config.ts`) plus one headless Chromium. The first call takes 10–20 seconds. After that, each shot takes about 100 ms. The first shot after a source edit takes 1–2 seconds because the page reloads. The server restarts itself when the fixture scripts change and exits after 20 idle minutes. + +Chromium resolution order: `ORCA_FIXTURES_BROWSER` (an executable path), then the Playwright-managed `chromium-headless-shell`, then an installed Chrome or Edge. Every option runs headless, so screenshots never open a window or take focus. + +## Browsing + +`pnpm dev:fixtures` serves the explorer for humans. Open `/fixtures` for the index, or `/fixtures?id=sidebar/DeleteWorktreeDirtyChangeHint:dirty&theme=light` for a single fixture. + +## MCP + +`.mcp.json` registers `orca-component-fixtures`, a stdio server at `config/scripts/component-fixtures/fixtures-mcp-server.mjs`. It has two tools: + +- `list_fixtures({ filter? })` +- `screenshot_fixture({ id, theme?, scale? })`: returns the PNGs inline (up to 6) along with their paths. diff --git a/package.json b/package.json index 308f246023a..a776d2722a5 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,8 @@ "dev:fresh": "pnpm run build:packages && node config/scripts/dev-fresh-profile.mjs", "dev:seed-profile": "node config/scripts/dev-profile-seed.mjs", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", + "dev:fixtures": "vite --config config/vite.fixtures.config.ts", + "fixtures:shot": "node config/scripts/component-fixtures/fixtures-shot.mjs", "build:wsl-guest": "pnpm run build:packages && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/build-wsl-guest.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", "build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs", diff --git a/src/renderer/fixtures.html b/src/renderer/fixtures.html new file mode 100644 index 00000000000..46a65d084af --- /dev/null +++ b/src/renderer/fixtures.html @@ -0,0 +1,12 @@ + + + + + + Orca Component Fixtures + + +
+ + + diff --git a/src/renderer/src/component-fixtures/ComponentFixtureFrame.tsx b/src/renderer/src/component-fixtures/ComponentFixtureFrame.tsx new file mode 100644 index 00000000000..d2deb364371 --- /dev/null +++ b/src/renderer/src/component-fixtures/ComponentFixtureFrame.tsx @@ -0,0 +1,136 @@ +import { useEffect, type ReactNode } from 'react' +import { TooltipProvider } from '@/components/ui/tooltip' +import { RecoverableRenderErrorBoundary } from '@/components/error-boundaries/RecoverableRenderErrorBoundary' +import { I18nProvider } from '../i18n/I18nProvider' +import type { ComponentFixture } from './component-fixture' + +export type ComponentFixtureState = 'loading' | 'ready' | 'error' + +const FRAME_ID = 'component-fixture-frame' +const CLIP_PADDING_PX = 8 +const ANIMATION_SETTLE_TIMEOUT_MS = 1000 + +// The screenshot server waits on these attributes instead of guessing with timeouts. +export function markComponentFixtureState(state: ComponentFixtureState, message?: string): void { + const root = document.documentElement + root.dataset.fixtureState = state + if (state === 'loading') { + delete root.dataset.fixtureClip + } + if (message) { + root.dataset.fixtureError = message + } else { + delete root.dataset.fixtureError + } +} + +function waitForPaint(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) +} + +async function waitForAnimationsToSettle(): Promise { + // Why finite only: looping spinners never finish and would stall every capture. + const finite = document + .getAnimations() + .filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity) + const finished = Promise.allSettled(finite.map((animation) => animation.finished)) + await Promise.race([ + finished, + new Promise((resolve) => setTimeout(resolve, ANIMATION_SETTLE_TIMEOUT_MS)) + ]) +} + +// Why union: popovers, menus and dialogs portal to , outside the frame. +function recordCaptureClip(): void { + const rects = [document.getElementById(FRAME_ID), ...document.body.children] + .filter((element): element is HTMLElement => element instanceof HTMLElement) + .filter((element) => element.id !== 'root' && element.tagName !== 'SCRIPT') + .map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0) + const frameRect = rects[0] + if (!frameRect) { + return + } + const left = Math.max(0, Math.min(...rects.map((rect) => rect.left)) - CLIP_PADDING_PX) + const top = Math.max(0, Math.min(...rects.map((rect) => rect.top)) - CLIP_PADDING_PX) + const right = Math.max(...rects.map((rect) => rect.right)) + CLIP_PADDING_PX + const bottom = Math.max(...rects.map((rect) => rect.bottom)) + CLIP_PADDING_PX + document.documentElement.dataset.fixtureClip = JSON.stringify({ + x: left, + y: top, + width: right - left, + height: bottom - top + }) +} + +function FixtureReadySignal({ interact }: { interact: ComponentFixture['interact'] }): null { + useEffect(() => { + let cancelled = false + const settle = async (): Promise => { + await document.fonts.ready + await waitForPaint() + const frame = document.getElementById(FRAME_ID) + if (interact && frame) { + await interact(frame) + await waitForPaint() + } + await waitForAnimationsToSettle() + if (cancelled || document.documentElement.dataset.fixtureState === 'error') { + return + } + recordCaptureClip() + markComponentFixtureState('ready') + } + settle().catch((error: unknown) => { + markComponentFixtureState('error', error instanceof Error ? error.message : String(error)) + }) + return () => { + cancelled = true + } + }, [interact]) + return null +} + +function FixtureRenderError({ error }: { error: Error | null }): React.JSX.Element { + const message = error?.message ?? 'Fixture render failed.' + useEffect(() => { + markComponentFixtureState('error', message) + }, [message]) + return
{message}
+} + +function renderFixtureError({ error }: { error: Error | null }): React.JSX.Element { + return +} + +export function ComponentFixtureFrame({ + fixture, + children +}: { + fixture: ComponentFixture + children: ReactNode +}): React.JSX.Element { + return ( + + +
+ + {children} + + +
+
+
+ ) +} diff --git a/src/renderer/src/component-fixtures/component-fixture-explorer.tsx b/src/renderer/src/component-fixtures/component-fixture-explorer.tsx new file mode 100644 index 00000000000..750dfa45512 --- /dev/null +++ b/src/renderer/src/component-fixtures/component-fixture-explorer.tsx @@ -0,0 +1,138 @@ +import '../assets/main.css' + +import ReactDOM from 'react-dom/client' +import { getDefaultSettings } from '../../../shared/constants' +import { applyDocumentTheme } from '../lib/document-theme' +import { useAppStore } from '../store' +import { installWebPreloadApi } from '../web/web-preload-api' +import type { ComponentFixtureTheme } from './component-fixture' +import { + listComponentFixtures, + loadComponentFixture, + type ComponentFixtureEntry +} from './component-fixture-registry' +import { ComponentFixtureFrame, markComponentFixtureState } from './ComponentFixtureFrame' + +type FixtureSummary = { id: string; file: string } + +// Driven by config/scripts/component-fixtures/fixture-server.mjs to re-render in a warm page. +type ComponentFixtureHost = { + render: (id: string, theme: ComponentFixtureTheme) => Promise + list: () => Promise +} + +function parseTheme(value: string | null): ComponentFixtureTheme { + return value === 'light' ? 'light' : 'dark' +} + +const params = new URLSearchParams(window.location.search) +const pageTheme = parseTheme(params.get('theme')) + +markComponentFixtureState('loading') +// Why: reuse the web client's preload shims so components get the same +// `window.api` defaults the browser build ships, with no runtime paired. +installWebPreloadApi() +// Fixtures preview the desktop renderer; keep web-only component branches off. +Reflect.deleteProperty(window, '__ORCA_WEB_CLIENT__') +const defaultSettings = getDefaultSettings('/home/fixture') +useAppStore.setState({ settings: { ...defaultSettings, theme: pageTheme } }) +const baseStoreState = useAppStore.getState() +document.body.classList.add('bg-background') + +function requireRootElement(): HTMLElement { + const element = document.getElementById('root') + if (!element) { + throw new Error('Fixture root element not found.') + } + return element +} + +const rootElement = requireRootElement() +let root: ReactDOM.Root | null = null + +function freshRoot(): ReactDOM.Root { + root?.unmount() + root = ReactDOM.createRoot(rootElement) + return root +} + +function applyTheme(theme: ComponentFixtureTheme): void { + applyDocumentTheme(theme, { disableTransitions: false }) +} + +function fixtureHref(id: string, linkTheme: ComponentFixtureTheme): string { + return `?${new URLSearchParams({ id, theme: linkTheme }).toString()}` +} + +function FixtureIndex({ entries }: { entries: ComponentFixtureEntry[] }): React.JSX.Element { + const otherTheme = pageTheme === 'dark' ? 'light' : 'dark' + return ( +
+

Component fixtures

+

+ {entries.length} fixtures · files named *.fixture.tsx under src/renderer/src +

+ +
+ ) +} + +async function renderFixture(id: string, theme: ComponentFixtureTheme): Promise { + markComponentFixtureState('loading') + const target = freshRoot() + // Why replace: each fixture starts from the same seeded store, even in a reused page. + useAppStore.setState(baseStoreState, true) + useAppStore.setState({ settings: { ...defaultSettings, theme } }) + applyTheme(theme) + try { + const entry = await loadComponentFixture(id) + if (entry.fixture.store) { + useAppStore.setState(entry.fixture.store) + } + entry.fixture.setup?.() + target.render( + + {entry.fixture.render()} + + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + markComponentFixtureState('error', message) + target.render(
{message}
) + } +} + +async function listFixtures(): Promise { + return (await listComponentFixtures()).map(({ id, file }) => ({ id, file })) +} + +const host: ComponentFixtureHost = { render: renderFixture, list: listFixtures } +Object.assign(window, { __orcaComponentFixtureHost: host }) + +const requestedId = params.get('id') +applyTheme(pageTheme) +if (requestedId) { + void renderFixture(requestedId, pageTheme) +} else if (params.has('worker')) { + markComponentFixtureState('ready') +} else { + void listComponentFixtures().then((entries) => { + freshRoot().render() + markComponentFixtureState('ready') + }) +} diff --git a/src/renderer/src/component-fixtures/component-fixture-registry.ts b/src/renderer/src/component-fixtures/component-fixture-registry.ts new file mode 100644 index 00000000000..08fd9ff5a6b --- /dev/null +++ b/src/renderer/src/component-fixtures/component-fixture-registry.ts @@ -0,0 +1,70 @@ +import { + isComponentFixture, + type ComponentFixture, + type ComponentFixtureModule +} from './component-fixture' + +// Why lazy: the explorer only pays for the fixture module it renders, so one +// screenshot does not transform every fixture's component graph. +const fixtureModuleLoaders = import.meta.glob('../**/*.fixture.tsx') + +export type ComponentFixtureEntry = { + id: string + file: string + fixture: ComponentFixture +} + +function fixtureFileToComponentId(file: string): string { + return file + .replace(/^\.\.\//, '') + .replace(/^components\//, '') + .replace(/\.fixture\.tsx$/, '') +} + +function componentIdToFixtureFile(componentId: string): string | null { + return ( + Object.keys(fixtureModuleLoaders).find( + (file) => fixtureFileToComponentId(file) === componentId + ) ?? null + ) +} + +function collectFixtures(file: string, module: ComponentFixtureModule): ComponentFixtureEntry[] { + const componentId = fixtureFileToComponentId(file) + return Object.entries(module).flatMap(([name, value]) => + isComponentFixture(value) + ? [ + { + id: `${componentId}:${name}`, + file: `src/renderer/src/${file.slice(3)}`, + fixture: value + } + ] + : [] + ) +} + +export async function listComponentFixtures(): Promise { + const groups = await Promise.all( + Object.entries(fixtureModuleLoaders).map(async ([file, load]) => + collectFixtures(file, await load()) + ) + ) + return groups.flat().sort((left, right) => left.id.localeCompare(right.id)) +} + +export async function loadComponentFixture(id: string): Promise { + const separator = id.lastIndexOf(':') + const componentId = separator === -1 ? id : id.slice(0, separator) + const file = componentIdToFixtureFile(componentId) + if (!file) { + throw new Error(`No fixture file for "${componentId}" (expected ${componentId}.fixture.tsx).`) + } + const entries = collectFixtures(file, await fixtureModuleLoaders[file]()) + const entry = separator === -1 ? entries[0] : entries.find((candidate) => candidate.id === id) + if (!entry) { + const known = entries.map((candidate) => candidate.id).join(', ') + throw new Error(`Unknown fixture "${id}". Known in this file: ${known || 'none'}.`) + } + return entry +} diff --git a/src/renderer/src/component-fixtures/component-fixture.ts b/src/renderer/src/component-fixtures/component-fixture.ts new file mode 100644 index 00000000000..911963735fb --- /dev/null +++ b/src/renderer/src/component-fixtures/component-fixture.ts @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' +import type { AppState } from '../store/types' + +export type ComponentFixture = { + render: () => ReactNode + /** Merged into the default-seeded app store before the first render. */ + store?: Partial + /** Runs after the store and `window.api` bridge exist, before render (e.g. stub an api call). */ + setup?: () => void + /** Runs after first paint, e.g. click a trigger so an open popover is captured. */ + interact?: (frame: HTMLElement) => void | Promise + /** Frame width in CSS pixels; height always hugs the content. */ + width?: number +} + +export type ComponentFixtureTheme = 'dark' | 'light' + +export type ComponentFixtureModule = Record + +export function defineFixture(fixture: ComponentFixture): ComponentFixture { + return fixture +} + +export function isComponentFixture(value: unknown): value is ComponentFixture { + return ( + typeof value === 'object' && + value !== null && + 'render' in value && + typeof value.render === 'function' + ) +} diff --git a/src/renderer/src/components/AgentStateDot.fixture.tsx b/src/renderer/src/components/AgentStateDot.fixture.tsx new file mode 100644 index 00000000000..36180c57d56 --- /dev/null +++ b/src/renderer/src/components/AgentStateDot.fixture.tsx @@ -0,0 +1,44 @@ +import { defineFixture } from '../component-fixtures/component-fixture' +import { AgentStateDot, agentStateLabel, type AgentDotState } from './AgentStateDot' + +const ALL_STATES: AgentDotState[] = [ + 'working', + 'monitoring', + 'blocked', + 'waiting', + 'permission', + 'interrupted', + 'failed', + 'done', + 'idle', + 'unverifiable', + 'unconfirmed' +] + +function StateGrid({ + size, + progress +}: { + size: 'sm' | 'md' + progress?: number +}): React.JSX.Element { + return ( +
    + {ALL_STATES.map((state) => ( +
  • + + {agentStateLabel(state)} +
  • + ))} +
+ ) +} + +export const allStates = defineFixture({ width: 420, render: () => }) + +export const allStatesMedium = defineFixture({ width: 420, render: () => }) + +export const withProgress = defineFixture({ + width: 420, + render: () => +}) diff --git a/src/renderer/src/components/DetachedHeadBadge.fixture.tsx b/src/renderer/src/components/DetachedHeadBadge.fixture.tsx new file mode 100644 index 00000000000..2a854b2862e --- /dev/null +++ b/src/renderer/src/components/DetachedHeadBadge.fixture.tsx @@ -0,0 +1,34 @@ +import { getWorktreeGitIdentityDisplay } from '../../../shared/worktree/git-identity-display' +import { defineFixture } from '../component-fixtures/component-fixture' +import { DetachedHeadBadge } from './DetachedHeadBadge' + +function detachedDisplay(): Parameters[0]['display'] { + const display = getWorktreeGitIdentityDisplay({ + head: '3f9c2a1d8e7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a' + }) + if (display?.kind !== 'detached') { + throw new Error('Expected a detached HEAD display.') + } + return display +} + +export const labels = defineFixture({ + width: 320, + render: () => ( +
+ + +
+ ) +}) + +// Mirrors the sidebar card meta row, which renders the badge at 16px tall. +export const inSidebarMetaRow = defineFixture({ + width: 300, + render: () => ( +
+ fix-login + +
+ ) +}) diff --git a/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.fixture.tsx b/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.fixture.tsx new file mode 100644 index 00000000000..f184a1b0f5a --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceAdvancedDisclosure.fixture.tsx @@ -0,0 +1,51 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import { Label } from '../ui/label' +import { Switch } from '../ui/switch' +import { AppearanceAdvancedDisclosure } from './AppearanceAdvancedDisclosure' + +function AdvancedFontControls(): React.JSX.Element { + return ( +
+
+ + +
+
+ + +
+
+ ) +} + +export const collapsed = defineFixture({ + width: 420, + render: () => ( + + + + ) +}) + +export const expanded = defineFixture({ + width: 420, + render: () => ( + + + + ), + interact: (frame) => { + frame.querySelector('button[aria-expanded]')?.click() + } +}) + +// An active settings search force-opens the disclosure and disables its toggle. +export const forcedOpenBySearch = defineFixture({ + width: 420, + store: { settingsSearchQuery: 'font' }, + render: () => ( + + + + ) +}) diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.fixture.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.fixture.tsx new file mode 100644 index 00000000000..721eaef0cc6 --- /dev/null +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDirtyChangeHint.fixture.tsx @@ -0,0 +1,55 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint' +import type { DeleteWorktreeDirtyChangePreview } from './delete-worktree-dirty-change-counts' + +const preview: DeleteWorktreeDirtyChangePreview = { + files: [ + { + path: 'src/renderer/src/components/sidebar/WorktreeCard.tsx', + status: 'modified', + hasUnresolvedConflict: false + }, + { + path: 'src/shared/worktree/git-identity-display.ts', + status: 'modified', + hasUnresolvedConflict: true + }, + { path: 'docs/reference/component-fixtures.md', status: 'added', hasUnresolvedConflict: false }, + { path: 'scratch/notes.txt', status: 'untracked', hasUnresolvedConflict: false }, + { path: 'src/main/legacy-relay.ts', status: 'deleted', hasUnresolvedConflict: false } + ], + remainingPathCount: 12 +} + +// The hint sits under the workspace name inside the delete dialog's target list. +const ROW_WIDTH = 360 + +export const checking = defineFixture({ + width: ROW_WIDTH, + render: () => +}) + +export const clean = defineFixture({ + width: ROW_WIDTH, + render: () => +}) + +export const unavailable = defineFixture({ + width: ROW_WIDTH, + render: () => +}) + +export const dirty = defineFixture({ + width: ROW_WIDTH, + render: () => +}) + +export const dirtyWithPathsOpen = defineFixture({ + width: ROW_WIDTH, + render: () => ( + + ), + interact: (frame) => { + frame.querySelector('button')?.click() + } +}) diff --git a/src/renderer/src/components/ui/button.fixture.tsx b/src/renderer/src/components/ui/button.fixture.tsx new file mode 100644 index 00000000000..cb8bcaa4a95 --- /dev/null +++ b/src/renderer/src/components/ui/button.fixture.tsx @@ -0,0 +1,57 @@ +import { GitBranch, Plus, Trash2 } from 'lucide-react' +import { defineFixture } from '../../component-fixtures/component-fixture' +import { Button } from './button' + +const VARIANTS = ['default', 'secondary', 'outline', 'ghost', 'destructive', 'link'] as const +const SIZES = ['xs', 'sm', 'default', 'lg'] as const + +export const variants = defineFixture({ + width: 640, + render: () => ( +
+ {VARIANTS.map((variant) => ( +
+ {variant} + + + +
+ ))} +
+ ) +}) + +export const sizes = defineFixture({ + width: 640, + render: () => ( +
+ {SIZES.map((size) => ( +
+ {size} + + +
+ ))} +
+ icon + + + +
+
+ ) +})