peachsim 0.1.527 → 0.1.571

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 (169) hide show
  1. package/README.md +28 -28
  2. package/cli/app-fonts.ts +1 -1
  3. package/cli/app-splash.ts +1 -1
  4. package/cli/bin.ts +1 -1
  5. package/cli/bridge-diagnostics.ts +1 -1
  6. package/cli/bridge-flow-runner.ts +72 -12
  7. package/cli/cloud-client.ts +273 -6
  8. package/cli/cloud-dispatch.ts +23 -2
  9. package/cli/cloud-session.ts +60 -4
  10. package/cli/cloud-test-result-files.ts +97 -0
  11. package/cli/cloud-test-tunnel.ts +354 -0
  12. package/cli/commands/box/bundle-plane.ts +2 -2
  13. package/cli/commands/box/checkout-plane.ts +1 -1
  14. package/cli/commands/box/rnx-command.ts +2 -2
  15. package/cli/commands/box/search-command.ts +2 -2
  16. package/cli/commands/box.ts +8 -8
  17. package/cli/commands/compat.ts +1 -1
  18. package/cli/commands/control.ts +22 -3
  19. package/cli/commands/detox.ts +1 -1
  20. package/cli/commands/device.ts +1 -1
  21. package/cli/commands/diagnose.ts +1 -1
  22. package/cli/commands/film.ts +1 -1
  23. package/cli/commands/flow.ts +60 -3
  24. package/cli/commands/inspect/actions.ts +1 -1
  25. package/cli/commands/inspect/core.ts +6 -7
  26. package/cli/commands/inspect/find.ts +2 -1
  27. package/cli/commands/inspect/resolve-target.ts +5 -3
  28. package/cli/commands/inspect/shared.ts +1 -1
  29. package/cli/commands/inspect/wait-event.ts +1 -1
  30. package/cli/commands/inspect/wait-selector.ts +15 -4
  31. package/cli/commands/inspect.ts +19 -14
  32. package/cli/commands/maestro.ts +582 -69
  33. package/cli/commands/permissions.ts +1 -1
  34. package/cli/commands/platform.ts +205 -8
  35. package/cli/commands/preview.ts +2 -2
  36. package/cli/commands/record.ts +2 -2
  37. package/cli/commands/report-issue.ts +2 -2
  38. package/cli/commands/screenshot-layers.ts +1 -1
  39. package/cli/commands/screenshot.ts +1 -1
  40. package/cli/commands/setup.ts +2 -2
  41. package/cli/commands/test.ts +600 -117
  42. package/cli/commands/three-mode.ts +1 -1
  43. package/cli/commands/upload.ts +5 -5
  44. package/cli/commands/what-happened.ts +1 -1
  45. package/cli/desktop-companion.ts +3 -3
  46. package/cli/drivers/playwright-sim-host.ts +2 -2
  47. package/cli/flow-file.ts +7 -0
  48. package/cli/flow-live-status.ts +65 -8
  49. package/cli/help-core.ts +3 -3
  50. package/cli/hidden-runtime-alias.ts +1 -1
  51. package/cli/maestro-report.ts +180 -0
  52. package/cli/main.ts +1 -1
  53. package/cli/outbound-endpoints.ts +187 -136
  54. package/cli/parse-args.ts +1 -1
  55. package/cli/registry.ts +1 -1
  56. package/cli/resolve-assets.ts +1 -1
  57. package/cli/run-registry.ts +10 -1
  58. package/cli/send-to-box.ts +4 -4
  59. package/cli/ws-bridge.ts +3 -5
  60. package/detox/expectations.ts +1 -1
  61. package/detox/gestures.ts +2 -2
  62. package/detox/index.ts +71 -29
  63. package/detox/jest-preset.cjs +5 -5
  64. package/detox/jest-setup-after-env.cjs +1 -1
  65. package/dist-lib/agent-daemon-client.cjs +2 -2
  66. package/dist-lib/agent-events.cjs +1 -1
  67. package/dist-lib/agent-identity.cjs +1 -1
  68. package/dist-lib/agent-sessions.cjs +2 -2
  69. package/dist-lib/attached-projects.cjs +2 -2
  70. package/dist-lib/auth/shared-session.cjs +1 -1
  71. package/dist-lib/backend-origin.cjs +1 -1
  72. package/dist-lib/beta.cjs +1 -1
  73. package/dist-lib/beta.mjs +1 -1
  74. package/dist-lib/bridge-constants.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.cjs +1 -1
  76. package/dist-lib/bridge-contract-input.mjs +1 -1
  77. package/dist-lib/bridge-contract.cjs +1 -1
  78. package/dist-lib/bridge-contract.mjs +1 -1
  79. package/dist-lib/capture-contract.cjs +94 -3
  80. package/dist-lib/capture-contract.mjs +93 -3
  81. package/dist-lib/cli-constants.cjs +1 -1
  82. package/dist-lib/cloud-contract.cjs +1 -1
  83. package/dist-lib/cloud-contract.mjs +1 -1
  84. package/dist-lib/cloud-test-args.cjs +116 -0
  85. package/dist-lib/cloud-test-args.mjs +86 -0
  86. package/dist-lib/cloud.cjs +2 -2
  87. package/dist-lib/cloud.mjs +2 -2
  88. package/dist-lib/config.cjs +1 -1
  89. package/dist-lib/detox/index.cjs +106 -25
  90. package/dist-lib/detox/jest-preset.cjs +5 -5
  91. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  92. package/dist-lib/home-paths.cjs +1 -1
  93. package/dist-lib/host/bridge-host.cjs +150 -65
  94. package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
  95. package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
  96. package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
  97. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  98. package/dist-lib/host/websocket-proxy.cjs +1 -1
  99. package/dist-lib/index.cjs +224 -41
  100. package/dist-lib/jump-to-source-babel.cjs +1 -1
  101. package/dist-lib/jump-to-source-native.cjs +1 -1
  102. package/dist-lib/menu.cjs +2 -2
  103. package/dist-lib/menu.mjs +2 -2
  104. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  105. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  106. package/dist-lib/metro-production-bundle.cjs +3 -3
  107. package/dist-lib/metro-production-bundle.mjs +3 -3
  108. package/dist-lib/metro.cjs +8 -8
  109. package/dist-lib/profiles.cjs +1 -1
  110. package/dist-lib/public-brand.cjs +2 -2
  111. package/dist-lib/react-native-host-modules.cjs +1 -1
  112. package/dist-lib/react-native-host-modules.mjs +1 -1
  113. package/dist-lib/render-mode.cjs +1 -1
  114. package/dist-lib/scripts/dev-server-scanner.cjs +31 -15
  115. package/dist-lib/sdk.cjs +3 -2
  116. package/dist-lib/sdk.mjs +3 -2
  117. package/dist-lib/skills.cjs +478 -266
  118. package/dist-lib/swift-compile.cjs +372 -0
  119. package/dist-lib/swift-compile.mjs +339 -0
  120. package/dist-lib/swift-package.cjs +78 -0
  121. package/dist-lib/swift-package.mjs +52 -0
  122. package/dist-lib/swift-submit.cjs +1 -1
  123. package/dist-lib/swift-submit.mjs +1 -1
  124. package/dist-lib/swift.cjs +583 -16
  125. package/dist-lib/vite.cjs +483 -150
  126. package/package.json +30 -9
  127. package/scripts/dev-server-scanner.ts +41 -14
  128. package/scripts/optional-demo-registry.ts +3 -3
  129. package/skills/contrast/SKILL.md +9 -9
  130. package/skills/rnx-debug/SKILL.md +14 -4
  131. package/skills/rnx-perf/SKILL.md +5 -5
  132. package/skills/rnx-setup/SKILL.md +11 -11
  133. package/skills/rnx-test/SKILL.md +14 -14
  134. package/skills/rnx-visual/SKILL.md +6 -6
  135. package/src/agent-sessions.ts +3 -3
  136. package/src/attached-projects.ts +1 -1
  137. package/src/bridge-contract-input.ts +1 -1
  138. package/src/bridge-contract.ts +23 -5
  139. package/src/capture-contract.ts +145 -3
  140. package/src/cloud-test-args.ts +100 -0
  141. package/src/config.ts +2 -2
  142. package/src/connect.ts +2 -2
  143. package/src/home-paths.ts +3 -3
  144. package/src/host/bridge-host.ts +2 -2
  145. package/src/host/fetch-proxy-handler.ts +4 -3
  146. package/src/host/fetch-proxy-overrides.ts +41 -0
  147. package/src/host/plane-host.ts +2 -2
  148. package/src/index.ts +3 -0
  149. package/src/metro-plugin.ts +4 -4
  150. package/src/metro-production-bundle.ts +2 -2
  151. package/src/native-seam-manifest.ts +6 -6
  152. package/src/public-brand.ts +1 -1
  153. package/src/rolldown-plugin-swift.ts +16 -12
  154. package/src/runtime-assets.ts +8 -6
  155. package/src/screenshots/frame-compose.ts +24 -56
  156. package/src/screenshots/org-deck.ts +1 -1
  157. package/src/screenshots/registry.ts +4 -4
  158. package/src/screenshots/schema.ts +1 -1
  159. package/src/sdk.ts +2 -2
  160. package/src/settings-bundle.ts +1 -1
  161. package/src/sim-client.ts +2 -4
  162. package/src/skills/builtin/compat-check.ts +1 -1
  163. package/src/swift-compile.ts +359 -0
  164. package/src/swift-package.ts +69 -0
  165. package/src/swift-submit.ts +3 -2
  166. package/src/vite-plugin-one.ts +1 -1
  167. package/src/vite-plugin-swift.ts +114 -113
  168. package/src/vite-plugin.ts +30 -17
  169. package/src/worklets-babel.ts +2 -2
