// rnx screenshot — capture the canvas as a PNG // // prefers the WS bridge when one is reachable so the screenshot comes from // the user's current sim (and can be cropped to a logical // rect via --area / --id / --text). falls back to a fresh headless // chromium when nothing is running, so CI / one-shot capture still works // without a daemon. import { mkdirSync, writeFileSync } from 'fs' import { dirname, resolve } from 'path' import { devices, type DeviceModel } from 'sootsim-engine/settings' import { launchReapedChrome } from '../../../../scripts/lib/reap-browser' import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants' import { composeFramedScreenshot } from '../../src/screenshots/frame-compose' import { buildResolveRectEval, type SampleRect } from '../browser-evals' import { createCloudBridgeForParsed } from '../cloud-client' import { rnxExit } from '../run-rnx' import { callInBridge, checkSimHealth, createBridge, createBridgeFromParsed, parseBridgeCliArgs, } from '../ws-bridge' import { inspectWaitReady, waitReadyReason } from './inspect/core' interface ScreenshotOptions { port?: number verbose?: boolean } export async function runScreenshot(rawArgs: string[], opts: ScreenshotOptions) { // resolve common aliases up front so `-o`, `--out`, `--testid`, etc. land // in the same code path as their canonical spellings. before this, agents // typing `-o foo.png` got "unknown flag" and lost the value. const args = normalizeAliasFlags(rawArgs) if (args.includes('--help') || args.includes('-h')) { console.log(` rnx screenshot — capture the canvas as a PNG usage: rnx screenshot [output] [options] rnx screenshot [options] --output options: --output output file path (default: /tmp/rnx-inspect.png) first positional arg works too: \`screenshot /tmp/x.png\` --with-frame wrap the captured screen in a tight device frame --no-frame capture the raw screen bitmap (default) --no-shell tenant-only capture (no status bar, keyboard, toasts, notification center, or other shell overlays). use for clean app screenshots when a shell surface is in the way. --shell-only capture only the shell chrome, no tenant content --allow-loading capture after caller-owned readiness checks even when visible app copy matches a generic loading phrase --area x,y,w,h crop to a logical rnx rect --id crop to a node's bounding box --text crop to a node found by text content examples: rnx screenshot rnx screenshot --no-shell --output app.png rnx screenshot --with-frame --output framed.png rnx screenshot --area 0,200,393,400 --output hero.png rnx screenshot --id loginButton --output button.png `) rnxExit(0) } const unknown = findUnknownFlags(args) if (unknown.length > 0) { for (const { flag, suggestion } of unknown) { console.error( suggestion ? ` unknown flag: ${flag} (did you mean ${suggestion}?)` : ` unknown flag: ${flag}`, ) } console.error(' run `rnx screenshot --help` for the list of supported flags') rnxExit(1) } const allowLoading = args.includes('--allow-loading') const withFrame = resolveFrameMode(args) const layers = resolveLayers(args) if ( withFrame && args.some((arg) => arg === '--area' || arg === '--id' || arg === '--text') ) { console.error(' --with-frame only supports full-screen capture for now') console.error(' remove --area / --id / --text, or capture raw and compose later') rnxExit(1) } // share the standard bridge arg parser so --sim / --port / saved sims // behave identically to every other bridge command. const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: [ '--with-frame', '--no-frame', '--no-shell', '--shell-only', '--allow-loading', ], stripValueFlags: ['--output', '--area', '--id', '--text'], }) const outputArg = resolveOutputPath(args) const cloudBridge = createCloudBridgeForParsed(parsed) if (cloudBridge) { try { const dataUrl: unknown = await cloudBridge.send({ type: 'screenshot' }) const pngPrefix = 'data:image/png;base64,' if (typeof dataUrl !== 'string' || !dataUrl.startsWith(pngPrefix)) { throw new Error('remote simulator screenshot returned invalid image data') } const outputPath = resolve(process.cwd(), outputArg || '/tmp/rnx-inspect.png') mkdirSync(dirname(outputPath), { recursive: true }) const png = Buffer.from(dataUrl.slice(pngPrefix.length), 'base64') if (png.length === 0) throw new Error('remote simulator screenshot returned no image data') writeFileSync(outputPath, png) console.log(` saved: ${outputPath}`) } catch (error) { console.error( ` screenshot failed: ${error instanceof Error ? error.message : String(error)}`, ) rnxExit(1) } finally { cloudBridge.close() } return } const rect = await parseRect(args) const usingBridge = await probeBridge(parsed.wsPort) if (usingBridge) { // captureViaBridge throws plain Errors for expected conditions (unknown // --id / --text, missing device model). surface them as a one-line // failure instead of an uncaught internal stack trace. try { await captureViaBridge(parsed, outputArg, rect, withFrame, layers, allowLoading) } catch (err) { console.error( ` screenshot failed: ${err instanceof Error ? err.message : String(err)}`, ) rnxExit(1) } return } if (withFrame) { console.error(' --with-frame requires a running rnx bridge') console.error(' open the app in a live sim first, then rerun the command') rnxExit(1) } if (rect) { console.error(' --area / --id / --text require a running rnx bridge') console.error( ' start one with `rnx serve`, `rnx desktop`, or your project dev server', ) rnxExit(1) } await runPlaywrightScreenshot(args, opts) } async function probeBridge(port: number): Promise { const bridge = createBridge(port, { commandTimeoutMs: 1000 }) try { await bridge.listSims() return true } catch { return false } finally { bridge.close() } } async function parseRect(args: string[]): Promise { const areaArg = args.find((_, i) => args[i - 1] === '--area') if (areaArg) { const parts = areaArg.split(',').map((p) => Number(p.trim())) if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) { throw new Error(`--area expects x,y,w,h (got "${areaArg}")`) } const [x, y, w, h] = parts return { x, y, w, h } } const idArg = args.find((_, i) => args[i - 1] === '--id') const textArg = args.find((_, i) => args[i - 1] === '--text') if (idArg || textArg) { // defer to the bridge-side resolver in captureViaBridge since the node // must be looked up at capture time. return { __matcher: { id: idArg, text: textArg } } as unknown as SampleRect } return null } async function captureViaBridge( parsed: ReturnType, outputArg: string | undefined, rect: SampleRect | null, withFrame: boolean, layers: 'full' | 'tenant' | 'shell' | undefined, allowLoading: boolean, ) { // 45s, not 10s: a first-paint capture on a headless CI runner with software // GL (SwiftShader) is bounded but slow. forceRenderAll caps at ~6s (two // 3s-bounded passes) and the WebGL→2D drawImage readback + PNG encode of a // full ~3.2M-px device frame adds several seconds more under build // contention (metro serving a 24MB bundle, asset capture running alongside). // every step is bounded/synchronous, so the capture always completes — but // the prior 10s ceiling fired mid-capture, so every branch build landed with // a null screenshotKey (no org-grid thumbnail). runtime proof: 3pc Contrast // build 27864867412 logged `screenshot failed: command timed out after 10s`. // a generous ceiling costs nothing on fast hardware — bridge.send resolves // the instant the frame is ready (~1.5s), never waiting out the timeout. const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 45000 }) try { // a hidden tab paints to a throttled buffer — the canvas is whatever was // last drawn before rAF stopped pumping. unlike interaction warnings, this // affects screenshot fidelity directly, so call it out explicitly so the // user doesn't accept a stale frame as ground truth. const health = await checkSimHealth(bridge) if (health.hidden) { process.stderr.write( ' ⚠ screenshot will capture the LAST PAINTED canvas — likely stale.\n' + ' navigation/`shell` commands sent before this screenshot may not\n' + ' have repainted yet. focus the tab manually or `rnx --sim `\n' + ' to target a visible sim before capturing.\n\n', ) } // re-assert readiness on THIS bridge connection right before capturing. // the readiness gate (`rnx wait ready`) and this capture run as // separate CLI processes; between them the engine can re-enter its // boot/connect overlay — a late reload fires `sootsim:externalAppReloadStart` // and clears `__sootsimExternalAppReady` — so a capture taken now can grab // the "opening app" loading card instead of the rendered app. that race is // why branch-build thumbnails landed showing the boot spinner over a faint // login screen. gate on the same probe `wait ready` uses, on this very // connection, so the capture only ever fires on real painted content; a // genuinely-stuck app yields no image (run.sh treats that as no thumbnail, // which beats a boot-card thumbnail). already-ready apps clear in <1s. if (!allowLoading) { const readyMaxMs = Number(process.env.CONTRAST_SCREENSHOT_READY_MAX_MS) || 90_000 const readiness = await inspectWaitReady(bridge, readyMaxMs) // "don't baseline this" is not "don't capture this". a boot/loading card // is transient and capturing it produces a thumbnail that lies. a crashed // app is the opposite: the red box IS the evidence a boot-failure report // needs, and it is the only screen that app will ever paint. capture it, // and say plainly what it is so nobody blesses it as a baseline. if (!readiness.ready && readiness.externalError) { process.stderr.write( ` ⚠ capturing an ERROR screen. the app failed to load: ${readiness.externalError}\n` + ' this is failure evidence, not app content. never use it as a baseline.\n\n', ) } else if (!readiness.ready) { throw new Error( `app not ready to capture after ${Math.round(readiness.elapsedMs / 1000)}s ` + `(${waitReadyReason(readiness)})` + '; refusing to capture the boot/loading card. retry once the app paints.', ) } } let resolvedRect: SampleRect | null = rect const matcher = (rect as { __matcher?: { id?: string; text?: string } })?.__matcher if (matcher) { const node = await bridge.send({ type: 'evaluate', code: buildResolveRectEval(matcher), }) if (!node) { throw new Error( matcher.id ? `no node with id "${matcher.id}"` : `no node matching text "${matcher.text}"`, ) } resolvedRect = node as SampleRect } const cropRect = resolvedRect ? { x: resolvedRect.x, y: resolvedRect.y, w: resolvedRect.w, h: resolvedRect.h } : undefined const request: { type: 'screenshot' layers?: typeof layers crop?: typeof cropRect } = { type: 'screenshot', } if (layers) request.layers = layers if (cropRect) request.crop = cropRect const dataUrl: string = await bridge.send(request) const pngPrefix = 'data:image/png;base64,' // never write a 0-byte "saved" file from an empty or malformed payload — // the sim may be alive but not painting (check `rnx get errors`) if (typeof dataUrl !== 'string' || dataUrl.length <= pngPrefix.length) { throw new Error( 'screenshot capture returned no image data — the sim is connected but not painting; check `rnx get errors`', ) } const base64 = dataUrl.replace(/^data:image\/png;base64,/, '') if (cropRect) { console.log( ` area: x=${cropRect.x} y=${cropRect.y} w=${cropRect.w} h=${cropRect.h}`, ) } if (layers) console.log(` layers: ${layers}`) const outputPath = resolve(process.cwd(), outputArg || '/tmp/rnx-inspect.png') mkdirSync(dirname(outputPath), { recursive: true }) const rawBuffer = Buffer.from(base64, 'base64') if (withFrame) { const model = await readCurrentDeviceModel(bridge) if (!model) { throw new Error('could not read current device model from the target sim') } const framed = await composeFramedScreenshot(rawBuffer, model) writeFileSync(outputPath, framed) console.log(` frame: ${model}`) } else { writeFileSync(outputPath, rawBuffer) } console.log(` saved: ${outputPath}`) } finally { bridge.close() } } async function readCurrentDeviceModel( bridge: ReturnType, ): Promise { const settings = (await callInBridge | null>( bridge, 'SootSim.bridges.settings.get', )) ?? { deviceModel: null } const model = typeof settings.deviceModel === 'string' ? settings.deviceModel : null if (!model || !(model in devices)) return null return model as DeviceModel } function resolveFrameMode(args: string[]): boolean { let withFrame = false for (const arg of args) { if (arg === '--with-frame') withFrame = true if (arg === '--no-frame') withFrame = false } return withFrame } function resolveLayers(args: string[]): 'full' | 'tenant' | 'shell' | undefined { if (args.includes('--shell-only')) return 'shell' if (args.includes('--no-shell')) return 'tenant' return undefined } // known flags for `rnx screenshot`. booleans take no value, value flags // consume the next arg. kept local so this validation can't drift from the // flags the command actually reads. const SCREENSHOT_BOOLEAN_FLAGS = new Set([ '--help', '-h', '--verbose', '-v', '--with-frame', '--no-frame', '--no-shell', '--shell-only', '--allow-loading', ]) const SCREENSHOT_VALUE_FLAGS = new Set([ '--output', '--area', '--id', '--text', '--url', '--port', '--timeout', '--sim', ]) // common typos agents reach for, mapped to the real flag. const SCREENSHOT_FLAG_SUGGESTIONS: Record = { '--rect': '--area', '--crop': '--area', '--region': '--area', '--bounds': '--area', '--box': '--area', '--xywh': '--area', '--out': '--output', '-o': '--output', '--file': '--output', '--path': '--output', '--testid': '--id', '--test-id': '--id', '--test_id': '--id', } // preprocess args: normalize common aliases to their canonical flag so the // rest of the parser only has to think about one spelling. handled here // rather than in findUnknownFlags so the value-consuming code paths see // the canonical flag too — otherwise `-o /tmp/x.png` would print a // suggestion *and* lose the value. function normalizeAliasFlags(args: string[]): string[] { const out: string[] = [] const valueAliases: Record = { '-o': '--output', '--out': '--output', '--file': '--output', '--path': '--output', '--testid': '--id', '--test-id': '--id', '--test_id': '--id', '--rect': '--area', '--crop': '--area', '--region': '--area', '--bounds': '--area', '--box': '--area', '--xywh': '--area', } for (const arg of args) { const replacement = valueAliases[arg] out.push(replacement ?? arg) } return out } function findUnknownFlags(args: string[]): Array<{ flag: string; suggestion?: string }> { const unknown: Array<{ flag: string; suggestion?: string }> = [] for (let i = 0; i < args.length; i++) { const arg = args[i] if (!arg.startsWith('-')) continue if (SCREENSHOT_VALUE_FLAGS.has(arg)) { i++ continue } if (SCREENSHOT_BOOLEAN_FLAGS.has(arg)) continue unknown.push({ flag: arg, suggestion: SCREENSHOT_FLAG_SUGGESTIONS[arg] }) } return unknown } // resolve output path: prefer --output , fall back to the first // positional arg so `rnx screenshot /tmp/foo.png` works the way every // other screenshot tool does. function resolveOutputPath(args: string[]): string | undefined { const explicit = args.find((_, i) => args[i - 1] === '--output') if (explicit) return explicit for (let i = 0; i < args.length; i++) { const arg = args[i] if (arg.startsWith('-')) { if (SCREENSHOT_VALUE_FLAGS.has(arg)) i++ continue } return arg } return undefined } // capture a page in headless chromium when no bridge is running. async function runPlaywrightScreenshot(args: string[], opts: ScreenshotOptions) { const output = resolveOutputPath(args) || 'rnx-screenshot.png' const url = args.find((_, i) => args[i - 1] === '--url') || `http://localhost:${opts.port || 5173}` const { chromium } = await import('playwright') const browser = await launchReapedChrome((options) => chromium.launch(options), { headless: true, }) try { const page = await browser.newPage({ viewport: { width: 500, height: 900 } }) await page.goto(url, { waitUntil: 'networkidle' }) await page.waitForTimeout(2000) const outputPath = resolve(process.cwd(), output) mkdirSync(dirname(outputPath), { recursive: true }) await page.screenshot({ path: outputPath }) console.log(` saved: ${outputPath}`) await page.close() } catch (err: any) { console.error(` screenshot failed: ${err.message}`) process.exitCode = 1 } finally { await browser.close() } }