// shared Maestro YAML playback and live-authoring implementation. // discovers an rnx target when needed, then reuses the shared bridge action layer import * as fs from 'fs' import { createHash, randomUUID } from 'node:crypto' import { tmpdir } from 'os' import * as path from 'path' import { scanDevServers } from '../../scripts/dev-server-scanner' import { loadOptionalDemoApps } from '../../scripts/optional-demo-registry' import { applyRNXConfigToUrl, type RNXConfig } from '../../src/config' import { printBridgeFailureDiagnostics } from '../bridge-diagnostics' import { SootSimBridgeFlowRunner, type SootSimFlowProfileResult, type SootSimFlowTraceStep, } from '../bridge-flow-runner' import { readCurrentSimId, saveCurrentSimId } from '../current-sim' import { findDesktopCompanion } from '../desktop-companion' import { exportFlowSession } from '../flow-export' import { parseFlowFile, validateFlowFile } from '../flow-file' import { FlowLiveStatusReporter } from '../flow-live-status' import { finalizeFlowSession, keepFlowCandidate, startFlowSession } from '../flow-session' import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args' import { rnxExit } from '../run-rnx' import { BRIDGE_VALUE_FLAGS, createBridge, createBridgeFromParsed, parseBridgeCliArgs, resolveBridgePortForPin, } from '../ws-bridge' import { buildShellUrl, closeSimsBulk, resolveShellBaseUrlForBridgePort, runOpenCommand, terminatePlaywrightHostsForSims, } from './control' import { printShellPerfReport } from './inspect' import { resolveDefaultUploadOrigin } from './upload' import type { UploadResult } from './upload' export { parseFlowFile, validateFlowFile } from '../flow-file' // the complete flag vocabulary of `runFlowPlayback`. it is both the strip list // handed to parseBridgeCliArgs and the vocabulary the unknown-argument check // enforces, so a flag added to the runner and left out of here gets rejected // rather than silently dropped. const FLOW_BOOLEAN_FLAGS = [ '--record', '--profile', '--electron', '--new', '--preview', '--preview-open', '--headless', '--headed', '--proof', '--no-shell', '--shell-only', '--dry-run', '--no-run', ] const FLOW_VALUE_FLAGS = [ '--app', '--out', '--base-url', '--device', '--theme', '--slow', '--tail-wait', '--url', '--replace', '--remap', '--screenshots', '--screenshot-paths', '--preview-origin', '--preview-public-origin', '--driver', '--billing-kind', '--owner', '--repo', ] // the strip lists parseBridgeCliArgs needs to see a flow argv the way the // runner does. exported so a command that pre-parses argv before handing it to // runFlowPlayback reads the same vocabulary instead of keeping its own copy. export const FLOW_BRIDGE_ARG_OPTIONS = { stripBooleanFlags: FLOW_BOOLEAN_FLAGS, stripValueFlags: FLOW_VALUE_FLAGS, } // every flag that swallows the token after it on a flow argv. a caller picking // the positional flow path out of raw argv has to skip those tokens, or // `maestro test --sim a2 login.yaml` resolves `a2` as the flow file. export const FLOW_ARG_VALUE_FLAGS = [...FLOW_VALUE_FLAGS, ...BRIDGE_VALUE_FLAGS] export interface UnexpectedFlowArg { arg: string message: string } // runFlowPlayback is the end of the line for argv: everything below it is built // from named values, never forwarded raw. so an argument it does not read is // read by nobody, and dropping one is how a run reports success while doing // something other than what was asked — a stale build took --proof, discarded // it, and produced non-proof screenshots under a proof-mode label. // // parseBridgeCliArgs already removes every flag this command understands and // every flag the bridge owns, each with its value, so whatever it leaves behind // past the flow path at args[0] is an argument nothing here consumes. export function findUnexpectedFlowArgs(args: string[]): UnexpectedFlowArg[] { const known = new Set([...FLOW_BOOLEAN_FLAGS, ...FLOW_VALUE_FLAGS]) const problems: UnexpectedFlowArg[] = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS) .positional.slice(1) .map((arg) => { const eq = arg.indexOf('=') const head = eq > 0 ? arg.slice(0, eq) : '' if (known.has(head)) { return { arg, message: `${head} takes its value as a separate argument: ${head} ${arg.slice(eq + 1)}`, } } const globalTakesValue = GLOBAL_FLAG_TAKES_VALUE.get(arg) if (globalTakesValue !== undefined) { return { arg, message: `${arg} is a global rnx flag and goes before the command: rnx ${arg}${ globalTakesValue ? ' ' : '' } `, } } if (arg.startsWith('-')) return { arg, message: `unknown flag: ${arg}` } return { arg, message: `unexpected argument: ${arg}` } }) // a value flag left without a value keeps the runner's default instead of // what the caller meant to set, which is the same silent substitution. for (let i = 0; i < args.length; i++) { if (!FLOW_VALUE_FLAGS.includes(args[i])) continue const value = args[i + 1] if (value === undefined || value.startsWith('-')) { problems.push({ arg: args[i], message: `${args[i]} expects a value` }) } i++ } return problems } let lastPreviewUploadResult: UploadResult | null = null export function getLastFlowPreviewUploadResult(): UploadResult | null { return lastPreviewUploadResult } // per-step trace of the most recent playback, preview or not — callers // Maestro callers fold this into their run registration. let lastFlowTraceSteps: SootSimFlowTraceStep[] = [] export function getLastFlowTraceSteps(): SootSimFlowTraceStep[] { return lastFlowTraceSteps } // resolve a frontmatter `app:` value that may be a port, a URL, or a // registered demo name (`bluesky`, `3pc`, `uniswap`, …). ports and URLs pass // through untouched. a registered name also carries the launcher's preferred // port and runtime config into the open command. export async function resolveAppFrontmatterTarget( raw: string, ): Promise<{ target: string; runtimeConfig?: RNXConfig }> { if (!raw) return { target: '' } const trimmed = raw.trim() if (!trimmed) return { target: '' } if (/^\d+$/.test(trimmed)) return { target: trimmed } if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { return { target: trimmed } } // try a case-insensitive name match against the OPTIONAL internal demo // registry (in-repo only; empty on a published install). unknown strings // fall through unchanged so other targets (host:port, raw bundle URLs // without a scheme) still reach the legacy resolver. const apps = await loadOptionalDemoApps() const match = apps.find((app) => app.name.toLowerCase() === trimmed.toLowerCase()) if (match) { return { target: String(match.preferredPort), runtimeConfig: match.runtimeConfig, } } return { target: trimmed } } // discover an rnx shell URL — prefer patched servers, fall back to any match. // exported so `detox.ts` and `maestro.ts` can reuse the same discovery logic. export async function discoverSootsimUrl(): Promise { const servers = await scanDevServers() const patched = servers.find((s) => s.patched) if (patched) return `http://localhost:${patched.port}/__soot/` const any = servers[0] if (any) return `http://localhost:${any.port}/__soot/` return null } export async function runFlowPlayback(args: string[]): Promise { lastPreviewUploadResult = null lastFlowTraceSteps = [] if (args.length === 0 || args.includes('--help') || args.includes('-h')) { console.log(` rnx maestro — author and run Maestro YAML flows usage: rnx maestro test [options] rnx maestro load [options] rnx maestro start rnx maestro keep rnx maestro end [--output ] [--validate] [--video] rnx maestro validate options: --dry-run, --no-run load flow into the runner idle without executing steps --record record a webm artifact while the flow runs --profile capture perf stats while the flow runs --out output directory (default: /tmp/rnx-recordings) --device override the flow frontmatter device for this run --theme override the flow frontmatter theme (light or dark) for this run --slow delay between steps for natural pacing --tail-wait hold the recording open after the last step so the final UI state is captured (default: smart idle up to 6000ms for --preview, fixed 2000ms for --record, 0 otherwise) --url load this target before running the flow --base-url run the flow against the shell at this URL instead of the discovered one, and poll that shell's own bridge. errors when no live rnx world serves it, so a pinned run can never quietly measure the default shell instead --screenshot-paths screenshot path mode: dir (default) or flow --no-shell capture every screenshot tenant-only (no status bar, keyboard, notification center, or other shell overlays). use for clean demo asset sets. per-step \`layers:\` still wins over this default. --shell-only inverse — capture shell chrome only --replace = replace a Metro module with a local file for this run --new open a fresh sim before running the flow --driver when paired with --new, use playwright or electron; browser sims default to isolated playwright --headless pass headless=true to the launch driver (useful with --driver playwright) --headed force a fresh, VISIBLE window for this run (implies --new; overrides --headless). use when you want to watch the flow run live even if the current sim is headless or stale --proof deterministic-capture mode: pin the TextInput caret visible instead of blinking, stop notifications auto-dismissing, and leave the device corners unclipped to match a native simctl framebuffer. a screen holding a focused text field cannot reach three identical idle frames without this, so its screenshot fails after 12 tries. use it for any run whose screenshots get compared to anything --electron prefer opening the target in the desktop companion --sim target a specific current sim --validate replay the drafted flow before clearing it --video replay and record the drafted flow before clearing it --preview record events+video, upload, and print /preview/ --preview-open open the uploaded preview link after a successful run --preview-origin upload target for --preview (default: auto) --preview-public-origin public link origin for --preview (default: rnxsim.com for prod uploads) --billing-kind test_run meter the chained preview upload as a test run --owner associate preview upload with a linked org repo --repo repo name for --owner; user must belong to the team frontmatter (top of flow.yaml between --- markers): app: 8081 load this target before the flow starts device: iphone-16 set the live device model for this flow run electron: true prefer the desktop companion sim for this flow default runtime: bridge-backed flow playback against the current sim if no current sim exists, flow opens the discovered target first recording and profiling both run on the same bridge-backed path examples: rnx maestro test login.yaml rnx maestro test demo.yaml --url 8081 --record --slow 500 --out ./recordings rnx maestro test demo.yaml --sim a2 rnx maestro start rnx maestro keep rnx maestro end --output ./.maestro/draft.yaml rnx maestro end --output ./.maestro/draft.yaml --validate rnx maestro end --output ./.maestro/draft.yaml --video rnx maestro validate ./.maestro/draft.yaml flow extension: takeScreenshot: hero takeScreenshot: path: marketing/hero withFrame: true `) rnxExit(0) } const unexpected = findUnexpectedFlowArgs(args) if (unexpected.length > 0) { for (const problem of unexpected) { console.error(` error: ${problem.message}`) } console.error('\n run `rnx maestro --help` for the flags this command accepts') rnxExit(1) } const flowPath = args[0] if (!fs.existsSync(flowPath)) { console.error(` error: ${flowPath} not found`) rnxExit(1) } const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name) const profile = args.includes('--profile') // --headed forces a fresh visible window: implies --new and overrides // --headless (you asked to watch it run). const headed = args.includes('--headed') // deterministic-capture mode. pins everything that would otherwise change // between two frames of an idle screen, so `takeScreenshot` reaches its // three identical frames on the first try instead of hunting for them. const proofMode = args.includes('--proof') const openInNewSim = args.includes('--new') || headed const outDir = getFlag('--out') || '/tmp/rnx-recordings' // when --screenshots isn't explicitly set, default to the --out directory. // this matches what most users expect: one flag controls the whole output // bundle (recording + screenshots) for a demo run. // previously, screenshots silently landed in /tmp/rnx-flow even when // --out was set, requiring a second --screenshots flag to redirect them. const screenshotDir = getFlag('--screenshots') || (getFlag('--out') ? outDir : '/tmp/rnx-flow') const screenshotPathMode = getFlag('--screenshot-paths') === 'flow' ? 'flow' : ('dir' as const) // --no-shell makes every takeScreenshot in the flow capture tenant-only // (no status bar, keyboard, notification center). useful for demo asset // sets where shell chrome would obscure the app or stale overlays would // pollute the result. per-step `layers:` still wins over this default. const screenshotLayers: 'full' | 'tenant' | 'shell' | undefined = args.includes( '--no-shell', ) ? 'tenant' : args.includes('--shell-only') ? 'shell' : undefined const deviceOverride = getFlag('--device')?.trim() || '' // --device and --theme are also global flags, but a global sets the settings // store inside THIS process and the sim renders in the shell's browser page, // so `rnx --theme light maestro test` reaches nothing. both have to be read // here and pushed over the bridge, and they are read the same way so a caller // that gets one right cannot get the other silently ignored. const themeOverride = getFlag('--theme')?.trim() || '' const slow = args.includes('--slow') ? +(getFlag('--slow') || '500') : 0 const cliElectron = args.includes('--electron') const preview = args.includes('--preview') // only --preview reads this (start event, entitlement origin, uploader). // resolving it unconditionally probed https://contrast.localhost:3000 and // charged every non-preview flow the probe's full 2s timeout whenever no // local Contrast stack was listening. const previewOrigin = preview ? await resolveDefaultUploadOrigin(getFlag('--preview-origin')) : '' const previewPublicOrigin = getFlag('--preview-public-origin') const previewOpen = args.includes('--preview-open') const previewOwner = getFlag('--owner')?.trim() || '' const previewRepo = getFlag('--repo')?.trim() || '' const previewBillingKind = getFlag('--billing-kind')?.trim() || '' const previewRunScope = preview ? `maestro:${ process.env.GITHUB_RUN_ID ? `github:${process.env.GITHUB_RUN_ID}:attempt:${process.env.GITHUB_RUN_ATTEMPT || '1'}:job:${process.env.GITHUB_JOB || 'preview'}` : `local:${randomUUID()}` }:flow:${createHash('sha256') .update(path.relative(process.cwd(), path.resolve(flowPath))) .digest('hex') .slice(0, 16)}` : '' if (previewBillingKind && previewBillingKind !== 'test_run') { console.error(` error: invalid --billing-kind: ${previewBillingKind}`) rnxExit(1) } const replaceArgs = args.flatMap((arg, index) => args[index - 1] === '--replace' ? ['--replace', arg] : [], ) // forward --remap = to the open command so the sim // boots with the guest-fetch network remap (config.network.remap). const remapArgs = args.flatMap((arg, index) => args[index - 1] === '--remap' ? ['--remap', arg] : [], ) // --preview implies --record: the preview page layers the mp4 over // the canvas during scrub so the viewer sees smooth motion while the // real app is catching up. upload chains the video in automatically. const record = args.includes('--record') || preview // hold the final UI state in the recording so viewers see the result // of the flow, not just a frame mid-transition. short flows otherwise // cut off ~600ms in. caller can override with --tail-wait . const tailWaitMs = args.includes('--tail-wait') ? Math.max(0, +(getFlag('--tail-wait') || '2000')) : record ? preview ? 6000 : 2000 : 0 // parse flow file (frontmatter + steps). `${...}` templates stay verbatim // here — the runner interpolates per step at execution time. const flowContent = fs.readFileSync(flowPath, 'utf8') const { frontmatter, steps } = parseFlowFile(flowContent) if (steps.length === 0) { console.error(' error: flow file must contain a YAML array of steps') rnxExit(1) } const targetDevice = deviceOverride || frontmatter.device || '' const targetTheme = themeOverride || frontmatter.theme || '' const wantElectron = cliElectron || frontmatter.electron === true const rawTargetApp = frontmatter.app === undefined || frontmatter.app === null ? '' : typeof frontmatter.app === 'number' ? String(frontmatter.app) : frontmatter.app // accept `app: ` (e.g. `app: 3pc`) in addition to ports/URLs. // resolve via APPS — the launcher's preferredPort is the explicit target. const targetAppResolution = await resolveAppFrontmatterTarget(rawTargetApp) const targetApp = targetAppResolution.target const parsedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS) const url = getFlag('--app') || getFlag('--url') || '' // --base-url pins the run to one shell, so a caller can build an engine from // a known tree, serve it, and measure that build instead of whatever the // discovered shell happens to hold. the page and the bridge have to stay in // one world: without this retarget the flow would open the pinned shell and // then poll the default world's bridge, waiting out its timeout with no sign // of which shell it actually ran against. const pinnedShellBaseUrl = getFlag('--base-url') || '' if (pinnedShellBaseUrl) { const pinned = resolveBridgePortForPin({ baseUrl: pinnedShellBaseUrl, wsPort: parsedBridgeArgs.wsPort, explicitPort: parsedBridgeArgs.explicitPort, }) if (pinned.error !== undefined) { console.error(` error: ${pinned.error}`) rnxExit(1) } else { parsedBridgeArgs.wsPort = pinned.port } } if (wantElectron && !findDesktopCompanion()) { console.error( ' error: desktop companion not found. install or build it first with `bun run build:electron`', ) rnxExit(1) } const mode = wantElectron ? 'desktop companion' : 'bridge' const appLabel = targetApp ? ` | target: ${targetApp}` : '' console.log(` rnx maestro — ${path.basename(flowPath)} ${steps.length} steps | ${mode}${appLabel}${record ? ' | recording' : ''}${profile ? ' | profiling' : ''}${slow ? ` | ${slow}ms delay` : ''} `) let openTarget = url || targetApp || '' const effectiveOpenInNewSim = openInNewSim || (wantElectron && parsedBridgeArgs.simIdSource !== 'flag') const driverFlag = getFlag('--driver') || '' let closeFlowOwnedSimAfterRun = false const ownedSimIds = new Set() // if we weren't told exactly where to go, and there's already a primary // open tab, just reuse it. opening a fresh tab that auto-discovers some // other dev server on the host is almost never what the user wants — they // already pointed the current tab at the app they want to drive. let reuseActivePrimary = false if (!openTarget && !effectiveOpenInNewSim && parsedBridgeArgs.simIdSource === 'none') { const probe = createBridgeFromParsed(parsedBridgeArgs) try { const sims = await probe.listSims() const primary = sims.find((b) => b.isPrimary && b.readyState === 'open') const firstOpen = sims.find((b) => b.readyState === 'open') const active = primary ?? firstOpen if (active) { reuseActivePrimary = true console.log(` reusing active sim: ${active.id}`) } } catch { // bridge not reachable — fall through to discovery } finally { probe.close() } } if ( !openTarget && !reuseActivePrimary && (effectiveOpenInNewSim || parsedBridgeArgs.simIdSource === 'none') ) { const discovered = await discoverSootsimUrl() if (!discovered) { console.error(' error: no current sim and no rnx target found') rnxExit(1) } openTarget = discovered } if (openTarget && targetDevice) { const shellBaseUrl = applyRNXConfigToUrl( pinnedShellBaseUrl || resolveShellBaseUrlForBridgePort(parsedBridgeArgs.wsPort), targetAppResolution.runtimeConfig, ) const url = new URL( await buildShellUrl(openTarget, shellBaseUrl, { proof: proofMode }), ) url.searchParams.set('device', targetDevice) openTarget = url.toString() } if (openTarget) { const openArgs = [openTarget] if (pinnedShellBaseUrl) openArgs.push('--base-url', pinnedShellBaseUrl) openArgs.push(...replaceArgs) openArgs.push(...remapArgs) if (effectiveOpenInNewSim) { openArgs.push('--new') } openArgs.push('--no-describe') if (parsedBridgeArgs.simIdSource === 'flag' && parsedBridgeArgs.simId) { openArgs.push('--sim', parsedBridgeArgs.simId) } if (driverFlag) { openArgs.push('--driver', driverFlag) } if (args.includes('--headless') && !headed) { openArgs.push('--headless') } if (proofMode) openArgs.push('--proof') const opened = await runOpenCommand(openArgs, { port: parsedBridgeArgs.wsPort, timeoutMs: parsedBridgeArgs.commandTimeoutMs, runtimeConfig: targetAppResolution.runtimeConfig, }) // a flow that launched its own sim owns it, and owning it means closing it. // the detached host's watchdog polls the *top* ancestor // (getStableOwnerPid -> findTopAncestor), which is the shell, agent // session, or runner daemon, not the run. that pid can live for days, so // a launched sim nobody closes stays open and keeps its flow lock. closeFlowOwnedSimAfterRun = opened?.launched === true || !!driverFlag if (closeFlowOwnedSimAfterRun && opened) ownedSimIds.add(opened.simId) } const resolvedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS) const pinnedSimId = resolvedBridgeArgs.simIdSource === 'flag' ? resolvedBridgeArgs.simId : readCurrentSimId() || resolvedBridgeArgs.simId // carry the source through so the target-sim notice reads `via --sim` // (not the misleading `primary fallback — no sim pinned`) when the flow // was actually pinned with --sim or a `rnx use` selection. const pinnedSimIdSource: typeof resolvedBridgeArgs.simIdSource = resolvedBridgeArgs.simIdSource === 'flag' ? 'flag' : pinnedSimId ? 'saved' : 'none' const bridge = createBridge(resolvedBridgeArgs.wsPort, { commandTimeoutMs: resolvedBridgeArgs.commandTimeoutMs, simId: pinnedSimId, simIdSource: pinnedSimIdSource, // the shell prints `lockedBy` over the device while the flow drives it. // without a label that falls back to the cli identity key, which is an // internal `ENV_VAR:value` string rather than something a viewer reads. cliLabel: 'maestro test', }) let driver: SootSimBridgeFlowRunner const applySimSettings = async () => { const statusBarTime = frontmatter.env?.RNX_STATUS_BAR_TIME if (statusBarTime) { if (!/^(?:[1-9]|1[0-2]):[0-5][0-9]$/.test(statusBarTime)) { throw new Error('RNX_STATUS_BAR_TIME must be a 12-hour time such as 9:41') } await bridge.send({ type: 'evaluate', simId: driver.simId, code: `window.dispatchEvent(new CustomEvent('sootsim:statusBarOverride', { detail: { time: ${JSON.stringify(statusBarTime)} } }))`, }) } if (targetTheme) { await bridge.send({ type: 'call', simId: driver.simId, path: 'SootSim.bridges.settings.set', args: ['colorScheme', targetTheme], }) } if (!targetDevice) return false return Boolean( await bridge.send({ type: 'call', simId: driver.simId, path: 'SootSim.bridges.settings.set', args: ['deviceModel', targetDevice], }), ) } driver = new SootSimBridgeFlowRunner(bridge, { screenshotDir, flowDir: path.dirname(path.resolve(flowPath)), screenshotPathMode, screenshotLayers, simId: pinnedSimId, recordingOutputDir: record ? outDir : undefined, recordingFormat: preview ? 'mp4' : 'webm', // preview mode uploads to previewOrigin; entitlement check must // talk to the same origin, not whatever the shared desktop sim // happens to have cached. billingOriginOverride: preview ? previewOrigin : undefined, // any recording reached via a github installation token is a ci / // pr-preview run, so allow it for preview uploads. local flow recording // writes a file and matches `rnx record --mode video`. allowGitHubRecording: preview, // a test_run bills its recording from balance at finalize (preflighted // at upload init), not from a plan entitlement: the plan gate would // refuse every pay-as-you-go hosted test. requireRecordingEntitlement: preview && previewBillingKind !== 'test_run', // re-arm the event recorder after every launchApp-with-reload. the // recorder lives in-page state and gets wiped by window.location.reload. onAfterLaunch: async (simId) => { if (simId) saveCurrentSimId(simId) const deviceChanged = await applySimSettings() if (deviceChanged) await driver.waitForTree(120000) if (!preview) return await bridge.send({ type: 'evaluate', simId: driver.simId, code: `(() => { const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder return r?.start?.() ?? false })()`, }) }, }) if (slow) driver.stepDelay = slow // live step rail + pause gate in the sim devtools "test" tab, named by the // flow file so the rail identifies what is running. driver.liveStatus = new FlowLiveStatusReporter( bridge, path.basename(flowPath), flowContent, () => driver.simId, ) // maestro default vars + flow-level `env:` frontmatter, defined into the // flow's JS context before the first step (upstream withDefaultEnvVars + // DefineVariablesCommand). driver.js.putEnv('MAESTRO_FILENAME', path.basename(flowPath, path.extname(flowPath))) if (frontmatter.env && typeof frontmatter.env === 'object') { for (const [key, value] of Object.entries(frontmatter.env)) { driver.js.putEnv(key, value) } } let profileResult: SootSimFlowProfileResult | null = null let videoPath: string | null = null let videoDurationMs: number | null = null let capturedEvents: Array> | null = null let previewScreenshotPath: string | null = null let failureBundleDir: string | null = null let exitCode = 0 const dryRun = args.includes('--dry-run') || args.includes('--no-run') const startsWithLaunchApp = !!steps[0]?.launchApp try { if (!startsWithLaunchApp) { await driver.waitForTree(120000) } const deviceChanged = await applySimSettings() if (deviceChanged && !startsWithLaunchApp) await driver.waitForTree(120000) // preview replay must reproduce the SAME initial conditions the agent // explored from. the explore session runs on a fresh ephemeral profile // (cold, logged-out), so its recorded steps assume that cold start — // e.g. step 1 taps a dev/login button. but the replay tab reuses the // explore tab's now-dirtied storage (persisted auth/session), so without // a reset the app boots already-authenticated and the recorded login // step targets an element that no longer exists — the flow hangs on // step 1 ("made no progress … bridge or bundle probably hung"). flows // that open with an explicit launchApp manage their own state; every // other preview flow gets a clean cold launch here so the recording // captures the same journey from the same starting point. app-agnostic, // no per-repo configuration — the bot just figures it out. // skip when the caller already established a clean cold state (e.g. the // contrastbot run.sh pre-replay reset reopens an --ephemeral sim: fresh // profile, logged-out, brought to readiness). a second clearState+reload // here is redundant and triggers a sim-id rotation on the reload that // routes the flow to a stale sim, hanging step 1 ("made no progress … // bridge or bundle probably hung"). one reset is enough — the bot // signals it did one via RNX_PREVIEW_RESET_DONE. if (preview && !startsWithLaunchApp && !process.env.RNX_PREVIEW_RESET_DONE) { await driver.launchApp({ clearState: true }) } if (dryRun) { await driver.planFlow(steps, { dryRun: true }) console.log( `[flow] flow loaded (${steps.length} steps) — waiting for play in test runner…`, ) await driver.liveStatus?.waitForStart() console.log('[flow] starting flow playback…') if (record) { if (startsWithLaunchApp) { driver.prepareRecording() } else { await driver.startRecording() } } } else if (record) { if (startsWithLaunchApp) { driver.prepareRecording() } else { await driver.startRecording() } } if (profile) { await driver.startProfile() } if (preview) { // start the preview event recorder on the tab so agent-driven taps // (via sootsim:agentAction) land in the capture stream for replay. // some shells expose the recorder via window.SootSim, others via // the legacy alias only — try both. const started = await bridge.send({ type: 'evaluate', simId: driver.simId, code: `(() => { const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder return r?.start?.() ?? false })()`, }) if (!started) { console.warn( ' warn: eventRecorder.start() returned false — preview will upload bundle without replay events', ) } } await driver.runFlow(steps, { skipPlan: dryRun }) if (profile) { profileResult = await driver.stopProfile() } await driver.waitForRecordingTail({ maxMs: tailWaitMs, smart: preview }) if (record) { videoPath = await driver.stopRecording() videoDurationMs = driver.getLastRecordingDurationMs() } if (preview) { capturedEvents = (await bridge.send({ type: 'evaluate', simId: driver.simId, code: `(() => { const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder return r?.stop?.() ?? [] })()`, })) as Array> console.log(` captured ${capturedEvents.length} replay events`) previewScreenshotPath = path.join(screenshotDir, 'preview-final.png') await driver.captureScreenshot(previewScreenshotPath) console.log(` final screenshot: ${previewScreenshotPath}`) } const screenshotSteps = steps.filter( (s: any) => s && typeof s === 'object' && 'takeScreenshot' in s, ).length console.log(`\n + completed (${steps.length} steps)`) if (screenshotSteps > 0) { // surface where the assets actually landed so the user doesn't have to // grep through verbose [flow] step output for paths console.log(` screenshots: ${screenshotSteps} → ${screenshotDir}`) } if (profileResult) { printFlowProfile(profileResult) } if (videoPath) { console.log(` video: ${videoPath}`) } console.log() } catch (err: any) { if (profile && !profileResult) { try { profileResult = await driver.stopProfile() } catch { // ignore profiling teardown failures while already handling the main error } } if (record && !videoPath) { try { videoPath = await driver.stopRecording() videoDurationMs = driver.getLastRecordingDurationMs() } catch (recordErr: any) { console.warn( ` recording stop failed: ${recordErr?.message || String(recordErr)}`, ) } } if (preview && !capturedEvents) { try { capturedEvents = (await bridge.send({ type: 'evaluate', simId: driver.simId, code: `(() => { const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder return r?.stop?.() ?? [] })()`, })) as Array> console.log(` captured ${capturedEvents.length} partial replay events`) } catch (eventErr: any) { console.warn( ` event recorder stop failed: ${eventErr?.message || String(eventErr)}`, ) } } console.error(`\n x failed: ${err.message}\n`) if (profileResult) { console.log(' partial profile:') printFlowProfile(profileResult) } if (videoPath) { console.log(` partial video: ${videoPath}`) } try { // rich per-failure bundle: screenshot + describe.json + a11y + // tree + console + failed-requests. dir is named by the failing // step so consecutive failing runs don't overwrite each other. const failedStep = driver.lastFailedStep const bundleName = failedStep ? `step-${String(failedStep.index + 1).padStart(2, '0')}-${failedStep.kind}` : 'unstaged' const bundleDir = path.join(screenshotDir, bundleName) await driver.captureFailureBundle(bundleDir, { error: err, stepIndex: failedStep?.index, stepKind: failedStep?.kind, stepTarget: failedStep?.target, }) failureBundleDir = bundleDir console.log(` failure bundle: ${bundleDir}`) console.log( ` (contents: screenshot.png, describe.json, a11y.txt, tree.txt, console.json, error.json)`, ) } catch (bundleErr) { // fall back to the bare screenshot if the full bundle blew up — // at least the viewer has one thing to look at. try { const errShot = path.join(screenshotDir, 'error.png') await driver.captureScreenshot(errShot) console.log(` error screenshot: ${errShot}`) } catch {} console.log( ` (failure bundle capture failed: ${ bundleErr instanceof Error ? bundleErr.message : String(bundleErr) })`, ) } await printBridgeFailureDiagnostics(bridge, { errorsCommand: `rnx get errors 5${driver.simId ? ` --sim ${driver.simId}` : ''}`, warningsCommand: `rnx get warnings 5${driver.simId ? ` --sim ${driver.simId}` : ''}`, requestsCommand: `rnx get requests 5${driver.simId ? ` --sim ${driver.simId}` : ''}`, }) exitCode = 1 } await driver.liveStatus?.end(exitCode === 0 ? 'passed' : 'failed') // under --preview, kick the captured events into the preview uploader so the viewer // gets a real /preview/ with scrubbable replay. failed flows upload the // partial recording when available so dashboard runs can link to the trace. if (preview && (exitCode === 0 || videoPath || capturedEvents)) { try { const eventsJsonlGz = await prepareEventsFile(capturedEvents ?? []) const uploadArgs = ['--origin', previewOrigin, '--events', eventsJsonlGz] if (previewPublicOrigin) { uploadArgs.push('--public-origin', previewPublicOrigin) } // chain the recorded mp4 so preview-page can layer it over the // canvas during scrub for smooth motion while the real app // catches up. if (videoPath) uploadArgs.push('--video', videoPath) if (previewScreenshotPath) { uploadArgs.push('--screenshot', previewScreenshotPath) } if (videoPath && videoDurationMs && Number.isFinite(videoDurationMs)) { uploadArgs.push('--video-duration-ms', String(Math.round(videoDurationMs))) uploadArgs.push('--recorded-duration-ms', String(Math.round(videoDurationMs))) } const recordingStartedAtMs = driver.getLastRecordingStartedAtMs() if (recordingStartedAtMs && Number.isFinite(recordingStartedAtMs)) { uploadArgs.push( '--recording-started-at-ms', String(Math.round(recordingStartedAtMs)), ) } if (parsedBridgeArgs.wsPort) { uploadArgs.push('--port', String(parsedBridgeArgs.wsPort)) } if (driver.simId) { uploadArgs.push('--sim', driver.simId) } if (previewOpen) uploadArgs.push('--open') if (previewOwner) uploadArgs.push('--owner', previewOwner) if (previewRepo) uploadArgs.push('--repo', previewRepo) uploadArgs.push('--run-scope', previewRunScope) if (previewBillingKind) { uploadArgs.push('--billing-kind', previewBillingKind) } console.log(`\n preparing preview upload…`) const flowTraceSteps = driver.getFlowTraceSteps() if (flowTraceSteps.length > 0) { uploadArgs.push( '--timeline-events', await prepareFlowStepTimelineFile(flowTraceSteps), ) } const snapshotManifest = await prepareFlowSnapshotManifest( flowTraceSteps, recordingStartedAtMs, ) if (snapshotManifest) { uploadArgs.push('--snapshot-manifest', snapshotManifest) } if (failureBundleDir) { // failed runs ship the per-step debug bundle with the share so the // dashboard's run row can show the failing screen + full context. uploadArgs.push('--failure-bundle', failureBundleDir) } const frameStats = driver.getLastRecordingFrameStats() if (frameStats) { uploadArgs.push('--trace-frame-stats', await prepareFrameStatsFile(frameStats)) } const { runUpload } = await import('./upload') lastPreviewUploadResult = await runUpload(uploadArgs, {}) } catch (err: any) { console.error(` preview upload failed: ${err?.message || err}`) exitCode = 1 } } if (dryRun) { console.log('[flow] run completed — waiting for reset or new action in test runner…') while (true) { const action = await driver.liveStatus?.waitForResetOrRerun() if (action === 'reset') { console.log('[flow] reset requested from devtools — resetting runner state') driver.resetFlowState() await driver.planFlow(steps, { dryRun: true }) console.log('[flow] flow reloaded — waiting for play in test runner…') await driver.liveStatus?.waitForStart() console.log('[flow] re-running flow playback…') try { await driver.runFlow(steps, { skipPlan: true }) await driver.liveStatus?.end('passed') console.log('\n + completed replay successfully\n') } catch (replayErr: any) { console.error(`\n x failed replay: ${replayErr?.message || replayErr}\n`) await driver.liveStatus?.end('failed') } } else if (action === 'start') { console.log('[flow] play requested from devtools — re-running flow playback…') try { await driver.runFlow(steps, { skipPlan: true }) await driver.liveStatus?.end('passed') console.log('\n + completed replay successfully\n') } catch (replayErr: any) { console.error(`\n x failed replay: ${replayErr?.message || replayErr}\n`) await driver.liveStatus?.end('failed') } } else { break } } } lastFlowTraceSteps = driver.getFlowTraceSteps() bridge.close() if (closeFlowOwnedSimAfterRun) { if (driver.simId) ownedSimIds.add(driver.simId) await closeFlowOwnedSims(parsedBridgeArgs.wsPort, parsedBridgeArgs.commandTimeoutMs, [ ...ownedSimIds, ]) } else if (driver.simId && driver.simId !== pinnedSimId) { // `launchApp clearState` rotates the sim id mid-flow (the shell reconnects // under a fresh id). the runner tracks that in-memory, but a directory run // (`rnx maestro test `) spawns the next flow fresh — it re-reads // the saved sim id, which would still point at the pre-rotation sim and // fail with "sim not responding / stale orphan". persist the rotated id so // the next flow reuses the live sim. only when we're NOT closing it. saveCurrentSimId(driver.simId) } return exitCode } async function closeFlowOwnedSims( wsPort: number, commandTimeoutMs: number, simIds: string[], ): Promise { const ids = [...new Set(simIds.filter(Boolean))] if (ids.length === 0) return const bridge = createBridge(wsPort, { commandTimeoutMs }) try { const sims = await bridge.listSims() const openIds = ids.filter((id) => sims.some((sim) => sim.id === id && sim.readyState === 'open'), ) if (openIds.length === 0) return const result = await closeSimsBulk(bridge, wsPort, commandTimeoutMs, openIds) await terminatePlaywrightHostsForSims(sims, openIds) if (result.closed.length > 0) { console.log(` closed flow sim(s): ${result.closed.join(', ')}`) } if (result.remaining.length > 0) { console.warn( ` warn: flow sim(s) still connected after close: ${result.remaining.join(', ')}`, ) } } catch (err) { console.warn( ` warn: failed to close flow sim(s): ${ err instanceof Error ? err.message : String(err) }`, ) } finally { bridge.close() } } // write the captured event stream to a gzipped jsonl file in a temp dir. // returns the absolute path for the upload command to pick up. async function prepareEventsFile(events: Array>): Promise { const { gzipSync } = await import('zlib') const lines = events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '') const gz = gzipSync(Buffer.from(lines, 'utf8')) const outPath = path.join(tmpdir(), `rnx-events-${Date.now()}.jsonl.gz`) fs.writeFileSync(outPath, gz) console.log(` events: ${events.length} written to ${outPath} (${gz.length} bytes gz)`) return outPath } async function prepareFlowStepTimelineFile( steps: SootSimFlowTraceStep[], ): Promise { const { gzipSync } = await import('zlib') const events = steps.map((step, index) => ({ schemaVersion: 1, t: step.endedAtMs, seq: 1_000_000 + index, context: 'host', kind: 'flow-step', id: `flow-step-${step.stepIndex}`, data: { stepIndex: step.stepIndex, stepNumber: step.stepIndex + 1, stepName: step.stepName, ...(step.targetLabel ? { targetLabel: step.targetLabel } : {}), status: step.status, durationMs: step.durationMs, startedAtMs: step.startedAtMs, endedAtMs: step.endedAtMs, ...(step.error ? { error: step.error } : {}), }, })) const lines = events.map((event) => JSON.stringify(event)).join('\n') + '\n' const gz = gzipSync(Buffer.from(lines, 'utf8')) const outPath = path.join(tmpdir(), `rnx-flow-steps-${Date.now()}.jsonl.gz`) fs.writeFileSync(outPath, gz) console.log( ` flow trace: ${events.length} step events written to ${outPath} (${gz.length} bytes gz)`, ) return outPath } async function prepareFlowSnapshotManifest( steps: SootSimFlowTraceStep[], recordingStartedAtMs: number | null, ): Promise { if (!recordingStartedAtMs) return null const snapshots = steps.flatMap((step) => step.screenshotPath ? [ { id: `flow-step-${step.stepIndex}`, label: step.targetLabel ?? `Step ${step.stepIndex + 1}`, kind: 'interaction', t: Math.max(0, step.endedAtMs - recordingStartedAtMs), path: step.screenshotPath, }, ] : [], ) if (snapshots.length === 0) return null const outPath = path.join(tmpdir(), `rnx-flow-snapshots-${Date.now()}.json`) fs.writeFileSync(outPath, JSON.stringify(snapshots)) console.log(` flow snapshots: ${snapshots.length} written to ${outPath}`) return outPath } async function prepareFrameStatsFile(frameStats: unknown): Promise { const outPath = path.join(tmpdir(), `rnx-frame-stats-${Date.now()}.json`) fs.writeFileSync(outPath, JSON.stringify(frameStats)) console.log(` frame stats: written to ${outPath}`) return outPath } async function runFlowSession(args: string[]) { const subcommand = args[0] const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name) switch (subcommand) { case 'start': { const { path, state } = startFlowSession() console.log(` flow draft started`) console.log(` session: ${path}`) console.log(` steps: ${state.steps.length}`) return } case 'keep': { const result = keepFlowCandidate() if (!result.active) { console.error(' no active flow draft — run `rnx maestro start` first') rnxExit(1) } if (!result.kept) { console.log(' no pending action to keep') return } console.log(` kept: ${result.candidate.summary}`) console.log(` steps: ${result.stepCount}`) return } case 'end': { const outputPath = getFlag('--output') || (args[1] && !args[1].startsWith('-') ? args[1] : undefined) const validate = args.includes('--validate') || args.includes('--video') const video = args.includes('--video') // ending the draft commits the trailing pending action. the last // successful `do` is presumably one the user wants; without this, // `maestro start → do → maestro end` (no explicit keep) exported an empty // [] and silently dropped the work (F13-4). const autoKept = keepFlowCandidate() if (autoKept.active && autoKept.kept) { console.log(` auto-kept trailing action: ${autoKept.candidate.summary}`) } const result = exportFlowSession( outputPath || (validate ? createDraftValidationPath() : undefined), ) if (!result.active) { console.error(' no active flow draft — run `rnx maestro start` first') rnxExit(1) } if (!result.valid) { console.error(' flow draft is not valid:') for (const issue of result.issues) console.error(` - ${issue}`) console.error( ' draft preserved — keep at least one real interaction or run `rnx maestro start` to reset', ) rnxExit(1) } if (validate) { const playbackArgs = buildFlowValidationArgs(args, result.outputPath) const exitCode = await runFlowPlayback(playbackArgs) if (exitCode !== 0) { console.error( '\n validation failed — draft preserved so you can keep iterating', ) process.exitCode = exitCode return } finalizeFlowSession() console.log( ` flow draft validated (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`, ) if (result.outputPath) { console.log(` saved: ${result.outputPath}`) } if (video) { console.log(' video: recorded during validation run') } return } finalizeFlowSession() console.log( ` flow draft ended (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`, ) if (result.outputPath) { console.log(` saved: ${result.outputPath}`) console.log(` next: rnx maestro test ${result.outputPath} --record`) return } console.log('') process.stdout.write(result.yaml) return } case 'validate': { const flowPath = args[1] if (!flowPath || flowPath.startsWith('-')) { console.error(' usage: rnx maestro validate ') rnxExit(1) } const issues = validateFlowFile(flowPath) if (issues.length > 0) { console.error(` x ${flowPath} failed validation:`) for (const issue of issues) console.error(` - ${issue}`) rnxExit(1) } console.log(` + ${flowPath} looks valid`) return } } } // the subcommand / flow-file path is the first positional arg, but a // `--sim ` pair can sit in front of it: bin.ts prepends the global // `--sim` to every command's args, and `rnx maestro --sim a2 start` is a // documented form. hoist a leading `--sim ` to the end so `args[0]` // is the real subcommand again (downstream getFlag / parseBridgeCliArgs // still find `--sim` wherever it lands). without this, `--sim 6a5 flow // start` parsed `--sim` as the flow path and failed `--sim not found`. export function hoistLeadingSimFlag(args: string[]): string[] { if (args[0] === '--sim' && args.length >= 2) { return [...args.slice(2), '--sim', args[1]] } return args } export async function runMaestroAuthoring(rawArgs: string[]): Promise { const args = hoistLeadingSimFlag(rawArgs) const subcommand = args[0] if ( subcommand === 'start' || subcommand === 'keep' || subcommand === 'good' || subcommand === 'end' || subcommand === 'validate' ) { await runFlowSession(args) return 0 } const exitCode = await runFlowPlayback(args) if (exitCode !== 0) { process.exitCode = exitCode } return exitCode } function printFlowProfile(result: SootSimFlowProfileResult) { console.log('') printShellPerfReport(result) } function createDraftValidationPath() { return path.join(tmpdir(), `rnx-flow-draft-${Date.now()}.yaml`) } function buildFlowValidationArgs(args: string[], outputPath: string | null) { if (!outputPath) { throw new Error('validated flow draft requires an output path') } const nextArgs = [outputPath] for (let i = 1; i < args.length; i++) { const arg = args[i] if (arg === '--validate' || arg === '--video') continue if (arg === '--output') { i += 1 continue } if (i === 1 && !arg.startsWith('-')) { continue } nextArgs.push(arg) } if (!nextArgs.includes('--record') && args.includes('--video')) { nextArgs.push('--record') } return nextArgs }