@@ -10,7 +10,17 @@ 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 { cloudTestResultFileNames } from '../cloud-test-result-files'
14
+ import { parseFlowFile, type FlowFrontmatter } from '../flow-file'
15
+ import {
16
+ buildFlowCommandsJson,
17
+ buildFlowLogLines,
18
+ buildJUnitXml,
19
+ buildSessionLog,
20
+ failureFromTraceSteps,
21
+ type JUnitCaseInput,
22
+ type SessionLogFlow,
23
+ } from '../maestro-report'
14
24
  import { registerRun, shouldRegisterRun, stepSummaryFromTrace } from '../run-registry'
15
25
  import { RnxExit } from '../run-rnx'
16
26
  import {
@@ -38,6 +48,7 @@ rnx maestro — author and run Maestro YAML flows against rnx
38
48
  usage:
39
49
  rnx maestro # discover .maestro/ or maestro/ in cwd, run all
40
50
  rnx maestro test <flow-or-dir> # mirrors "maestro test"
51
+ rnx maestro load <flow> # load flow into runner without running (--dry-run)
41
52
  rnx maestro test .maestro/ # every *.yaml / *.yml in a directory
42
53
  rnx maestro generate "<goal>" # generate, run, and upload a Maestro flow
43
54
  rnx maestro start|keep|end # author a flow from live CLI actions
@@ -46,9 +57,31 @@ usage:
46
57
  rnx maestro --list-compat # print supported/unsupported verbs
47
58
 
48
59
  options:
60
+ --dry-run, --no-run load flow into runner idle without auto-running
49
61
  --env KEY=VALUE set env vars for \${KEY} interpolation (repeatable)
50
62
  --continuous accepted for maestro cli compat; rnx runs the flow once
51
- --format <fmt> accepted for maestro cli compat (not used)
63
+ --include-tags <a,b> run only flows carrying one of these tags (comma-
64
+ separated; CLI wins over config.yaml includeTags)
65
+ --exclude-tags <a,b> skip flows carrying any of these tags
66
+ --config <path> read the workspace config (flows/tags) from this file
67
+ instead of <dir>/config.yaml
68
+ --format junit write a junit xml report (default ./report.xml)
69
+ JUNIT, NOOP also accepted (case-insensitive)
70
+ --output <file> junit report path (needs --format junit)
71
+ --test-output-dir <dir>
72
+ per-flow screenshots, commands.json, and logs/maestro.log
73
+ (config key testOutputDir:; CLI wins over config)
74
+ --debug-output <dir> write a session maestro.log there
75
+ --flatten-debug-output accepted; rnx output is already flat
76
+ --test-suite-name <n> junit testsuite name (default "Test Suite")
77
+ --platform ios accepted; any other platform is refused (rnx is iOS-only)
78
+ (-p stays the rnx bridge-port short)
79
+ --no-ansi accepted; rnx output is already plain text
80
+ --udid <id> warned; rnx manages its own sim (pin one with --sim)
81
+ -s, --shards <n> warned; rnx runs the full selection on one sim
82
+ --shard-all, --shard-split <spec>
83
+ warned; rnx runs the full selection on one sim
84
+ -c warned; --continuous runs once under rnx
52
85
  --device <model> override the flow frontmatter device for this run
53
86
  --new force a fresh browser window for this run
54
87
  --headed force a fresh, VISIBLE window (implies --new,
@@ -93,8 +126,19 @@ supported verbs:
93
126
  (env vars as globals, output.*, maestro.*, \${EXPR || 'default'}).
94
127
 
95
128
  workspace mode (directory runs):
96
- config.yaml / config.yml discovery, flows: inclusion globs,
97
- includeTags / excludeTags against flow frontmatter tags.
129
+ config.yaml / config.yml discovery, flows: inclusion globs (with !
130
+ negation; at least one positive pattern), includeTags / excludeTags
131
+ against flow frontmatter tags.
132
+
133
+ cli flags (maestro test parity):
134
+ --include-tags / --exclude-tags (comma-separated, OR within a flag;
135
+ CLI wins over config.yaml), --config <path>, --format junit with
136
+ --output (default report.xml; flow name:/properties: shape the
137
+ testcase), --test-output-dir (config key testOutputDir:, CLI wins),
138
+ --debug-output, --platform ios (others refused), --no-ansi (rnx
139
+ output is already plain), -d for --device, -e for --env.
140
+ --continuous warns and runs once; --format html warns and runs
141
+ without a report.
98
142
 
99
143
  launch behavior:
100
144
  launchApp.arguments: a JSON object available before guest app entry through
@@ -128,11 +172,13 @@ function looksLikeFlag(s: string): boolean {
128
172
  // extract --env KEY=VALUE pairs from argv and apply to process.env for the
129
173
  // duration of the run. returns argv with the consumed flags removed so the
130
174
  // rest can be forwarded to runFlowPlayback verbatim.
131
- function applyEnvFlags(args: string[]): string[] {
175
+ export function applyEnvFlags(args: string[]): string[] {
132
176
  const out: string[] = []
133
177
  for (let i = 0; i < args.length; i++) {
134
- if (args[i] === '--env' || args[i] === '-e') {
135
- const kv = args[i + 1]
178
+ const arg = args[i] as string
179
+ const inline = arg.startsWith('--env=') ? arg.slice('--env='.length) : null
180
+ if (arg === '--env' || arg === '-e' || inline !== null) {
181
+ const kv = inline ?? args[i + 1]
136
182
  if (!kv || !kv.includes('=')) {
137
183
  throw new Error(`--env expects KEY=VALUE (got ${JSON.stringify(kv ?? '')})`)
138
184
  }
@@ -140,27 +186,150 @@ function applyEnvFlags(args: string[]): string[] {
140
186
  const key = kv.slice(0, eq)
141
187
  const value = kv.slice(eq + 1)
142
188
  process.env[key] = value
143
- i += 1
189
+ if (inline === null) i += 1
144
190
  continue
145
191
  }
146
- out.push(args[i])
192
+ out.push(arg)
147
193
  }
148
194
  return out
149
195
  }
150
196
 
151
- // maestro-cli flags with no rnx equivalent, accepted so an existing script can
152
- // swap `maestro` for `rnx maestro` unedited. each one says out loud that it is
153
- // being ignored: an option that is quietly dropped is how a run reports success
154
- // while doing something other than what was asked. `--origin` is consumed here
155
- // because only this command reads it; everything else this function leaves
156
- // alone rides on to the flow runner, which rejects what it does not know.
157
- function stripMaestroCompatFlags(args: string[]): string[] {
158
- const out: string[] = []
197
+ // maestro-cli flags this command consumes itself, accepted so an existing
198
+ // script can swap `maestro test` for `rnx maestro test` unedited. each one
199
+ // either takes effect below or says out loud that it does not: an option
200
+ // that is quietly dropped is how a run reports success while doing something
201
+ // other than what was asked. `--origin` is consumed here because only this
202
+ // command reads it; everything this function leaves alone rides on to the
203
+ // flow runner, which rejects what it does not know.
204
+ //
205
+ // both `--flag value` and `--flag=value` spellings work: maestro's own docs
206
+ // use the `=` form for tags and output dirs, so rejecting it would break the
207
+ // exact command lines users paste from there.
208
+ export interface MaestroCompatOptions {
209
+ // null = flag absent. CLI lists take precedence over config.yaml (maestro
210
+ // documents CLI-always-wins), so absent must stay distinguishable from [].
211
+ includeTags: string[] | null
212
+ excludeTags: string[] | null
213
+ configPath: string | null
214
+ format: string | null
215
+ output: string | null
216
+ testOutputDir: string | null
217
+ debugOutput: string | null
218
+ platform: string | null
219
+ testSuiteName: string | null
220
+ }
221
+
222
+ const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
223
+ '--include-tags',
224
+ '--exclude-tags',
225
+ '--config',
226
+ '--format',
227
+ '--output',
228
+ '--test-output-dir',
229
+ '--debug-output',
230
+ '--platform',
231
+ '--udid',
232
+ '--test-suite-name',
233
+ '--shards',
234
+ '--shard-all',
235
+ '--shard-split',
236
+ '--api-key',
237
+ '--api-url',
238
+ '--screen-size',
239
+ ])
240
+
241
+ function splitTagList(value: string): string[] {
242
+ return value
243
+ .split(',')
244
+ .map((tag) => tag.trim())
245
+ .filter((tag) => tag.length > 0)
246
+ }
247
+
248
+ export function extractMaestroCompatFlags(args: string[]): {
249
+ rest: string[]
250
+ compat: MaestroCompatOptions
251
+ } {
252
+ const compat: MaestroCompatOptions = {
253
+ includeTags: null,
254
+ excludeTags: null,
255
+ configPath: null,
256
+ format: null,
257
+ output: null,
258
+ testOutputDir: null,
259
+ debugOutput: null,
260
+ platform: null,
261
+ testSuiteName: null,
262
+ }
263
+ const rest: string[] = []
159
264
  for (let i = 0; i < args.length; i++) {
160
265
  const a = args[i]
161
- if (a === '--format' || a === '--output') {
162
- console.warn(` warn: ${a} is accepted for maestro cli compatibility and ignored`)
163
- i += 1
266
+ const eq = a.indexOf('=')
267
+ const head = eq > 0 ? a.slice(0, eq) : a
268
+ if (MAESTRO_COMPAT_VALUE_FLAGS.has(head)) {
269
+ const inline = eq > 0 ? a.slice(eq + 1) : null
270
+ const next = args[i + 1]
271
+ const value =
272
+ inline !== null && inline.length > 0
273
+ ? inline
274
+ : next !== undefined && !next.startsWith('-')
275
+ ? next
276
+ : null
277
+ if (inline === null || inline.length === 0) i += 1
278
+ if (value === null || value.length === 0) {
279
+ throw new Error(`${head} expects a value`)
280
+ }
281
+ switch (head) {
282
+ case '--include-tags':
283
+ compat.includeTags = [...(compat.includeTags ?? []), ...splitTagList(value)]
284
+ break
285
+ case '--exclude-tags':
286
+ compat.excludeTags = [...(compat.excludeTags ?? []), ...splitTagList(value)]
287
+ break
288
+ case '--config':
289
+ compat.configPath = value
290
+ break
291
+ case '--format':
292
+ // maestro spells it JUNIT; normalize so either case works.
293
+ compat.format = value.toLowerCase()
294
+ break
295
+ case '--output':
296
+ compat.output = value
297
+ break
298
+ case '--test-output-dir':
299
+ compat.testOutputDir = value
300
+ break
301
+ case '--debug-output':
302
+ compat.debugOutput = value
303
+ break
304
+ case '--platform':
305
+ if (value.toLowerCase() !== 'ios') {
306
+ throw new Error(`rnx emulates iOS only; --platform ${value} is not supported`)
307
+ }
308
+ compat.platform = value
309
+ break
310
+ case '--udid':
311
+ console.warn(
312
+ ' warn: --udid selects a Maestro simulator; rnx manages its own sim — pin one with --sim instead',
313
+ )
314
+ break
315
+ case '--test-suite-name':
316
+ compat.testSuiteName = value
317
+ break
318
+ case '--shards':
319
+ case '--shard-all':
320
+ case '--shard-split':
321
+ console.warn(
322
+ ` warn: ${head} shards across Maestro devices; rnx runs the full selection on one sim`,
323
+ )
324
+ break
325
+ case '--api-key':
326
+ case '--api-url':
327
+ console.warn(` warn: ${head} is Maestro beta AI analysis; rnx ignores it`)
328
+ break
329
+ case '--screen-size':
330
+ console.warn(' warn: --screen-size is web-only; rnx ignores it')
331
+ break
332
+ }
164
333
  continue
165
334
  }
166
335
  if (a === '--origin') {
@@ -172,16 +341,66 @@ function stripMaestroCompatFlags(args: string[]): string[] {
172
341
  if (a === '-d') {
173
342
  // maestro's short form for --device. rnx's device flag names a device
174
343
  // model, and the flow runner reads it under the long spelling only.
175
- out.push('--device')
344
+ rest.push('--device')
345
+ continue
346
+ }
347
+ if (a === '--no-ansi' || a === '--no-color') {
348
+ // trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
349
+ continue
350
+ }
351
+ if (a === '--ansi' || a === '--color') {
352
+ console.warn(' warn: rnx prints no ANSI escapes; colored output is unavailable')
176
353
  continue
177
354
  }
178
- if (a === '--continuous') {
355
+ if (a === '--continuous' || a === '-c') {
179
356
  console.warn(' warn: --continuous is not implemented in rnx — running once')
180
357
  continue
181
358
  }
182
- out.push(a)
359
+ if (a === '--flatten-debug-output') {
360
+ // rnx writes one <dir>/maestro.log with no per-run subfolders or
361
+ // timestamps, so the maestro layout flag is already satisfied.
362
+ continue
363
+ }
364
+ if (a === '--analyze') {
365
+ console.warn(' warn: --analyze is Maestro beta AI analysis; rnx ignores it')
366
+ continue
367
+ }
368
+ if (a === '-s') {
369
+ const count = args[i + 1]
370
+ if (!count || count.startsWith('-')) throw new Error('-s expects a value')
371
+ i += 1
372
+ console.warn(
373
+ ' warn: -s shards across Maestro devices; rnx runs the full selection on one sim',
374
+ )
375
+ continue
376
+ }
377
+ if (head === '-p') {
378
+ // -p is the rnx bridge-port short, not maestro's --platform short. a
379
+ // non-numeric value is a pasted maestro command line; fail loud with
380
+ // the mapping instead of pinning the bridge to a NaN port.
381
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1]
382
+ if (value !== undefined && !value.startsWith('-') && !/^\d+$/.test(value)) {
383
+ throw new Error(
384
+ `-p is the rnx bridge-port short; use --platform for the Maestro platform (got ${JSON.stringify(value)})`,
385
+ )
386
+ }
387
+ rest.push(a)
388
+ continue
389
+ }
390
+ rest.push(a)
183
391
  }
184
- return out
392
+ if (
393
+ compat.format !== null &&
394
+ !['junit', 'html', 'html-detailed', 'noop'].includes(compat.format)
395
+ ) {
396
+ throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`)
397
+ }
398
+ if (compat.format === 'html' || compat.format === 'html-detailed') {
399
+ console.warn(
400
+ ' warn: --format html is not implemented in rnx — running without a report',
401
+ )
402
+ }
403
+ return { rest, compat }
185
404
  }
186
405
 
187
406
  function findDefaultMaestroTarget(cwd: string): string | null {
@@ -196,36 +415,88 @@ interface MaestroWorkspaceConfig {
196
415
  flows?: string[]
197
416
  includeTags?: string[]
198
417
  excludeTags?: string[]
418
+ testOutputDir?: string
419
+ }
420
+
421
+ export interface CollectFlowFilesOptions {
422
+ // explicit `--config <path>`; globs still resolve under the target dir.
423
+ configPath?: string
424
+ // CLI tag lists; null/undefined falls back to the workspace config (maestro
425
+ // documents CLI-always-wins, never a union).
426
+ includeTags?: string[] | null
427
+ excludeTags?: string[] | null
428
+ }
429
+
430
+ // read the workspace config for a directory target: an explicit --config
431
+ // path, else config.yaml/config.yml beside the flows, else empty policy.
432
+ export function loadMaestroWorkspaceConfig(
433
+ dir: string,
434
+ configPath?: string,
435
+ ): { path: string | null; config: MaestroWorkspaceConfig } {
436
+ const found =
437
+ configPath ??
438
+ ['config.yaml', 'config.yml']
439
+ .map((name) => path.join(dir, name))
440
+ .find((p) => fs.existsSync(p))
441
+ if (!found) return { path: null, config: {} }
442
+ if (!fs.existsSync(found)) {
443
+ throw new Error(`maestro config not found: ${configPath}`)
444
+ }
445
+ // yaml.parse returns any; shape-check the fields we use below instead
446
+ // of trusting the file
447
+ const parsed: unknown = yaml.parse(fs.readFileSync(found, 'utf8'))
448
+ if (!parsed || typeof parsed !== 'object') return { path: found, config: {} }
449
+ const raw = parsed as Record<string, unknown>
450
+ const config: MaestroWorkspaceConfig = {}
451
+ if (Array.isArray(raw.flows)) {
452
+ config.flows = raw.flows.filter((g): g is string => typeof g === 'string')
453
+ }
454
+ if (Array.isArray(raw.includeTags)) {
455
+ config.includeTags = raw.includeTags.filter((t): t is string => typeof t === 'string')
456
+ }
457
+ if (Array.isArray(raw.excludeTags)) {
458
+ config.excludeTags = raw.excludeTags.filter((t): t is string => typeof t === 'string')
459
+ }
460
+ if (typeof raw.testOutputDir === 'string' && raw.testOutputDir.length > 0) {
461
+ config.testOutputDir = raw.testOutputDir
462
+ }
463
+ return { path: found, config }
199
464
  }
200
465
 
201
466
  // workspace planning for directory targets — mirrors maestro's
202
467
  // WorkspaceExecutionPlanner: discover config.yaml/config.yml in the
203
468
  // directory, walk it recursively for *.yaml/*.yml flow files (config files
204
469
  // excluded), keep the ones matching the config's `flows:` globs (default
205
- // `*` = top level only), then filter by includeTags/excludeTags against
206
- // each flow's frontmatter `tags:`.
207
- export function collectFlowFiles(target: string): string[] {
470
+ // `*` = top level only; `!`-prefixed globs subtract from the positive
471
+ // matches), then filter by includeTags/excludeTags against each flow's
472
+ // frontmatter `tags:` (OR within a flag, include-then-exclude between them).
473
+ //
474
+ // an explicit file target always runs: maestro runs a named flow even when
475
+ // its tags would exclude it from a directory run.
476
+ export function collectFlowFiles(
477
+ target: string,
478
+ opts: CollectFlowFilesOptions = {},
479
+ ): string[] {
208
480
  const stat = fs.statSync(target)
209
481
  if (stat.isFile()) return [target]
210
482
  if (!stat.isDirectory()) {
211
483
  throw new Error(`not a file or directory: ${target}`)
212
484
  }
213
485
 
214
- let config: MaestroWorkspaceConfig = {}
215
- const configPath = ['config.yaml', 'config.yml']
216
- .map((name) => path.join(target, name))
217
- .find((p) => fs.existsSync(p))
218
- if (configPath) {
219
- // yaml.parse returns any; shape-check the fields we use below instead
220
- // of trusting the file
221
- const parsed = yaml.parse(fs.readFileSync(configPath, 'utf8'))
222
- if (parsed && typeof parsed === 'object') {
223
- config = parsed
224
- }
225
- }
486
+ const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath)
226
487
 
227
488
  const globs =
228
489
  Array.isArray(config.flows) && config.flows.length > 0 ? config.flows : ['*']
490
+ const positive = globs.filter((glob) => !glob.startsWith('!'))
491
+ const negative = globs
492
+ .filter((glob) => glob.startsWith('!'))
493
+ .map((glob) => glob.slice(1))
494
+ .filter((glob) => glob.length > 0)
495
+ if (positive.length === 0) {
496
+ throw new Error(
497
+ `flows: needs at least one positive pattern in ${configPath ?? target} (got only exclusions)`,
498
+ )
499
+ }
229
500
  const isFlowFile = (rel: string) => {
230
501
  const base = path.basename(rel)
231
502
  if (base.startsWith('.')) return false
@@ -234,17 +505,22 @@ export function collectFlowFiles(target: string): string[] {
234
505
  return true
235
506
  }
236
507
  const matched = new Set<string>()
237
- for (const glob of globs) {
508
+ for (const glob of positive) {
238
509
  for (const rel of fs.globSync(glob, { cwd: target })) {
239
510
  if (!isFlowFile(rel)) continue
240
511
  const abs = path.join(target, rel)
241
512
  if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs)
242
513
  }
243
514
  }
515
+ for (const glob of negative) {
516
+ for (const rel of fs.globSync(glob, { cwd: target })) {
517
+ matched.delete(path.join(target, rel))
518
+ }
519
+ }
244
520
  let files = [...matched].sort()
245
521
 
246
- const includeTags = Array.isArray(config.includeTags) ? config.includeTags : []
247
- const excludeTags = Array.isArray(config.excludeTags) ? config.excludeTags : []
522
+ const includeTags = opts.includeTags ?? config.includeTags ?? []
523
+ const excludeTags = opts.excludeTags ?? config.excludeTags ?? []
248
524
  if (includeTags.length > 0 || excludeTags.length > 0) {
249
525
  files = files.filter((file) => {
250
526
  const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8'))
@@ -259,6 +535,23 @@ export function collectFlowFiles(target: string): string[] {
259
535
  return files
260
536
  }
261
537
 
538
+ function flowFileStem(file: string): string {
539
+ return path.basename(file).replace(/\.(ya?ml)$/i, '')
540
+ }
541
+
542
+ // a per-flow artifact dir under the test-output root. never reuses a dir,
543
+ // so a re-run cannot overwrite the previous run's evidence.
544
+ function uniqueFlowDir(root: string, file: string): string {
545
+ const slug = flowFileStem(file).replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'
546
+ let dir = path.join(root, slug)
547
+ let n = 2
548
+ while (fs.existsSync(dir)) {
549
+ dir = path.join(root, `${slug}-${n}`)
550
+ n += 1
551
+ }
552
+ return dir
553
+ }
554
+
262
555
  async function runInit(cwd: string): Promise<number> {
263
556
  const dir = path.join(cwd, '.maestro')
264
557
  fs.mkdirSync(dir, { recursive: true })
@@ -316,7 +609,15 @@ export async function runMaestro(
316
609
  remaining = hoistLeadingSimFlag(remaining)
317
610
 
318
611
  const cwd = process.cwd()
319
- const sub = remaining[0]
612
+ let sub = remaining[0]
613
+
614
+ if (sub === 'load') {
615
+ remaining[0] = 'test'
616
+ sub = 'test'
617
+ if (!remaining.includes('--dry-run') && !remaining.includes('--no-run')) {
618
+ remaining.push('--dry-run')
619
+ }
620
+ }
320
621
 
321
622
  if (sub === 'init') {
322
623
  return runInit(cwd)
@@ -335,11 +636,27 @@ export async function runMaestro(
335
636
  return runMaestroAuthoring(remaining)
336
637
  }
337
638
  // `--origin` names the Contrast instance that hosts the run row. this command
338
- // is its only reader, so read it before stripMaestroCompatFlags consumes it.
639
+ // is its only reader, so read it before the compat extractor consumes it.
339
640
  const originFlagIndex = remaining.indexOf('--origin')
340
641
  const originFlagValue =
341
642
  originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined
342
- remaining = stripMaestroCompatFlags(remaining)
643
+ let compat: MaestroCompatOptions
644
+ try {
645
+ const extracted = extractMaestroCompatFlags(remaining)
646
+ remaining = extracted.rest
647
+ compat = extracted.compat
648
+ } catch (err) {
649
+ console.error(` error: ${(err as Error).message}`)
650
+ return 1
651
+ }
652
+ if (compat.format === 'html' || compat.format === 'html-detailed') {
653
+ console.warn(
654
+ ` warn: --format ${compat.format} reports are not implemented in rnx — running without writing a report`,
655
+ )
656
+ }
657
+ if (compat.output && compat.format !== 'junit') {
658
+ console.warn(' warn: --output is ignored without --format junit')
659
+ }
343
660
 
344
661
  // resolve the target flow file or directory.
345
662
  let target: string | null = null
@@ -358,6 +675,11 @@ export async function runMaestro(
358
675
  if (positionalIndex >= 0) {
359
676
  target = path.resolve(cwd, rest[positionalIndex])
360
677
  forwardArgs = rest.filter((_, i) => i !== positionalIndex)
678
+ } else if (compat.configPath) {
679
+ // no positional with an explicit workspace config: run the directory
680
+ // the config lives in, like `rnx test --config` does.
681
+ target = path.resolve(cwd, path.dirname(compat.configPath))
682
+ forwardArgs = rest
361
683
  } else {
362
684
  target = findDefaultMaestroTarget(cwd)
363
685
  forwardArgs = rest
@@ -401,18 +723,42 @@ export async function runMaestro(
401
723
  }
402
724
 
403
725
  let files: string[]
726
+ let workspaceConfig: MaestroWorkspaceConfig = {}
727
+ let workspaceConfigPath: string | null = null
404
728
  try {
405
- files = collectFlowFiles(target)
729
+ if (fs.statSync(target).isDirectory()) {
730
+ const loaded = loadMaestroWorkspaceConfig(target, compat.configPath ?? undefined)
731
+ workspaceConfig = loaded.config
732
+ workspaceConfigPath = loaded.path
733
+ }
734
+ files = collectFlowFiles(target, {
735
+ configPath: compat.configPath ?? undefined,
736
+ includeTags: compat.includeTags,
737
+ excludeTags: compat.excludeTags,
738
+ })
406
739
  } catch (err) {
407
740
  console.error(` error: ${(err as Error).message}`)
408
741
  return 1
409
742
  }
410
743
 
411
744
  if (files.length === 0) {
745
+ if (compat.includeTags !== null || compat.excludeTags !== null) {
746
+ console.error(` error: no flows in ${target} match the requested tags`)
747
+ return 1
748
+ }
412
749
  console.error(` error: no *.yaml or *.yml files found in ${target}`)
413
750
  return 1
414
751
  }
415
752
 
753
+ // maestro's priority rule: CLI flag, then config.yaml, then the default
754
+ // (which for rnx is no test-output routing at all). a config-relative dir
755
+ // resolves against the config file, a CLI dir against the invocation cwd.
756
+ const testOutputRoot = compat.testOutputDir
757
+ ? path.resolve(cwd, compat.testOutputDir)
758
+ : workspaceConfig.testOutputDir && workspaceConfigPath
759
+ ? path.resolve(path.dirname(workspaceConfigPath), workspaceConfig.testOutputDir)
760
+ : null
761
+
416
762
  console.log(`
417
763
  rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
418
764
  root: ${path.relative(cwd, target) || '.'}
@@ -435,11 +781,36 @@ export async function runMaestro(
435
781
  flagValue('--preview-origin') ?? originFlagValue,
436
782
  ))
437
783
 
784
+ const sessionStartedAt = new Date().toISOString()
438
785
  let worstExit = 0
439
- const results: Array<{ file: string; exit: number }> = []
440
- for (const file of files) {
786
+ const results: Array<{
787
+ file: string
788
+ exit: number
789
+ durationMs: number
790
+ steps: ReturnType<typeof getLastFlowTraceSteps>
791
+ frontmatter: FlowFrontmatter
792
+ }> = []
793
+ for (const [flowIndex, file] of files.entries()) {
441
794
  console.log(`\n ▶ ${path.relative(cwd, file)}`)
442
795
  const playbackArgs = [file, ...forwardArgs]
796
+ let flowTestDir: string | null = null
797
+ if (testOutputRoot) {
798
+ flowTestDir = uniqueFlowDir(testOutputRoot, file)
799
+ try {
800
+ fs.mkdirSync(path.join(flowTestDir, 'screenshots'), { recursive: true })
801
+ fs.mkdirSync(path.join(flowTestDir, 'logs'), { recursive: true })
802
+ } catch (err) {
803
+ console.error(
804
+ ` error: could not create test output dir: ${(err as Error).message}`,
805
+ )
806
+ results.push({ file, exit: 1, durationMs: 0, steps: [], frontmatter: {} })
807
+ if (worstExit === 0) worstExit = 1
808
+ continue
809
+ }
810
+ // an explicit --screenshots wins over this routing: flow.ts reads the
811
+ // FIRST occurrence, and the user's flag sits earlier in playbackArgs.
812
+ playbackArgs.push('--screenshots', path.join(flowTestDir, 'screenshots'))
813
+ }
443
814
  // `--port` is the BRIDGE port everywhere else in the CLI, so forward it as
444
815
  // one. sending it on as `--url` made it an app target instead, which left
445
816
  // the playback bridge on whichever world resolveBridgeWorld picks by
@@ -466,28 +837,63 @@ export async function runMaestro(
466
837
  exit = 1
467
838
  }
468
839
  }
469
- results.push({ file, exit })
840
+ const traceSteps = getLastFlowTraceSteps()
841
+ const durationMs = Date.now() - startedAt
842
+ let frontmatter: FlowFrontmatter = {}
843
+ try {
844
+ frontmatter = parseFlowFile(fs.readFileSync(file, 'utf8')).frontmatter
845
+ } catch {
846
+ frontmatter = {}
847
+ }
848
+ const result = { file, exit, durationMs, steps: traceSteps, frontmatter }
849
+ results.push(result)
470
850
  if (exit !== 0 && worstExit === 0) worstExit = exit
471
851
 
472
- const upload = getLastFlowPreviewUploadResult()
473
- if (upload?.shareId || upload?.previewUrl) {
852
+ if (flowTestDir) {
474
853
  try {
475
854
  fs.writeFileSync(
476
- path.join(cwd, 'preview-result.json'),
477
- JSON.stringify({
478
- previewId: upload.shareId,
479
- previewUrl: upload.previewUrl,
480
- }),
855
+ path.join(flowTestDir, 'commands.json'),
856
+ buildFlowCommandsJson(traceSteps),
481
857
  )
482
- } catch {}
483
- } else if (args.includes('--preview')) {
858
+ fs.writeFileSync(
859
+ path.join(flowTestDir, 'logs', 'maestro.log'),
860
+ `${buildFlowLogLines({
861
+ file: path.relative(cwd, file) || file,
862
+ passed: exit === 0,
863
+ durationMs,
864
+ steps: traceSteps,
865
+ }).join('\n')}\n`,
866
+ )
867
+ } catch (err) {
868
+ console.error(
869
+ ` error: could not write test artifacts: ${(err as Error).message}`,
870
+ )
871
+ if (exit === 0) exit = 1
872
+ if (worstExit === 0) worstExit = 1
873
+ }
874
+ }
875
+
876
+ const upload = getLastFlowPreviewUploadResult()
877
+ // the receipt files are the per-flow manifest: always written, with
878
+ // null ids when the upload failed, so the flow never disappears from
879
+ // the collected entries.
880
+ const previewPayload = {
881
+ flow: path.relative(cwd, file) || file,
882
+ previewId: upload?.shareId ?? null,
883
+ previewUrl: upload?.previewUrl ?? null,
884
+ }
885
+ try {
886
+ for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
887
+ fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
888
+ }
889
+ } catch {}
890
+ if (!upload?.shareId && !upload?.previewUrl && args.includes('--preview')) {
484
891
  console.error(' error: required hosted recording upload failed')
485
892
  if (exit === 0) exit = 1
486
893
  if (worstExit === 0) worstExit = 1
487
894
  }
488
895
 
489
896
  if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
490
- const traceSteps = getLastFlowTraceSteps()
491
897
  const failedStep = traceSteps.find((step) => step.status === 'failure')
492
898
  const run = await registerRun({
493
899
  origin: await registerOrigin(),
@@ -504,22 +910,28 @@ export async function runMaestro(
504
910
  })
505
911
  if (run) {
506
912
  console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`)
507
- try {
508
- fs.writeFileSync(
509
- path.join(cwd, 'test-result.json'),
510
- JSON.stringify({
511
- runId: run.id,
512
- previewUrl: run.previewUrl,
513
- traceUrl: run.traceUrl,
514
- }),
515
- )
516
- } catch {}
517
913
  } else if (process.env.RNX_REQUIRE_REGISTRATION === 'true') {
518
914
  console.error(' error: required hosted run registration failed')
519
915
  if (exit === 0) exit = 1
520
916
  if (worstExit === 0) worstExit = 1
521
917
  }
918
+ // like the preview receipt above: always written, null ids when the
919
+ // registration failed, so the flow keeps its collected entry.
920
+ const testPayload = {
921
+ flow: path.relative(cwd, file) || file,
922
+ runId: run?.id ?? null,
923
+ previewUrl: run?.previewUrl ?? null,
924
+ traceUrl: run?.traceUrl ?? null,
925
+ }
926
+ try {
927
+ for (const name of cloudTestResultFileNames('test', flowIndex, files.length)) {
928
+ fs.writeFileSync(path.join(cwd, name), JSON.stringify(testPayload))
929
+ }
930
+ } catch {}
522
931
  }
932
+ // artifact, upload, and registration failures above flip the local exit
933
+ // after the result was pushed; the report below must see the final one.
934
+ result.exit = exit
523
935
  }
524
936
 
525
937
  if (files.length > 1) {
@@ -532,5 +944,106 @@ export async function runMaestro(
532
944
  console.log(` ${passed}/${results.length} passed`)
533
945
  }
534
946
 
947
+ // one device per invocation, like maestro: the explicit override, else the
948
+ // sim default. reports print it; playback already resolved it per flow.
949
+ const deviceLabel = flagValue('--device')?.trim() || 'iPhone 17 Pro'
950
+ if (compat.format === 'junit') {
951
+ const junitPath = path.resolve(cwd, compat.output ?? 'report.xml')
952
+ try {
953
+ fs.mkdirSync(path.dirname(junitPath), { recursive: true })
954
+ fs.writeFileSync(
955
+ junitPath,
956
+ buildJUnitXml({
957
+ device: deviceLabel,
958
+ cases: results.map((r) => junitCaseForResult(cwd, r)),
959
+ ...(compat.testSuiteName ? { name: compat.testSuiteName } : {}),
960
+ }),
961
+ )
962
+ console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`)
963
+ } catch (err) {
964
+ console.error(` error: could not write junit report: ${(err as Error).message}`)
965
+ if (worstExit === 0) worstExit = 1
966
+ }
967
+ }
968
+ if (compat.debugOutput) {
969
+ const logPath = path.resolve(cwd, compat.debugOutput, 'maestro.log')
970
+ try {
971
+ fs.mkdirSync(path.dirname(logPath), { recursive: true })
972
+ const sessionFlows: SessionLogFlow[] = results.map((r) => ({
973
+ file: path.relative(cwd, r.file) || r.file,
974
+ passed: r.exit === 0,
975
+ durationMs: r.durationMs,
976
+ steps: r.steps,
977
+ }))
978
+ fs.writeFileSync(
979
+ logPath,
980
+ buildSessionLog({
981
+ startedAtIso: sessionStartedAt,
982
+ root: path.relative(cwd, target) || '.',
983
+ device: deviceLabel,
984
+ flows: sessionFlows,
985
+ }),
986
+ )
987
+ console.log(` debug log: ${path.relative(cwd, logPath) || logPath}`)
988
+ } catch (err) {
989
+ console.error(` error: could not write debug log: ${(err as Error).message}`)
990
+ if (worstExit === 0) worstExit = 1
991
+ }
992
+ }
993
+
535
994
  return worstExit
536
995
  }
996
+
997
+ // a junit testcase from one flow result. yaml frontmatter is untrusted, so
998
+ // every field is shape-checked back to the documented scalar shapes.
999
+ function junitCaseForResult(
1000
+ cwd: string,
1001
+ result: {
1002
+ file: string
1003
+ exit: number
1004
+ durationMs: number
1005
+ steps: ReturnType<typeof getLastFlowTraceSteps>
1006
+ frontmatter: FlowFrontmatter
1007
+ },
1008
+ ): JUnitCaseInput {
1009
+ const raw = result.frontmatter
1010
+ const name =
1011
+ typeof raw.name === 'string' && raw.name.trim().length > 0
1012
+ ? raw.name.trim()
1013
+ : flowFileStem(result.file)
1014
+ const custom: Record<string, string> = {}
1015
+ let junitId: string | null = null
1016
+ let junitClassname: string | null = null
1017
+ const props: unknown = raw.properties
1018
+ if (props && typeof props === 'object') {
1019
+ for (const [key, value] of Object.entries(props)) {
1020
+ if (
1021
+ typeof value !== 'string' &&
1022
+ typeof value !== 'number' &&
1023
+ typeof value !== 'boolean'
1024
+ ) {
1025
+ continue
1026
+ }
1027
+ const text = String(value)
1028
+ if (key === 'junitId') junitId = text
1029
+ else if (key === 'junitClassname') junitClassname = text
1030
+ else custom[key] = text
1031
+ }
1032
+ }
1033
+ const tags = Array.isArray(raw.tags)
1034
+ ? raw.tags.filter((tag): tag is string => typeof tag === 'string')
1035
+ : []
1036
+ return {
1037
+ file: path.relative(cwd, result.file) || result.file,
1038
+ name,
1039
+ id: junitId ?? name,
1040
+ classname: junitClassname ?? name,
1041
+ properties: custom,
1042
+ tags,
1043
+ timeSeconds: result.durationMs / 1000,
1044
+ failure:
1045
+ result.exit === 0
1046
+ ? null
1047
+ : failureFromTraceSteps(result.steps, 'flow playback failed'),
1048
+ }
1049
+ }