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
@@ -2,14 +2,20 @@
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
10
  import { authHeaderValue, resolveCliAuth } from '../auth'
11
+ import {
12
+ parseCloudTestSidecarPorts,
13
+ parseLiveDevStackApp,
14
+ publishLiveDevStack,
15
+ } from '../cloud-test-tunnel'
16
+ import { parseFlowFile } from '../flow-file'
11
17
  import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
12
- import { hoistLeadingSimFlag } from './flow'
18
+ import { FLOW_ARG_VALUE_FLAGS, hoistLeadingSimFlag } from './flow'
13
19
  import { resolveDefaultUploadOrigin } from './upload'
14
20
 
15
21
  export interface RunTestOptions {
@@ -24,6 +30,11 @@ interface TestAppTarget {
24
30
  close: () => Promise<void>
25
31
  }
26
32
 
33
+ // every flag that swallows the token after it, so the positional scan finds
34
+ // the flow/suite target no matter where the flags sit (`rnx test
35
+ // --include-tags smoke .maestro/` must not take `smoke` as the target).
36
+ // maestro compat flags live here alongside the flow-runner vocabulary;
37
+ // without them a flag-first maestro command line mis-resolves its target.
27
38
  const VALUE_FLAGS = new Set([
28
39
  '--app',
29
40
  '--bundle',
@@ -34,11 +45,22 @@ const VALUE_FLAGS = new Set([
34
45
  '--tab',
35
46
  '--port',
36
47
  '--device',
48
+ '-d',
49
+ '--env',
50
+ '-e',
37
51
  '--driver',
38
52
  '--base-url',
39
53
  '--url',
54
+ '--include-tags',
55
+ '--exclude-tags',
56
+ '--format',
57
+ '--output',
58
+ '--test-output-dir',
59
+ '--debug-output',
60
+ '--platform',
40
61
  '-t',
41
62
  '--testNamePattern',
63
+ '--testTimeout',
42
64
  '--grep',
43
65
  '--maxWorkers',
44
66
  '--shard',
@@ -48,6 +70,9 @@ const VALUE_FLAGS = new Set([
48
70
  '--repo',
49
71
  '--poll-interval',
50
72
  '--timeout',
73
+ '--sidecar-ports',
74
+ '--cancel',
75
+ ...FLOW_ARG_VALUE_FLAGS,
51
76
  ])
52
77
 
53
78
  function valueAfter(args: string[], name: string): string | null {
@@ -59,7 +84,7 @@ function valueAfter(args: string[], name: string): string | null {
59
84
  return value && !value.startsWith('-') ? value : null
60
85
  }
61
86
 
62
- function targetFromArgs(args: string[]): string | null {
87
+ function targetIndexFromArgs(args: string[]): number {
63
88
  for (let index = 0; index < args.length; index++) {
64
89
  const arg = args[index]
65
90
  if (VALUE_FLAGS.has(arg)) {
@@ -69,20 +94,31 @@ function targetFromArgs(args: string[]): string | null {
69
94
  if (arg.startsWith('--') && arg.includes('=')) {
70
95
  continue
71
96
  }
72
- if (!arg.startsWith('-')) return arg
97
+ if (!arg.startsWith('-')) return index
73
98
  }
74
- return null
99
+ return -1
100
+ }
101
+
102
+ function targetFromArgs(args: string[]): string | null {
103
+ const index = targetIndexFromArgs(args)
104
+ return index >= 0 ? args[index] : null
105
+ }
106
+
107
+ const APP_FLAGS = ['--app', '--bundle', '--bundle-url']
108
+
109
+ function isAppFlag(arg: string): boolean {
110
+ return APP_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`))
75
111
  }
76
112
 
77
113
  function withoutApp(args: string[]): string[] {
78
114
  const result: string[] = []
79
115
  for (let i = 0; i < args.length; i++) {
80
116
  const arg = args[i]
81
- if (arg === '--app') {
117
+ if (APP_FLAGS.includes(arg)) {
82
118
  i += 1
83
119
  continue
84
120
  }
85
- if (arg.startsWith('--app=')) {
121
+ if (isAppFlag(arg)) {
86
122
  continue
87
123
  }
88
124
  result.push(arg)
@@ -90,6 +126,25 @@ function withoutApp(args: string[]): string[] {
90
126
  return result
91
127
  }
92
128
 
129
+ // the maestro runner reads the app target as --url; rnx test spells it
130
+ // --app/--bundle/--bundle-url. map each spelling explicitly: a prefix
131
+ // replace would turn --bundle-url into --url-url.
132
+ function mapAppFlagToUrl(arg: string): string {
133
+ if (APP_FLAGS.includes(arg)) return '--url'
134
+ for (const flag of APP_FLAGS) {
135
+ if (arg.startsWith(`${flag}=`)) return `--url=${arg.slice(flag.length + 1)}`
136
+ }
137
+ return arg
138
+ }
139
+
140
+ function appFromArgs(args: string[]): string | null {
141
+ return (
142
+ valueAfter(args, '--app') ??
143
+ valueAfter(args, '--bundle') ??
144
+ valueAfter(args, '--bundle-url')
145
+ )
146
+ }
147
+
93
148
  function closeServer(server: Server): Promise<void> {
94
149
  return new Promise((resolveClose, rejectClose) => {
95
150
  server.close((error) => {
@@ -153,14 +208,30 @@ function restoreRnxUrl(previous: string | undefined): void {
153
208
  }
154
209
  }
155
210
 
211
+ // `--config` is claimed by both interpreters: a YAML value is a maestro
212
+ // workspace config, anything else is a detox jest config.
213
+ function isMaestroConfigValue(value: string | null): boolean {
214
+ return value !== null && /\.ya?ml$/i.test(value)
215
+ }
216
+
156
217
  // dispatch only: each interpreter retains its own selection and execution rules.
157
218
  export function resolveTestCommand(args: string[], cwd = process.cwd()) {
158
219
  const forwarded = hoistLeadingSimFlag(args)
159
- const target = forwarded[0]?.startsWith('-') ? undefined : forwarded[0]
160
- const config = forwarded.some(
220
+ const hasConfig = forwarded.some(
161
221
  (arg) => arg === '--config' || arg.startsWith('--config='),
162
222
  )
163
- if (config) return { command: 'detox', args: forwarded }
223
+ const maestroConfig =
224
+ hasConfig && isMaestroConfigValue(valueAfter(forwarded, '--config'))
225
+ if (hasConfig && !maestroConfig) return { command: 'detox', args: forwarded }
226
+ // the target is the first positional, wherever it sits: maestro users put
227
+ // flags before the path (`maestro test --include-tags smoke .maestro/`),
228
+ // so reading only argv[0] breaks the exact pasted command line.
229
+ let target = targetFromArgs(forwarded) ?? undefined
230
+ if (!target && maestroConfig) {
231
+ // `rnx test --config e2e/config.yaml`: the config describes a workspace,
232
+ // so run the directory it lives in.
233
+ target = dirname(resolve(cwd, valueAfter(forwarded, '--config') as string))
234
+ }
164
235
  if (!target) {
165
236
  throw new Error(
166
237
  'pass a Maestro flow/directory, a Jest suite, or --config <jest-config>',
@@ -182,19 +253,22 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
182
253
  ['.maestro', 'maestro'].includes(basename(absolute))
183
254
  : /\.ya?ml$/.test(extname(absolute))
184
255
  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
- ],
256
+ // when the target names a workspace config file, run its directory.
257
+ // the replacement lands on the target positional by index: with
258
+ // flag-first argv the target is not argv[0], and a path can equal a
259
+ // flag value elsewhere in the line.
260
+ const targetIndex = targetIndexFromArgs(forwarded)
261
+ const runConfigDir = !directory && /^config\.ya?ml$/.test(basename(absolute))
262
+ const mapped = forwarded.map((arg, index) => {
263
+ if (index === targetIndex && runConfigDir) return dirname(absolute)
264
+ return mapAppFlagToUrl(arg)
265
+ })
266
+ if (targetIndex < 0) {
267
+ // synthesized target (`--config e2e/config.yaml` with no positional):
268
+ // name the workspace dir so maestro does not auto-discover cwd.
269
+ mapped.push(target)
197
270
  }
271
+ return { command: 'maestro', args: ['test', ...mapped] }
198
272
  }
199
273
  if (!directory && !/\.[cm]?[jt]sx?$/.test(extname(absolute))) {
200
274
  throw new Error(`expected Maestro YAML or a Detox/Jest suite: ${target}`)
@@ -202,6 +276,55 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
202
276
  return { command: 'detox', args: forwarded }
203
277
  }
204
278
 
279
+ // helper files a single flow pulls in through runFlow / runScript file
280
+ // refs, resolved relative to each referencing file exactly like the runner,
281
+ // transitively with a cycle guard. a ref that does not exist is the runner's
282
+ // loud failure, not the submitter's: warn and let the hosted run report it
283
+ // at its step, like a local run does.
284
+ function collectFlowHelperPaths(flowAbsPath: string): string[] {
285
+ if (!flowAbsPath.endsWith('.yml') && !flowAbsPath.endsWith('.yaml')) return []
286
+ const helpers: string[] = []
287
+ const visited = new Set<string>([resolve(flowAbsPath)])
288
+ const queue = [resolve(flowAbsPath)]
289
+ while (queue.length > 0) {
290
+ const current = queue.pop() as string
291
+ if (!current.endsWith('.yml') && !current.endsWith('.yaml')) continue
292
+ let steps: unknown[]
293
+ try {
294
+ steps = parseFlowFile(readFileSync(current, 'utf8')).steps
295
+ } catch (err) {
296
+ console.warn(
297
+ ` warn: could not parse flow refs in ${current}: ${err instanceof Error ? err.message : String(err)}`,
298
+ )
299
+ continue
300
+ }
301
+ for (const step of steps) {
302
+ if (typeof step !== 'object' || step === null) continue
303
+ for (const key of ['runFlow', 'runScript'] as const) {
304
+ const spec = (step as Record<string, unknown>)[key]
305
+ if (spec === undefined || spec === null) continue
306
+ const file =
307
+ typeof spec === 'string'
308
+ ? spec
309
+ : typeof (spec as Record<string, unknown>).file === 'string'
310
+ ? ((spec as Record<string, unknown>).file as string)
311
+ : null
312
+ if (!file) continue
313
+ const abs = resolve(dirname(current), file)
314
+ if (visited.has(abs)) continue
315
+ visited.add(abs)
316
+ if (existsSync(abs) && statSync(abs).isFile()) {
317
+ helpers.push(abs)
318
+ queue.push(abs)
319
+ } else {
320
+ console.warn(` warn: flow ref not found: ${file} (from ${current})`)
321
+ }
322
+ }
323
+ }
324
+ }
325
+ return helpers
326
+ }
327
+
205
328
  function collectTestFiles(
206
329
  targetPath: string,
207
330
  ignoredPath: string | null,
@@ -240,8 +363,48 @@ function collectTestFiles(
240
363
  }
241
364
  walk(targetPath, '')
242
365
  } else if (stat.isFile()) {
243
- add(targetPath, basename(targetPath))
244
366
  const parent = dirname(targetPath)
367
+ const helperAbsPaths = collectFlowHelperPaths(targetPath)
368
+ if (helperAbsPaths.length === 0) {
369
+ add(targetPath, basename(targetPath))
370
+ } else {
371
+ // the flow references helpers by path relative to itself (runFlow /
372
+ // runScript file). key every file by its path under their common root
373
+ // so the recorder's unpack preserves those relative refs.
374
+ const all = [resolve(targetPath), ...helperAbsPaths]
375
+ const split = all.map((abs) => abs.split(sep))
376
+ const commonParts = split[0]?.slice(0) ?? []
377
+ while (
378
+ commonParts.length > 0 &&
379
+ !split.every(
380
+ (parts) =>
381
+ parts.length >= commonParts.length &&
382
+ commonParts.every((part, i) => parts[i] === part),
383
+ )
384
+ ) {
385
+ commonParts.pop()
386
+ }
387
+ const common = commonParts.join(sep) || sep
388
+ const keys = all.map((abs) => relative(common, abs).split(sep).join('/'))
389
+ for (const [index, abs] of all.entries()) {
390
+ add(abs, keys[index] as string)
391
+ }
392
+ // the recorder runs an unpacked multi-file upload as a workspace,
393
+ // whose default glob only sees top-level flows: a nested entry would
394
+ // lose to a top-level helper. pin a config that selects the entry.
395
+ if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
396
+ if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
397
+ throw new Error(
398
+ `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
399
+ )
400
+ }
401
+ const entryKey = keys[0] as string
402
+ const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
403
+ files['config.yaml'] =
404
+ '# synthesized by rnx test --cloud: run exactly the submitted entry flow.\n' +
405
+ `flows:\n - ${JSON.stringify(glob)}\n`
406
+ }
407
+ }
245
408
  for (const configName of [
246
409
  'rnx-detox.config.cjs',
247
410
  'jest.config.cjs',
@@ -262,6 +425,82 @@ function collectTestFiles(
262
425
  return files
263
426
  }
264
427
 
428
+ async function readJobStatus(
429
+ endpoint: string,
430
+ authorization: string,
431
+ ): Promise<string | null> {
432
+ try {
433
+ const res = await fetch(endpoint, { headers: { authorization } })
434
+ if (!res.ok) return null
435
+ const json = (await res.json()) as { status?: unknown } | null
436
+ return json && typeof json.status === 'string' ? json.status : null
437
+ } catch {
438
+ return null
439
+ }
440
+ }
441
+
442
+ export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
443
+ const auth = resolveCliAuth()
444
+ if (!auth) {
445
+ console.error(' error: cancelling a cloud test requires `rnx login` or RNX_API_KEY')
446
+ return 1
447
+ }
448
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
449
+ let response: Response
450
+ try {
451
+ response = await fetch(endpoint, {
452
+ method: 'DELETE',
453
+ headers: { authorization: authHeaderValue(auth) },
454
+ })
455
+ } catch (err) {
456
+ console.error(
457
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
458
+ )
459
+ return 1
460
+ }
461
+ if (response.status === 404) {
462
+ console.error(` error: cloud test job not found: ${jobId}`)
463
+ return 1
464
+ }
465
+ if (!response.ok) {
466
+ const body = await response.text()
467
+ let message = body
468
+ try {
469
+ const json = JSON.parse(body)
470
+ if (json.message) message = json.message
471
+ } catch {}
472
+ console.error(` error: cancel failed (${response.status}): ${message}`)
473
+ return 1
474
+ }
475
+ let status: string | null = null
476
+ try {
477
+ const json = (await response.json()) as { status?: unknown } | null
478
+ if (json && typeof json.status === 'string') status = json.status
479
+ } catch {
480
+ // an older server answers 2xx with no cancel body; fall through to the
481
+ // read-back below rather than claim anything.
482
+ }
483
+ if (status === null) {
484
+ status = await readJobStatus(endpoint, authHeaderValue(auth))
485
+ if (status === 'queued' || status === 'running') {
486
+ console.error(
487
+ ` error: the server left ${jobId} ${status}; cancel needs a newer job api`,
488
+ )
489
+ return 1
490
+ }
491
+ if (status === null) {
492
+ console.error(` error: cancel sent but the state of ${jobId} is unknown`)
493
+ return 1
494
+ }
495
+ }
496
+ if (status === 'cancelled') {
497
+ console.log(` cloud test job cancelled: ${jobId}`)
498
+ } else {
499
+ console.log(` cloud test job already ${status}: ${jobId}`)
500
+ }
501
+ return 0
502
+ }
503
+
265
504
  export async function runCloudTest(
266
505
  args: string[],
267
506
  opts: RunTestOptions = {},
@@ -269,6 +508,14 @@ export async function runCloudTest(
269
508
  const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted')
270
509
  const originFlag = valueAfter(forwarded, '--origin')
271
510
  const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined)
511
+ const cancelId = valueAfter(forwarded, '--cancel')
512
+ if (forwarded.includes('--cancel') || cancelId !== null) {
513
+ if (!cancelId) {
514
+ console.error(' usage: rnx test --cancel <job-id>')
515
+ return 1
516
+ }
517
+ return cancelCloudTest(cancelId, origin)
518
+ }
272
519
  const app =
273
520
  valueAfter(forwarded, '--app') ||
274
521
  valueAfter(forwarded, '--bundle') ||
@@ -284,7 +531,11 @@ export async function runCloudTest(
284
531
  let target = targetFromArgs(forwarded)
285
532
  const config = valueAfter(forwarded, '--config')
286
533
  if (!target && config) {
287
- target = config
534
+ // a maestro workspace config uploads its directory; a jest config is
535
+ // collected from its own dir by the path logic below.
536
+ target = isMaestroConfigValue(config)
537
+ ? dirname(resolve(process.cwd(), config))
538
+ : config
288
539
  }
289
540
  if (!target) {
290
541
  for (const cand of ['.maestro', 'maestro', 'flows', 'e2e']) {
@@ -307,11 +558,25 @@ export async function runCloudTest(
307
558
 
308
559
  if (!app) {
309
560
  console.error(
310
- ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url>',
561
+ ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url|http://localhost:port>',
311
562
  )
312
563
  return 1
313
564
  }
314
565
 
566
+ const localOnly = [
567
+ '--include-tags',
568
+ '--exclude-tags',
569
+ '--format',
570
+ '--output',
571
+ '--test-output-dir',
572
+ '--debug-output',
573
+ ].filter((flag) => forwarded.some((arg) => arg === flag || arg.startsWith(`${flag}=`)))
574
+ if (localOnly.length > 0) {
575
+ console.warn(
576
+ ` warn: ${localOnly.join(', ')} apply to local runs only and are ignored by --cloud`,
577
+ )
578
+ }
579
+
315
580
  const auth = resolveCliAuth()
316
581
  if (!auth) {
317
582
  console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
@@ -336,18 +601,41 @@ export async function runCloudTest(
336
601
  let bundleUrl: string | undefined
337
602
  let bundle: string | undefined
338
603
  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=/, '')
604
+ let tunnelTargetUrl: string | undefined
605
+ let tunnelHostHeader: string | undefined
606
+ let tunnelJobToken: string | undefined
607
+ let tunnelSidecarPorts: string | undefined
608
+ let closeTunnel: (() => void) | null = null
609
+
610
+ const liveDevStack = parseLiveDevStackApp(app)
611
+ const sidecarFlag = valueAfter(forwarded, '--sidecar-ports')
612
+ let liveSidecarPorts: number[] | null = null
613
+ if (liveDevStack) {
614
+ const parsedSidecars = parseCloudTestSidecarPorts(sidecarFlag, liveDevStack.port)
615
+ if (!parsedSidecars.ok) {
616
+ console.error(` error: ${parsedSidecars.message}`)
617
+ return 1
618
+ }
619
+ liveSidecarPorts = parsedSidecars.ports
344
620
  } 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}`)
621
+ if (sidecarFlag !== null) {
622
+ console.error(
623
+ ' error: --sidecar-ports requires a live dev stack --app (http://localhost:<port>)',
624
+ )
348
625
  return 1
349
626
  }
350
- bundle = readFileSync(localBundle, 'utf8')
627
+ if (app.startsWith('http://') || app.startsWith('https://')) {
628
+ bundleUrl = app
629
+ } else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) {
630
+ bundleShareId = app.replace(/^.*id=/, '')
631
+ } else {
632
+ const localBundle = resolve(process.cwd(), app)
633
+ if (!existsSync(localBundle) || !statSync(localBundle).isFile()) {
634
+ console.error(` error: --app bundle file not found: ${app}`)
635
+ return 1
636
+ }
637
+ bundle = readFileSync(localBundle, 'utf8')
638
+ }
351
639
  }
352
640
 
353
641
  const targetName = basename(targetPath)
@@ -370,6 +658,26 @@ export async function runCloudTest(
370
658
  return 1
371
659
  }
372
660
 
661
+ // the tunnel stays open from submit until the job reaches a terminal
662
+ // state; tearing it down early strands the recorder mid-run.
663
+ if (liveDevStack && liveSidecarPorts) {
664
+ try {
665
+ const published = await publishLiveDevStack({
666
+ devPort: liveDevStack.port,
667
+ sidecarPorts: liveSidecarPorts,
668
+ })
669
+ tunnelTargetUrl = published.targetUrl
670
+ tunnelHostHeader = published.hostHeader
671
+ tunnelJobToken = published.jobToken
672
+ tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
673
+ closeTunnel = published.close
674
+ console.log(` live dev stack published: ${published.targetUrl}`)
675
+ } catch (err) {
676
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
677
+ return 1
678
+ }
679
+ }
680
+
373
681
  const submitPayload: Record<string, unknown> = {
374
682
  kind: 'test',
375
683
  testFiles,
@@ -377,114 +685,131 @@ export async function runCloudTest(
377
685
  if (bundleUrl) submitPayload.bundleUrl = bundleUrl
378
686
  if (bundle) submitPayload.bundle = bundle
379
687
  if (bundleShareId) submitPayload.bundleShareId = bundleShareId
688
+ if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
689
+ if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
690
+ if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
691
+ if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
380
692
  if (owner) submitPayload.owner = owner
381
693
  if (repo) submitPayload.repo = repo
382
694
 
383
- const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
384
- let response: Response
385
695
  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
696
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
697
+ let response: Response
404
698
  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
423
- try {
424
- pollRes = await fetch(pollUrl, {
425
- headers: { authorization: authHeaderValue(auth) },
699
+ response = await fetch(endpoint, {
700
+ method: 'POST',
701
+ headers: {
702
+ 'content-type': 'application/json',
703
+ authorization: authHeaderValue(auth),
704
+ },
705
+ body: JSON.stringify(submitPayload),
426
706
  })
427
707
  } catch (err) {
428
- console.warn(
429
- ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
708
+ console.error(
709
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
430
710
  )
431
- continue
432
- }
433
- if (!pollRes.ok) {
434
- console.warn(` warn: polling error: HTTP ${pollRes.status}`)
435
- continue
711
+ return 1
436
712
  }
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
713
+
714
+ if (!response.ok) {
715
+ const body = await response.text()
716
+ let message = body
717
+ try {
718
+ const json = JSON.parse(body)
719
+ if (json.message) message = json.message
720
+ } catch {}
721
+ console.error(
722
+ ` error: cloud test submission failed (${response.status}): ${message}`,
723
+ )
724
+ return 1
447
725
  }
448
726
 
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}`,
727
+ const { id } = (await response.json()) as { id: string }
728
+ console.log(` cloud test job submitted: ${id}`)
729
+
730
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
731
+ const startedAt = Date.now()
732
+
733
+ while (Date.now() - startedAt < timeoutMs) {
734
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
735
+ let pollRes: Response
736
+ try {
737
+ pollRes = await fetch(pollUrl, {
738
+ headers: { authorization: authHeaderValue(auth) },
739
+ })
740
+ } catch (err) {
741
+ console.warn(
742
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
462
743
  )
744
+ continue
463
745
  }
464
- if (result?.runId) {
465
- console.log(` run: ${result.runId}`)
746
+ if (!pollRes.ok) {
747
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
748
+ continue
466
749
  }
467
- if (result?.failureReason) {
468
- console.error(` failure: ${result.failureReason}`)
750
+ const job = (await pollRes.json()) as {
751
+ id: string
752
+ status: string
753
+ result?: {
754
+ previewId?: string | null
755
+ previewUrl?: string | null
756
+ runId?: string | null
757
+ exitCode?: number | null
758
+ failureReason?: string | null
759
+ } | null
760
+ }
761
+
762
+ if (
763
+ job.status === 'done' ||
764
+ job.status === 'failed' ||
765
+ job.status === 'expired' ||
766
+ job.status === 'cancelled'
767
+ ) {
768
+ const result = job.result
769
+ // a job the recorder failed after a passing suite (missing recording or
770
+ // registration) is still a failed run.
771
+ const exitCode =
772
+ job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
773
+ console.log(` status: ${job.status}`)
774
+ console.log(` exit code: ${exitCode}`)
775
+ if (result?.previewUrl) {
776
+ console.log(` preview: ${result.previewUrl}`)
777
+ } else if (result?.previewId) {
778
+ console.log(
779
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
780
+ )
781
+ }
782
+ if (result?.runId) {
783
+ console.log(` run: ${result.runId}`)
784
+ }
785
+ if (result?.failureReason) {
786
+ console.error(` failure: ${result.failureReason}`)
787
+ }
788
+ return exitCode
469
789
  }
470
- return exitCode
471
790
  }
472
- }
473
791
 
474
- console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
475
- return 1
792
+ console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
793
+ return 1
794
+ } finally {
795
+ closeTunnel?.()
796
+ }
476
797
  }
477
798
 
478
799
  export async function runTest(
479
800
  args: string[],
480
801
  opts: RunTestOptions = {},
481
802
  ): Promise<number> {
482
- if (args.includes('--cloud') || args.includes('--hosted')) {
803
+ if (
804
+ args.includes('--cloud') ||
805
+ args.includes('--hosted') ||
806
+ args.some((arg) => arg === '--cancel' || arg.startsWith('--cancel='))
807
+ ) {
483
808
  return runCloudTest(args, opts)
484
809
  }
485
810
 
486
811
  const selected = resolveTestCommand(args)
487
- const app = valueAfter(args, '--app')
812
+ const app = appFromArgs(args)
488
813
  const appTarget = app
489
814
  ? await resolveTestAppTarget(app)
490
815
  : { target: '', close: async () => {} }