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
@@ -2,14 +2,21 @@
2
2
 
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
4
4
  import { createServer, type Server } from 'node:http'
5
- import { basename, dirname, extname, join, resolve } from 'node:path'
5
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
6
6
  import {
7
7
  RNX_CLOUD_TEST_FILES_MAX_BYTES,
8
8
  RNX_CLOUD_TEST_FILES_MAX_COUNT,
9
9
  } from '../../src/cloud-contract'
10
+ import { isCloudTestValueFlag, parseCloudTestArgs } from '../../src/cloud-test-args'
10
11
  import { authHeaderValue, resolveCliAuth } from '../auth'
12
+ import {
13
+ parseCloudTestSidecarPorts,
14
+ parseLiveDevStackApp,
15
+ publishLiveDevStack,
16
+ } from '../cloud-test-tunnel'
17
+ import { parseFlowFile } from '../flow-file'
11
18
  import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
12
- import { hoistLeadingSimFlag } from './flow'
19
+ import { FLOW_ARG_VALUE_FLAGS, hoistLeadingSimFlag } from './flow'
13
20
  import { resolveDefaultUploadOrigin } from './upload'
14
21
 
15
22
  export interface RunTestOptions {
@@ -24,6 +31,11 @@ interface TestAppTarget {
24
31
  close: () => Promise<void>
25
32
  }
26
33
 
34
+ // every flag that swallows the token after it, so the positional scan finds
35
+ // the flow/suite target no matter where the flags sit (`rnx test
36
+ // --include-tags smoke .maestro/` must not take `smoke` as the target).
37
+ // maestro compat flags live here alongside the flow-runner vocabulary;
38
+ // without them a flag-first maestro command line mis-resolves its target.
27
39
  const VALUE_FLAGS = new Set([
28
40
  '--app',
29
41
  '--bundle',
@@ -34,11 +46,32 @@ const VALUE_FLAGS = new Set([
34
46
  '--tab',
35
47
  '--port',
36
48
  '--device',
49
+ '-d',
50
+ '--env',
51
+ '-e',
37
52
  '--driver',
38
53
  '--base-url',
39
54
  '--url',
55
+ '--include-tags',
56
+ '--exclude-tags',
57
+ '--format',
58
+ '--output',
59
+ '--test-output-dir',
60
+ '--debug-output',
61
+ '--platform',
62
+ '--udid',
63
+ '--test-suite-name',
64
+ '--shards',
65
+ '-s',
66
+ '--shard-all',
67
+ '--shard-split',
68
+ '--api-key',
69
+ '--api-url',
70
+ '--screen-size',
40
71
  '-t',
41
72
  '--testNamePattern',
73
+ '--testPathPattern',
74
+ '--testTimeout',
42
75
  '--grep',
43
76
  '--maxWorkers',
44
77
  '--shard',
@@ -48,6 +81,9 @@ const VALUE_FLAGS = new Set([
48
81
  '--repo',
49
82
  '--poll-interval',
50
83
  '--timeout',
84
+ '--sidecar-ports',
85
+ '--cancel',
86
+ ...FLOW_ARG_VALUE_FLAGS,
51
87
  ])
52
88
 
53
89
  function valueAfter(args: string[], name: string): string | null {
@@ -59,30 +95,160 @@ function valueAfter(args: string[], name: string): string | null {
59
95
  return value && !value.startsWith('-') ? value : null
60
96
  }
61
97
 
62
- function targetFromArgs(args: string[]): string | null {
98
+ function targetIndexFromArgs(args: string[]): number {
63
99
  for (let index = 0; index < args.length; index++) {
64
100
  const arg = args[index]
65
101
  if (VALUE_FLAGS.has(arg)) {
66
- index += 1
102
+ // a missing value must not swallow the next flag: only skip a
103
+ // present, non-flag-shaped token, like valueAfter.
104
+ const next = args[index + 1]
105
+ if (next !== undefined && !next.startsWith('-')) index += 1
67
106
  continue
68
107
  }
69
108
  if (arg.startsWith('--') && arg.includes('=')) {
70
109
  continue
71
110
  }
72
- if (!arg.startsWith('-')) return arg
111
+ if (!arg.startsWith('-')) return index
112
+ }
113
+ return -1
114
+ }
115
+
116
+ function targetFromArgs(args: string[]): string | null {
117
+ const index = targetIndexFromArgs(args)
118
+ return index >= 0 ? args[index] : null
119
+ }
120
+
121
+ // flags the cloud submitter consumes into the submission itself (app,
122
+ // origin, tunnel, polling): skipped silently by the testArgs walk, with
123
+ // their values. contract: harden owns the list's server half.
124
+ const CLOUD_CONSUMED_FLAGS = new Set([
125
+ '--app',
126
+ '--bundle',
127
+ '--bundle-url',
128
+ '--url',
129
+ '--origin',
130
+ '--owner',
131
+ '--repo',
132
+ '--config',
133
+ '--cancel',
134
+ '--poll-interval',
135
+ '--timeout',
136
+ '--sidecar-ports',
137
+ '--hosted',
138
+ '--cloud',
139
+ ])
140
+
141
+ // execution-environment flags the hosted recorder owns: warned on every
142
+ // cloud submit, never forwarded.
143
+ const RECORDER_OWNED_FLAGS = new Set([
144
+ '--maxWorkers',
145
+ '--testTimeout',
146
+ '--shard',
147
+ '--device',
148
+ '--sim',
149
+ '--headless',
150
+ '--preview',
151
+ ])
152
+
153
+ // report/output flags the local-only block already warned about: skipped
154
+ // silently here so one flag never warns twice.
155
+ const CLOUD_ALREADY_WARNED_FLAGS = new Set([
156
+ '--format',
157
+ '--output',
158
+ '--test-output-dir',
159
+ '--debug-output',
160
+ ])
161
+
162
+ export interface CloudTestArgsWalk {
163
+ testArgs: string[]
164
+ warnings: string[]
165
+ }
166
+
167
+ // split the cloud argv into the testArgs slice the recorder appends
168
+ // verbatim plus one warning per dropped arg. pure: the caller prints
169
+ // the warnings and validates through parseCloudTestArgs, so a filter is
170
+ // never silently dropped and malformed selection fails pre-submit with
171
+ // the server's own message.
172
+ export function collectCloudTestArgs(forwarded: string[]): CloudTestArgsWalk {
173
+ const testArgs: string[] = []
174
+ const warnings: string[] = []
175
+ const targetIndex = targetIndexFromArgs(forwarded)
176
+ const takesValue = (name: string): boolean =>
177
+ isCloudTestValueFlag(name) || VALUE_FLAGS.has(name)
178
+ let i = 0
179
+ while (i < forwarded.length) {
180
+ const arg = forwarded[i]
181
+ if (i === targetIndex) {
182
+ i += 1
183
+ continue
184
+ }
185
+ if (!arg.startsWith('-') || arg === '-') {
186
+ warnings.push(
187
+ `cloud ignores ${arg}: unexpected positional argument (one test target only)`,
188
+ )
189
+ i += 1
190
+ continue
191
+ }
192
+ const eq = arg.indexOf('=')
193
+ const head = eq > 0 ? arg.slice(0, eq) : arg
194
+ const inline = eq > 0
195
+ // a missing value must not swallow the next flag: only a present,
196
+ // non-flag-shaped token is consumed as a value.
197
+ const nextIsValue =
198
+ !inline &&
199
+ takesValue(head) &&
200
+ i + 1 < forwarded.length &&
201
+ !forwarded[i + 1].startsWith('-')
202
+ if (CLOUD_CONSUMED_FLAGS.has(head) || CLOUD_ALREADY_WARNED_FLAGS.has(head)) {
203
+ i += nextIsValue ? 2 : 1
204
+ continue
205
+ }
206
+ if (isCloudTestValueFlag(head)) {
207
+ if (inline) {
208
+ testArgs.push(arg)
209
+ i += 1
210
+ continue
211
+ }
212
+ const value = forwarded[i + 1]
213
+ if (value !== undefined && !value.startsWith('-')) {
214
+ testArgs.push(arg, value)
215
+ i += 2
216
+ continue
217
+ }
218
+ // a lone selection flag: emit it so the pre-submit parse names it
219
+ // (`<flag> expects a value`) instead of running unfiltered.
220
+ testArgs.push(arg)
221
+ i += 1
222
+ continue
223
+ }
224
+ if (RECORDER_OWNED_FLAGS.has(head)) {
225
+ warnings.push(
226
+ `cloud ignores ${head}: the hosted recorder owns the execution environment`,
227
+ )
228
+ i += nextIsValue ? 2 : 1
229
+ continue
230
+ }
231
+ warnings.push(`cloud ignores ${arg}: applies to local runs only`)
232
+ i += nextIsValue ? 2 : 1
73
233
  }
74
- return null
234
+ return { testArgs, warnings }
235
+ }
236
+
237
+ const APP_FLAGS = ['--app', '--bundle', '--bundle-url']
238
+
239
+ function isAppFlag(arg: string): boolean {
240
+ return APP_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`))
75
241
  }
76
242
 
77
243
  function withoutApp(args: string[]): string[] {
78
244
  const result: string[] = []
79
245
  for (let i = 0; i < args.length; i++) {
80
246
  const arg = args[i]
81
- if (arg === '--app') {
247
+ if (APP_FLAGS.includes(arg)) {
82
248
  i += 1
83
249
  continue
84
250
  }
85
- if (arg.startsWith('--app=')) {
251
+ if (isAppFlag(arg)) {
86
252
  continue
87
253
  }
88
254
  result.push(arg)
@@ -90,6 +256,25 @@ function withoutApp(args: string[]): string[] {
90
256
  return result
91
257
  }
92
258
 
259
+ // the maestro runner reads the app target as --url; rnx test spells it
260
+ // --app/--bundle/--bundle-url. map each spelling explicitly: a prefix
261
+ // replace would turn --bundle-url into --url-url.
262
+ function mapAppFlagToUrl(arg: string): string {
263
+ if (APP_FLAGS.includes(arg)) return '--url'
264
+ for (const flag of APP_FLAGS) {
265
+ if (arg.startsWith(`${flag}=`)) return `--url=${arg.slice(flag.length + 1)}`
266
+ }
267
+ return arg
268
+ }
269
+
270
+ function appFromArgs(args: string[]): string | null {
271
+ return (
272
+ valueAfter(args, '--app') ??
273
+ valueAfter(args, '--bundle') ??
274
+ valueAfter(args, '--bundle-url')
275
+ )
276
+ }
277
+
93
278
  function closeServer(server: Server): Promise<void> {
94
279
  return new Promise((resolveClose, rejectClose) => {
95
280
  server.close((error) => {
@@ -153,14 +338,30 @@ function restoreRnxUrl(previous: string | undefined): void {
153
338
  }
154
339
  }
155
340
 
341
+ // `--config` is claimed by both interpreters: a YAML value is a maestro
342
+ // workspace config, anything else is a detox jest config.
343
+ function isMaestroConfigValue(value: string | null): boolean {
344
+ return value !== null && /\.ya?ml$/i.test(value)
345
+ }
346
+
156
347
  // dispatch only: each interpreter retains its own selection and execution rules.
157
348
  export function resolveTestCommand(args: string[], cwd = process.cwd()) {
158
349
  const forwarded = hoistLeadingSimFlag(args)
159
- const target = forwarded[0]?.startsWith('-') ? undefined : forwarded[0]
160
- const config = forwarded.some(
350
+ const hasConfig = forwarded.some(
161
351
  (arg) => arg === '--config' || arg.startsWith('--config='),
162
352
  )
163
- if (config) return { command: 'detox', args: forwarded }
353
+ const maestroConfig =
354
+ hasConfig && isMaestroConfigValue(valueAfter(forwarded, '--config'))
355
+ if (hasConfig && !maestroConfig) return { command: 'detox', args: forwarded }
356
+ // the target is the first positional, wherever it sits: maestro users put
357
+ // flags before the path (`maestro test --include-tags smoke .maestro/`),
358
+ // so reading only argv[0] breaks the exact pasted command line.
359
+ let target = targetFromArgs(forwarded) ?? undefined
360
+ if (!target && maestroConfig) {
361
+ // `rnx test --config e2e/config.yaml`: the config describes a workspace,
362
+ // so run the directory it lives in.
363
+ target = dirname(resolve(cwd, valueAfter(forwarded, '--config') as string))
364
+ }
164
365
  if (!target) {
165
366
  throw new Error(
166
367
  'pass a Maestro flow/directory, a Jest suite, or --config <jest-config>',
@@ -182,19 +383,22 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
182
383
  ['.maestro', 'maestro'].includes(basename(absolute))
183
384
  : /\.ya?ml$/.test(extname(absolute))
184
385
  if (maestro) {
185
- return {
186
- command: 'maestro',
187
- args: [
188
- 'test',
189
- ...forwarded.map((arg, index) => {
190
- if (index === 0 && !directory && /^config\.ya?ml$/.test(basename(absolute)))
191
- return dirname(absolute)
192
- return arg === '--app' || arg.startsWith('--app=')
193
- ? arg.replace('--app', '--url')
194
- : arg
195
- }),
196
- ],
386
+ // when the target names a workspace config file, run its directory.
387
+ // the replacement lands on the target positional by index: with
388
+ // flag-first argv the target is not argv[0], and a path can equal a
389
+ // flag value elsewhere in the line.
390
+ const targetIndex = targetIndexFromArgs(forwarded)
391
+ const runConfigDir = !directory && /^config\.ya?ml$/.test(basename(absolute))
392
+ const mapped = forwarded.map((arg, index) => {
393
+ if (index === targetIndex && runConfigDir) return dirname(absolute)
394
+ return mapAppFlagToUrl(arg)
395
+ })
396
+ if (targetIndex < 0) {
397
+ // synthesized target (`--config e2e/config.yaml` with no positional):
398
+ // name the workspace dir so maestro does not auto-discover cwd.
399
+ mapped.push(target)
197
400
  }
401
+ return { command: 'maestro', args: ['test', ...mapped] }
198
402
  }
199
403
  if (!directory && !/\.[cm]?[jt]sx?$/.test(extname(absolute))) {
200
404
  throw new Error(`expected Maestro YAML or a Detox/Jest suite: ${target}`)
@@ -202,6 +406,55 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
202
406
  return { command: 'detox', args: forwarded }
203
407
  }
204
408
 
409
+ // helper files a single flow pulls in through runFlow / runScript file
410
+ // refs, resolved relative to each referencing file exactly like the runner,
411
+ // transitively with a cycle guard. a ref that does not exist is the runner's
412
+ // loud failure, not the submitter's: warn and let the hosted run report it
413
+ // at its step, like a local run does.
414
+ function collectFlowHelperPaths(flowAbsPath: string): string[] {
415
+ if (!flowAbsPath.endsWith('.yml') && !flowAbsPath.endsWith('.yaml')) return []
416
+ const helpers: string[] = []
417
+ const visited = new Set<string>([resolve(flowAbsPath)])
418
+ const queue = [resolve(flowAbsPath)]
419
+ while (queue.length > 0) {
420
+ const current = queue.pop() as string
421
+ if (!current.endsWith('.yml') && !current.endsWith('.yaml')) continue
422
+ let steps: unknown[]
423
+ try {
424
+ steps = parseFlowFile(readFileSync(current, 'utf8')).steps
425
+ } catch (err) {
426
+ console.warn(
427
+ ` warn: could not parse flow refs in ${current}: ${err instanceof Error ? err.message : String(err)}`,
428
+ )
429
+ continue
430
+ }
431
+ for (const step of steps) {
432
+ if (typeof step !== 'object' || step === null) continue
433
+ for (const key of ['runFlow', 'runScript'] as const) {
434
+ const spec = (step as Record<string, unknown>)[key]
435
+ if (spec === undefined || spec === null) continue
436
+ const file =
437
+ typeof spec === 'string'
438
+ ? spec
439
+ : typeof (spec as Record<string, unknown>).file === 'string'
440
+ ? ((spec as Record<string, unknown>).file as string)
441
+ : null
442
+ if (!file) continue
443
+ const abs = resolve(dirname(current), file)
444
+ if (visited.has(abs)) continue
445
+ visited.add(abs)
446
+ if (existsSync(abs) && statSync(abs).isFile()) {
447
+ helpers.push(abs)
448
+ queue.push(abs)
449
+ } else {
450
+ console.warn(` warn: flow ref not found: ${file} (from ${current})`)
451
+ }
452
+ }
453
+ }
454
+ }
455
+ return helpers
456
+ }
457
+
205
458
  function collectTestFiles(
206
459
  targetPath: string,
207
460
  ignoredPath: string | null,
@@ -240,8 +493,48 @@ function collectTestFiles(
240
493
  }
241
494
  walk(targetPath, '')
242
495
  } else if (stat.isFile()) {
243
- add(targetPath, basename(targetPath))
244
496
  const parent = dirname(targetPath)
497
+ const helperAbsPaths = collectFlowHelperPaths(targetPath)
498
+ if (helperAbsPaths.length === 0) {
499
+ add(targetPath, basename(targetPath))
500
+ } else {
501
+ // the flow references helpers by path relative to itself (runFlow /
502
+ // runScript file). key every file by its path under their common root
503
+ // so the recorder's unpack preserves those relative refs.
504
+ const all = [resolve(targetPath), ...helperAbsPaths]
505
+ const split = all.map((abs) => abs.split(sep))
506
+ const commonParts = split[0]?.slice(0) ?? []
507
+ while (
508
+ commonParts.length > 0 &&
509
+ !split.every(
510
+ (parts) =>
511
+ parts.length >= commonParts.length &&
512
+ commonParts.every((part, i) => parts[i] === part),
513
+ )
514
+ ) {
515
+ commonParts.pop()
516
+ }
517
+ const common = commonParts.join(sep) || sep
518
+ const keys = all.map((abs) => relative(common, abs).split(sep).join('/'))
519
+ for (const [index, abs] of all.entries()) {
520
+ add(abs, keys[index] as string)
521
+ }
522
+ // the recorder runs an unpacked multi-file upload as a workspace,
523
+ // whose default glob only sees top-level flows: a nested entry would
524
+ // lose to a top-level helper. pin a config that selects the entry.
525
+ if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
526
+ if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
527
+ throw new Error(
528
+ `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
529
+ )
530
+ }
531
+ const entryKey = keys[0] as string
532
+ const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
533
+ files['config.yaml'] =
534
+ '# synthesized by rnx test --cloud: run exactly the submitted entry flow.\n' +
535
+ `flows:\n - ${JSON.stringify(glob)}\n`
536
+ }
537
+ }
245
538
  for (const configName of [
246
539
  'rnx-detox.config.cjs',
247
540
  'jest.config.cjs',
@@ -262,6 +555,82 @@ function collectTestFiles(
262
555
  return files
263
556
  }
264
557
 
558
+ async function readJobStatus(
559
+ endpoint: string,
560
+ authorization: string,
561
+ ): Promise<string | null> {
562
+ try {
563
+ const res = await fetch(endpoint, { headers: { authorization } })
564
+ if (!res.ok) return null
565
+ const json = (await res.json()) as { status?: unknown } | null
566
+ return json && typeof json.status === 'string' ? json.status : null
567
+ } catch {
568
+ return null
569
+ }
570
+ }
571
+
572
+ export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
573
+ const auth = resolveCliAuth()
574
+ if (!auth) {
575
+ console.error(' error: cancelling a cloud test requires `rnx login` or RNX_API_KEY')
576
+ return 1
577
+ }
578
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
579
+ let response: Response
580
+ try {
581
+ response = await fetch(endpoint, {
582
+ method: 'DELETE',
583
+ headers: { authorization: authHeaderValue(auth) },
584
+ })
585
+ } catch (err) {
586
+ console.error(
587
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
588
+ )
589
+ return 1
590
+ }
591
+ if (response.status === 404) {
592
+ console.error(` error: cloud test job not found: ${jobId}`)
593
+ return 1
594
+ }
595
+ if (!response.ok) {
596
+ const body = await response.text()
597
+ let message = body
598
+ try {
599
+ const json = JSON.parse(body)
600
+ if (json.message) message = json.message
601
+ } catch {}
602
+ console.error(` error: cancel failed (${response.status}): ${message}`)
603
+ return 1
604
+ }
605
+ let status: string | null = null
606
+ try {
607
+ const json = (await response.json()) as { status?: unknown } | null
608
+ if (json && typeof json.status === 'string') status = json.status
609
+ } catch {
610
+ // an older server answers 2xx with no cancel body; fall through to the
611
+ // read-back below rather than claim anything.
612
+ }
613
+ if (status === null) {
614
+ status = await readJobStatus(endpoint, authHeaderValue(auth))
615
+ if (status === 'queued' || status === 'running') {
616
+ console.error(
617
+ ` error: the server left ${jobId} ${status}; cancel needs a newer job api`,
618
+ )
619
+ return 1
620
+ }
621
+ if (status === null) {
622
+ console.error(` error: cancel sent but the state of ${jobId} is unknown`)
623
+ return 1
624
+ }
625
+ }
626
+ if (status === 'cancelled') {
627
+ console.log(` cloud test job cancelled: ${jobId}`)
628
+ } else {
629
+ console.log(` cloud test job already ${status}: ${jobId}`)
630
+ }
631
+ return 0
632
+ }
633
+
265
634
  export async function runCloudTest(
266
635
  args: string[],
267
636
  opts: RunTestOptions = {},
@@ -269,6 +638,14 @@ export async function runCloudTest(
269
638
  const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted')
270
639
  const originFlag = valueAfter(forwarded, '--origin')
271
640
  const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined)
641
+ const cancelId = valueAfter(forwarded, '--cancel')
642
+ if (forwarded.includes('--cancel') || cancelId !== null) {
643
+ if (!cancelId) {
644
+ console.error(' usage: rnx test --cancel <job-id>')
645
+ return 1
646
+ }
647
+ return cancelCloudTest(cancelId, origin)
648
+ }
272
649
  const app =
273
650
  valueAfter(forwarded, '--app') ||
274
651
  valueAfter(forwarded, '--bundle') ||
@@ -284,7 +661,11 @@ export async function runCloudTest(
284
661
  let target = targetFromArgs(forwarded)
285
662
  const config = valueAfter(forwarded, '--config')
286
663
  if (!target && config) {
287
- target = config
664
+ // a maestro workspace config uploads its directory; a jest config is
665
+ // collected from its own dir by the path logic below.
666
+ target = isMaestroConfigValue(config)
667
+ ? dirname(resolve(process.cwd(), config))
668
+ : config
288
669
  }
289
670
  if (!target) {
290
671
  for (const cand of ['.maestro', 'maestro', 'flows', 'e2e']) {
@@ -307,11 +688,39 @@ export async function runCloudTest(
307
688
 
308
689
  if (!app) {
309
690
  console.error(
310
- ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url>',
691
+ ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url|http://localhost:port>',
311
692
  )
312
693
  return 1
313
694
  }
314
695
 
696
+ const localOnly = [
697
+ '--format',
698
+ '--output',
699
+ '--test-output-dir',
700
+ '--debug-output',
701
+ ].filter((flag) => forwarded.some((arg) => arg === flag || arg.startsWith(`${flag}=`)))
702
+ if (localOnly.length > 0) {
703
+ console.warn(
704
+ ` warn: ${localOnly.join(', ')} apply to local runs only and are ignored by --cloud`,
705
+ )
706
+ }
707
+
708
+ // selection flags ride testArgs to the recorder; everything else the
709
+ // walk drops gets one warning, never silence. validated pre-submit so
710
+ // malformed selection fails here with the server's own message,
711
+ // before any tunnel or upload side effect.
712
+ const walk = collectCloudTestArgs(forwarded)
713
+ for (const warning of walk.warnings) console.warn(` warn: ${warning}`)
714
+ let cloudTestArgs: string[] | null = null
715
+ if (walk.testArgs.length > 0) {
716
+ const parsed = parseCloudTestArgs(walk.testArgs)
717
+ if (!parsed.ok) {
718
+ console.error(` error: ${parsed.message}`)
719
+ return 1
720
+ }
721
+ cloudTestArgs = parsed.args
722
+ }
723
+
315
724
  const auth = resolveCliAuth()
316
725
  if (!auth) {
317
726
  console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
@@ -336,18 +745,41 @@ export async function runCloudTest(
336
745
  let bundleUrl: string | undefined
337
746
  let bundle: string | undefined
338
747
  let bundleShareId: string | undefined
339
-
340
- if (app.startsWith('http://') || app.startsWith('https://')) {
341
- bundleUrl = app
342
- } else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) {
343
- bundleShareId = app.replace(/^.*id=/, '')
748
+ let tunnelTargetUrl: string | undefined
749
+ let tunnelHostHeader: string | undefined
750
+ let tunnelJobToken: string | undefined
751
+ let tunnelSidecarPorts: string | undefined
752
+ let closeTunnel: (() => void) | null = null
753
+
754
+ const liveDevStack = parseLiveDevStackApp(app)
755
+ const sidecarFlag = valueAfter(forwarded, '--sidecar-ports')
756
+ let liveSidecarPorts: number[] | null = null
757
+ if (liveDevStack) {
758
+ const parsedSidecars = parseCloudTestSidecarPorts(sidecarFlag, liveDevStack.port)
759
+ if (!parsedSidecars.ok) {
760
+ console.error(` error: ${parsedSidecars.message}`)
761
+ return 1
762
+ }
763
+ liveSidecarPorts = parsedSidecars.ports
344
764
  } else {
345
- const localBundle = resolve(process.cwd(), app)
346
- if (!existsSync(localBundle) || !statSync(localBundle).isFile()) {
347
- console.error(` error: --app bundle file not found: ${app}`)
765
+ if (sidecarFlag !== null) {
766
+ console.error(
767
+ ' error: --sidecar-ports requires a live dev stack --app (http://localhost:<port>)',
768
+ )
348
769
  return 1
349
770
  }
350
- bundle = readFileSync(localBundle, 'utf8')
771
+ if (app.startsWith('http://') || app.startsWith('https://')) {
772
+ bundleUrl = app
773
+ } else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) {
774
+ bundleShareId = app.replace(/^.*id=/, '')
775
+ } else {
776
+ const localBundle = resolve(process.cwd(), app)
777
+ if (!existsSync(localBundle) || !statSync(localBundle).isFile()) {
778
+ console.error(` error: --app bundle file not found: ${app}`)
779
+ return 1
780
+ }
781
+ bundle = readFileSync(localBundle, 'utf8')
782
+ }
351
783
  }
352
784
 
353
785
  const targetName = basename(targetPath)
@@ -370,6 +802,26 @@ export async function runCloudTest(
370
802
  return 1
371
803
  }
372
804
 
805
+ // the tunnel stays open from submit until the job reaches a terminal
806
+ // state; tearing it down early strands the recorder mid-run.
807
+ if (liveDevStack && liveSidecarPorts) {
808
+ try {
809
+ const published = await publishLiveDevStack({
810
+ devPort: liveDevStack.port,
811
+ sidecarPorts: liveSidecarPorts,
812
+ })
813
+ tunnelTargetUrl = published.targetUrl
814
+ tunnelHostHeader = published.hostHeader
815
+ tunnelJobToken = published.jobToken
816
+ tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
817
+ closeTunnel = published.close
818
+ console.log(` live dev stack published: ${published.targetUrl}`)
819
+ } catch (err) {
820
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
821
+ return 1
822
+ }
823
+ }
824
+
373
825
  const submitPayload: Record<string, unknown> = {
374
826
  kind: 'test',
375
827
  testFiles,
@@ -377,114 +829,145 @@ export async function runCloudTest(
377
829
  if (bundleUrl) submitPayload.bundleUrl = bundleUrl
378
830
  if (bundle) submitPayload.bundle = bundle
379
831
  if (bundleShareId) submitPayload.bundleShareId = bundleShareId
832
+ if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
833
+ if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
834
+ if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
835
+ if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
380
836
  if (owner) submitPayload.owner = owner
381
837
  if (repo) submitPayload.repo = repo
838
+ if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
382
839
 
383
- const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
384
- let response: Response
385
840
  try {
386
- response = await fetch(endpoint, {
387
- method: 'POST',
388
- headers: {
389
- 'content-type': 'application/json',
390
- authorization: authHeaderValue(auth),
391
- },
392
- body: JSON.stringify(submitPayload),
393
- })
394
- } catch (err) {
395
- console.error(
396
- ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
397
- )
398
- return 1
399
- }
400
-
401
- if (!response.ok) {
402
- const body = await response.text()
403
- let message = body
404
- try {
405
- const json = JSON.parse(body)
406
- if (json.message) message = json.message
407
- } catch {}
408
- console.error(
409
- ` error: cloud test submission failed (${response.status}): ${message}`,
410
- )
411
- return 1
412
- }
413
-
414
- const { id } = (await response.json()) as { id: string }
415
- console.log(` cloud test job submitted: ${id}`)
416
-
417
- const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
418
- const startedAt = Date.now()
419
-
420
- while (Date.now() - startedAt < timeoutMs) {
421
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
422
- let pollRes: Response
841
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
842
+ let response: Response
423
843
  try {
424
- pollRes = await fetch(pollUrl, {
425
- headers: { authorization: authHeaderValue(auth) },
844
+ response = await fetch(endpoint, {
845
+ method: 'POST',
846
+ headers: {
847
+ 'content-type': 'application/json',
848
+ authorization: authHeaderValue(auth),
849
+ },
850
+ body: JSON.stringify(submitPayload),
426
851
  })
427
852
  } catch (err) {
428
- console.warn(
429
- ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
853
+ console.error(
854
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
430
855
  )
431
- continue
856
+ return 1
432
857
  }
433
- if (!pollRes.ok) {
434
- console.warn(` warn: polling error: HTTP ${pollRes.status}`)
435
- continue
858
+
859
+ if (!response.ok) {
860
+ const body = await response.text()
861
+ let message = body
862
+ try {
863
+ const json = JSON.parse(body)
864
+ if (json.message) message = json.message
865
+ } catch {}
866
+ console.error(
867
+ ` error: cloud test submission failed (${response.status}): ${message}`,
868
+ )
869
+ return 1
436
870
  }
437
- const job = (await pollRes.json()) as {
438
- id: string
439
- status: string
440
- result?: {
441
- previewId?: string | null
442
- previewUrl?: string | null
443
- runId?: string | null
444
- exitCode?: number | null
445
- failureReason?: string | null
446
- } | null
447
- }
448
-
449
- if (job.status === 'done' || job.status === 'failed' || job.status === 'expired') {
450
- const result = job.result
451
- // a job the recorder failed after a passing suite (missing recording or
452
- // registration) is still a failed run.
453
- const exitCode =
454
- job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
455
- console.log(` status: ${job.status}`)
456
- console.log(` exit code: ${exitCode}`)
457
- if (result?.previewUrl) {
458
- console.log(` preview: ${result.previewUrl}`)
459
- } else if (result?.previewId) {
460
- console.log(
461
- ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
871
+
872
+ const { id } = (await response.json()) as { id: string }
873
+ console.log(` cloud test job submitted: ${id}`)
874
+
875
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
876
+ const startedAt = Date.now()
877
+
878
+ while (Date.now() - startedAt < timeoutMs) {
879
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
880
+ let pollRes: Response
881
+ try {
882
+ pollRes = await fetch(pollUrl, {
883
+ headers: { authorization: authHeaderValue(auth) },
884
+ })
885
+ } catch (err) {
886
+ console.warn(
887
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
462
888
  )
889
+ continue
463
890
  }
464
- if (result?.runId) {
465
- console.log(` run: ${result.runId}`)
891
+ if (!pollRes.ok) {
892
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
893
+ continue
466
894
  }
467
- if (result?.failureReason) {
468
- console.error(` failure: ${result.failureReason}`)
895
+ const job = (await pollRes.json()) as {
896
+ id: string
897
+ status: string
898
+ result?: {
899
+ previewId?: string | null
900
+ previewUrl?: string | null
901
+ runId?: string | null
902
+ flows?: Array<{
903
+ flow?: string | null
904
+ previewId?: string | null
905
+ previewUrl?: string | null
906
+ runId?: string | null
907
+ }> | null
908
+ exitCode?: number | null
909
+ failureReason?: string | null
910
+ } | null
911
+ }
912
+
913
+ if (
914
+ job.status === 'done' ||
915
+ job.status === 'failed' ||
916
+ job.status === 'expired' ||
917
+ job.status === 'cancelled'
918
+ ) {
919
+ const result = job.result
920
+ // a job the recorder failed after a passing suite (missing recording or
921
+ // registration) is still a failed run.
922
+ const exitCode =
923
+ job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
924
+ console.log(` status: ${job.status}`)
925
+ console.log(` exit code: ${exitCode}`)
926
+ if (result?.previewUrl) {
927
+ console.log(` preview: ${result.previewUrl}`)
928
+ } else if (result?.previewId) {
929
+ console.log(
930
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
931
+ )
932
+ }
933
+ if (result?.runId) {
934
+ console.log(` run: ${result.runId}`)
935
+ }
936
+ if (result?.flows && result.flows.length > 1) {
937
+ result.flows.forEach((flow, index) => {
938
+ console.log(
939
+ ` flow ${index + 1}: ${flow.flow ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
940
+ )
941
+ })
942
+ }
943
+ if (result?.failureReason) {
944
+ console.error(` failure: ${result.failureReason}`)
945
+ }
946
+ return exitCode
469
947
  }
470
- return exitCode
471
948
  }
472
- }
473
949
 
474
- console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
475
- return 1
950
+ console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
951
+ return 1
952
+ } finally {
953
+ closeTunnel?.()
954
+ }
476
955
  }
477
956
 
478
957
  export async function runTest(
479
958
  args: string[],
480
959
  opts: RunTestOptions = {},
481
960
  ): Promise<number> {
482
- if (args.includes('--cloud') || args.includes('--hosted')) {
961
+ if (
962
+ args.includes('--cloud') ||
963
+ args.includes('--hosted') ||
964
+ args.some((arg) => arg === '--cancel' || arg.startsWith('--cancel='))
965
+ ) {
483
966
  return runCloudTest(args, opts)
484
967
  }
485
968
 
486
969
  const selected = resolveTestCommand(args)
487
- const app = valueAfter(args, '--app')
970
+ const app = appFromArgs(args)
488
971
  const appTarget = app
489
972
  ? await resolveTestAppTarget(app)
490
973
  : { target: '', close: async () => {} }