rnxsim 0.1.549 → 0.1.550

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/cli/bridge-flow-runner.ts +26 -0
  2. package/cli/commands/maestro.ts +453 -42
  3. package/cli/commands/test.ts +107 -23
  4. package/cli/flow-file.ts +7 -0
  5. package/cli/maestro-report.ts +178 -0
  6. package/dist-lib/agent-daemon-client.cjs +1 -1
  7. package/dist-lib/agent-events.cjs +1 -1
  8. package/dist-lib/agent-identity.cjs +1 -1
  9. package/dist-lib/agent-sessions.cjs +1 -1
  10. package/dist-lib/attached-projects.cjs +1 -1
  11. package/dist-lib/auth/shared-session.cjs +1 -1
  12. package/dist-lib/backend-origin.cjs +1 -1
  13. package/dist-lib/beta.cjs +1 -1
  14. package/dist-lib/beta.mjs +1 -1
  15. package/dist-lib/bridge-constants.cjs +1 -1
  16. package/dist-lib/bridge-contract-input.cjs +1 -1
  17. package/dist-lib/bridge-contract-input.mjs +1 -1
  18. package/dist-lib/bridge-contract.cjs +1 -1
  19. package/dist-lib/bridge-contract.mjs +1 -1
  20. package/dist-lib/capture-contract.cjs +1 -1
  21. package/dist-lib/capture-contract.mjs +1 -1
  22. package/dist-lib/cli-constants.cjs +1 -1
  23. package/dist-lib/cloud-contract.cjs +1 -1
  24. package/dist-lib/cloud-contract.mjs +1 -1
  25. package/dist-lib/cloud.cjs +1 -1
  26. package/dist-lib/cloud.mjs +1 -1
  27. package/dist-lib/config.cjs +1 -1
  28. package/dist-lib/detox/index.cjs +1 -1
  29. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  30. package/dist-lib/home-paths.cjs +1 -1
  31. package/dist-lib/host/bridge-host.cjs +8 -6
  32. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  33. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  34. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  35. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  36. package/dist-lib/host/websocket-proxy.cjs +1 -1
  37. package/dist-lib/index.cjs +1 -1
  38. package/dist-lib/jump-to-source-babel.cjs +1 -1
  39. package/dist-lib/jump-to-source-native.cjs +1 -1
  40. package/dist-lib/menu.cjs +1 -1
  41. package/dist-lib/menu.mjs +1 -1
  42. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  43. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  44. package/dist-lib/metro-production-bundle.cjs +1 -1
  45. package/dist-lib/metro-production-bundle.mjs +1 -1
  46. package/dist-lib/metro.cjs +1 -1
  47. package/dist-lib/profiles.cjs +1 -1
  48. package/dist-lib/public-brand.cjs +1 -1
  49. package/dist-lib/react-native-host-modules.cjs +1 -1
  50. package/dist-lib/react-native-host-modules.mjs +1 -1
  51. package/dist-lib/render-mode.cjs +1 -1
  52. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  53. package/dist-lib/sdk.cjs +1 -1
  54. package/dist-lib/sdk.mjs +1 -1
  55. package/dist-lib/skills.cjs +28 -7
  56. package/dist-lib/swift-submit.cjs +1 -1
  57. package/dist-lib/swift-submit.mjs +1 -1
  58. package/dist-lib/swift.cjs +23 -5
  59. package/dist-lib/vite.cjs +24 -11
  60. package/package.json +1 -1
  61. package/src/rolldown-plugin-swift.ts +8 -5
  62. package/src/vite-plugin-swift.ts +8 -5
