peachsim 0.1.527 → 0.1.570

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 (158) hide show
  1. package/README.md +26 -26
  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 +57 -5
  7. package/cli/cloud-client.ts +273 -6
  8. package/cli/cloud-dispatch.ts +22 -2
  9. package/cli/cloud-session.ts +60 -4
  10. package/cli/cloud-test-tunnel.ts +354 -0
  11. package/cli/commands/box/bundle-plane.ts +2 -2
  12. package/cli/commands/box/checkout-plane.ts +1 -1
  13. package/cli/commands/box/rnx-command.ts +2 -2
  14. package/cli/commands/box/search-command.ts +2 -2
  15. package/cli/commands/box.ts +8 -8
  16. package/cli/commands/compat.ts +1 -1
  17. package/cli/commands/control.ts +22 -3
  18. package/cli/commands/detox.ts +1 -1
  19. package/cli/commands/device.ts +1 -1
  20. package/cli/commands/diagnose.ts +1 -1
  21. package/cli/commands/film.ts +1 -1
  22. package/cli/commands/flow.ts +60 -3
  23. package/cli/commands/inspect/actions.ts +1 -1
  24. package/cli/commands/inspect/core.ts +6 -7
  25. package/cli/commands/inspect/find.ts +2 -1
  26. package/cli/commands/inspect/resolve-target.ts +5 -3
  27. package/cli/commands/inspect/shared.ts +1 -1
  28. package/cli/commands/inspect/wait-event.ts +1 -1
  29. package/cli/commands/inspect/wait-selector.ts +15 -4
  30. package/cli/commands/inspect.ts +19 -14
  31. package/cli/commands/maestro.ts +464 -43
  32. package/cli/commands/permissions.ts +1 -1
  33. package/cli/commands/platform.ts +205 -8
  34. package/cli/commands/preview.ts +2 -2
  35. package/cli/commands/record.ts +2 -2
  36. package/cli/commands/report-issue.ts +2 -2
  37. package/cli/commands/screenshot-layers.ts +1 -1
  38. package/cli/commands/screenshot.ts +1 -1
  39. package/cli/commands/setup.ts +2 -2
  40. package/cli/commands/test.ts +440 -115
  41. package/cli/commands/three-mode.ts +1 -1
  42. package/cli/commands/upload.ts +5 -5
  43. package/cli/commands/what-happened.ts +1 -1
  44. package/cli/desktop-companion.ts +3 -3
  45. package/cli/drivers/playwright-sim-host.ts +2 -2
  46. package/cli/flow-file.ts +7 -0
  47. package/cli/flow-live-status.ts +65 -8
  48. package/cli/help-core.ts +3 -3
  49. package/cli/hidden-runtime-alias.ts +1 -1
  50. package/cli/maestro-report.ts +178 -0
  51. package/cli/main.ts +1 -1
  52. package/cli/outbound-endpoints.ts +185 -136
  53. package/cli/parse-args.ts +1 -1
  54. package/cli/registry.ts +1 -1
  55. package/cli/resolve-assets.ts +1 -1
  56. package/cli/run-registry.ts +8 -0
  57. package/cli/send-to-box.ts +4 -4
  58. package/cli/ws-bridge.ts +3 -5
  59. package/detox/expectations.ts +1 -1
  60. package/detox/gestures.ts +2 -2
  61. package/detox/index.ts +71 -29
  62. package/detox/jest-preset.cjs +3 -3
  63. package/detox/jest-setup-after-env.cjs +1 -1
  64. package/dist-lib/agent-daemon-client.cjs +2 -2
  65. package/dist-lib/agent-events.cjs +1 -1
  66. package/dist-lib/agent-identity.cjs +1 -1
  67. package/dist-lib/agent-sessions.cjs +2 -2
  68. package/dist-lib/attached-projects.cjs +2 -2
  69. package/dist-lib/auth/shared-session.cjs +1 -1
  70. package/dist-lib/backend-origin.cjs +1 -1
  71. package/dist-lib/beta.cjs +1 -1
  72. package/dist-lib/beta.mjs +1 -1
  73. package/dist-lib/bridge-constants.cjs +1 -1
  74. package/dist-lib/bridge-contract-input.cjs +1 -1
  75. package/dist-lib/bridge-contract-input.mjs +1 -1
  76. package/dist-lib/bridge-contract.cjs +1 -1
  77. package/dist-lib/bridge-contract.mjs +1 -1
  78. package/dist-lib/capture-contract.cjs +94 -3
  79. package/dist-lib/capture-contract.mjs +93 -3
  80. package/dist-lib/cli-constants.cjs +1 -1
  81. package/dist-lib/cloud-contract.cjs +1 -1
  82. package/dist-lib/cloud-contract.mjs +1 -1
  83. package/dist-lib/cloud.cjs +2 -2
  84. package/dist-lib/cloud.mjs +2 -2
  85. package/dist-lib/config.cjs +1 -1
  86. package/dist-lib/detox/index.cjs +106 -25
  87. package/dist-lib/detox/jest-preset.cjs +3 -3
  88. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  89. package/dist-lib/home-paths.cjs +1 -1
  90. package/dist-lib/host/bridge-host.cjs +137 -61
  91. package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
  92. package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
  93. package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
  94. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  95. package/dist-lib/host/websocket-proxy.cjs +1 -1
  96. package/dist-lib/index.cjs +209 -16
  97. package/dist-lib/jump-to-source-babel.cjs +1 -1
  98. package/dist-lib/jump-to-source-native.cjs +1 -1
  99. package/dist-lib/menu.cjs +2 -2
  100. package/dist-lib/menu.mjs +2 -2
  101. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  102. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  103. package/dist-lib/metro-production-bundle.cjs +3 -3
  104. package/dist-lib/metro-production-bundle.mjs +3 -3
  105. package/dist-lib/metro.cjs +8 -8
  106. package/dist-lib/profiles.cjs +1 -1
  107. package/dist-lib/public-brand.cjs +2 -2
  108. package/dist-lib/react-native-host-modules.cjs +1 -1
  109. package/dist-lib/react-native-host-modules.mjs +1 -1
  110. package/dist-lib/render-mode.cjs +1 -1
  111. package/dist-lib/scripts/dev-server-scanner.cjs +31 -15
  112. package/dist-lib/sdk.cjs +3 -2
  113. package/dist-lib/sdk.mjs +3 -2
  114. package/dist-lib/skills.cjs +362 -163
  115. package/dist-lib/swift-submit.cjs +1 -1
  116. package/dist-lib/swift-submit.mjs +1 -1
  117. package/dist-lib/swift.cjs +583 -16
  118. package/dist-lib/vite.cjs +29 -14
  119. package/package.json +9 -9
  120. package/scripts/dev-server-scanner.ts +41 -14
  121. package/scripts/optional-demo-registry.ts +3 -3
  122. package/skills/contrast/SKILL.md +9 -9
  123. package/skills/rnx-debug/SKILL.md +14 -4
  124. package/skills/rnx-perf/SKILL.md +5 -5
  125. package/skills/rnx-setup/SKILL.md +11 -11
  126. package/skills/rnx-test/SKILL.md +11 -11
  127. package/skills/rnx-visual/SKILL.md +6 -6
  128. package/src/agent-sessions.ts +3 -3
  129. package/src/attached-projects.ts +1 -1
  130. package/src/bridge-contract-input.ts +1 -1
  131. package/src/bridge-contract.ts +23 -5
  132. package/src/capture-contract.ts +145 -3
  133. package/src/config.ts +2 -2
  134. package/src/connect.ts +2 -2
  135. package/src/home-paths.ts +3 -3
  136. package/src/host/bridge-host.ts +2 -2
  137. package/src/host/fetch-proxy-handler.ts +4 -3
  138. package/src/host/fetch-proxy-overrides.ts +41 -0
  139. package/src/host/plane-host.ts +2 -2
  140. package/src/index.ts +3 -0
  141. package/src/metro-plugin.ts +4 -4
  142. package/src/metro-production-bundle.ts +2 -2
  143. package/src/native-seam-manifest.ts +6 -6
  144. package/src/public-brand.ts +1 -1
  145. package/src/rolldown-plugin-swift.ts +8 -5
  146. package/src/runtime-assets.ts +8 -6
  147. package/src/screenshots/frame-compose.ts +24 -56
  148. package/src/screenshots/org-deck.ts +1 -1
  149. package/src/screenshots/registry.ts +4 -4
  150. package/src/screenshots/schema.ts +1 -1
  151. package/src/sdk.ts +2 -2
  152. package/src/settings-bundle.ts +1 -1
  153. package/src/sim-client.ts +2 -4
  154. package/src/skills/builtin/compat-check.ts +1 -1
  155. package/src/vite-plugin-one.ts +1 -1
  156. package/src/vite-plugin-swift.ts +11 -6
  157. package/src/vite-plugin.ts +30 -17
  158. package/src/worklets-babel.ts +2 -2
