Files
orca/config/scripts/component-fixtures/fixture-server-client.mjs
T
Neil a30d4f4f28 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 <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.

* 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
2026-10-25 00:23:32 -07:00

138 lines
3.6 KiB
JavaScript

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
}