// rnx maestro — drop-in replacement for the real `maestro` cli. // // users with an existing `.maestro/` directory can swap `maestro test` // for `rnx maestro test` and have their flows execute against // rnx's in-browser engine. the heavy lifting lives in `flow.ts` + // `bridge-flow-runner.ts`; this file just re-maps the maestro argv shape // onto the existing flow runner and handles directory iteration + init. import * as fs from 'fs' import * as path from 'path' import yaml from 'yaml' import { resolveCliAuth } from '../auth' import { parseFlowFile, type FlowFrontmatter } from '../flow-file' import { buildFlowCommandsJson, buildFlowLogLines, buildJUnitXml, buildSessionLog, failureFromTraceSteps, type JUnitCaseInput, type SessionLogFlow, } from '../maestro-report' import { registerRun, shouldRegisterRun, stepSummaryFromTrace } from '../run-registry' import { RnxExit } from '../run-rnx' import { findUnexpectedFlowArgs, FLOW_ARG_VALUE_FLAGS, getLastFlowPreviewUploadResult, getLastFlowTraceSteps, hoistLeadingSimFlag, runMaestroAuthoring, runFlowPlayback, } from './flow' import { resolveDefaultUploadOrigin } from './upload' export interface RunMaestroOptions { port?: number verbose?: boolean } const MAESTRO_DIRS = ['.maestro', 'maestro'] function printHelp() { console.log(` rnx maestro — author and run Maestro YAML flows against rnx usage: rnx maestro # discover .maestro/ or maestro/ in cwd, run all rnx maestro test # mirrors "maestro test" rnx maestro load # load flow into runner without running (--dry-run) rnx maestro test .maestro/ # every *.yaml / *.yml in a directory rnx maestro generate "" # generate, run, and upload a Maestro flow rnx maestro start|keep|end # author a flow from live CLI actions rnx maestro validate # validate a Maestro YAML file rnx maestro init # scaffold .maestro/login.yaml rnx maestro --list-compat # print supported/unsupported verbs options: --dry-run, --no-run load flow into runner idle without auto-running --env KEY=VALUE set env vars for \${KEY} interpolation (repeatable) --continuous accepted for maestro cli compat; rnx runs the flow once --include-tags run only flows carrying one of these tags (comma- separated; CLI wins over config.yaml includeTags) --exclude-tags skip flows carrying any of these tags --config read the workspace config (flows/tags) from this file instead of /config.yaml --format junit write a junit xml report (default ./report.xml) --output junit report path (needs --format junit) --test-output-dir per-flow screenshots, commands.json, and logs/maestro.log (config key testOutputDir:; CLI wins over config) --debug-output write a session maestro.log there --platform ios accepted; any other platform is refused (rnx is iOS-only) --no-ansi accepted; rnx output is already plain text --device override the flow frontmatter device for this run --new force a fresh browser window for this run --headed force a fresh, VISIBLE window (implies --new, overrides --headless) — watch the flow run live --record record a webm while the flow runs --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 --preview-public-origin public link origin for --preview --slow delay between steps for natural pacing examples: rnx maestro test .maestro/login.yaml rnx maestro test .maestro/ rnx maestro generate "log in and verify the welcome screen" rnx maestro start rnx maestro end --output .maestro/login.yaml --validate rnx maestro --env USERNAME=alice test .maestro/login.yaml rnx maestro init `) } function printCompatMatrix() { console.log(` rnx maestro — compatibility matrix supported verbs: launchApp (including arguments), stopApp, clearState, clearKeychain (warn-only), tapOn, tapAtCoords, longPressOn, inputText, pressKey, dispatchKey, hideKeyboard, eraseText, assertVisible, assertNotVisible, assertTreeContains, waitFor, extendedWaitUntil, waitForAnimationToEnd, scroll, scrollUntilVisible, scrollTo, swipe, pinch, takeScreenshot, dumpTree, back, repeat (times), retry (maxRetries + commands), runFlow (file / inline commands / env / when), runScript (maestro JS: http, json(), output, env vars, console.log), evalScript, copyTextFrom (\${maestro.copiedText}), openLink, when: (visible / notVisible / platform / true), optional:, label:, onFlowStart, onFlowComplete, \${...} JS-expression interpolation at step execution time (env vars as globals, output.*, maestro.*, \${EXPR || 'default'}). workspace mode (directory runs): config.yaml / config.yml discovery, flows: inclusion globs (with ! negation; at least one positive pattern), includeTags / excludeTags against flow frontmatter tags. cli flags (maestro test parity): --include-tags / --exclude-tags (comma-separated, OR within a flag; CLI wins over config.yaml), --config , --format junit with --output (default report.xml; flow name:/properties: shape the testcase), --test-output-dir (config key testOutputDir:, CLI wins), --debug-output, --platform ios (others refused), --no-ansi (rnx output is already plain), -d for --device, -e for --env. --continuous warns and runs once; --format html warns and runs without a report. launch behavior: launchApp.arguments: a JSON object available before guest app entry through react-native-launch-arguments and NativeModules.SettingsManager.settings. stopApp: keeps the guest stopped until launchApp or openLink starts it. openLink: replaces the guest, then delivers the URL to the new tenant. partial: clearKeychain — warn-only, rnx has no keychain surface. when.platform — rnx reports iOS for this runner. openLink: app routing works; no OS-level Safari fallback. launchApp.resetRuntime — rnx-only extension for recorded benchmarks: keep tenant storage/auth, but soft-reload the guest app inside launchApp. takeScreenshot — maestro string form works unchanged; rnx also accepts { path, withFrame } for framed export. env — rnx seeds the JS env from the full shell environment, not just MAESTRO_* (so \${SOOTSIM_*} credentials resolve); a bare \${NAME} that is undefined fails the step loudly instead of typing "undefined". not yet implemented (will throw "unsupported maestro verb"): travel, setLocation, setAirplaneMode, killApp, faker, addMedia, startRecording, stopRecording (use --record flag), repeat.while (only repeat.times is supported). `) } function looksLikeFlag(s: string): boolean { return s.startsWith('-') } // extract --env KEY=VALUE pairs from argv and apply to process.env for the // duration of the run. returns argv with the consumed flags removed so the // rest can be forwarded to runFlowPlayback verbatim. function applyEnvFlags(args: string[]): string[] { const out: string[] = [] for (let i = 0; i < args.length; i++) { if (args[i] === '--env' || args[i] === '-e') { const kv = args[i + 1] if (!kv || !kv.includes('=')) { throw new Error(`--env expects KEY=VALUE (got ${JSON.stringify(kv ?? '')})`) } const eq = kv.indexOf('=') const key = kv.slice(0, eq) const value = kv.slice(eq + 1) process.env[key] = value i += 1 continue } out.push(args[i]) } return out } // maestro-cli flags this command consumes itself, accepted so an existing // script can swap `maestro test` for `rnx maestro test` unedited. each one // either takes effect below or says out loud that it does not: an option // that is quietly dropped is how a run reports success while doing something // other than what was asked. `--origin` is consumed here because only this // command reads it; everything this function leaves alone rides on to the // flow runner, which rejects what it does not know. // // both `--flag value` and `--flag=value` spellings work: maestro's own docs // use the `=` form for tags and output dirs, so rejecting it would break the // exact command lines users paste from there. export interface MaestroCompatOptions { // null = flag absent. CLI lists take precedence over config.yaml (maestro // documents CLI-always-wins), so absent must stay distinguishable from []. includeTags: string[] | null excludeTags: string[] | null configPath: string | null format: string | null output: string | null testOutputDir: string | null debugOutput: string | null platform: string | null } const MAESTRO_COMPAT_VALUE_FLAGS = new Set([ '--include-tags', '--exclude-tags', '--config', '--format', '--output', '--test-output-dir', '--debug-output', '--platform', ]) function splitTagList(value: string): string[] { return value .split(',') .map((tag) => tag.trim()) .filter((tag) => tag.length > 0) } export function extractMaestroCompatFlags(args: string[]): { rest: string[] compat: MaestroCompatOptions } { const compat: MaestroCompatOptions = { includeTags: null, excludeTags: null, configPath: null, format: null, output: null, testOutputDir: null, debugOutput: null, platform: null, } const rest: string[] = [] for (let i = 0; i < args.length; i++) { const a = args[i] const eq = a.indexOf('=') const head = eq > 0 ? a.slice(0, eq) : a if (MAESTRO_COMPAT_VALUE_FLAGS.has(head)) { const inline = eq > 0 ? a.slice(eq + 1) : null const next = args[i + 1] const value = inline !== null && inline.length > 0 ? inline : next !== undefined && !next.startsWith('-') ? next : null if (inline === null || inline.length === 0) i += 1 if (value === null || value.length === 0) { throw new Error(`${head} expects a value`) } switch (head) { case '--include-tags': compat.includeTags = [...(compat.includeTags ?? []), ...splitTagList(value)] break case '--exclude-tags': compat.excludeTags = [...(compat.excludeTags ?? []), ...splitTagList(value)] break case '--config': compat.configPath = value break case '--format': compat.format = value break case '--output': compat.output = value break case '--test-output-dir': compat.testOutputDir = value break case '--debug-output': compat.debugOutput = value break case '--platform': if (value.toLowerCase() !== 'ios') { throw new Error(`rnx emulates iOS only; --platform ${value} is not supported`) } compat.platform = value break } continue } if (a === '--origin') { // read above for the run-registration origin. the flow runner has no // --origin, so it must not ride into playbackArgs. i += 1 continue } if (a === '-d') { // maestro's short form for --device. rnx's device flag names a device // model, and the flow runner reads it under the long spelling only. rest.push('--device') continue } if (a === '--no-ansi') { // trivially satisfied: rnx prints no ANSI escapes anywhere on this path. continue } if (a === '--continuous') { console.warn(' warn: --continuous is not implemented in rnx — running once') continue } rest.push(a) } if ( compat.format !== null && !['junit', 'html', 'html-detailed'].includes(compat.format) ) { throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`) } return { rest, compat } } function findDefaultMaestroTarget(cwd: string): string | null { for (const dir of MAESTRO_DIRS) { const abs = path.join(cwd, dir) if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return abs } return null } interface MaestroWorkspaceConfig { flows?: string[] includeTags?: string[] excludeTags?: string[] testOutputDir?: string } export interface CollectFlowFilesOptions { // explicit `--config `; globs still resolve under the target dir. configPath?: string // CLI tag lists; null/undefined falls back to the workspace config (maestro // documents CLI-always-wins, never a union). includeTags?: string[] | null excludeTags?: string[] | null } // read the workspace config for a directory target: an explicit --config // path, else config.yaml/config.yml beside the flows, else empty policy. export function loadMaestroWorkspaceConfig( dir: string, configPath?: string, ): { path: string | null; config: MaestroWorkspaceConfig } { const found = configPath ?? ['config.yaml', 'config.yml'] .map((name) => path.join(dir, name)) .find((p) => fs.existsSync(p)) if (!found) return { path: null, config: {} } if (!fs.existsSync(found)) { throw new Error(`maestro config not found: ${configPath}`) } // yaml.parse returns any; shape-check the fields we use below instead // of trusting the file const parsed: unknown = yaml.parse(fs.readFileSync(found, 'utf8')) if (!parsed || typeof parsed !== 'object') return { path: found, config: {} } const raw = parsed as Record const config: MaestroWorkspaceConfig = {} if (Array.isArray(raw.flows)) { config.flows = raw.flows.filter((g): g is string => typeof g === 'string') } if (Array.isArray(raw.includeTags)) { config.includeTags = raw.includeTags.filter((t): t is string => typeof t === 'string') } if (Array.isArray(raw.excludeTags)) { config.excludeTags = raw.excludeTags.filter((t): t is string => typeof t === 'string') } if (typeof raw.testOutputDir === 'string' && raw.testOutputDir.length > 0) { config.testOutputDir = raw.testOutputDir } return { path: found, config } } // workspace planning for directory targets — mirrors maestro's // WorkspaceExecutionPlanner: discover config.yaml/config.yml in the // directory, walk it recursively for *.yaml/*.yml flow files (config files // excluded), keep the ones matching the config's `flows:` globs (default // `*` = top level only; `!`-prefixed globs subtract from the positive // matches), then filter by includeTags/excludeTags against each flow's // frontmatter `tags:` (OR within a flag, include-then-exclude between them). // // an explicit file target always runs: maestro runs a named flow even when // its tags would exclude it from a directory run. export function collectFlowFiles( target: string, opts: CollectFlowFilesOptions = {}, ): string[] { const stat = fs.statSync(target) if (stat.isFile()) return [target] if (!stat.isDirectory()) { throw new Error(`not a file or directory: ${target}`) } const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath) const globs = Array.isArray(config.flows) && config.flows.length > 0 ? config.flows : ['*'] const positive = globs.filter((glob) => !glob.startsWith('!')) const negative = globs .filter((glob) => glob.startsWith('!')) .map((glob) => glob.slice(1)) .filter((glob) => glob.length > 0) if (positive.length === 0) { throw new Error( `flows: needs at least one positive pattern in ${configPath ?? target} (got only exclusions)`, ) } const isFlowFile = (rel: string) => { const base = path.basename(rel) if (base.startsWith('.')) return false if (!base.endsWith('.yaml') && !base.endsWith('.yml')) return false if (base === 'config.yaml' || base === 'config.yml') return false return true } const matched = new Set() for (const glob of positive) { for (const rel of fs.globSync(glob, { cwd: target })) { if (!isFlowFile(rel)) continue const abs = path.join(target, rel) if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs) } } for (const glob of negative) { for (const rel of fs.globSync(glob, { cwd: target })) { matched.delete(path.join(target, rel)) } } let files = [...matched].sort() const includeTags = opts.includeTags ?? config.includeTags ?? [] const excludeTags = opts.excludeTags ?? config.excludeTags ?? [] if (includeTags.length > 0 || excludeTags.length > 0) { files = files.filter((file) => { const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8')) const tags: string[] = Array.isArray(frontmatter.tags) ? frontmatter.tags : [] if (includeTags.length > 0 && !tags.some((t) => includeTags.includes(t))) { return false } if (tags.some((t) => excludeTags.includes(t))) return false return true }) } return files } function flowFileStem(file: string): string { return path.basename(file).replace(/\.(ya?ml)$/i, '') } // a per-flow artifact dir under the test-output root. never reuses a dir, // so a re-run cannot overwrite the previous run's evidence. function uniqueFlowDir(root: string, file: string): string { const slug = flowFileStem(file).replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow' let dir = path.join(root, slug) let n = 2 while (fs.existsSync(dir)) { dir = path.join(root, `${slug}-${n}`) n += 1 } return dir } async function runInit(cwd: string): Promise { const dir = path.join(cwd, '.maestro') fs.mkdirSync(dir, { recursive: true }) const outPath = path.join(dir, 'login.yaml') if (fs.existsSync(outPath)) { console.error(` error: ${outPath} already exists`) return 1 } const starter = `# rnx maestro starter flow — drop-in compatible with the maestro cli. # run: rnx maestro test .maestro/login.yaml appId: com.example.app --- - launchApp - tapOn: id: "email" - inputText: "user@example.com" - tapOn: id: "password" - inputText: "secret123" - tapOn: "Sign in" - assertVisible: "Welcome" ` fs.writeFileSync(outPath, starter, 'utf8') console.log(` + wrote ${outPath}`) console.log(` next: rnx maestro test .maestro/login.yaml`) return 0 } export async function runMaestro( args: string[], opts: RunMaestroOptions = {}, ): Promise { if (args.includes('--help') || args.includes('-h')) { printHelp() return 0 } if (args.includes('--list-compat')) { printCompatMatrix() return 0 } let remaining: string[] try { remaining = applyEnvFlags(args) } catch (err) { console.error(` error: ${(err as Error).message}`) return 1 } // bin.ts prepends the global `--sim ` to argv. without hoisting it // out, `remaining[0]` is `--sim` instead of the `test` subcommand, so // maestro auto-discovered a default flow and forwarded `--sim`/`test`/ // the path as bogus flow args — the explicit sim was silently dropped // (QA F21-4). hoist it to the tail so it still rides into forwardArgs → // runFlowPlayback, where parseBridgeCliArgs sees simIdSource === 'flag'. remaining = hoistLeadingSimFlag(remaining) const cwd = process.cwd() let sub = remaining[0] if (sub === 'load') { remaining[0] = 'test' sub = 'test' if (!remaining.includes('--dry-run') && !remaining.includes('--no-run')) { remaining.push('--dry-run') } } if (sub === 'init') { return runInit(cwd) } if (sub === 'generate') { const { runMaestroGenerate } = await import('./maestro-generate') return runMaestroGenerate(remaining.slice(1)) } if ( sub === 'start' || sub === 'keep' || sub === 'good' || sub === 'end' || sub === 'validate' ) { return runMaestroAuthoring(remaining) } // `--origin` names the Contrast instance that hosts the run row. this command // is its only reader, so read it before the compat extractor consumes it. const originFlagIndex = remaining.indexOf('--origin') const originFlagValue = originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined let compat: MaestroCompatOptions try { const extracted = extractMaestroCompatFlags(remaining) remaining = extracted.rest compat = extracted.compat } catch (err) { console.error(` error: ${(err as Error).message}`) return 1 } if (compat.format === 'html' || compat.format === 'html-detailed') { console.warn( ` warn: --format ${compat.format} reports are not implemented in rnx — running without writing a report`, ) } if (compat.output && compat.format !== 'junit') { console.warn(' warn: --output is ignored without --format junit') } // resolve the target flow file or directory. let target: string | null = null let forwardArgs: string[] if (sub === 'test') { // `rnx maestro test [flags...]`. the flow path is the first // token that is neither a flag nor the value of one — without the second // half, `test --sim a2 login.yaml` takes `a2` as the flow file. remove it // by index, since a path can equal a flag value elsewhere in the argv. const rest = remaining.slice(1) const positionalIndex = rest.findIndex( (a, i) => !looksLikeFlag(a) && !(i > 0 && FLOW_ARG_VALUE_FLAGS.includes(rest[i - 1])), ) if (positionalIndex >= 0) { target = path.resolve(cwd, rest[positionalIndex]) forwardArgs = rest.filter((_, i) => i !== positionalIndex) } else if (compat.configPath) { // no positional with an explicit workspace config: run the directory // the config lives in, like `rnx test --config` does. target = path.resolve(cwd, path.dirname(compat.configPath)) forwardArgs = rest } else { target = findDefaultMaestroTarget(cwd) forwardArgs = rest } } else if (sub && !looksLikeFlag(sub)) { // `rnx maestro ` — tolerate the shorthand. target = path.resolve(cwd, sub) forwardArgs = remaining.slice(1) } else { // no positional — auto-discover. target = findDefaultMaestroTarget(cwd) forwardArgs = remaining } // every flag here is forwarded to each flow, so check the vocabulary once, // before anything opens a sim. runFlowPlayback checks again for its // programmatic callers; doing it here keeps a directory run from printing // the same rejection once per flow after it has already started work. const unexpected = findUnexpectedFlowArgs(['', ...forwardArgs]) 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') return 1 } if (!target) { console.error(` error: no maestro flows found. expected one of: ${MAESTRO_DIRS.map((d) => `./${d}/`).join(', ')} or pass a flow explicitly: rnx maestro test path/to/flow.yaml or scaffold a starter flow: rnx maestro init `) return 1 } if (!fs.existsSync(target)) { console.error(` error: ${target} not found`) return 1 } let files: string[] let workspaceConfig: MaestroWorkspaceConfig = {} let workspaceConfigPath: string | null = null try { if (fs.statSync(target).isDirectory()) { const loaded = loadMaestroWorkspaceConfig(target, compat.configPath ?? undefined) workspaceConfig = loaded.config workspaceConfigPath = loaded.path } files = collectFlowFiles(target, { configPath: compat.configPath ?? undefined, includeTags: compat.includeTags, excludeTags: compat.excludeTags, }) } catch (err) { console.error(` error: ${(err as Error).message}`) return 1 } if (files.length === 0) { if (compat.includeTags !== null || compat.excludeTags !== null) { console.error(` error: no flows in ${target} match the requested tags`) return 1 } console.error(` error: no *.yaml or *.yml files found in ${target}`) return 1 } // maestro's priority rule: CLI flag, then config.yaml, then the default // (which for rnx is no test-output routing at all). a config-relative dir // resolves against the config file, a CLI dir against the invocation cwd. const testOutputRoot = compat.testOutputDir ? path.resolve(cwd, compat.testOutputDir) : workspaceConfig.testOutputDir && workspaceConfigPath ? path.resolve(path.dirname(workspaceConfigPath), workspaceConfig.testOutputDir) : null console.log(` rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'} root: ${path.relative(cwd, target) || '.'} `) // registration target for hosted run rows — resolved once per invocation. const auth = resolveCliAuth() const flagValue = (name: string) => { const index = forwardArgs.indexOf(name) return index >= 0 ? forwardArgs[index + 1] : undefined } // only `registerRun` below consumes this, and only for runs that register. // resolving it up front probed https://contrast.localhost:3000 and blocked // every flow — including ones that upload and register nothing — for the // probe's full 2s timeout whenever no local Contrast stack was listening. // resolve on first use instead, and keep the one probe per invocation. let registerOriginPromise: Promise | null = null const registerOrigin = () => (registerOriginPromise ??= resolveDefaultUploadOrigin( flagValue('--preview-origin') ?? originFlagValue, )) const sessionStartedAt = new Date().toISOString() let worstExit = 0 const results: Array<{ file: string exit: number durationMs: number steps: ReturnType frontmatter: FlowFrontmatter }> = [] for (const file of files) { console.log(`\n ▶ ${path.relative(cwd, file)}`) const playbackArgs = [file, ...forwardArgs] let flowTestDir: string | null = null if (testOutputRoot) { flowTestDir = uniqueFlowDir(testOutputRoot, file) try { fs.mkdirSync(path.join(flowTestDir, 'screenshots'), { recursive: true }) fs.mkdirSync(path.join(flowTestDir, 'logs'), { recursive: true }) } catch (err) { console.error( ` error: could not create test output dir: ${(err as Error).message}`, ) results.push({ file, exit: 1, durationMs: 0, steps: [], frontmatter: {} }) if (worstExit === 0) worstExit = 1 continue } // an explicit --screenshots wins over this routing: flow.ts reads the // FIRST occurrence, and the user's flag sits earlier in playbackArgs. playbackArgs.push('--screenshots', path.join(flowTestDir, 'screenshots')) } // `--port` is the BRIDGE port everywhere else in the CLI, so forward it as // one. sending it on as `--url` made it an app target instead, which left // the playback bridge on whichever world resolveBridgeWorld picks by // default (a live Vite dev shell selected from the development registry). // so `rnx --port maestro …` silently ran the flow // against the dev engine, then failed trying to resolve a bundle at the // bridge port. an explicit command-level `--port` still wins, since // parseBridgeCliArgs takes the last occurrence. if (opts.port) { playbackArgs.push('--port', String(opts.port)) } let exit = 0 const startedAt = Date.now() try { exit = await runFlowPlayback(playbackArgs) } catch (err) { // rnxExit is a deliberate exit that already printed its own reason. // reporting the sentinel's own text on top of it reads like a second, // unrelated failure. if (err instanceof RnxExit) { exit = err.code || 1 } else { console.error(` x ${(err as Error).message}`) exit = 1 } } const traceSteps = getLastFlowTraceSteps() const durationMs = Date.now() - startedAt let frontmatter: FlowFrontmatter = {} try { frontmatter = parseFlowFile(fs.readFileSync(file, 'utf8')).frontmatter } catch { frontmatter = {} } const result = { file, exit, durationMs, steps: traceSteps, frontmatter } results.push(result) if (exit !== 0 && worstExit === 0) worstExit = exit if (flowTestDir) { try { fs.writeFileSync( path.join(flowTestDir, 'commands.json'), buildFlowCommandsJson(traceSteps), ) fs.writeFileSync( path.join(flowTestDir, 'logs', 'maestro.log'), `${buildFlowLogLines({ file: path.relative(cwd, file) || file, passed: exit === 0, durationMs, steps: traceSteps, }).join('\n')}\n`, ) } catch (err) { console.error( ` error: could not write test artifacts: ${(err as Error).message}`, ) if (exit === 0) exit = 1 if (worstExit === 0) worstExit = 1 } } const upload = getLastFlowPreviewUploadResult() if (upload?.shareId || upload?.previewUrl) { try { fs.writeFileSync( path.join(cwd, 'preview-result.json'), JSON.stringify({ previewId: upload.shareId, previewUrl: upload.previewUrl, }), ) } catch {} } else if (args.includes('--preview')) { console.error(' error: required hosted recording upload failed') if (exit === 0) exit = 1 if (worstExit === 0) worstExit = 1 } if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) { const failedStep = traceSteps.find((step) => step.status === 'failure') const run = await registerRun({ origin: await registerOrigin(), kind: 'maestro', name: path.relative(cwd, file), status: exit === 0 ? 'passed' : 'failed', failureMessage: exit === 0 ? null : (failedStep?.error ?? 'flow playback failed'), previewShareId: upload?.shareId ?? null, owner: flagValue('--owner') ?? null, repo: flagValue('--repo') ?? null, durationMs: Date.now() - startedAt, ...stepSummaryFromTrace(traceSteps), auth, }) if (run) { console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`) try { fs.writeFileSync( path.join(cwd, 'test-result.json'), JSON.stringify({ runId: run.id, previewUrl: run.previewUrl, traceUrl: run.traceUrl, }), ) } catch {} } else if (process.env.RNX_REQUIRE_REGISTRATION === 'true') { console.error(' error: required hosted run registration failed') if (exit === 0) exit = 1 if (worstExit === 0) worstExit = 1 } } // artifact, upload, and registration failures above flip the local exit // after the result was pushed; the report below must see the final one. result.exit = exit } if (files.length > 1) { console.log(`\n maestro summary:`) for (const r of results) { const label = r.exit === 0 ? 'pass' : 'fail' console.log(` ${label} ${path.relative(cwd, r.file)}`) } const passed = results.filter((r) => r.exit === 0).length console.log(` ${passed}/${results.length} passed`) } // one device per invocation, like maestro: the explicit override, else the // sim default. reports print it; playback already resolved it per flow. const deviceLabel = flagValue('--device')?.trim() || 'iPhone 17 Pro' if (compat.format === 'junit') { const junitPath = path.resolve(cwd, compat.output ?? 'report.xml') try { fs.mkdirSync(path.dirname(junitPath), { recursive: true }) fs.writeFileSync( junitPath, buildJUnitXml({ device: deviceLabel, cases: results.map((r) => junitCaseForResult(cwd, r)), }), ) console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`) } catch (err) { console.error(` error: could not write junit report: ${(err as Error).message}`) if (worstExit === 0) worstExit = 1 } } if (compat.debugOutput) { const logPath = path.resolve(cwd, compat.debugOutput, 'maestro.log') try { fs.mkdirSync(path.dirname(logPath), { recursive: true }) const sessionFlows: SessionLogFlow[] = results.map((r) => ({ file: path.relative(cwd, r.file) || r.file, passed: r.exit === 0, durationMs: r.durationMs, steps: r.steps, })) fs.writeFileSync( logPath, buildSessionLog({ startedAtIso: sessionStartedAt, root: path.relative(cwd, target) || '.', device: deviceLabel, flows: sessionFlows, }), ) console.log(` debug log: ${path.relative(cwd, logPath) || logPath}`) } catch (err) { console.error(` error: could not write debug log: ${(err as Error).message}`) if (worstExit === 0) worstExit = 1 } } return worstExit } // a junit testcase from one flow result. yaml frontmatter is untrusted, so // every field is shape-checked back to the documented scalar shapes. function junitCaseForResult( cwd: string, result: { file: string exit: number durationMs: number steps: ReturnType frontmatter: FlowFrontmatter }, ): JUnitCaseInput { const raw = result.frontmatter const name = typeof raw.name === 'string' && raw.name.trim().length > 0 ? raw.name.trim() : flowFileStem(result.file) const custom: Record = {} let junitId: string | null = null let junitClassname: string | null = null const props: unknown = raw.properties if (props && typeof props === 'object') { for (const [key, value] of Object.entries(props)) { if ( typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean' ) { continue } const text = String(value) if (key === 'junitId') junitId = text else if (key === 'junitClassname') junitClassname = text else custom[key] = text } } const tags = Array.isArray(raw.tags) ? raw.tags.filter((tag): tag is string => typeof tag === 'string') : [] return { file: path.relative(cwd, result.file) || result.file, name, id: junitId ?? name, classname: junitClassname ?? name, properties: custom, tags, timeSeconds: result.durationMs / 1000, failure: result.exit === 0 ? null : failureFromTraceSteps(result.steps, 'flow playback failed'), } }