@@ -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 {
@@ -38,6 +47,7 @@ rnx maestro — author and run Maestro YAML flows against rnx
38
47
  usage:
39
48
  rnx maestro # discover .maestro/ or maestro/ in cwd, run all
40
49
  rnx maestro test <flow-or-dir> # mirrors "maestro test"
50
+ rnx maestro load <flow> # load flow into runner without running (--dry-run)
41
51
  rnx maestro test .maestro/ # every *.yaml / *.yml in a directory
42
52
  rnx maestro generate "<goal>" # generate, run, and upload a Maestro flow
43
53
  rnx maestro start|keep|end # author a flow from live CLI actions
@@ -46,9 +56,22 @@ usage:
46
56
  rnx maestro --list-compat # print supported/unsupported verbs
47
57
 
48
58
  options:
59
+ --dry-run, --no-run load flow into runner idle without auto-running
49
60
  --env KEY=VALUE set env vars for \${KEY} interpolation (repeatable)
50
61
  --continuous accepted for maestro cli compat; rnx runs the flow once
51
- --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
52
75
  --device <model> override the flow frontmatter device for this run
53
76
  --new force a fresh browser window for this run
54
77
  --headed force a fresh, VISIBLE window (implies --new,
@@ -93,8 +116,19 @@ supported verbs:
93
116
  (env vars as globals, output.*, maestro.*, \${EXPR || 'default'}).
94
117
 
95
118
  workspace mode (directory runs):
96
- config.yaml / config.yml discovery, flows: inclusion globs,
97
- 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.
98
132
 
99
133
  launch behavior:
100
134
  launchApp.arguments: a JSON object available before guest app entry through
@@ -148,19 +182,109 @@ function applyEnvFlags(args: string[]): string[] {
148
182
  return out
149
183
  }
150
184
 
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[] = []
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[] = []
159
242
  for (let i = 0; i < args.length; i++) {
160
243
  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
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
+ }
164
288
  continue
165
289
  }
166
290
  if (a === '--origin') {
@@ -172,16 +296,26 @@ function stripMaestroCompatFlags(args: string[]): string[] {
172
296
  if (a === '-d') {
173
297
  // maestro's short form for --device. rnx's device flag names a device
174
298
  // model, and the flow runner reads it under the long spelling only.
175
- 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.
176
304
  continue
177
305
  }
178
306
  if (a === '--continuous') {
179
307
  console.warn(' warn: --continuous is not implemented in rnx — running once')
180
308
  continue
181
309
  }
182
- out.push(a)
310
+ rest.push(a)
183
311
  }
184
- 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 }
185
319
  }