@@ -2027,7 +2027,33 @@ export class SootSimBridgeFlowRunner {
2027
2027
  // begin its pixels only after launch has mounted and settled, leaving the
2028
2028
  // loading prelude outside the playable video while its trace stays visible.
2029
2029
  await this.waitForVisualSettle()
2030
+ const videoAlreadyStarted = this.recordingStartedAtMs != null
2030
2031
  await this.ensureRecordingStarted()
2032
+ if (!videoAlreadyStarted && this.recordingStartedAtMs != null) {
2033
+ // the preview event recorder has been running since before launch,
2034
+ // so its clock includes the boot prelude the video just skipped.
2035
+ // rebase it to the video's first frame — without this every event
2036
+ // timestamp overshoots the playable video and preview playback can
2037
+ // never align (play stuck disabled). no-op unless the preview
2038
+ // event recorder is running.
2039
+ await this.rebaseEventRecorderToVideoStart(this.recordingStartedAtMs)
2040
+ }
2041
+ }
2042
+ }
2043
+
2044
+ private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) {
2045
+ try {
2046
+ await this.evaluate(
2047
+ `(() => {
2048
+ const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
2049
+ if (!recorder?.isRecording?.()) return 'not-recording'
2050
+ return recorder.markVisibleStart?.(${JSON.stringify(videoStartedAtMs)}) ?? 'no-mark'
2051
+ })()`,
2052
+ )
2053
+ } catch (err) {
2054
+ console.warn(
2055
+ ` warn: event-recorder rebase to video start failed: ${err instanceof Error ? err.message : err}`,
2056
+ )
2031
2057
  }
2032
2058
  }
2033
2059
 
@@ -10,7 +10,16 @@ import * as fs from 'fs'
10
10
  import * as path from 'path'
11
11
  import yaml from 'yaml'
12
12
  import { resolveCliAuth } from '../auth'
13
- import { parseFlowFile } from '../flow-file'
13
+ import { parseFlowFile, type FlowFrontmatter } from '../flow-file'
14
+ import {
15
+ buildFlowCommandsJson,
16
+ buildFlowLogLines,
17
+ buildJUnitXml,
18
+ buildSessionLog,
19
+ failureFromTraceSteps,
20
+ type JUnitCaseInput,
21
+ type SessionLogFlow,
22
+ } from '../maestro-report'
14
23
  import { registerRun, shouldRegisterRun, stepSummaryFromTrace } from '../run-registry'
15
24
  import { RnxExit } from '../run-rnx'
