import * as fs from 'fs' import * as path from 'path' import { devices, type DeviceModel } from 'sootsim-engine/settings' import yaml from 'yaml' import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs' import { flowTimeoutScale } from '../src/flow-timeout-scale' import { composeFramedScreenshot } from '../src/screenshots/frame-compose' import { inspectWaitReady, waitReadyReason } from './commands/inspect/core' import { waitForSootsimIdle } from './commands/inspect/settling' import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared' import { saveCurrentSimId } from './current-sim' import { FlowLiveStatusReporter } from './flow-live-status' import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js' import { ensureCliRecordingEntitlement } from './recording-access' import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract' import type { WsBridge } from './ws-bridge' import type { SootSimReplayTarget, SootSimTapResult } from '@rnx/globals' import type { SootSimExternalAppLifecycleOptions, SootSimLaunchArguments, } from '@rnx/globals' const DEFAULT_TIMEOUT = 10000 const FLOW_TIMEOUT_SCALE = flowTimeoutScale() const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE) const SCREEN_W = 393 const SCREEN_H = 852 type FlowScreenshotPathMode = 'dir' | 'flow' type ReplayTapTarget = SootSimReplayTarget type FlowTargetSelector = { id?: string text?: string index?: number childOf?: { id?: string; text?: string } } type FlowPointTarget = FlowTargetSelector & { point?: string } type FocusedTextInputInfo = { nodeId?: number | null id?: string | null testID?: string | null } type ShellState = Record // shape of window.__sootsimShellPerf.stop() — the shell worker frame profile // (the worker that paints app pixels) merged with the render-profile counters. // see sootsim-engine shell-worker.ts stopShellFrameProfile. export interface SootSimFlowProfileResult { frames: number skippedFrames: number totalMs: number avgMs: number maxMs: number p50: number p95: number p99: number jankFrames: number jankSampleFrames?: number jankPct: number avgOverlayMs: number avgAuxMs: number avgLayoutMs: number auxSurfaces: Array> worstFrames: Array> frameSeries?: Array> jank?: Record compositorCadence?: Record renderProfile?: Record } export type SootSimFlowTraceStep = { stepIndex: number stepName: string targetLabel?: string startedAtMs: number endedAtMs: number durationMs: number status: 'success' | 'skipped' | 'failure' error?: string screenshotPath?: string } export interface MaestroStep { tapOn?: | string | { id?: string text?: string index?: number point?: string childOf?: { id?: string; text?: string } optional?: boolean } longPressOn?: | string | { id?: string text?: string index?: number point?: string childOf?: { id?: string; text?: string } optional?: boolean } tapAtCoords?: { x: number; y: number } doubleTapAtCoords?: { x: number; y: number; gapMs?: number } assertVisible?: | string | { id?: string text?: string index?: number childOf?: { id?: string; text?: string } optional?: boolean } assertNotVisible?: | string | { id?: string text?: string index?: number childOf?: { id?: string; text?: string } optional?: boolean } inputText?: string pressKey?: string dispatchKey?: string swipe?: { direction?: string duration?: number start?: string end?: string from?: { id?: string; text?: string } optional?: boolean } scroll?: { direction?: string; optional?: boolean } scrollTo?: { id?: string; nodeId?: number; x: number; y: number; optional?: boolean } pinch?: { from: [number, number, number, number] to: [number, number, number, number] steps?: number stepMs?: number optional?: boolean } takeScreenshot?: | string | { path?: string name?: string withFrame?: boolean // 'full' (default) captures everything; 'tenant' skips shell overlays // (status bar, keyboard, toasts, notification center) for a clean // app-only capture; 'shell' is the inverse. layers?: 'full' | 'tenant' | 'shell' } waitFor?: { text?: string; id?: string; timeout?: number; optional?: boolean } waitForAnimationToEnd?: boolean | number | { timeout?: number } back?: boolean hideKeyboard?: boolean launchApp?: any // maestro canonical form: object with `file` XOR inline `commands`, plus // optional `when` gate and `env` vars scoped to the sub-flow. legacy // rnx also accepts a bare path string. runFlow?: | string | { file?: string commands?: MaestroStep[] env?: Record label?: string when?: { visible?: string | { id?: string; text?: string } notVisible?: string | { id?: string; text?: string } platform?: string true?: string | boolean } } // maestro runScript: execute a JS file in the flow's shared JS context // (host-side — NOT the app page). env vars are scoped to the script. runScript?: | string | { file: string env?: Record label?: string when?: { visible?: string | { id?: string; text?: string } notVisible?: string | { id?: string; text?: string } platform?: string true?: string | boolean } } repeat?: { times?: number | string when?: NonNullable condition?: NonNullable commands: MaestroStep[] } // maestro retry: run the body, and on a failed step rerun the whole body. // maxRetries counts retries after the first attempt (upstream // retryCommand), so maxRetries: 3 allows at most 4 runs. retry?: { maxRetries?: number | string commands: MaestroStep[] } scrollUntilVisible?: { element: string centerElement?: boolean direction?: string timeout?: number optional?: boolean } extendedWaitUntil?: { visible?: string | { id?: string; text?: string } notVisible?: string | { id?: string; text?: string } timeout?: number optional?: boolean } eraseText?: number wait?: number dumpTree?: number assertTreeContains?: string // maestro parity verbs stopApp?: boolean | string | { appId?: string } clearState?: boolean | { appId?: string } clearKeychain?: boolean copyTextFrom?: string | { id?: string; text?: string } evalScript?: string openLink?: string | { link: string; autoVerify?: boolean; browser?: boolean } // maestro `when:` conditional runs the step only if the predicate holds. // supports `visible` / `notVisible` with the same matcher shape used elsewhere. when?: { visible?: string | { id?: string; text?: string } notVisible?: string | { id?: string; text?: string } platform?: string true?: string | boolean } // maestro lifecycle bookends — multi-doc flows may emit single-key steps // with these keys carrying a sub-step array; handled in flattenSteps. onFlowStart?: MaestroStep[] onFlowComplete?: MaestroStep[] } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } function isObjectRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } function readStringKey(value: unknown, key: string): string | null { if (!isObjectRecord(value)) return null const raw = value[key] return typeof raw === 'string' && raw.length > 0 ? raw : null } function readBooleanKey(value: unknown, key: string): boolean { if (!isObjectRecord(value)) return false return value[key] === true } function readRecentShellAppId(state: ShellState | null): string | null { if (!state) return null const recentApps = state.recentApps if (Array.isArray(recentApps)) { for (const app of recentApps) { const id = readStringKey(app, 'id') if (id) return id } } const bindings = state.surfaceBindings return readStringKey(bindings, 'app:one') || readStringKey(bindings, 'app:two') || null } export function resolveFlowScreenshotPath(screenshotDir: string, name: string): string { return resolveFlowScreenshotPathWithMode(screenshotDir, name, { mode: 'dir', }) } function resolveProjectRelativeFlowBase(flowDir?: string): string { if (!flowDir) return process.cwd() return path.basename(flowDir) === '.maestro' ? path.dirname(flowDir) : flowDir } export function resolveFlowScreenshotPathWithMode( screenshotDir: string, name: string, opts: { mode: FlowScreenshotPathMode flowDir?: string }, ): string { const normalizedName = name.endsWith('.png') ? name : `${name}.png` if (path.isAbsolute(normalizedName)) return normalizedName if (opts.mode === 'flow' && /[\\/]/.test(normalizedName)) { const baseDir = normalizedName.startsWith('./') || normalizedName.startsWith('../') ? (opts.flowDir ?? process.cwd()) : resolveProjectRelativeFlowBase(opts.flowDir) return path.resolve(baseDir, normalizedName) } return path.join(screenshotDir, normalizedName) } export function normalizeFlowScreenshotSpec( step: | string | { path?: string name?: string withFrame?: boolean layers?: 'full' | 'tenant' | 'shell' }, ): { path: string; withFrame: boolean; layers?: 'full' | 'tenant' | 'shell' } { if (typeof step === 'string') { return { path: step, withFrame: false } } const name = step.path?.trim() || step.name?.trim() if (!name) { throw new Error('takeScreenshot object form requires path or name') } return { path: name, withFrame: step.withFrame === true, layers: step.layers, } } function parsePoint( value: string, bounds?: { x: number; y: number; width: number; height: number }, ) { const [rawX, rawY] = value.split(',').map((part) => part.trim()) const parseCoord = (coord: string, max: number) => coord.endsWith('%') ? (Number.parseFloat(coord) / 100) * max : Number.parseFloat(coord) const originX = bounds?.x ?? 0 const originY = bounds?.y ?? 0 return { x: originX + parseCoord(rawX, bounds?.width ?? SCREEN_W), y: originY + parseCoord(rawY, bounds?.height ?? SCREEN_H), } } // maestro accepts several commands as a bare YAML string (`- back`, // `- waitForAnimationToEnd`, `- scrollUp`, …). map the string to the // `{ verb: … }` object the dispatcher understands. directional `scroll*` // shorthands map onto the `scroll` verb (its swipe inverts DOWN/UP). function normalizeBareStringStep(verb: string): MaestroStep { switch (verb) { case 'back': return { back: true } case 'hideKeyboard': return { hideKeyboard: true } case 'waitForAnimationToEnd': return { waitForAnimationToEnd: true } case 'stopApp': return { stopApp: true } case 'clearState': return { clearState: true } case 'clearKeychain': return { clearKeychain: true } case 'eraseText': return { eraseText: 50 } case 'scroll': case 'scrollDown': return { scroll: { direction: 'DOWN' } } case 'scrollUp': return { scroll: { direction: 'UP' } } default: // unknown bare verb — surface it as the offending step (the dispatcher // throws "unsupported flow step") rather than silently no-op. return { [verb]: true } as MaestroStep } } // parse the steps document of a flow file. `${...}` templates stay verbatim: // interpolation is per-step at execution time (runStep), matching maestro — // values produced mid-flow (runScript output.*, copyTextFrom) resolve // correctly in later steps. export function parseFlowSteps(content: string): MaestroStep[] { const parts = content.split(/^---$/m) const candidate = parts.length > 1 ? parts[parts.length - 1] : content const parsed = yaml.parse(candidate) return Array.isArray(parsed) ? parsed : [] } export class SootSimBridgeFlowRunner { stepDelay = 0 // maestro-compatible JS context for the whole flow run (shared with // runFlow sub-flows): `${...}` templates, runScript, evalScript, output, // maestro.copiedText. one per runner = one per flow file, like upstream. readonly js = new MaestroJsContext({ platform: 'ios', onLog: (msg) => console.log(`[flow] js: ${msg}`), }) private firstLaunchDone = false private appStopped = false private launchArgumentsActive = false private profilingEnabled = false private recordingEnabled = false private recordingAccessChecked = false private recordingStartedAtMs: number | null = null private lastRecordingStartedAtMs: number | null = null private lastRecordingDurationMs: number | null = null private lastRecordingFrameStats: unknown = null private flowTraceSteps: SootSimFlowTraceStep[] = [] // set by runStep when a step throws — lets the outer flow command // dump a rich per-step failure bundle (screenshot + describe.json + // console + network) keyed by the failing step. lastFailedStep: { index: number; kind: string; target: unknown } | null = null constructor( readonly bridge: WsBridge, // simId identifies the browser tab for the whole flow, including reloads; // the rest of opts is stable too. private readonly opts: { screenshotDir: string flowDir: string screenshotPathMode?: FlowScreenshotPathMode // flow-wide default for screenshot layers — applied to every // takeScreenshot step unless that step has its own `layers:`. set by // `rnx maestro test --no-shell` to produce clean tenant-only captures // (useful when a stuck shell overlay would otherwise pollute the // demo asset set). screenshotLayers?: 'full' | 'tenant' | 'shell' simId?: string recordingOutputDir?: string /** billing-origin override for the recording entitlement check. set * by `maestro test --preview` so the entitlement is verified against the * same origin that receives the upload (e.g. https://contrast.localhost:3000), * not whatever origin the shared desktop sim happens to * remember. */ billingOriginOverride?: string /** pr preview uploads may authenticate with a github installation * token instead of a paid desktop sim. upload/finalize already * accepts that identity, so preview recording should use the same * trust path. */ allowGitHubRecording?: boolean /** local file capture is unmetered; preview/upload recording still * verifies entitlement before producing a shareable cloud artifact. */ requireRecordingEntitlement?: boolean recordingFormat?: 'webm' | 'mp4' // fires after every page reload (launchApp clear-state) once the // tree is back and simId has been rotated. lets preview mode // re-start the event recorder, since `window.location.reload()` // wipes any listeners the caller installed before the flow ran. onAfterLaunch?: (simId: string | undefined) => Promise | void }, ) {} get simId(): string | undefined { return this.opts.simId } private async evaluate(code: string, timeoutMs?: number): Promise { return this.bridge.send( { type: 'evaluate', simId: this.opts.simId, code, }, timeoutMs ? { timeoutMs } : undefined, ) as Promise } private async callTest(method: string, ...args: unknown[]): Promise { return this.bridge.send({ type: 'call', simId: this.opts.simId, path: `__sootsimTest.${method}`, args, }) as Promise } // ordered input batch executed inside the page in one round-trip; the // engine emits agent-cursor actions for every step natively. private async perform(steps: PerformStep[]): Promise { return this.bridge.send({ type: 'perform', simId: this.opts.simId, steps, }) } async waitForTree(timeout: number = DEFAULT_TIMEOUT) { let status = await inspectWaitReady(this.bridge, scaledTimeout(timeout), { simId: this.opts.simId, }) if (status.ready) return if (status.externalError) { throw new Error(`app failed to load: ${status.externalError}`) } if (status.bridgeError) { // the guest re-registers under a new sim id whenever its page reloads, // and every command here pins this.opts.simId, so a reload strands the // whole flow on a disposed render host. re-pin to the same browser // host's successor and read the tree again before calling it dead. // the successor does not appear the instant the old sim is disposed — // the page has to finish reloading and re-register — so give it the same // budget the rest of the flow waits on rather than looking exactly once. const pinned = this.opts.simId let successor: string | null = null if (pinned) { const deadline = Date.now() + scaledTimeout(15000) do { successor = await this.bridge.resolveReloadedSim(pinned) if (successor) break await sleep(500) } while (Date.now() < deadline) } if (successor) { console.error( ` note: sim ${pinned} reloaded and reconnected as ${successor}; following it`, ) this.opts.simId = successor saveCurrentSimId(successor) status = await inspectWaitReady(this.bridge, scaledTimeout(timeout), { simId: this.opts.simId, }) if (status.ready) return } } if (status.bridgeError) { throw new Error( `target sim is not responding to the bridge (${status.bridgeError}). ` + `it is likely a stale orphan; run \`rnx list\`, then close it ` + `with \`rnx close \` or start a fresh one with \`rnx open --new \`.`, ) } throw new Error(`app not ready after ${timeout}ms: ${waitReadyReason(status)}`) } private async ensureRecordingStarted() { if (!this.opts.recordingOutputDir) { throw new Error('recording output directory not configured') } if (!this.recordingAccessChecked) { if (this.opts.requireRecordingEntitlement) { await ensureCliRecordingEntitlement('flow --video', { originOverride: this.opts.billingOriginOverride, allowGitHubAuth: this.opts.allowGitHubRecording, }) } this.recordingAccessChecked = true } const startedAt = Date.now() const result = await this.evaluate<{ ok: boolean error?: string format?: string startedAtMs?: number }>(`(() => { const rec = window.__sootsimRecorder if (!rec) return { ok: false, error: 'recorder unavailable on this page' } const format = ${JSON.stringify(this.opts.recordingFormat ?? 'webm')} if (rec.state() === 'recording') return { ok: true, format } return rec.start({ format, fps: 24 }) })()`) if (!result?.ok) { throw new Error(result?.error || 'recording unavailable on this page') } if (!this.recordingStartedAtMs) { this.recordingStartedAtMs = typeof result.startedAtMs === 'number' && Number.isFinite(result.startedAtMs) ? result.startedAtMs : startedAt } } async startRecording() { this.recordingEnabled = true await this.ensureRecordingStarted() } prepareRecording() { this.recordingEnabled = true } getLastRecordingDurationMs(): number | null { return this.lastRecordingDurationMs } getLastRecordingStartedAtMs(): number | null { return this.lastRecordingStartedAtMs } getLastRecordingFrameStats(): unknown { return this.lastRecordingFrameStats } getFlowTraceSteps(): SootSimFlowTraceStep[] { return [...this.flowTraceSteps] } private flowStepTargetLabel(step: MaestroStep, stepName: string): string | undefined { if ( stepName === 'inputText' || stepName === 'runScript' || stepName === 'evalScript' ) { return undefined } const value = (step as Record)[stepName] if (typeof value === 'string') return value.slice(0, 80) if (typeof value === 'number' || typeof value === 'boolean') return String(value) if (!value || typeof value !== 'object') return undefined const record = value as Record const direct = record.id ?? record.text ?? record.name ?? record.path if (typeof direct === 'string' && direct) return direct.slice(0, 80) // extendedWaitUntil keeps its target under visible/notVisible, so without // this the step every flow waits on renders as a bare "Wait". const awaited = record.visible ?? record.notVisible if (typeof awaited === 'string' && awaited) return awaited.slice(0, 80) if (awaited && typeof awaited === 'object') { const target = awaited as Record const label = target.id ?? target.text if (typeof label === 'string' && label) return label.slice(0, 80) } if (record.point && typeof record.point === 'string') return record.point.slice(0, 80) if (typeof record.x === 'number' && typeof record.y === 'number') { return `${Math.round(record.x)}, ${Math.round(record.y)}` } return undefined } private recordFlowTraceStep(args: { stepIndex: number stepName: string targetLabel?: string startedAtMs: number status: SootSimFlowTraceStep['status'] error?: unknown screenshotPath?: string }) { const endedAtMs = Date.now() const entry: SootSimFlowTraceStep = { stepIndex: args.stepIndex, stepName: args.stepName, ...(args.targetLabel ? { targetLabel: args.targetLabel } : {}), startedAtMs: args.startedAtMs, endedAtMs, durationMs: Math.max(0, endedAtMs - args.startedAtMs), status: args.status, } if (args.error != null) { entry.error = args.error instanceof Error ? args.error.message : String(args.error) } if (args.screenshotPath) { entry.screenshotPath = args.screenshotPath } this.flowTraceSteps.push(entry) } async stopRecording(): Promise { if (!this.recordingEnabled || !this.opts.recordingOutputDir) return null this.recordingEnabled = false const stoppedAt = Date.now() const stopped = await this.evaluate<{ ok: boolean error?: string size?: number mime?: string frameStats?: unknown }>(`window.__sootsimRecorder.stop()`) if (!stopped?.ok) { this.recordingStartedAtMs = null this.lastRecordingFrameStats = null throw new Error(stopped?.error || 'recording stop failed') } this.lastRecordingStartedAtMs = this.recordingStartedAtMs this.lastRecordingDurationMs = this.recordingStartedAtMs ? Math.max(1, stoppedAt - this.recordingStartedAtMs) : null this.lastRecordingFrameStats = stopped.frameStats ?? null this.recordingStartedAtMs = null // drain blob in chunks const chunks: Buffer[] = [] let offset = 0 while (true) { const part = await this.evaluate<{ data: string size: number offset: number done: boolean mime: string } | null>( `window.__sootsimRecorder.getBlobBase64({ offset: ${offset}, chunk: ${ 2 * 1024 * 1024 } })`, ) if (!part) break chunks.push(Buffer.from(part.data, 'base64')) offset = part.offset if (part.done) break } if (chunks.length === 0) { throw new Error('recording requested but no video buffer was produced') } fs.mkdirSync(this.opts.recordingOutputDir, { recursive: true }) const videoMime = stopped.mime || this.opts.recordingFormat || 'video/webm' const extension = videoMime.includes('mp4') ? 'mp4' : 'webm' const outputPath = path.join( this.opts.recordingOutputDir, `rnx-${Date.now()}.${extension}`, ) fs.writeFileSync(outputPath, Buffer.concat(chunks)) return outputPath } async waitForRecordingTail(opts: { maxMs: number; smart?: boolean }) { if (!this.recordingEnabled) return const maxMs = Math.max(0, Math.round(opts.maxMs)) if (maxMs <= 0) return if (!opts.smart) { await sleep(maxMs) return } const result = await waitForSootsimIdle({ bridge: this.bridge, simId: this.opts.simId, maxMs, pollMs: 80, stablePolls: 6, strict: true, }) await this.waitForVisualSettle() console.log( result.settled ? ` [flow] recording tail settled in ${result.elapsed}ms` : ` [flow] recording tail reached ${result.elapsed}ms budget`, ) } private async findElement(opts: FlowTargetSelector) { // patterns and combined selectors need the full candidate set so id and // text constrain the same node. literal single selectors keep the direct lookup. if ( opts.childOf != null || typeof opts.index === 'number' || (opts.id && opts.text) || [opts.id, opts.text].some((value) => value && /[.*+?^$()[\]{}|\\]/.test(value)) ) { return this.findElementScoped(opts) } return this.evaluate(`(async () => { const test = window.__sootsimTest if (!test) return null let node = null if (${JSON.stringify(!!opts.text)}) { // maestro text selectors match rendered text and exact accessibility // labels. the engine resolves both in one visibility-aware tree pass. node = await test.findByTextOrLabel(${JSON.stringify(opts.text || '')}) } if (!node && ${JSON.stringify(!!opts.id)}) { node = (await test.findByTestId(${JSON.stringify(opts.id || '')})) || (await test.findById(${JSON.stringify(opts.id || '')})) } if (!node || !node.absolutePosition || !node.layout) return null return { nodeId: typeof node.nodeId === 'number' ? node.nodeId : null, id: node.id || null, testID: node.testID || null, text: node.text || null, absolutePosition: node.absolutePosition, layout: node.layout, // the engine's authoritative answer to "is this painted": layout // bounds minus clipping and minus every frontward sibling, overlay // and portal that fully covers it. visibility assertions read this, // never the raw layout box. visibleFrame: node.visibleFrame || null, isTextInput: !!node.isTextInput, } })()`) } // maestro `index:` (nth match) and `childOf:` (match scoped to a container) // resolver. queryAll returns every matching node in tree order; childOf keeps // only candidates whose center sits inside the container's bounds. private async findElementScoped(opts: FlowTargetSelector) { const index = typeof opts.index === 'number' ? opts.index : 0 return this.evaluate(`(async () => { const test = window.__sootsimTest if (!test || !test.queryAll) return null const compile = (value) => { if (!value) return null let pattern = null try { pattern = new RegExp(value) } catch {} return (candidate) => typeof candidate === 'string' && (candidate === value || !!pattern?.test(candidate)) } const matchesId = compile(${JSON.stringify(opts.id)}) const matchesText = compile(${JSON.stringify(opts.text)}) const all = ((await test.queryAll(${JSON.stringify( opts.id && !opts.childOf ? { idPattern: opts.id } : {}, )})) || []).filter( (n) => n && n.absolutePosition && n.layout, ) let candidates = all.filter((n) => (!matchesId || matchesId(n.testID) || matchesId(n.id)) && (!matchesText || matchesText(n.text) || matchesText(n.accessibilityLabel)), ) const literalText = candidates.filter((n) => n.text === ${JSON.stringify(opts.text)} || n.accessibilityLabel === ${JSON.stringify(opts.text)}, ) if (matchesText && literalText.length > 0) candidates = literalText // prefer on-screen matches (findByTestId's visible-first behavior); only // fall back to off-screen ones when nothing is on-screen, so index: still // resolves a target that needs scrolling into view. const onScreen = candidates.filter((n) => { const cy = n.absolutePosition.y + n.layout.height / 2 return cy > 0 && cy < ${SCREEN_H} }) if (onScreen.length > 0) candidates = onScreen ${ opts.childOf ? ` const matchesContainerId = compile(${JSON.stringify(opts.childOf.id)}) const matchesContainerText = compile(${JSON.stringify(opts.childOf.text)}) const container = all.find((n) => (!matchesContainerId || matchesContainerId(n.testID) || matchesContainerId(n.id)) && (!matchesContainerText || matchesContainerText(n.text) || matchesContainerText(n.accessibilityLabel)), ) if (!container || !container.absolutePosition || !container.layout) return null const x0 = container.absolutePosition.x const y0 = container.absolutePosition.y const x1 = x0 + container.layout.width const y1 = y0 + container.layout.height candidates = candidates.filter((n) => { const cx = n.absolutePosition.x + n.layout.width / 2 const cy = n.absolutePosition.y + n.layout.height / 2 return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1 })` : '' } if (${JSON.stringify(opts.index === undefined && !!opts.text && !opts.id)}) { candidates.sort((a, b) => (a.text || '').length - (b.text || '').length) } const node = candidates[${index}] if (!node) return null return { nodeId: typeof node.nodeId === 'number' ? node.nodeId : null, id: node.id || null, testID: node.testID || null, text: node.text || null, absolutePosition: node.absolutePosition, layout: node.layout, // authoritative visibility, as in findElement above. visibleFrame: node.visibleFrame || null, isTextInput: !!node.isTextInput, } })()`) } private async resolveTapTarget(nodeId: number) { return this.evaluate(`(async () => { const test = window.__sootsimTest if (!test?.resolveTapTarget) return null return await test.resolveTapTarget(${nodeId}) })()`) } private async activatePressTarget(opts: { text?: string; id?: string }) { const replayTarget: ReplayTapTarget = opts.id ? { id: opts.id, testID: opts.id, text: null } : { id: null, testID: null, text: opts.text ?? null } return this.evaluate(`(async () => { const test = window.__sootsimTest if (!test) return null const node = ${ opts.id ? `(await test.findByTestId?.(${JSON.stringify(opts.id)})) || (await test.findById?.(${JSON.stringify(opts.id)}))` : `await test.findByText?.(${JSON.stringify(opts.text || '')})` } let point = null if (node?.absolutePosition && node?.layout) { point = { x: node.absolutePosition.x + node.layout.width / 2, y: node.absolutePosition.y + node.layout.height / 2, } if (typeof node.nodeId === 'number' && test.resolveTapTarget) { const resolved = await test.resolveTapTarget(node.nodeId) if ( resolved && Number.isFinite(resolved.cx) && Number.isFinite(resolved.cy) ) { point = { x: resolved.cx, y: resolved.cy } } } } ${ opts.id ? `const direct = await test.activatePressById?.(${JSON.stringify(opts.id)})` : `const direct = await test.activatePressByText?.(${JSON.stringify( opts.text || '', )})` } if (direct?.ok && point) { window.dispatchEvent( new CustomEvent('sootsim:agentAction', { detail: { type: 'tap', x: point.x, y: point.y, target: ${JSON.stringify( replayTarget, )} }, }), ) } return direct || null })()`) } private async activatePressAt(x: number, y: number) { return this.evaluate(`(async () => { const test = window.__sootsimTest if (!test?.activatePressAt) return null const direct = await test.activatePressAt(${x}, ${y}) if (direct?.ok) { window.dispatchEvent( new CustomEvent('sootsim:agentAction', { detail: { type: 'tap', x: ${x}, y: ${y} }, }), ) } return direct })()`) } private async tap( x: number, y: number, target?: ReplayTapTarget, ): Promise { return (await this.bridge.send({ type: 'tap', simId: this.opts.simId, x, y, target, })) as SootSimTapResult | null } private async longPress( x: number, y: number, durationMs: number, target?: ReplayTapTarget, ): Promise<{ ok?: boolean; value?: boolean } | null> { return (await this.bridge.send({ type: 'longPress', simId: this.opts.simId, x, y, durationMs, target, })) as { ok?: boolean; value?: boolean } | null } private async waitForElement(opts: FlowTargetSelector, timeoutMs = 5000) { const deadline = Date.now() + timeoutMs let element = await this.findElement(opts) while (!element && Date.now() < deadline) { await sleep(150) element = await this.findElement(opts) } if (!element) return element // a found element can still be mid-transition (modal slide-in, sheet // spring, list settle). resolving tap coordinates against a moving frame // taps whatever occupies that point after the animation lands — a 3pc // new-thread title tap opened the photo picker this way (2026-07-10). // require two consecutive samples at the same absolute position before // interacting, bounded so a perpetually-animating target still proceeds. const stabilityDeadline = Date.now() + 1200 let prev = element while (Date.now() < stabilityDeadline) { await sleep(120) const next = await this.findElement(opts) if (!next) break const moved = Math.abs(next.absolutePosition.x - prev.absolutePosition.x) > 0.5 || Math.abs(next.absolutePosition.y - prev.absolutePosition.y) > 0.5 || Math.abs(next.layout.width - prev.layout.width) > 0.5 || Math.abs(next.layout.height - prev.layout.height) > 0.5 element = next if (!moved) return element prev = next } return element } private replayTargetForElement( opts: FlowTargetSelector, element: any, tapTarget?: any, ): ReplayTapTarget { const id = opts.id ?? tapTarget?.target?.id ?? element.id return { nodeId: tapTarget?.target?.nodeId ?? element.nodeId, id: typeof id === 'string' ? id : null, testID: opts.id ?? tapTarget?.target?.testID ?? element.testID ?? null, text: opts.text ?? tapTarget?.target?.text ?? tapTarget?.target?.accessibilityLabel ?? element.text ?? element.accessibilityLabel ?? null, type: tapTarget?.target?.type ?? element.type ?? null, } } private async resolveInteractionPoint(opts: FlowPointTarget) { if ( opts.point && !opts.id && !opts.text && opts.index === undefined && !opts.childOf ) { return { ...parsePoint(opts.point), element: null, target: undefined, } } const element = await this.waitForElement(opts) if (!element) return null const tapTarget = typeof element.nodeId === 'number' ? await this.resolveTapTarget(element.nodeId) : null const target = this.replayTargetForElement(opts, element, tapTarget) if (opts.point) { const point = parsePoint(opts.point, { x: element.absolutePosition.x, y: element.absolutePosition.y, width: element.layout.width, height: element.layout.height, }) return { ...point, element, target } } return { x: tapTarget?.cx ?? element.absolutePosition.x + element.layout.width / 2, y: tapTarget?.cy ?? element.absolutePosition.y + element.layout.height / 2, element, target, } } async tapOn(target: string | FlowPointTarget) { const opts = typeof target === 'string' ? { text: target } : target if (opts.point) { const point = await this.resolveInteractionPoint(opts) if (!point) throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`) const direct = await this.activatePressAt(point.x, point.y) if (!direct?.ok) { await this.tap(point.x, point.y, point.target) } await sleep(300) return } // auto-wait like real maestro: if the target is not mounted yet, poll // before giving up. saves flows from adding an explicit wait before every // tapOn just to handle the post-nav frame or two before render. let element = await this.waitForElement(opts) // coord-based tap first — this is what fires the agent-cursor animation // (via the ws-bridge 'tap' case emitting sootsim:agentAction). the // activatePress fallback below bypasses that event, so we reserve it // for when coord-tap doesn't register a hit (rare: fully-virtualized or // off-screen targets). if (element) { // tapping a text input must actually focus it before the next step // (almost always an inputText) runs. focus propagates async across the // tenant→shell worker boundary, and right after a re-render storm (e.g. // bluesky's custom-server dialog dismiss re-mounting the login fields) a // single tap can land on a transitioning node and never grant focus — // the keys then go nowhere and the field stays empty, silently breaking // submit (the bluesky login-submit RCA, 2026-06-11). re-resolve + re-tap // until the input reports focused, the same gate rnx's `type-into` // command uses. non-text-input taps keep the single-tap path. const tapInputAttempts = element.isTextInput ? 4 : 1 for (let attempt = 0; attempt < tapInputAttempts; attempt++) { const current = attempt === 0 ? element : await this.findElement({ text: opts.text, id: opts.id }) if (!current) break const tapTarget = typeof current.nodeId === 'number' ? await this.resolveTapTarget(current.nodeId) : null const cx = tapTarget?.cx ?? current.absolutePosition.x + current.layout.width / 2 const cy = tapTarget?.cy ?? current.absolutePosition.y + current.layout.height / 2 const hit = await this.tap( cx, cy, this.replayTargetForElement(opts, current, tapTarget), ) if (hit?.requestedTargetMatched === false) throw new Error(`tapOn refused: ${JSON.stringify(hit)}`) if (hit?.hit === true) { await sleep(300) if (!current.isTextInput) return // confirm focus landed on THIS text input; retry the tap if a // previous field still owns focus. if (await this.waitForFocusedTextInput(600, current)) return continue } break } } // fallback: bypass hit-testing by activating press handlers directly. // skips the cursor animation, but keeps the flow running on targets the // canvas hit-test missed (e.g. clipped or zero-area hit regions). const direct = await this.activatePressTarget(opts) if (direct?.ok) { await sleep(300) return } throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`) } async longPressOn(target: string | FlowPointTarget) { const opts = typeof target === 'string' ? { text: target } : target const point = await this.resolveInteractionPoint(opts) if (!point) throw new Error(`longPressOn: element not found: ${JSON.stringify(opts)}`) const result = await this.longPress(point.x, point.y, 3000, point.target) if (result?.ok === false || result?.value === false) { throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`) } await sleep(300) } async tapAtCoords(x: number, y: number) { await this.tap(x, y) await sleep(300) } async doubleTapAtCoords(x: number, y: number, gapMs = 80) { const result = await this.perform([ { type: 'doubleTap', x, y, gapMs: Math.max(0, Math.round(gapMs)) }, ]) if (!result?.ok) { throw new Error( `doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`, ) } await sleep(300) } async assertVisible(target: string | { id?: string; text?: string }) { const opts = typeof target === 'string' ? { text: target } : target for (let attempt = 0; attempt < 15; attempt++) { if (await this.isElementVisible(opts)) { return } await sleep(200) } // report the geometry rather than a guess at the cause. a zero visibleFrame // says the node paints nothing; it cannot on its own separate scrolled-away // from painted-over, so print the frame and let the reader judge. a missing // node stays clearly distinct from a found one. the diff is one bridge call. const found = await this.findElement(opts) const frame = found?.visibleFrame const detail = found ? ` (matched node at y=${Math.round(found.absolutePosition.y)} h=${Math.round(found.layout.height)}, ` + `visibleFrame ${JSON.stringify(frame ?? null)})` : ' (no matching node in tree)' throw new Error(`assertVisible: ${JSON.stringify(opts)} not visible${detail}`) } async assertNotVisible(target: string | { id?: string; text?: string }) { const opts = typeof target === 'string' ? { text: target } : target if (await this.isElementVisible(opts)) { const found = await this.findElement(opts) const where = found ? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})` : '' throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`) } } // visibility is the engine's call, not a layout-bounds guess. the old test // here was a vertical band (`y + height > 0 && y < SCREEN_H`), so a node // sealed under an opaque overlay, pushed off the left or right edge, or // clipped to nothing by an ancestor all read as visible — a demo flow could // assert a button that no finger could reach. visibleFrame already carries // the clipped, occlusion-subtracted rect, and tap-target refuses a covered // node on the same basis, so assertions and taps now agree. private async isElementVisible(opts: { id?: string; text?: string }) { const element = await this.findElement(opts) if (!element) return false const frame = element.visibleFrame const usable = !!frame && Number.isFinite(frame.width) && Number.isFinite(frame.height) && Number.isFinite(frame.area) if (!usable) { // absent or non-numeric geometry is unknown visibility, never "hidden". // returning false here would quietly hand assertNotVisible a pass it did // not earn, which is the same shape of false green this change removes. throw new Error( `authoritative visibility unavailable for ${JSON.stringify(opts)}: the engine ` + `returned visibleFrame ${JSON.stringify(frame ?? null)}`, ) } return frame.width > 0 && frame.height > 0 && frame.area > 0 } async inputText(text: string) { // real maestro types into the currently-focused field, and its driver's // tap synchronously focuses it. in rnx, focus propagates async across // the tenant→shell worker boundary (tap → focusTextInput → keyboard show), // so a preceding `tapOn` on a text input may not have landed focus by the // time we dispatch keystrokes — especially right after a re-render storm // (e.g. bluesky dismissing the custom-server dialog before tapping the // username field). without a focused input the keys go nowhere and the // field stays empty, silently breaking submit. wait for a focused text // input first — the same focus gate rnx's own `type-into` uses (see // the 2026-04-17 bluesky tap-routing regression in commands/inspect.ts). if (!(await this.waitForFocusedTextInput())) { throw new Error( 'inputText: no focused TextInput; tap the input and wait for focus before typing', ) } await this.bridge.send({ type: 'keyboard', simId: this.opts.simId, action: 'type', text, }) await sleep(200) } // poll for a focused text input. returns true once getFocusedNode reports a // node (focus landed), false if the budget elapses. callers use it both as a // tap-focus confirmation (tapOn retry) and a pre-type barrier (inputText). private focusedTextInputMatches( focused: FocusedTextInputInfo, expected?: FocusedTextInputInfo | null, ): boolean { if (!expected) return true if ( typeof expected.nodeId === 'number' && typeof focused.nodeId === 'number' && expected.nodeId === focused.nodeId ) { return true } const expectedTestID = expected.testID ?? null const focusedTestID = focused.testID ?? null if (expectedTestID && focusedTestID && expectedTestID === focusedTestID) { return true } const expectedId = expected.id ?? null const focusedId = focused.id ?? null if (expectedId && focusedId && expectedId === focusedId) { return true } return false } private async waitForFocusedTextInput( timeoutMs = 1500, expected?: FocusedTextInputInfo | null, ): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const focused = await this.callTest('getFocusedNode') if (focused && this.focusedTextInputMatches(focused, expected)) return true } catch { // bridge hiccup — keep polling within the budget } await sleep(50) } return false } async pressKey(name: string) { await this.bridge.send({ type: 'keyboard', simId: this.opts.simId, action: 'press', text: name, }) await sleep(120) } async dispatchKey(ch: string) { await this.bridge.send({ type: 'keyboard', simId: this.opts.simId, action: 'dispatchKey', text: ch, }) await sleep(120) } async hideKeyboard() { // CLI flows need "dismiss keyboard" to mean "get the focused TextInput // out of the way before the next tap", not just "ask shell chrome to // animate down eventually". If the TextInput stays focused, the next tap // can still be swallowed by keyboard-dismiss policy on ScrollViews. try { await this.callTest('blurFocusedTextInput') } catch { // fall through to the shell-level dismiss below; older bridges may not // expose the text-input helper yet. } await this.bridge.send({ type: 'keyboard', simId: this.opts.simId, action: 'dismiss', }) const deadline = Date.now() + 3000 while (Date.now() < deadline) { // the host test bridge proxies the tenant, so isTextInputFocused // resolves a promise; reading it unawaited is always truthy. const state = await this.evaluate<{ visible: boolean focused: boolean }>(`(async () => { const keyboard = window.__sootsimKeyboard ?? window.SootSim?.bridges?.keyboard ?? null const test = window.__sootsimTest return { visible: !!keyboard?.isVisible?.(), focused: !!(await test?.isTextInputFocused?.()), } })()`) if (!state?.visible && !state?.focused) { await sleep(80) return } await sleep(80) } } async eraseText(count: number) { const steps = Array.from({ length: Math.max(0, Math.ceil(count)) }, () => ({ type: 'key' as const, key: 'Backspace', })) if (steps.length > 0) { const result = await this.perform(steps) if (!result?.ok) { throw new Error(`eraseText failed: ${result?.error ?? 'backspace missed'}`) } } await sleep(100) } async waitFor(opts: { text?: string; id?: string; timeout?: number }) { const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT) while (Date.now() < deadline) { const element = await this.findElement(opts) if (element) return await sleep(200) } // surface whether the element existed but was off-screen vs never // showed up at all — the two failure modes need different debugging // (scroll-position bug vs missing wait-step / wrong screen). const final = await this.findElement(opts) const detail = final ? ` (matched node y=${Math.round(final.absolutePosition.y)} — still off-screen at deadline)` : '' throw new Error( `waitFor: ${JSON.stringify(opts)} not found after ${opts.timeout || DEFAULT_TIMEOUT}ms${detail}`, ) } async takeScreenshot( step: | string | { path?: string name?: string withFrame?: boolean layers?: 'full' | 'tenant' | 'shell' }, ): Promise { // flow `takeScreenshot: ` may be a plain name ("hero") or a full // relative path with slashes ("apps/foo/screenshots/en/02-forum") — // maestro flows commonly use the latter. mkdir the parent of the // resolved output rather than just the base screenshotDir. const spec = normalizeFlowScreenshotSpec(step) const outputPath = resolveFlowScreenshotPathWithMode( this.opts.screenshotDir, spec.path, { mode: this.opts.screenshotPathMode ?? 'dir', flowDir: this.opts.flowDir, }, ) fs.mkdirSync(path.dirname(outputPath), { recursive: true }) // resolve layers: per-step `layers:` wins over the flow-wide opts default. const layers = spec.layers ?? this.opts.screenshotLayers const screenshotRequest: { type: 'screenshot' simId?: string layers?: 'full' | 'tenant' | 'shell' } = { type: 'screenshot', simId: this.opts.simId, } if (layers && layers !== 'full') screenshotRequest.layers = layers let previousBuffer: Buffer | null = null let identicalFrames = 1 let rawBuffer: Buffer | null = null for (let attempt = 0; attempt < 12; attempt++) { await this.waitForVisualSettle() const dataUrl: string = await this.bridge.send(screenshotRequest) const current = Buffer.from( dataUrl.replace(/^data:image\/png;base64,/, ''), 'base64', ) identicalFrames = framesIdentical(previousBuffer, current) ? identicalFrames + 1 : 1 previousBuffer = current // checked after the capture, so the constant is a capture count: at 1 // the first settled frame is the screenshot and nothing re-captures. if (identicalFrames >= REQUIRED_IDENTICAL_FLOW_FRAMES) { rawBuffer = current break } } if (!rawBuffer) { throw new Error( `screenshot did not reach ${REQUIRED_IDENTICAL_FLOW_FRAMES} identical idle frames`, ) } if (spec.withFrame) { const model = await this.readCurrentDeviceModel() if (!model) { throw new Error('could not read current device model for framed screenshot') } const framed = await composeFramedScreenshot(rawBuffer, model) fs.writeFileSync(outputPath, framed) console.log(`[flow] screenshot: ${outputPath} (frame: ${model})`) return outputPath } fs.writeFileSync(outputPath, rawBuffer) console.log(`[flow] screenshot: ${outputPath}`) return outputPath } private async readCurrentDeviceModel(): Promise { const settings = (await this.bridge.send({ type: 'call', simId: this.opts.simId, path: 'SootSim.bridges.settings.get', args: [], })) as Record | null const model = settings && typeof settings.deviceModel === 'string' ? settings.deviceModel : null if (!model || !(model in devices)) return null return model as DeviceModel } async captureScreenshot(outputPath: string) { await this.takeScreenshot({ path: outputPath }) } // writes a per-failure debug bundle: screenshot + structured describe // + a11y tree + console errors/warnings + failed network requests + // shell state, all under one directory so post-mortem is one grep away. async captureFailureBundle( outputDir: string, context: { error: Error stepIndex?: number stepKind?: string stepTarget?: unknown }, ): Promise { fs.mkdirSync(outputDir, { recursive: true }) const write = (name: string, content: string | Buffer) => { try { fs.writeFileSync(path.join(outputDir, name), content) } catch { // best-effort; don't let one failed write block the others } } const safeEval = async (code: string): Promise => { try { return (await this.bridge.send({ type: 'evaluate', simId: this.opts.simId, code, })) as T } catch { return null } } // 1. error context — also pull the sim's current URL so a bundle-load // failure (where the screenshot is blank and describe is empty) still // has a debuggable trail. `simId`, `simUrl` and the optional metro // manifest help triage "did the right bundle even get loaded?" — the // most common failure mode of headless playwright demo runs. const env = await safeEval<{ simId?: string | null url?: string | null title?: string | null hidden?: boolean manifest?: unknown }>(`(async () => { const out = { simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null, url: location.href, title: document.title, hidden: document.hidden, } try { const params = new URL(location.href).searchParams const bundle = params.get('bundle') if (bundle) { const baseMatch = bundle.match(/^https?:\\/\\/[^/]+/) if (baseMatch) { const res = await fetch(baseMatch[0] + '/', { headers: { 'expo-platform': 'ios' }, cache: 'no-store', }) if (res.ok) { out.manifest = await res.json().catch(() => null) } } } } catch {} return out })()`) write( 'error.json', JSON.stringify( { message: context.error.message, stack: context.error.stack, stepIndex: context.stepIndex, stepKind: context.stepKind, stepTarget: context.stepTarget, capturedAt: new Date().toISOString(), sim: env ?? null, }, null, 2, ), ) // 2. screenshot (png) try { const dataUrl: string = await this.bridge.send({ type: 'screenshot', simId: this.opts.simId, }) const base64 = dataUrl.replace(/^data:image\/png;base64,/, '') write('screenshot.png', Buffer.from(base64, 'base64')) } catch {} // 3. high-resolution describe as json — every visible node with // full layout, style, a11y, transforms. the `--json` describe output // shape; inlined here so we don't depend on the cli reentering. const describeJson = await safeEval(`(async () => { const t = window.__sootsimTest const mainShell = window.SootSim?.bridges?.mainShell if (!t) return { error: 'no test bridge' } let shell = null try { shell = typeof mainShell?.getState === 'function' ? await mainShell.getState() : null } catch {} const all = await t.queryAll({ pruneHidden: true }) return { shell, nodes: all, url: location.href, title: document.title } })()`) if (describeJson) write('describe.json', JSON.stringify(describeJson, null, 2)) // 4. accessibility tree (flat text — what voiceover would read) const a11y = await safeEval( `(async () => await window.__sootsimTest?.dumpAccessibilityTree?.(20))()`, ) if (typeof a11y === 'string') write('a11y.txt', a11y) // 5. tree dump (nested — useful for layout bugs) const tree = await safeEval( `(async () => await window.__sootsimTest?.dumpTree?.(15))()`, ) if (typeof tree === 'string') write('tree.txt', tree) // 6. console buffer + failed requests (captured by rnx's native-globals // hook / request recorder; same shape as `rnx get errors` etc.). const consoleBuf = await safeEval<{ errors: unknown[] warnings: unknown[] requests: unknown[] } | null>(`(() => { const c = window.__sootsimConsole return { errors: c?.getErrors?.() ?? [], warnings: c?.getWarnings?.() ?? [], requests: (window.__sootsimGetFailedRequests?.() ?? []), } })()`) if (consoleBuf) { write('console.json', JSON.stringify(consoleBuf, null, 2)) } return outputDir } async swipe(direction: string = 'UP', duration: number = 300) { const centerX = SCREEN_W / 2 const centerY = SCREEN_H / 2 const distance = 200 let fromX = centerX let fromY = centerY let toX = centerX let toY = centerY switch (direction.toUpperCase()) { case 'UP': fromY += distance toY -= distance break case 'DOWN': fromY -= distance toY += distance break case 'LEFT': fromX += distance toX -= distance break case 'RIGHT': fromX -= distance toX += distance break } const steps = Math.max(10, Math.round(duration / 16)) await this.drag(fromX, fromY, toX, toY, steps, 16) } async drag( fromX: number, fromY: number, toX: number, toY: number, steps = 12, stepMs = 16, ) { const result = await this.perform([ { type: 'drag', fromX, fromY, toX, toY, steps: Math.max(1, Math.round(steps)), stepMs: Math.max(0, Math.round(stepMs)), }, ]) if (!result?.ok) { throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`) } await sleep(300) } async swipeCoords(start: string, end: string, duration = 300) { const from = parsePoint(start) const to = parsePoint(end) const steps = Math.max(10, Math.round(duration / 16)) await this.drag(from.x, from.y, to.x, to.y, steps, 16) } async swipeFrom( from: { id?: string; text?: string }, direction: string = 'UP', duration = 300, ) { const element = await this.findElement(from) if (!element) throw new Error(`swipeFrom: element not found: ${JSON.stringify(from)}`) const startX = element.absolutePosition.x + element.layout.width / 2 const startY = element.absolutePosition.y + element.layout.height / 2 const distance = 180 let endX = startX let endY = startY switch (direction.toUpperCase()) { case 'UP': endY -= distance break case 'DOWN': endY += distance break case 'LEFT': endX -= distance break case 'RIGHT': endX += distance break } const steps = Math.max(10, Math.round(duration / 16)) await this.drag(startX, startY, endX, endY, steps, 16) } async scrollTo(target: string | { nodeId: number }, x: number, y: number) { const result = await this.callTest<{ ok: boolean; reason?: string }>( 'scrollTo', target, x, y, false, ) if (!result?.ok) { throw new Error(`scrollTo failed: ${result?.reason || 'unknown error'}`) } await sleep(250) } async pinch(opts: { from: [number, number, number, number] to: [number, number, number, number] steps?: number stepMs?: number }) { const result = await this.perform([ { type: 'pinch', fromX1: opts.from[0], fromY1: opts.from[1], fromX2: opts.from[2], fromY2: opts.from[3], toX1: opts.to[0], toY1: opts.to[1], toX2: opts.to[2], toY2: opts.to[3], steps: opts.steps || 12, stepMs: opts.stepMs || 16, }, ]) if (!result?.ok) { throw new Error(`pinch failed: ${result?.error || 'unknown error'}`) } await sleep(250) } async dumpTree(depth = 6) { const tree = await this.bridge.send({ type: 'tree', simId: this.opts.simId, depth, }) console.log('[flow] tree:') console.log(typeof tree === 'string' ? tree : JSON.stringify(tree, null, 2)) return typeof tree === 'string' ? tree : JSON.stringify(tree) } async assertTreeContains(value: string) { const tree = await this.dumpTree(8) if (!tree.includes(value)) { throw new Error(`assertTreeContains: "${value}" not in tree`) } } async waitForAnimationToEnd(timeoutMs: number = 2000) { const maxMs = Math.max(0, Math.round(timeoutMs)) if (maxMs <= 0) return const result = await waitForSootsimIdle({ bridge: this.bridge, simId: this.opts.simId, maxMs, pollMs: 32, stablePolls: 2, strict: true, }) if (!result.settled) { throw new Error(`animation did not settle within ${maxMs}ms`) } } async back() { try { await this.tapOn('‹') } catch { await this.tapOn('<') } } async scrollUntilVisible(opts: { element: string centerElement?: boolean direction?: string timeout?: number }) { const deadline = Date.now() + (opts.timeout || 15000) const direction = opts.direction?.toUpperCase() || 'DOWN' const swipeDirection = direction === 'DOWN' ? 'UP' : direction === 'UP' ? 'DOWN' : direction while (Date.now() < deadline) { const element = await this.findElement({ text: opts.element }) if ( element && element.absolutePosition.y >= 0 && element.absolutePosition.y + element.layout.height > 0 && element.absolutePosition.y < SCREEN_H - 50 ) { if (opts.centerElement) { const targetY = SCREEN_H / 2 const diff = element.absolutePosition.y - targetY if (Math.abs(diff) > 100) { const distance = Math.min(150, Math.abs(diff) * 0.5) const endY = diff > 0 ? targetY - distance : targetY + distance await this.drag(SCREEN_W / 2, targetY, SCREEN_W / 2, endY, 10, 16) } } return } await this.swipe(swipeDirection, 250) await sleep(500) } throw new Error(`scrollUntilVisible: "${opts.element}" not found`) } async extendedWaitUntil(opts: { visible?: string | { id?: string; text?: string } notVisible?: string | { id?: string; text?: string } timeout?: number }) { const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT) while (Date.now() < deadline) { const visibleOkay = opts.visible ? await this.isElementVisible( typeof opts.visible === 'string' ? { text: opts.visible } : opts.visible, ) : true const notVisibleOkay = opts.notVisible ? !(await this.isElementVisible( typeof opts.notVisible === 'string' ? { text: opts.notVisible } : opts.notVisible, )) : true if (visibleOkay && notVisibleOkay) return await sleep(200) } throw new Error('extendedWaitUntil timed out') } private async reloadGuestApp() { // try the gentle reload first — in-place React re-mount preserves // recording state and avoids the full worker lifecycle tear-down. const reloaded = await this.bridge.send({ type: 'call', simId: this.opts.simId, path: 'SootSim.bridges.hotRemount.reloadExternalApp', args: [], }) if (reloaded) return // fallback for surfaces that don't export the hot-remount bridge: a // hard page reload resets the tenant worker. await this.evaluate('window.location.reload()').catch(() => {}) } private async resetGuestAppData( launchOptions?: Pick< SootSimExternalAppLifecycleOptions, 'initialUrl' | 'launchArguments' >, ) { const reset: ResetResult = await this.bridge.send( { type: 'reset', simId: this.opts.simId, resetOptions: { strategy: 'data', ...launchOptions }, }, { timeoutMs: 120000 }, ) if (!reset.ok) { throw new Error(reset.error ?? 'guest app data reset failed') } if (!reset.relaunched) { throw new Error('guest app data reset did not relaunch the app') } if (reset.workerReloaded !== true) { throw new Error('guest app data reset did not replace the tenant worker') } } private async reloadGuestAppRuntime(options: SootSimExternalAppLifecycleOptions) { await this.bridge.send( { type: 'call', simId: this.opts.simId, path: 'SootSim.bridges.hotRemount.reloadExternalApp', args: [options], }, { timeoutMs: 120000 }, ) } private async rearmCaptureAfterRuntimeReload() { if (!this.recordingEnabled && !this.profilingEnabled) return const deadline = Date.now() + 10000 let lastError: unknown = null while (Date.now() < deadline) { try { if (this.recordingEnabled) await this.ensureRecordingStarted() if (this.profilingEnabled) await this.startProfile() return } catch (err) { lastError = err await sleep(250) } } throw new Error( `capture re-arm failed after app reload: ${ lastError instanceof Error ? lastError.message : String(lastError) }`, ) } async launchApp(opts: any) { let hasLaunchArguments = false let launchArguments: SootSimLaunchArguments | undefined if (isObjectRecord(opts) && Object.prototype.hasOwnProperty.call(opts, 'arguments')) { if (!isObjectRecord(opts.arguments)) { throw new Error('launchApp.arguments must be a JSON object') } hasLaunchArguments = true launchArguments = opts.arguments } const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState) const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime') if (!this.firstLaunchDone) { this.firstLaunchDone = true if (wantsClear) { // bundle evaluation temporarily installs a reload bridge without the // storage contract. wait for the app before its first data reset. if (!this.appStopped) await this.waitForTree(120000) await this.resetGuestAppData({ launchArguments: hasLaunchArguments ? launchArguments : null, initialUrl: null, }) await this.waitForTree(120000) } else if (hasLaunchArguments) { const launched = await this.launchShellAppFromHomeIfNeeded(opts) if (launched) await this.waitForTree(120000) await this.reloadGuestAppRuntime({ launchArguments, initialUrl: null, }) await this.waitForTree(120000) await this.waitForTree(30000) } else if (wantsResetRuntime) { const launched = await this.launchShellAppFromHomeIfNeeded(opts) if (launched) await this.waitForTree(120000) await this.reloadGuestAppRuntime({}) await this.waitForTree(120000) await this.waitForTree(30000) } else if (this.appStopped) { await this.launchShellAppFromHomeIfNeeded(opts) await this.reloadGuestAppRuntime({ launchArguments: null, initialUrl: null, }) await this.waitForTree(120000) await this.waitForTree(30000) } else { await this.launchShellAppFromHomeIfNeeded(opts) await this.waitForTree(120000) await this.waitForTree(30000) } } else { if (wantsClear) { await this.resetGuestAppData({ launchArguments: hasLaunchArguments ? launchArguments : null, initialUrl: null, }) } else if (hasLaunchArguments || this.launchArgumentsActive || this.appStopped) { await this.reloadGuestAppRuntime({ launchArguments: hasLaunchArguments ? launchArguments : null, initialUrl: null, }) } else { await this.reloadGuestApp() } await this.waitForTree(120000) if (this.opts.onAfterLaunch) { try { await this.opts.onAfterLaunch(this.opts.simId) } catch (err) { // don't fail the whole flow if a post-launch hook throws — // surfaces as a warn at most, the run keeps going. console.warn( ` warn: onAfterLaunch hook threw: ${err instanceof Error ? err.message : err}`, ) } } } this.appStopped = false this.launchArgumentsActive = hasLaunchArguments if (this.profilingEnabled) { await this.startProfile() } if (this.recordingEnabled) { // a flow that starts with launchApp prepares recording before the step. // begin its pixels only after launch has mounted and settled, leaving the // loading prelude outside the playable video while its trace stays visible. await this.waitForVisualSettle() const videoAlreadyStarted = this.recordingStartedAtMs != null await this.ensureRecordingStarted() if (!videoAlreadyStarted && this.recordingStartedAtMs != null) { // the preview event recorder has been running since before launch, // so its clock includes the boot prelude the video just skipped. // rebase it to the video's first frame — without this every event // timestamp overshoots the playable video and preview playback can // never align (play stuck disabled). no-op unless the preview // event recorder is running. await this.rebaseEventRecorderToVideoStart(this.recordingStartedAtMs) } } } private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) { try { await this.evaluate( `(() => { const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder if (!recorder?.isRecording?.()) return 'not-recording' return recorder.markVisibleStart?.(${JSON.stringify(videoStartedAtMs)}) ?? 'no-mark' })()`, ) } catch (err) { console.warn( ` warn: event-recorder rebase to video start failed: ${err instanceof Error ? err.message : err}`, ) } } private async waitForVisualSettle() { const result = await waitForSootsimIdle({ bridge: this.bridge, simId: this.opts.simId, maxMs: 10_000, pollMs: 32, stablePolls: 3, strict: true, }) if (!result.settled) { throw new Error( `visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`, ) } } private resolveShellLaunchAppId(opts: unknown, state: ShellState | null): string { if (typeof opts === 'string' && opts.length > 0) return opts if (isObjectRecord(opts)) { const explicit = readStringKey(opts, 'appId') || readStringKey(opts, 'id') if (explicit) return explicit } return readRecentShellAppId(state) || 'connect' } private async waitForShellAppLaunched(appId: string, timeoutMs: number) { const deadline = Date.now() + timeoutMs let lastState: ShellState | null = null while (Date.now() < deadline) { try { lastState = await getShellState(this.bridge) } catch { lastState = null } if ( lastState?.state === 'app' && lastState.activeApp === appId && lastState.showSwitcher === false && typeof lastState.launchProgress === 'number' && lastState.launchProgress >= 0.98 ) { return } await sleep(16) } } private async launchShellAppFromHomeIfNeeded(opts: unknown): Promise { let state: ShellState | null = null try { state = await getShellState(this.bridge) } catch { return false } if (state?.state !== 'home' || state.activeApp != null) return false const appId = this.resolveShellLaunchAppId(opts, state) await callShellCommandWhenReady(this.bridge, 'launchApp', 1000, appId) await this.waitForShellAppLaunched(appId, 1000) return true } async startProfile() { const result = await this.evaluate(`(() => { if (!window.__sootsimShellPerf) { return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" } } window.__sootsimShellPerf.start() return { started: true } })()`) if (result?.error) { throw new Error(result.error) } this.profilingEnabled = true } async stopProfile(): Promise { const result = await this.evaluate(`(async () => { if (!window.__sootsimShellPerf) { return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" } } return await window.__sootsimShellPerf.stop() })()`) this.profilingEnabled = false if (result?.error) { throw new Error(result.error) } return result as SootSimFlowProfileResult } // maestro stopApp: tear down the current app runtime without clearing app // data. the next launchApp or openLink creates its replacement worker. async stopApp() { await this.reloadGuestAppRuntime({ relaunch: false }) this.appStopped = true } // maestro clearState: wipe app-owned storage and relaunch fresh. async clearState() { await this.resetGuestAppData() await this.rearmCaptureAfterRuntimeReload() } // maestro clearKeychain: there is no real keychain in rnx's browser // sandbox. log loudly so the author notices, then continue — matching the // "loud warning, not silent skip" policy in the plan. clearKeychain() { console.warn('[flow] clearKeychain: rnx has no keychain surface — no-op (warning)') } // maestro copyTextFrom: find an element and capture its text for use in // later steps via ${maestro.copiedText} (upstream form) or the legacy // ${maestroCopiedText}. the matcher shape matches tapOn. async copyTextFrom(target: string | { id?: string; text?: string }) { const opts = typeof target === 'string' ? { id: target } : target const node = await this.findElement(opts) if (!node) { throw new Error(`copyTextFrom: element not found: ${JSON.stringify(opts)}`) } const captured = (node as { text?: string | null }).text ?? (node as { testID?: string | null }).testID ?? '' this.js.setCopiedText(captured) this.js.putEnv('maestroCopiedText', captured) console.log(`[flow] copied text: ${JSON.stringify(captured)}`) } // maestro evalScript: run JS in the flow's shared HOST-side context (the // same engine that owns `output` and env vars) — never in the app page. // the canonical maestro form is `evalScript: ${output.x = 1}`, which is // pure template evaluation. a script with no `${}` at all would be a // silent no-op upstream; we charitably evaluate it as raw JS in the same // context so plain-JS evalScript blocks fail or work loudly. evalScript(code: string) { const hasTemplate = /(?`, result) } } // maestro runScript: execute a JS file in the shared flow context. env // vars are scoped to the script (upstream runInSubScope=true). paths // resolve relative to the flow file, like runFlow. runScript(target: NonNullable) { const spec = typeof target === 'string' ? { file: target } : target if (!spec.file) throw new Error('runScript requires a file') const scriptPath = path.resolve(this.opts.flowDir, spec.file) const source = fs.readFileSync(scriptPath, 'utf8') this.js.evaluate(source, { env: spec.env }) console.log(`[flow] runScript: ${spec.file} done`) } // each maestro openLink is a process launch boundary. replace the tenant so // guest timers, providers, navigation, and storage clients cannot survive // into the next flow state, then deliver the URL to the replacement guest. async openLink(target: string | { link: string }) { const link = typeof target === 'string' ? target : target.link if (!link) throw new Error('openLink: missing link') await this.reloadGuestAppRuntime({ initialUrl: null, launchArguments: null, }) await this.waitForTree(120000) const result = await this.callTest<{ ok: boolean; error?: string }>( 'openDeepLink', link, ) if (!result?.ok) { throw new Error(`openLink failed: ${result?.error || 'no test bridge'}`) } this.appStopped = false this.launchArgumentsActive = false this.firstLaunchDone = true } // evaluate a `when:` predicate. returns true if the step should run. // `visible:` and `notVisible:` answer the same question assertVisible does, // so they read the engine's visibleFrame too. resolving them by mere presence // in the tree let a guarded step fire on a node painted over by an overlay. private async evaluateWhen(when: NonNullable): Promise { if (when.visible !== undefined) { const opts = typeof when.visible === 'string' ? { text: when.visible } : when.visible return this.isElementVisible(opts) } if (when.notVisible !== undefined) { const opts = typeof when.notVisible === 'string' ? { text: when.notVisible } : when.notVisible return !(await this.isElementVisible(opts)) } if (when.platform !== undefined) { // rnx emulates iOS — match against the context's platform the way // upstream compares against cachedDeviceInfo.platform. keeps // cross-platform maestro flows running without stripping guards. return when.platform.toLowerCase() === this.js.maestro.platform.toLowerCase() } if (when.true !== undefined) { // the `true:` script string was template-evaluated with the step; // apply upstream's falsy rules (blank/false/undefined/null/0). return scriptConditionIsTruthy(when.true) } return true } private isOptional(step: MaestroStep): boolean { // maestro supports `optional: true` either on the matcher sub-object or // (in some dialects) as a top-level sibling of the verb. accept both. const topLevelOptional = (step as Record).optional === true if (topLevelOptional) return true const entry = Object.entries(step).find(([k]) => k !== 'when' && k !== 'optional') const value = entry ? entry[1] : undefined return !!( value && typeof value === 'object' && 'optional' in (value as object) && (value as { optional?: boolean }).optional ) } async runStep(rawStepInput: MaestroStep | string, stepIndex: number) { await this.liveStatus?.waitWhilePaused() // maestro allows several commands in BARE-STRING form (`- back`, // `- hideKeyboard`, `- waitForAnimationToEnd`, `- scrollUp`, …). yaml parses // those to a plain string, so normalize to the `{ verb: true }` object form // the dispatcher expects. without this, `Object.keys("back")` is `['0',…]` // and the step falls through to "unsupported flow step". const rawStep: MaestroStep = typeof rawStepInput === 'string' ? normalizeBareStringStep(rawStepInput) : rawStepInput const stepName = Object.keys(rawStep).find((k) => k !== 'when') || Object.keys(rawStep)[0] console.log(`[flow] step ${stepIndex + 1}: ${stepName}`) const startedAtMs = Date.now() let targetLabel = this.flowStepTargetLabel(rawStep, stepName) let step: MaestroStep try { // `${...}` templates resolve here, at execution time (like maestro's // per-command evaluateScripts) — values produced by earlier steps // (runScript output.*, copyTextFrom) are visible. nested command // lists are left verbatim; they interpolate when they run. step = this.js.interpolateStep(rawStep) targetLabel = this.flowStepTargetLabel(step, stepName) ?? targetLabel } catch (error) { if (this.isOptional(rawStep)) { console.log( `[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`, ) this.recordFlowTraceStep({ stepIndex, stepName, targetLabel, startedAtMs, status: 'skipped', error, }) return } this.lastFailedStep = { index: stepIndex, kind: stepName, target: (rawStep as Record)[stepName], } this.recordFlowTraceStep({ stepIndex, stepName, targetLabel, startedAtMs, status: 'failure', error, }) throw error } // maestro `when:` gate — evaluate before running anything. if the // predicate fails, log and continue to the next step. if (step.when) { const shouldRun = await this.evaluateWhen(step.when) if (!shouldRun) { console.log(`[flow] (when: predicate false, skipped)`) this.recordFlowTraceStep({ stepIndex, stepName, targetLabel, startedAtMs, status: 'skipped', }) return } } try { // per-step watchdog: abort if nothing forward-progresses for 10s. // catches dead bundles / frozen bridges / hung ws calls. explicit // wait steps (extendedWaitUntil, waitFor) opt out and keep their // own timeout — the user asked for that time; don't override. const hasExplicitWait = !!( step.extendedWaitUntil || step.waitFor || step.scrollUntilVisible || step.launchApp || step.runFlow || // retry reruns its body on failure, so its wall time is the body's // times the retry count — the 10s no-progress watchdog is the wrong // budget and would report "bridge probably hung" for a slow retry. step.retry || // runScript http calls are synchronous with a 5-minute upstream // timeout — the 10s watchdog would kill legitimate setup scripts. step.runScript || // takeScreenshot self-governs: it retries a bounded visual settle that // already fails with its own reason. sharing the watchdog's 10s budget // meant the watchdog always won that race, reporting "bridge probably // hung" for a screen that was merely still animating. step.takeScreenshot ) const STEP_WATCHDOG_MS = 10_000 const body = this.runStepInner(step) let screenshotPath: string | undefined if (hasExplicitWait) { const result = await body if (typeof result === 'string') screenshotPath = result } else { let timer: ReturnType | null = null const watchdog = new Promise((_, reject) => { timer = setTimeout(() => { reject( new Error( `step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`, ), ) }, STEP_WATCHDOG_MS) }) try { const result = await Promise.race([body, watchdog]) if (typeof result === 'string') screenshotPath = result } finally { if (timer) clearTimeout(timer) } } const isAssertion = !!( step.assertVisible || step.assertNotVisible || step.extendedWaitUntil || step.waitFor ) if (this.stepDelay > 0 && !isAssertion) { const chunkMs = 50 let elapsed = 0 while (elapsed < this.stepDelay) { await this.liveStatus?.waitWhilePaused() const toSleep = Math.min(chunkMs, this.stepDelay - elapsed) await sleep(toSleep) elapsed += toSleep } } this.recordFlowTraceStep({ stepIndex, stepName, targetLabel, startedAtMs, status: 'success', screenshotPath, }) } catch (error) { if (this.isOptional(step)) { console.log( `[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`, ) this.recordFlowTraceStep({ stepIndex, stepName, targetLabel, startedAtMs, status: 'skipped', error, }) return } this.lastFailedStep = { index: stepIndex, kind: stepName, target: (step as Record)[stepName], } this.recordFlowTraceStep({ stepIndex, stepName, targetLabel, startedAtMs, status: 'failure', error, }) throw error } } private async runStepInner(step: MaestroStep) { if (step.tapOn) await this.tapOn(step.tapOn) else if (step.longPressOn) await this.longPressOn(step.longPressOn) else if (step.scrollUntilVisible) await this.scrollUntilVisible(step.scrollUntilVisible) else if (step.extendedWaitUntil) await this.extendedWaitUntil(step.extendedWaitUntil) else if (step.assertVisible) await this.assertVisible(step.assertVisible) else if (step.assertNotVisible) await this.assertNotVisible(step.assertNotVisible) else if (step.inputText) await this.inputText(step.inputText) else if (step.pressKey) await this.pressKey(step.pressKey) else if (step.dispatchKey) await this.dispatchKey(step.dispatchKey) else if (step.waitFor) await this.waitFor(step.waitFor) else if (step.takeScreenshot) return this.takeScreenshot(step.takeScreenshot) else if (step.swipe) { if (step.swipe.start && step.swipe.end) { await this.swipeCoords(step.swipe.start, step.swipe.end, step.swipe.duration) } else if (step.swipe.from) { await this.swipeFrom(step.swipe.from, step.swipe.direction, step.swipe.duration) } else { await this.swipe(step.swipe.direction, step.swipe.duration) } } else if (step.scroll) { await this.swipe(step.scroll.direction === 'DOWN' ? 'UP' : 'DOWN') } else if (step.scrollTo) { const target = typeof step.scrollTo.nodeId === 'number' ? { nodeId: step.scrollTo.nodeId } : step.scrollTo.id if (!target) throw new Error('scrollTo requires id or nodeId') await this.scrollTo(target, step.scrollTo.x, step.scrollTo.y) } else if (step.pinch) { await this.pinch(step.pinch) } else if (step.waitForAnimationToEnd) { const waitSpec = step.waitForAnimationToEnd await this.waitForAnimationToEnd( typeof waitSpec === 'number' ? waitSpec : typeof waitSpec === 'object' && typeof waitSpec.timeout === 'number' ? waitSpec.timeout : 2000, ) } else if (step.back) { await this.back() } else if (step.hideKeyboard) { await this.hideKeyboard() } else if (step.launchApp) { await this.launchApp(step.launchApp) } else if (typeof step.wait === 'number') { await sleep(step.wait) } else if (step.dumpTree) { await this.dumpTree(step.dumpTree) } else if (step.tapAtCoords) { await this.tapAtCoords(step.tapAtCoords.x, step.tapAtCoords.y) } else if (step.doubleTapAtCoords) { await this.doubleTapAtCoords( step.doubleTapAtCoords.x, step.doubleTapAtCoords.y, step.doubleTapAtCoords.gapMs, ) } else if (step.assertTreeContains) { await this.assertTreeContains(step.assertTreeContains) } else if (typeof step.eraseText === 'number') { await this.eraseText(step.eraseText) } else if (step.repeat && Array.isArray(step.repeat.commands)) { // align with upstream Orchestra.repeatCommand: `times` is OPTIONAL (when // absent → Int.MAX_VALUE, i.e. bounded only by the condition), and a // `when`/`condition` predicate runs the body `while (cond && i < maxRuns)`. // `times` may arrive as a `${...}`-interpolated string. the `commands` // guard distinguishes this command-repeat from `tapOn: { repeat: N }`, // which is a tap-count and is handled inside tapOn, not here. const rawTimes = step.repeat.times const maxRuns = rawTimes === undefined || rawTimes === null || rawTimes === '' ? Number.MAX_SAFE_INTEGER : Number(rawTimes) if (!Number.isFinite(maxRuns)) { throw new Error(`repeat.times is not a number: ${rawTimes}`) } const condition = step.repeat.when ?? step.repeat.condition const checkCondition = async (): Promise => { if (!condition) return true return this.evaluateWhen(condition) } // upstream guards an unbounded `repeat` (no times, no condition that ever // goes false) only via the flow timeout; cap a condition-less infinite // repeat so a malformed flow can't hang the runner forever. const hardCap = condition ? maxRuns : Math.min(maxRuns, 1000) let i = 0 while (i < hardCap && (await checkCondition())) { for (let j = 0; j < step.repeat.commands.length; j++) { // re-interpolate per iteration (upstream resetCommand + evaluateScripts) await this.runStep(step.repeat.commands[j], j) } i++ } } else if (step.stopApp !== undefined) { await this.stopApp() } else if (step.retry && Array.isArray(step.retry.commands)) { // maestro retryCommand: rerun the whole body when any step in it fails, // up to maxRetries times after the first attempt. the body is replayed // through runStep so each nested command re-interpolates, exactly like // repeat; the last failure propagates so the flow still fails loudly. const rawMaxRetries = step.retry.maxRetries const maxRetries = rawMaxRetries === undefined || rawMaxRetries === null || rawMaxRetries === '' ? 0 : Number(rawMaxRetries) if (!Number.isInteger(maxRetries) || maxRetries < 0) { throw new Error( `retry.maxRetries is not a non-negative integer: ${rawMaxRetries}`, ) } for (let attempt = 0; ; attempt++) { try { for (let j = 0; j < step.retry.commands.length; j++) { await this.runStep(step.retry.commands[j], j) } break } catch (error) { if (attempt >= maxRetries) throw error console.log( `[flow] retry ${attempt + 1}/${maxRetries} after: ${(error as Error).message.slice(0, 120)}`, ) } } } else if (step.clearState !== undefined) { await this.clearState() } else if (step.clearKeychain !== undefined) { this.clearKeychain() } else if (step.copyTextFrom !== undefined) { await this.copyTextFrom(step.copyTextFrom) } else if (typeof step.evalScript === 'string') { this.evalScript(step.evalScript) } else if (step.runScript) { const rs = step.runScript const innerWhen = typeof rs === 'string' ? undefined : rs.when if (innerWhen && !(await this.evaluateWhen(innerWhen))) { console.log(`[flow] runScript skipped (when predicate false)`) return } this.runScript(rs) } else if (step.openLink !== undefined) { await this.openLink(step.openLink) } else if (step.runFlow) { const rf = step.runFlow const file = typeof rf === 'string' ? rf : rf.file const commands = typeof rf === 'string' ? undefined : rf.commands const env = typeof rf === 'string' ? undefined : rf.env const innerWhen = typeof rf === 'string' ? undefined : rf.when if (innerWhen) { const shouldRun = await this.evaluateWhen(innerWhen) if (!shouldRun) { console.log( `[flow] runFlow skipped (when predicate false): ${file ?? 'inline commands'}`, ) return } } if (!file && !commands) { throw new Error('runFlow requires either file or commands') } // env vars (and any putEnv inside) are scoped to the sub-flow, like // upstream's enterEnvScope/leaveEnvScope around runFlowCommand. this.js.enterEnvScope() try { if (env) { for (const [k, v] of Object.entries(env)) this.js.putEnv(k, v) } if (file) { const flowPath = path.resolve(this.opts.flowDir, file) const flowContent = fs.readFileSync(flowPath, 'utf8') const previousDir = this.opts.flowDir ;(this.opts as { flowDir: string }).flowDir = path.dirname(flowPath) try { await this.runFlow(parseFlowSteps(flowContent)) } finally { ;(this.opts as { flowDir: string }).flowDir = previousDir } } else if (commands) { for (let i = 0; i < commands.length; i++) { await this.runStep(commands[i], i) } } } finally { this.js.leaveEnvScope() } } else { throw new Error(`unsupported flow step: ${JSON.stringify(step)}`) } } // live progress reporter (shell devtools "test" tab). attached by // runFlowPlayback; absent for programmatic callers. liveStatus: FlowLiveStatusReporter | null = null resetFlowState() { this.flowTraceSteps = [] this.lastFailedStep = null } extractFlowBody(steps: MaestroStep[]) { // maestro `onFlowStart` / `onFlowComplete` lifecycle hooks: if any step // in the array is a sole-key entry whose key is one of these names and // whose value is a list of sub-steps, pull them out and run them as // bookend arrays. this matches how maestro flattens multi-doc YAML. const onStart: MaestroStep[] = [] const onComplete: MaestroStep[] = [] const body: MaestroStep[] = [] for (const step of steps) { const keys = Object.keys(step) if ( keys.length === 1 && keys[0] === 'onFlowStart' && Array.isArray(step.onFlowStart) ) { onStart.push(...step.onFlowStart) continue } if ( keys.length === 1 && keys[0] === 'onFlowComplete' && Array.isArray(step.onFlowComplete) ) { onComplete.push(...step.onFlowComplete) continue } body.push(step) } return { onStart, onComplete, body } } async planFlow(steps: MaestroStep[], options: { dryRun?: boolean } = {}) { const { body } = this.extractFlowBody(steps) if (this.liveStatus) { await this.liveStatus.plan( body.map((rawStep, index) => { // yaml parses bare-string steps (`- back`) as strings; mirror // runStep's normalization so the rail shows the verb, not chars. const step = typeof rawStep === 'string' ? normalizeBareStringStep(rawStep) : rawStep const name = Object.keys(step).find((k) => k !== 'when') ?? Object.keys(step)[0] return { index, name, target: this.flowStepTargetLabel(step, name) } }), options, ) } } async runFlow(steps: MaestroStep[], options: { skipPlan?: boolean } = {}) { const { onStart, onComplete, body } = this.extractFlowBody(steps) if (!options.skipPlan && this.liveStatus) { await this.planFlow(steps) } if (onStart.length > 0) { console.log(`[flow] onFlowStart (${onStart.length} steps)`) for (let i = 0; i < onStart.length; i++) { await this.runStep(onStart[i], i) } } let caughtError: Error | null = null try { await this.runFlowBody(body) } catch (err) { caughtError = err instanceof Error ? err : new Error(String(err)) } if (onComplete.length > 0) { console.log(`[flow] onFlowComplete (${onComplete.length} steps)`) for (let i = 0; i < onComplete.length; i++) { try { await this.runStep(onComplete[i], i) } catch (hookErr) { console.warn( `[flow] onFlowComplete step ${i + 1} failed: ${ hookErr instanceof Error ? hookErr.message : hookErr }`, ) } } } if (caughtError) throw caughtError } private async runFlowBody(steps: MaestroStep[]) { for (let i = 0; i < steps.length; i++) { const step = steps[i] // the ceiling must never fire before the step's own deadline. waitFor and // extendedWaitUntil both scale their declared timeout by // SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too — and it has to // count extendedWaitUntil at all. Missing that, a flow declaring // `extendedWaitUntil: {timeout: 90000}` was killed by the race at 60s while // runStep was still waiting, so runStep never reached its own catch and the // step was never recorded: a failed run whose every recorded step is green. const explicitWait = typeof step.wait === 'number' ? step.wait : 0 const waitForTimeout = typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0 const extendedWaitTimeout = typeof step.extendedWaitUntil?.timeout === 'number' ? step.extendedWaitUntil.timeout : 0 const stepCeiling = Math.max( 60_000, explicitWait + 15_000, scaledTimeout(waitForTimeout) + 15_000, scaledTimeout(extendedWaitTimeout) + 15_000, ) // pause gate + live rail: checked between top-level steps only, so a // devtools pause never interrupts a step mid-gesture. if (this.liveStatus) { await this.liveStatus.waitWhilePaused() await this.liveStatus.step(i, 'running') } const liveStartedAtMs = Date.now() const reportOutcome = async (failed: boolean, error?: unknown) => { if (!this.liveStatus) return // the last trace entry after runStep settles is the top-level step's // own record (nested runFlow/repeat sub-steps record before it), so // its status carries the skipped/success distinction for free. const last = this.flowTraceSteps[this.flowTraceSteps.length - 1] const traced = last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs await this.liveStatus.step( i, failed ? 'failure' : traced && last.status === 'skipped' ? 'skipped' : 'success', { durationMs: Date.now() - liveStartedAtMs, error: failed ? error instanceof Error ? error.message.slice(0, 300) : String(error).slice(0, 300) : (traced && last.error) || undefined, }, ) } try { // runFlow runs its own sub-steps (each separately deadlined) and // launchApp self-governs with an internal waitForTree (120s on a // clearState reload) — a cache-cleared reload of a heavy bundle (bluesky) // re-fetches every chunk from metro and routinely needs >60s, so the // outer per-step race would kill a launch that's legitimately still // booting. let both govern their own timing. if (step.runFlow || step.launchApp) { await this.runStep(step, i) } else { await Promise.race([ this.runStep(step, i), new Promise((_, reject) => setTimeout( () => reject( new Error( `step ${i + 1} (${Object.keys(step)[0]}) exceeded ${stepCeiling}ms deadline`, ), ), stepCeiling, ), ), ]) } } catch (error) { // a ceiling hit rejects the race while runStep is still awaiting, so // runStep never records the step itself. record it here so the trace // always names the step that failed. const last = this.flowTraceSteps[this.flowTraceSteps.length - 1] if (!(last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs)) { const stepName = Object.keys(step)[0] this.lastFailedStep = { index: i, kind: stepName, target: (step as Record)[stepName], } this.recordFlowTraceStep({ stepIndex: i, stepName, targetLabel: this.flowStepTargetLabel(step, stepName), startedAtMs: liveStartedAtMs, status: 'failure', error, }) } await reportOutcome(true, error) throw error } await reportOutcome(false) } } }