186
320
 
187
321
  function findDefaultMaestroTarget(cwd: string): string | null {
@@ -196,36 +330,88 @@ interface MaestroWorkspaceConfig {
196
330
  flows?: string[]
197
331
  includeTags?: string[]
198
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 }
199
379
  }
200
380
 
201
381
  // workspace planning for directory targets — mirrors maestro's
202
382
  // WorkspaceExecutionPlanner: discover config.yaml/config.yml in the
203
383
  // directory, walk it recursively for *.yaml/*.yml flow files (config files
204
384
  // 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[] {
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[] {
208
395
  const stat = fs.statSync(target)
209
396
  if (stat.isFile()) return [target]
210
397
  if (!stat.isDirectory()) {
211
398
  throw new Error(`not a file or directory: ${target}`)
212
399
  }
213
400
 
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
- }
401
+ const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath)
226
402
 
227
403
  const globs =
228
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
+ }
229
415
  const isFlowFile = (rel: string) => {
230
416
  const base = path.basename(rel)
231
417
  if (base.startsWith('.')) return false
@@ -234,17 +420,22 @@ export function collectFlowFiles(target: string): string[] {
234
420
  return true
235
421
  }
236
422
  const matched = new Set<string>()
237
- for (const glob of globs) {
423
+ for (const glob of positive) {
238
424
  for (const rel of fs.globSync(glob, { cwd: target })) {
239
425
  if (!isFlowFile(rel)) continue
240
426
  const abs = path.join(target, rel)
241
427
  if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs)
242
428
  }
243
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
+ }
244
435
  let files = [...matched].sort()
245
436
 
246
- const includeTags = Array.isArray(config.includeTags) ? config.includeTags : []
247
- const excludeTags = Array.isArray(config.excludeTags) ? config.excludeTags : []
437
+ const includeTags = opts.includeTags ?? config.includeTags ?? []
438
+ const excludeTags = opts.excludeTags ?? config.excludeTags ?? []
248
439
  if (includeTags.length > 0 || excludeTags.length > 0) {
249
440
  files = files.filter((file) => {
250
441
  const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8'))
@@ -259,6 +450,23 @@ export function collectFlowFiles(target: string): string[] {
259
450
  return files
260
451
  }
261
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
+
262
470
  async function runInit(cwd: string): Promise<number> {
263
471
  const dir = path.join(cwd, '.maestro')
264
472
  fs.mkdirSync(dir, { recursive: true })
@@ -316,7 +524,15 @@ export async function runMaestro(
316
524
  remaining = hoistLeadingSimFlag(remaining)
317
525
 
318
526
  const cwd = process.cwd()
319
- const sub = remaining[0]
527
+ let sub = remaining[0]
528
+
529
+ if (sub === 'load') {
530
+ remaining[0] = 'test'
531
+ sub = 'test'
532
+ if (!remaining.includes('--dry-run') && !remaining.includes('--no-run')) {
533
+ remaining.push('--dry-run')
534
+ }
535
+ }
320
536
 
321
537
  if (sub === 'init') {
322
538
  return runInit(cwd)
@@ -335,11 +551,27 @@ export async function runMaestro(
335
551
  return runMaestroAuthoring(remaining)
336
552
  }
337
553
  // `--origin` names the Contrast instance that hosts the run row. this command
338
- // 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.
339
555
  const originFlagIndex = remaining.indexOf('--origin')
340
556
  const originFlagValue =
341
557
  originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined
342
- 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
+ }
343
575
 
344
576
  // resolve the target flow file or directory.
345
577
  let target: string | null = null
@@ -358,6 +590,11 @@ export async function runMaestro(
358
590
  if (positionalIndex >= 0) {
359
591
  target = path.resolve(cwd, rest[positionalIndex])
360
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
361
598
  } else {
362
599
  target = findDefaultMaestroTarget(cwd)
363
600
  forwardArgs = rest
@@ -401,18 +638,42 @@ export async function runMaestro(
401
638
  }
402
639
 
403
640
  let files: string[]
641
+ let workspaceConfig: MaestroWorkspaceConfig = {}
642
+ let workspaceConfigPath: string | null = null
404
643
  try {
405
- 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
+ })
406
654
  } catch (err) {
407
655
  console.error(` error: ${(err as Error).message}`)
408
656
  return 1
409
657
  }
410
658
 
411
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
+ }
412
664
  console.error(` error: no *.yaml or *.yml files found in ${target}`)
413
665
  return 1
414
666
  }
415
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
+
416
677
  console.log(`
417
678
  rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
418
679
  root: ${path.relative(cwd, target) || '.'}
@@ -435,11 +696,36 @@ export async function runMaestro(
435
696
  flagValue('--preview-origin') ?? originFlagValue,
436
697
  ))
437
698
 
699
+ const sessionStartedAt = new Date().toISOString()
438
700
  let worstExit = 0
439
- 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
+ }> = []
440
708
  for (const file of files) {
441
709
  console.log(`\n ▶ ${path.relative(cwd, file)}`)
442
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
+ }
443
729
  // `--port` is the BRIDGE port everywhere else in the CLI, so forward it as
444
730
  // one. sending it on as `--url` made it an app target instead, which left
445
731
  // the playback bridge on whichever world resolveBridgeWorld picks by
@@ -466,9 +752,42 @@ export async function runMaestro(
466
752
  exit = 1
467
753
  }
468
754
  }
469
- 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)
470
765
  if (exit !== 0 && worstExit === 0) worstExit = exit
471
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
+
472
791
  const upload = getLastFlowPreviewUploadResult()
473
792
  if (upload?.shareId || upload?.previewUrl) {
474
793
  try {
@@ -487,7 +806,6 @@ export async function runMaestro(
487
806
  }
488
807
 
489
808
  if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
490
- const traceSteps = getLastFlowTraceSteps()
491
809
  const failedStep = traceSteps.find((step) => step.status === 'failure')
492
810
  const run = await registerRun({
493
811
  origin: await registerOrigin(),
@@ -520,6 +838,9 @@ export async function runMaestro(
520
838
  if (worstExit === 0) worstExit = 1
521
839
  }
522
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
523
844
  }
524
845
 
525
846
  if (files.length > 1) {
@@ -532,5 +853,105 @@ export async function runMaestro(
532
853
  console.log(` ${passed}/${results.length} passed`)
533
854
  }
534
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
+
535
902
  return worstExit
536
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
+ }
@@ -10,7 +10,7 @@ import type {
10
10
  SystemPermissionKind,
11
11
  SystemPermissionMutation,
12
12
  SystemPermissionStatus,
13
- } from '@rnx/globals'
13
+ } from '@peach/globals'
14
14
 
15
15
  interface PermissionsOptions {
16
16
  port?: number