16
25
  import {
@@ -50,7 +59,19 @@ options:
50
59
  --dry-run, --no-run load flow into runner idle without auto-running
51
60
  --env KEY=VALUE set env vars for \${KEY} interpolation (repeatable)
52
61
  --continuous accepted for maestro cli compat; rnx runs the flow once
53
- --format <fmt> accepted for maestro cli compat (not used)
62
+ --include-tags <a,b> run only flows carrying one of these tags (comma-
63
+ separated; CLI wins over config.yaml includeTags)
64
+ --exclude-tags <a,b> skip flows carrying any of these tags
65
+ --config <path> read the workspace config (flows/tags) from this file
66
+ instead of <dir>/config.yaml
67
+ --format junit write a junit xml report (default ./report.xml)
68
+ --output <file> junit report path (needs --format junit)
69
+ --test-output-dir <dir>
70
+ per-flow screenshots, commands.json, and logs/maestro.log
71
+ (config key testOutputDir:; CLI wins over config)
72
+ --debug-output <dir> write a session maestro.log there
73
+ --platform ios accepted; any other platform is refused (rnx is iOS-only)
74
+ --no-ansi accepted; rnx output is already plain text
54
75
  --device <model> override the flow frontmatter device for this run
55
76
  --new force a fresh browser window for this run
56
77
  --headed force a fresh, VISIBLE window (implies --new,
@@ -95,8 +116,19 @@ supported verbs:
95
116
  (env vars as globals, output.*, maestro.*, \${EXPR || 'default'}).
96
117
 
97
118
  workspace mode (directory runs):
98
- config.yaml / config.yml discovery, flows: inclusion globs,
99
- includeTags / excludeTags against flow frontmatter tags.
119
+ config.yaml / config.yml discovery, flows: inclusion globs (with !
120
+ negation; at least one positive pattern), includeTags / excludeTags
121
+ against flow frontmatter tags.
122
+
123
+ cli flags (maestro test parity):
124
+ --include-tags / --exclude-tags (comma-separated, OR within a flag;
125
+ CLI wins over config.yaml), --config <path>, --format junit with
126
+ --output (default report.xml; flow name:/properties: shape the
127
+ testcase), --test-output-dir (config key testOutputDir:, CLI wins),
128
+ --debug-output, --platform ios (others refused), --no-ansi (rnx
129
+ output is already plain), -d for --device, -e for --env.
130
+ --continuous warns and runs once; --format html warns and runs
131
+ without a report.
100
132
 
101
133
  launch behavior:
102
134
  launchApp.arguments: a JSON object available before guest app entry through
@@ -150,19 +182,109 @@ function applyEnvFlags(args: string[]): string[] {
150
182
  return out
151
183
  }
152
184
 
153
- // maestro-cli flags with no rnx equivalent, accepted so an existing script can
154
- // swap `maestro` for `rnx maestro` unedited. each one says out loud that it is
155
- // being ignored: an option that is quietly dropped is how a run reports success
156
- // while doing something other than what was asked. `--origin` is consumed here
157
- // because only this command reads it; everything else this function leaves
158
- // alone rides on to the flow runner, which rejects what it does not know.
159
- function stripMaestroCompatFlags(args: string[]): string[] {
160
- const out: string[] = []
185
+ // maestro-cli flags this command consumes itself, accepted so an existing
186
+ // script can swap `maestro test` for `rnx maestro test` unedited. each one
187
+ // either takes effect below or says out loud that it does not: an option
188
+ // that is quietly dropped is how a run reports success while doing something
189
+ // other than what was asked. `--origin` is consumed here because only this
190
+ // command reads it; everything this function leaves alone rides on to the
191
+ // flow runner, which rejects what it does not know.
192
+ //
193
+ // both `--flag value` and `--flag=value` spellings work: maestro's own docs
194
+ // use the `=` form for tags and output dirs, so rejecting it would break the
195
+ // exact command lines users paste from there.
196
+ export interface MaestroCompatOptions {
197
+ // null = flag absent. CLI lists take precedence over config.yaml (maestro
198
+ // documents CLI-always-wins), so absent must stay distinguishable from [].
199
+ includeTags: string[] | null
200
+ excludeTags: string[] | null
201
+ configPath: string | null
202
+ format: string | null
203
+ output: string | null
204
+ testOutputDir: string | null
205
+ debugOutput: string | null
206
+ platform: string | null
207
+ }
208
+
209
+ const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
210
+ '--include-tags',
211
+ '--exclude-tags',
212
+ '--config',
213
+ '--format',
214
+ '--output',
215
+ '--test-output-dir',
216
+ '--debug-output',
217
+ '--platform',
218
+ ])
219
+
220
+ function splitTagList(value: string): string[] {
221
+ return value
222
+ .split(',')
223
+ .map((tag) => tag.trim())
224
+ .filter((tag) => tag.length > 0)
225
+ }
226
+
227
+ export function extractMaestroCompatFlags(args: string[]): {
228
+ rest: string[]
229
+ compat: MaestroCompatOptions
230
+ } {
231
+ const compat: MaestroCompatOptions = {
232
+ includeTags: null,
233
+ excludeTags: null,
234
+ configPath: null,
235
+ format: null,
236
+ output: null,
237
+ testOutputDir: null,
238
+ debugOutput: null,
239
+ platform: null,
240
+ }
241
+ const rest: string[] = []
161
242
  for (let i = 0; i < args.length; i++) {
162
243
  const a = args[i]
163
- if (a === '--format' || a === '--output') {
164
- console.warn(` warn: ${a} is accepted for maestro cli compatibility and ignored`)
165
- i += 1
244
+ const eq = a.indexOf('=')
245
+ const head = eq > 0 ? a.slice(0, eq) : a
246
+ if (MAESTRO_COMPAT_VALUE_FLAGS.has(head)) {
247
+ const inline = eq > 0 ? a.slice(eq + 1) : null
248
+ const next = args[i + 1]
249
+ const value =
250
+ inline !== null && inline.length > 0
251
+ ? inline
252
+ : next !== undefined && !next.startsWith('-')
253
+ ? next
254
+ : null
255
+ if (inline === null || inline.length === 0) i += 1
256
+ if (value === null || value.length === 0) {
257
+ throw new Error(`${head} expects a value`)
258
+ }
259
+ switch (head) {
260
+ case '--include-tags':
261
+ compat.includeTags = [...(compat.includeTags ?? []), ...splitTagList(value)]
262
+ break
263
+ case '--exclude-tags':
264
+ compat.excludeTags = [...(compat.excludeTags ?? []), ...splitTagList(value)]
265
+ break
266
+ case '--config':
267
+ compat.configPath = value
268
+ break
269
+ case '--format':
270
+ compat.format = value
271
+ break
272
+ case '--output':
273
+ compat.output = value
274
+ break
275
+ case '--test-output-dir':
276
+ compat.testOutputDir = value
277
+ break
278
+ case '--debug-output':
279
+ compat.debugOutput = value
280
+ break
281
+ case '--platform':
282
+ if (value.toLowerCase() !== 'ios') {
283
+ throw new Error(`rnx emulates iOS only; --platform ${value} is not supported`)
284
+ }
285
+ compat.platform = value
286
+ break
287
+ }
166
288
  continue
167
289
  }
168
290
  if (a === '--origin') {
@@ -174,16 +296,26 @@ function stripMaestroCompatFlags(args: string[]): string[] {
174
296
  if (a === '-d') {
175
297
  // maestro's short form for --device. rnx's device flag names a device
176
298
  // model, and the flow runner reads it under the long spelling only.
177
- out.push('--device')
299
+ rest.push('--device')
300
+ continue
301
+ }
302
+ if (a === '--no-ansi') {
303
+ // trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
178
304
  continue
179
305
  }
180
306
  if (a === '--continuous') {
181
307
  console.warn(' warn: --continuous is not implemented in rnx — running once')
182
308
  continue
183
309
  }
184
- out.push(a)
310
+ rest.push(a)
185
311
  }
186
- return out
312
+ if (
313
+ compat.format !== null &&
314
+ !['junit', 'html', 'html-detailed'].includes(compat.format)
315
+ ) {
316
+ throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`)
317
+ }
318
+ return { rest, compat }
187
319
  }
188
320
 
189
321
  function findDefaultMaestroTarget(cwd: string): string | null {
@@ -198,36 +330,88 @@ interface MaestroWorkspaceConfig {
198
330
  flows?: string[]
199
331
  includeTags?: string[]
200
332
  excludeTags?: string[]
333
+ testOutputDir?: string
334
+ }
335
+
336
+ export interface CollectFlowFilesOptions {
337
+ // explicit `--config <path>`; globs still resolve under the target dir.
338
+ configPath?: string
339
+ // CLI tag lists; null/undefined falls back to the workspace config (maestro
340
+ // documents CLI-always-wins, never a union).
341
+ includeTags?: string[] | null
342
+ excludeTags?: string[] | null
343
+ }
344
+
345
+ // read the workspace config for a directory target: an explicit --config
346
+ // path, else config.yaml/config.yml beside the flows, else empty policy.
347
+ export function loadMaestroWorkspaceConfig(
348
+ dir: string,
349
+ configPath?: string,
350
+ ): { path: string | null; config: MaestroWorkspaceConfig } {
351
+ const found =
352
+ configPath ??
353
+ ['config.yaml', 'config.yml']
354
+ .map((name) => path.join(dir, name))
355
+ .find((p) => fs.existsSync(p))
356
+ if (!found) return { path: null, config: {} }
357
+ if (!fs.existsSync(found)) {
358
+ throw new Error(`maestro config not found: ${configPath}`)
359
+ }
360
+ // yaml.parse returns any; shape-check the fields we use below instead
361
+ // of trusting the file
362
+ const parsed: unknown = yaml.parse(fs.readFileSync(found, 'utf8'))
363
+ if (!parsed || typeof parsed !== 'object') return { path: found, config: {} }
364
+ const raw = parsed as Record<string, unknown>
365
+ const config: MaestroWorkspaceConfig = {}
366
+ if (Array.isArray(raw.flows)) {
367
+ config.flows = raw.flows.filter((g): g is string => typeof g === 'string')
368
+ }
369
+ if (Array.isArray(raw.includeTags)) {
370
+ config.includeTags = raw.includeTags.filter((t): t is string => typeof t === 'string')
371
+ }
372
+ if (Array.isArray(raw.excludeTags)) {
373
+ config.excludeTags = raw.excludeTags.filter((t): t is string => typeof t === 'string')
374
+ }
375
+ if (typeof raw.testOutputDir === 'string' && raw.testOutputDir.length > 0) {
376
+ config.testOutputDir = raw.testOutputDir
377
+ }
378
+ return { path: found, config }
201
379
  }
202
380
 
203
381
  // workspace planning for directory targets — mirrors maestro's
204
382
  // WorkspaceExecutionPlanner: discover config.yaml/config.yml in the
205
383
  // directory, walk it recursively for *.yaml/*.yml flow files (config files
206
384
  // excluded), keep the ones matching the config's `flows:` globs (default
207
- // `*` = top level only), then filter by includeTags/excludeTags against
208
- // each flow's frontmatter `tags:`.
209
- export function collectFlowFiles(target: string): string[] {
385
+ // `*` = top level only; `!`-prefixed globs subtract from the positive
386
+ // matches), then filter by includeTags/excludeTags against each flow's
387
+ // frontmatter `tags:` (OR within a flag, include-then-exclude between them).
388
+ //
389
+ // an explicit file target always runs: maestro runs a named flow even when
390
+ // its tags would exclude it from a directory run.
391
+ export function collectFlowFiles(
392
+ target: string,
393
+ opts: CollectFlowFilesOptions = {},
394
+ ): string[] {
210
395
  const stat = fs.statSync(target)
211
396
  if (stat.isFile()) return [target]
212
397
  if (!stat.isDirectory()) {
213
398
  throw new Error(`not a file or directory: ${target}`)
214
399
  }
215
400
 
216
- let config: MaestroWorkspaceConfig = {}
217
- const configPath = ['config.yaml', 'config.yml']
218
- .map((name) => path.join(target, name))
219
- .find((p) => fs.existsSync(p))
220
- if (configPath) {
221
- // yaml.parse returns any; shape-check the fields we use below instead
222
- // of trusting the file
223
- const parsed = yaml.parse(fs.readFileSync(configPath, 'utf8'))
224
- if (parsed && typeof parsed === 'object') {
225
- config = parsed
226
- }
227
- }
401
+ const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath)
228
402
 
229
403
  const globs =
230
404
  Array.isArray(config.flows) && config.flows.length > 0 ? config.flows : ['*']
405
+ const positive = globs.filter((glob) => !glob.startsWith('!'))
406
+ const negative = globs
407
+ .filter((glob) => glob.startsWith('!'))
408
+ .map((glob) => glob.slice(1))
409
+ .filter((glob) => glob.length > 0)
410
+ if (positive.length === 0) {
411
+ throw new Error(
412
+ `flows: needs at least one positive pattern in ${configPath ?? target} (got only exclusions)`,
413
+ )
414
+ }
231
415
  const isFlowFile = (rel: string) => {
232
416
  const base = path.basename(rel)
233
417
  if (base.startsWith('.')) return false
@@ -236,17 +420,22 @@ export function collectFlowFiles(target: string): string[] {
236
420
  return true
237
421
  }
238
422
  const matched = new Set<string>()
239
- for (const glob of globs) {
423
+ for (const glob of positive) {
240
424
  for (const rel of fs.globSync(glob, { cwd: target })) {
241
425
  if (!isFlowFile(rel)) continue
242
426
  const abs = path.join(target, rel)
243
427
  if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs)
244
428
  }
245
429
  }
430
+ for (const glob of negative) {
431
+ for (const rel of fs.globSync(glob, { cwd: target })) {
432
+ matched.delete(path.join(target, rel))
433
+ }
434
+ }
246
435
  let files = [...matched].sort()
247
436
 
248
- const includeTags = Array.isArray(config.includeTags) ? config.includeTags : []
249
- const excludeTags = Array.isArray(config.excludeTags) ? config.excludeTags : []
437
+ const includeTags = opts.includeTags ?? config.includeTags ?? []
438
+ const excludeTags = opts.excludeTags ?? config.excludeTags ?? []
250
439
  if (includeTags.length > 0 || excludeTags.length > 0) {
251
440
  files = files.filter((file) => {
252
441
  const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8'))
@@ -261,6 +450,23 @@ export function collectFlowFiles(target: string): string[] {
261
450
  return files
262
451
  }
263
452
 
453
+ function flowFileStem(file: string): string {
454
+ return path.basename(file).replace(/\.(ya?ml)$/i, '')
455
+ }
456
+
457
+ // a per-flow artifact dir under the test-output root. never reuses a dir,
458
+ // so a re-run cannot overwrite the previous run's evidence.
459
+ function uniqueFlowDir(root: string, file: string): string {
460
+ const slug = flowFileStem(file).replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'
461
+ let dir = path.join(root, slug)
462
+ let n = 2
463
+ while (fs.existsSync(dir)) {
464
+ dir = path.join(root, `${slug}-${n}`)
465
+ n += 1
466
+ }
467
+ return dir
468
+ }
469
+
264
470
  async function runInit(cwd: string): Promise<number> {
265
471
  const dir = path.join(cwd, '.maestro')
266
472
  fs.mkdirSync(dir, { recursive: true })
@@ -345,11 +551,27 @@ export async function runMaestro(
345
551
  return runMaestroAuthoring(remaining)
346
552
  }
347
553
  // `--origin` names the Contrast instance that hosts the run row. this command
348
- // is its only reader, so read it before stripMaestroCompatFlags consumes it.
554
+ // is its only reader, so read it before the compat extractor consumes it.
349
555
  const originFlagIndex = remaining.indexOf('--origin')
350
556
  const originFlagValue =
351
557
  originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined
352
- remaining = stripMaestroCompatFlags(remaining)
558
+ let compat: MaestroCompatOptions
559
+ try {
560
+ const extracted = extractMaestroCompatFlags(remaining)
561
+ remaining = extracted.rest
562
+ compat = extracted.compat
563
+ } catch (err) {
564
+ console.error(` error: ${(err as Error).message}`)
565
+ return 1
566
+ }
567
+ if (compat.format === 'html' || compat.format === 'html-detailed') {
568
+ console.warn(
569
+ ` warn: --format ${compat.format} reports are not implemented in rnx — running without writing a report`,
570
+ )
571
+ }
572
+ if (compat.output && compat.format !== 'junit') {
573
+ console.warn(' warn: --output is ignored without --format junit')
574
+ }
353
575
 
354
576
  // resolve the target flow file or directory.
355
577
  let target: string | null = null
@@ -368,6 +590,11 @@ export async function runMaestro(
368
590
  if (positionalIndex >= 0) {
369
591
  target = path.resolve(cwd, rest[positionalIndex])
370
592
  forwardArgs = rest.filter((_, i) => i !== positionalIndex)
593
+ } else if (compat.configPath) {
594
+ // no positional with an explicit workspace config: run the directory
595
+ // the config lives in, like `rnx test --config` does.
596
+ target = path.resolve(cwd, path.dirname(compat.configPath))
597
+ forwardArgs = rest
371
598
  } else {
372
599
  target = findDefaultMaestroTarget(cwd)
373
600
  forwardArgs = rest
@@ -411,18 +638,42 @@ export async function runMaestro(
411
638
  }
412
639
 
413
640
  let files: string[]
641
+ let workspaceConfig: MaestroWorkspaceConfig = {}
642
+ let workspaceConfigPath: string | null = null
414
643
  try {
415
- files = collectFlowFiles(target)
644
+ if (fs.statSync(target).isDirectory()) {
645
+ const loaded = loadMaestroWorkspaceConfig(target, compat.configPath ?? undefined)
646
+ workspaceConfig = loaded.config
647
+ workspaceConfigPath = loaded.path
648
+ }
649
+ files = collectFlowFiles(target, {
650
+ configPath: compat.configPath ?? undefined,
651
+ includeTags: compat.includeTags,
652
+ excludeTags: compat.excludeTags,
653
+ })
416
654
  } catch (err) {
417
655
  console.error(` error: ${(err as Error).message}`)
418
656
  return 1
419
657
  }
420
658
 
421
659
  if (files.length === 0) {
660
+ if (compat.includeTags !== null || compat.excludeTags !== null) {
661
+ console.error(` error: no flows in ${target} match the requested tags`)
662
+ return 1
663
+ }
422
664
  console.error(` error: no *.yaml or *.yml files found in ${target}`)
423
665
  return 1
424
666
  }
425
667
 
668
+ // maestro's priority rule: CLI flag, then config.yaml, then the default
669
+ // (which for rnx is no test-output routing at all). a config-relative dir
670
+ // resolves against the config file, a CLI dir against the invocation cwd.
671
+ const testOutputRoot = compat.testOutputDir
672
+ ? path.resolve(cwd, compat.testOutputDir)
673
+ : workspaceConfig.testOutputDir && workspaceConfigPath
674
+ ? path.resolve(path.dirname(workspaceConfigPath), workspaceConfig.testOutputDir)
675
+ : null
676
+
426
677
  console.log(`
427
678
  rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
428
679
  root: ${path.relative(cwd, target) || '.'}
@@ -445,11 +696,36 @@ export async function runMaestro(
445
696
  flagValue('--preview-origin') ?? originFlagValue,
446
697
  ))
447
698
 
699
+ const sessionStartedAt = new Date().toISOString()
448
700
  let worstExit = 0
449
- const results: Array<{ file: string; exit: number }> = []
701
+ const results: Array<{
702
+ file: string
703
+ exit: number
704
+ durationMs: number
705
+ steps: ReturnType<typeof getLastFlowTraceSteps>
706
+ frontmatter: FlowFrontmatter
707
+ }> = []
450
708
  for (const file of files) {
451
709
  console.log(`\n ▶ ${path.relative(cwd, file)}`)
452
710
  const playbackArgs = [file, ...forwardArgs]
711
+ let flowTestDir: string | null = null
712
+ if (testOutputRoot) {
713
+ flowTestDir = uniqueFlowDir(testOutputRoot, file)
714
+ try {
715
+ fs.mkdirSync(path.join(flowTestDir, 'screenshots'), { recursive: true })
716
+ fs.mkdirSync(path.join(flowTestDir, 'logs'), { recursive: true })
717
+ } catch (err) {
718
+ console.error(
719
+ ` error: could not create test output dir: ${(err as Error).message}`,
720
+ )
721
+ results.push({ file, exit: 1, durationMs: 0, steps: [], frontmatter: {} })
722
+ if (worstExit === 0) worstExit = 1
723
+ continue
724
+ }
725
+ // an explicit --screenshots wins over this routing: flow.ts reads the
726
+ // FIRST occurrence, and the user's flag sits earlier in playbackArgs.
727
+ playbackArgs.push('--screenshots', path.join(flowTestDir, 'screenshots'))
728
+ }
453
729
  // `--port` is the BRIDGE port everywhere else in the CLI, so forward it as
454
730
  // one. sending it on as `--url` made it an app target instead, which left
455
731
  // the playback bridge on whichever world resolveBridgeWorld picks by
@@ -476,9 +752,42 @@ export async function runMaestro(
476
752
  exit = 1
477
753
  }
478
754
  }
479
- results.push({ file, exit })
755
+ const traceSteps = getLastFlowTraceSteps()
756
+ const durationMs = Date.now() - startedAt
757
+ let frontmatter: FlowFrontmatter = {}
758
+ try {
759
+ frontmatter = parseFlowFile(fs.readFileSync(file, 'utf8')).frontmatter
760
+ } catch {
761
+ frontmatter = {}
762
+ }
763
+ const result = { file, exit, durationMs, steps: traceSteps, frontmatter }
764
+ results.push(result)
480
765
  if (exit !== 0 && worstExit === 0) worstExit = exit
481
766
 
767
+ if (flowTestDir) {
768
+ try {
769
+ fs.writeFileSync(
770
+ path.join(flowTestDir, 'commands.json'),
771
+ buildFlowCommandsJson(traceSteps),
772
+ )
773
+ fs.writeFileSync(
774
+ path.join(flowTestDir, 'logs', 'maestro.log'),
775
+ `${buildFlowLogLines({
776
+ file: path.relative(cwd, file) || file,
777
+ passed: exit === 0,
778
+ durationMs,
779
+ steps: traceSteps,
780
+ }).join('\n')}\n`,
781
+ )
782
+ } catch (err) {
783
+ console.error(
784
+ ` error: could not write test artifacts: ${(err as Error).message}`,
785
+ )
786
+ if (exit === 0) exit = 1
787
+ if (worstExit === 0) worstExit = 1
788
+ }
789
+ }
790
+
482
791
  const upload = getLastFlowPreviewUploadResult()
483
792
  if (upload?.shareId || upload?.previewUrl) {
484
793
  try {
@@ -497,7 +806,6 @@ export async function runMaestro(
497
806
  }
498
807
 
499
808
  if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
500
- const traceSteps = getLastFlowTraceSteps()
501
809
  const failedStep = traceSteps.find((step) => step.status === 'failure')
502
810
  const run = await registerRun({
503
811
  origin: await registerOrigin(),
@@ -530,6 +838,9 @@ export async function runMaestro(
530
838
  if (worstExit === 0) worstExit = 1
531
839
  }
532
840
  }
841
+ // artifact, upload, and registration failures above flip the local exit
842
+ // after the result was pushed; the report below must see the final one.
843
+ result.exit = exit
533
844
  }
534
845
 
535
846
  if (files.length > 1) {
@@ -542,5 +853,105 @@ export async function runMaestro(
542
853
  console.log(` ${passed}/${results.length} passed`)
543
854
  }
544
855
 
856
+ // one device per invocation, like maestro: the explicit override, else the
857
+ // sim default. reports print it; playback already resolved it per flow.
858
+ const deviceLabel = flagValue('--device')?.trim() || 'iPhone 17 Pro'
859
+ if (compat.format === 'junit') {
860
+ const junitPath = path.resolve(cwd, compat.output ?? 'report.xml')
861
+ try {
862
+ fs.mkdirSync(path.dirname(junitPath), { recursive: true })
863
+ fs.writeFileSync(
864
+ junitPath,
865
+ buildJUnitXml({
866
+ device: deviceLabel,
867
+ cases: results.map((r) => junitCaseForResult(cwd, r)),
868
+ }),
869
+ )
870
+ console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`)
871
+ } catch (err) {
872
+ console.error(` error: could not write junit report: ${(err as Error).message}`)
873
+ if (worstExit === 0) worstExit = 1
874
+ }
875
+ }
876
+ if (compat.debugOutput) {
877
+ const logPath = path.resolve(cwd, compat.debugOutput, 'maestro.log')
878
+ try {
879
+ fs.mkdirSync(path.dirname(logPath), { recursive: true })
880
+ const sessionFlows: SessionLogFlow[] = results.map((r) => ({
881
+ file: path.relative(cwd, r.file) || r.file,
882
+ passed: r.exit === 0,
883
+ durationMs: r.durationMs,
884
+ steps: r.steps,
885
+ }))
886
+ fs.writeFileSync(
887
+ logPath,
888
+ buildSessionLog({
889
+ startedAtIso: sessionStartedAt,
890
+ root: path.relative(cwd, target) || '.',
891
+ device: deviceLabel,
892
+ flows: sessionFlows,
893
+ }),
894
+ )
895
+ console.log(` debug log: ${path.relative(cwd, logPath) || logPath}`)
896
+ } catch (err) {
897
+ console.error(` error: could not write debug log: ${(err as Error).message}`)
898
+ if (worstExit === 0) worstExit = 1
899
+ }
900
+ }
901
+
545
902
  return worstExit
546
903
  }
904
+
905
+ // a junit testcase from one flow result. yaml frontmatter is untrusted, so
906
+ // every field is shape-checked back to the documented scalar shapes.
907
+ function junitCaseForResult(
908
+ cwd: string,
909
+ result: {
910
+ file: string
911
+ exit: number
912
+ durationMs: number
913
+ steps: ReturnType<typeof getLastFlowTraceSteps>
914
+ frontmatter: FlowFrontmatter
915
+ },
916
+ ): JUnitCaseInput {
917
+ const raw = result.frontmatter
918
+ const name =
919
+ typeof raw.name === 'string' && raw.name.trim().length > 0
920
+ ? raw.name.trim()
921
+ : flowFileStem(result.file)
922
+ const custom: Record<string, string> = {}
923
+ let junitId: string | null = null
924
+ let junitClassname: string | null = null
925
+ const props: unknown = raw.properties
926
+ if (props && typeof props === 'object') {
927
+ for (const [key, value] of Object.entries(props)) {
928
+ if (
929
+ typeof value !== 'string' &&
930
+ typeof value !== 'number' &&
931
+ typeof value !== 'boolean'
932
+ ) {
933
+ continue
934
+ }
935
+ const text = String(value)
936
+ if (key === 'junitId') junitId = text
937
+ else if (key === 'junitClassname') junitClassname = text
938
+ else custom[key] = text
939
+ }
940
+ }
941
+ const tags = Array.isArray(raw.tags)
942
+ ? raw.tags.filter((tag): tag is string => typeof tag === 'string')
943
+ : []
944
+ return {
945
+ file: path.relative(cwd, result.file) || result.file,
946
+ name,
947
+ id: junitId ?? name,
948
+ classname: junitClassname ?? name,
949
+ properties: custom,
950
+ tags,
951
+ timeSeconds: result.durationMs / 1000,
952
+ failure:
953
+ result.exit === 0
954
+ ? null
955
+ : failureFromTraceSteps(result.steps, 'flow playback failed'),
956
+ }
957
+ }