peachsim 0.1.570 → 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 (79) hide show
  1. package/README.md +2 -2
  2. package/cli/bridge-flow-runner.ts +15 -7
  3. package/cli/cloud-dispatch.ts +1 -0
  4. package/cli/cloud-test-result-files.ts +97 -0
  5. package/cli/commands/maestro.ts +123 -31
  6. package/cli/commands/test.ts +161 -3
  7. package/cli/maestro-report.ts +3 -1
  8. package/cli/outbound-endpoints.ts +2 -0
  9. package/cli/run-registry.ts +2 -1
  10. package/detox/jest-preset.cjs +2 -2
  11. package/dist-lib/agent-daemon-client.cjs +1 -1
  12. package/dist-lib/agent-events.cjs +1 -1
  13. package/dist-lib/agent-identity.cjs +1 -1
  14. package/dist-lib/agent-sessions.cjs +1 -1
  15. package/dist-lib/attached-projects.cjs +1 -1
  16. package/dist-lib/auth/shared-session.cjs +1 -1
  17. package/dist-lib/backend-origin.cjs +1 -1
  18. package/dist-lib/beta.cjs +1 -1
  19. package/dist-lib/beta.mjs +1 -1
  20. package/dist-lib/bridge-constants.cjs +1 -1
  21. package/dist-lib/bridge-contract-input.cjs +1 -1
  22. package/dist-lib/bridge-contract-input.mjs +1 -1
  23. package/dist-lib/bridge-contract.cjs +1 -1
  24. package/dist-lib/bridge-contract.mjs +1 -1
  25. package/dist-lib/capture-contract.cjs +1 -1
  26. package/dist-lib/capture-contract.mjs +1 -1
  27. package/dist-lib/cli-constants.cjs +1 -1
  28. package/dist-lib/cloud-contract.cjs +1 -1
  29. package/dist-lib/cloud-contract.mjs +1 -1
  30. package/dist-lib/cloud-test-args.cjs +116 -0
  31. package/dist-lib/cloud-test-args.mjs +86 -0
  32. package/dist-lib/cloud.cjs +1 -1
  33. package/dist-lib/cloud.mjs +1 -1
  34. package/dist-lib/config.cjs +1 -1
  35. package/dist-lib/detox/index.cjs +1 -1
  36. package/dist-lib/detox/jest-preset.cjs +2 -2
  37. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  38. package/dist-lib/home-paths.cjs +1 -1
  39. package/dist-lib/host/bridge-host.cjs +16 -7
  40. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  41. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  42. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  43. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  44. package/dist-lib/host/websocket-proxy.cjs +1 -1
  45. package/dist-lib/index.cjs +16 -26
  46. package/dist-lib/jump-to-source-babel.cjs +1 -1
  47. package/dist-lib/jump-to-source-native.cjs +1 -1
  48. package/dist-lib/menu.cjs +1 -1
  49. package/dist-lib/menu.mjs +1 -1
  50. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  51. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  52. package/dist-lib/metro-production-bundle.cjs +1 -1
  53. package/dist-lib/metro-production-bundle.mjs +1 -1
  54. package/dist-lib/metro.cjs +1 -1
  55. package/dist-lib/profiles.cjs +1 -1
  56. package/dist-lib/public-brand.cjs +1 -1
  57. package/dist-lib/react-native-host-modules.cjs +1 -1
  58. package/dist-lib/react-native-host-modules.mjs +1 -1
  59. package/dist-lib/render-mode.cjs +1 -1
  60. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  61. package/dist-lib/sdk.cjs +1 -1
  62. package/dist-lib/sdk.mjs +1 -1
  63. package/dist-lib/skills.cjs +119 -106
  64. package/dist-lib/swift-compile.cjs +372 -0
  65. package/dist-lib/swift-compile.mjs +339 -0
  66. package/dist-lib/swift-package.cjs +78 -0
  67. package/dist-lib/swift-package.mjs +52 -0
  68. package/dist-lib/swift-submit.cjs +1 -1
  69. package/dist-lib/swift-submit.mjs +1 -1
  70. package/dist-lib/swift.cjs +1 -1
  71. package/dist-lib/vite.cjs +456 -138
  72. package/package.json +22 -1
  73. package/skills/rnx-test/SKILL.md +3 -3
  74. package/src/cloud-test-args.ts +100 -0
  75. package/src/rolldown-plugin-swift.ts +8 -7
  76. package/src/swift-compile.ts +359 -0
  77. package/src/swift-package.ts +69 -0
  78. package/src/swift-submit.ts +3 -2
  79. package/src/vite-plugin-swift.ts +103 -107
package/README.md CHANGED
@@ -33,7 +33,7 @@ What this workspace gives you:
33
33
  - `rnxsim/jump-to-source-babel` — an optional Babel plugin that annotates JSX
34
34
  with source locations so inspect mode can open the selected element in your
35
35
  editor
36
- - `rnxsim/detox` — a drop-in Detox driver + jest preset
36
+ - `peachsim/detox` — a drop-in Detox driver + jest preset
37
37
  - `rnxsim/sdk` — the inspect/interact verbs as a programmatic API
38
38
  - `rnxsim/bridge-contract-input` — parsers turning untrusted JSON into
39
39
  bridge-contract arguments
@@ -445,7 +445,7 @@ no code changes — just add one line to your jest config:
445
445
 
446
446
  ```js
447
447
  // jest config
448
- { preset: 'rnxsim/detox' }
448
+ { preset: 'peachsim/detox' }
449
449
  ```
450
450
 
451
451
  Or use the CLI directly:
@@ -1163,7 +1163,9 @@ export class SootSimBridgeFlowRunner {
1163
1163
 
1164
1164
  async assertVisible(target: string | { id?: string; text?: string }) {
1165
1165
  const opts = typeof target === 'string' ? { text: target } : target
1166
- for (let attempt = 0; attempt < 15; attempt++) {
1166
+ // maestro auto-retries assertions for up to 7 seconds before failing; a
1167
+ // shorter window fails flows real maestro passes.
1168
+ for (let attempt = 0; attempt < 35; attempt++) {
1167
1169
  if (await this.isElementVisible(opts)) {
1168
1170
  return
1169
1171
  }
@@ -1184,13 +1186,19 @@ export class SootSimBridgeFlowRunner {
1184
1186
 
1185
1187
  async assertNotVisible(target: string | { id?: string; text?: string }) {
1186
1188
  const opts = typeof target === 'string' ? { text: target } : target
1187
- if (await this.isElementVisible(opts)) {
1188
- const found = await this.findElement(opts)
1189
- const where = found
1190
- ? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
1191
- : ''
1192
- throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
1189
+ // like assertVisible, maestro waits up to 7 seconds for the element to
1190
+ // disappear rather than failing on the first visible sample.
1191
+ for (let attempt = 0; attempt < 35; attempt++) {
1192
+ if (!(await this.isElementVisible(opts))) {
1193
+ return
1194
+ }
1195
+ await sleep(200)
1193
1196
  }
1197
+ const found = await this.findElement(opts)
1198
+ const where = found
1199
+ ? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
1200
+ : ''
1201
+ throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
1194
1202
  }
1195
1203
 
1196
1204
  // visibility is the engine's call, not a layout-bounds guess. the old test
@@ -29,6 +29,7 @@ const CLOUD_TARGET_COMMANDS = new Set([
29
29
  'shell',
30
30
  'state',
31
31
  'storage',
32
+ 'test',
32
33
  'timeline',
33
34
  'wait',
34
35
  'what-happened',
@@ -0,0 +1,97 @@
1
+ // shared cloud-test result-file contract between the test interpreters and
2
+ // the hosted recorder.
3
+ //
4
+ // each maestro flow writes its recording + registration ids where the
5
+ // recorder collects them. single-flow jobs write only the bare
6
+ // preview-result.json / test-result.json; a multi-flow run additionally
7
+ // writes one numbered pair per flow so no flow's ids overwrite another's.
8
+ // the bare file stays the last flow's, so older readers keep working.
9
+ // every flow writes its files unconditionally: a failed upload or
10
+ // registration leaves null ids, never a missing entry, so the collector
11
+ // always sees one entry per flow in run order.
12
+
13
+ import { readdirSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ export interface CloudTestFlowResult {
17
+ flow: string | null
18
+ previewId: string | null
19
+ previewUrl: string | null
20
+ runId: string | null
21
+ }
22
+
23
+ // every file one flow's ids land in: the bare file always (last flow wins,
24
+ // as before), plus the flow's own numbered file on multi-flow runs.
25
+ export function cloudTestResultFileNames(
26
+ kind: 'preview' | 'test',
27
+ flowIndex: number,
28
+ flowCount: number,
29
+ ): string[] {
30
+ const base = kind === 'preview' ? 'preview-result' : 'test-result'
31
+ if (flowCount < 2) return [`${base}.json`]
32
+ return [`${base}.json`, `${base}-${flowIndex + 1}.json`]
33
+ }
34
+
35
+ function readResultJson(workDir: string, name: string): Record<string, unknown> | null {
36
+ try {
37
+ const parsed: unknown = JSON.parse(readFileSync(join(workDir, name), 'utf8'))
38
+ return parsed && typeof parsed === 'object'
39
+ ? (parsed as Record<string, unknown>)
40
+ : null
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ function stringField(obj: Record<string, unknown> | null, name: string): string | null {
47
+ const value = obj?.[name]
48
+ return typeof value === 'string' ? value : null
49
+ }
50
+
51
+ function previewIdFrom(preview: Record<string, unknown> | null): string | null {
52
+ return (
53
+ stringField(preview, 'previewId') ??
54
+ stringField(preview, 'previewUrl')?.match(/\/preview\/([a-z0-9_-]+)/i)?.[1] ??
55
+ null
56
+ )
57
+ }
58
+
59
+ // every flow's ids in run order: the numbered files when the runner wrote
60
+ // them, else the single bare pair. missing or unparseable files read as
61
+ // null ids, never throw. discovery unions both sides: an upload failure
62
+ // leaves no preview file and a registration failure leaves no test file,
63
+ // and either way the flow keeps its entry with null ids instead of
64
+ // disappearing and letting a later flow pose as the last.
65
+ export function collectCloudTestFlowResults(
66
+ workDir: string,
67
+ cloudOrigin: string,
68
+ ): CloudTestFlowResult[] {
69
+ const one = (previewName: string, testName: string): CloudTestFlowResult => {
70
+ const preview = readResultJson(workDir, previewName)
71
+ const test = readResultJson(workDir, testName)
72
+ const previewId = previewIdFrom(preview)
73
+ return {
74
+ flow: stringField(preview, 'flow') ?? stringField(test, 'flow'),
75
+ previewId,
76
+ previewUrl:
77
+ stringField(preview, 'previewUrl') ??
78
+ (previewId ? `${cloudOrigin.replace(/\/$/, '')}/preview/${previewId}` : null),
79
+ runId: stringField(test, 'runId'),
80
+ }
81
+ }
82
+ let numbered: number[] = []
83
+ try {
84
+ const indices = new Set<number>()
85
+ for (const name of readdirSync(workDir)) {
86
+ const match = name.match(/^(?:preview|test)-result-(\d+)\.json$/)
87
+ if (match) indices.add(Number(match[1]))
88
+ }
89
+ numbered = [...indices].sort((a, b) => a - b)
90
+ } catch {
91
+ numbered = []
92
+ }
93
+ if (numbered.length > 0) {
94
+ return numbered.map((n) => one(`preview-result-${n}.json`, `test-result-${n}.json`))
95
+ }
96
+ return [one('preview-result.json', 'test-result.json')]
97
+ }
@@ -10,6 +10,7 @@ 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 { cloudTestResultFileNames } from '../cloud-test-result-files'
13
14
  import { parseFlowFile, type FlowFrontmatter } from '../flow-file'
14
15
  import {
15
16
  buildFlowCommandsJson,
@@ -65,13 +66,22 @@ options:
65
66
  --config <path> read the workspace config (flows/tags) from this file
66
67
  instead of <dir>/config.yaml
67
68
  --format junit write a junit xml report (default ./report.xml)
69
+ JUNIT, NOOP also accepted (case-insensitive)
68
70
  --output <file> junit report path (needs --format junit)
69
71
  --test-output-dir <dir>
70
72
  per-flow screenshots, commands.json, and logs/maestro.log
71
73
  (config key testOutputDir:; CLI wins over config)
72
74
  --debug-output <dir> write a session maestro.log there
75
+ --flatten-debug-output accepted; rnx output is already flat
76
+ --test-suite-name <n> junit testsuite name (default "Test Suite")
73
77
  --platform ios accepted; any other platform is refused (rnx is iOS-only)
78
+ (-p stays the rnx bridge-port short)
74
79
  --no-ansi accepted; rnx output is already plain text
80
+ --udid <id> warned; rnx manages its own sim (pin one with --sim)
81
+ -s, --shards <n> warned; rnx runs the full selection on one sim
82
+ --shard-all, --shard-split <spec>
83
+ warned; rnx runs the full selection on one sim
84
+ -c warned; --continuous runs once under rnx
75
85
  --device <model> override the flow frontmatter device for this run
76
86
  --new force a fresh browser window for this run
77
87
  --headed force a fresh, VISIBLE window (implies --new,
@@ -162,11 +172,13 @@ function looksLikeFlag(s: string): boolean {
162
172
  // extract --env KEY=VALUE pairs from argv and apply to process.env for the
163
173
  // duration of the run. returns argv with the consumed flags removed so the
164
174
  // rest can be forwarded to runFlowPlayback verbatim.
165
- function applyEnvFlags(args: string[]): string[] {
175
+ export function applyEnvFlags(args: string[]): string[] {
166
176
  const out: string[] = []
167
177
  for (let i = 0; i < args.length; i++) {
168
- if (args[i] === '--env' || args[i] === '-e') {
169
- const kv = args[i + 1]
178
+ const arg = args[i] as string
179
+ const inline = arg.startsWith('--env=') ? arg.slice('--env='.length) : null
180
+ if (arg === '--env' || arg === '-e' || inline !== null) {
181
+ const kv = inline ?? args[i + 1]
170
182
  if (!kv || !kv.includes('=')) {
171
183
  throw new Error(`--env expects KEY=VALUE (got ${JSON.stringify(kv ?? '')})`)
172
184
  }
@@ -174,10 +186,10 @@ function applyEnvFlags(args: string[]): string[] {
174
186
  const key = kv.slice(0, eq)
175
187
  const value = kv.slice(eq + 1)
176
188
  process.env[key] = value
177
- i += 1
189
+ if (inline === null) i += 1
178
190
  continue
179
191
  }
180
- out.push(args[i])
192
+ out.push(arg)
181
193
  }
182
194
  return out
183
195
  }
@@ -204,6 +216,7 @@ export interface MaestroCompatOptions {
204
216
  testOutputDir: string | null
205
217
  debugOutput: string | null
206
218
  platform: string | null
219
+ testSuiteName: string | null
207
220
  }
208
221
 
209
222
  const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
@@ -215,6 +228,14 @@ const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
215
228
  '--test-output-dir',
216
229
  '--debug-output',
217
230
  '--platform',
231
+ '--udid',
232
+ '--test-suite-name',
233
+ '--shards',
234
+ '--shard-all',
235
+ '--shard-split',
236
+ '--api-key',
237
+ '--api-url',
238
+ '--screen-size',
218
239
  ])
219
240
 
220
241
  function splitTagList(value: string): string[] {
@@ -237,6 +258,7 @@ export function extractMaestroCompatFlags(args: string[]): {
237
258
  testOutputDir: null,
238
259
  debugOutput: null,
239
260
  platform: null,
261
+ testSuiteName: null,
240
262
  }
241
263
  const rest: string[] = []
242
264
  for (let i = 0; i < args.length; i++) {
@@ -267,7 +289,8 @@ export function extractMaestroCompatFlags(args: string[]): {
267
289
  compat.configPath = value
268
290
  break
269
291
  case '--format':
270
- compat.format = value
292
+ // maestro spells it JUNIT; normalize so either case works.
293
+ compat.format = value.toLowerCase()
271
294
  break
272
295
  case '--output':
273
296
  compat.output = value
@@ -284,6 +307,28 @@ export function extractMaestroCompatFlags(args: string[]): {
284
307
  }
285
308
  compat.platform = value
286
309
  break
310
+ case '--udid':
311
+ console.warn(
312
+ ' warn: --udid selects a Maestro simulator; rnx manages its own sim — pin one with --sim instead',
313
+ )
314
+ break
315
+ case '--test-suite-name':
316
+ compat.testSuiteName = value
317
+ break
318
+ case '--shards':
319
+ case '--shard-all':
320
+ case '--shard-split':
321
+ console.warn(
322
+ ` warn: ${head} shards across Maestro devices; rnx runs the full selection on one sim`,
323
+ )
324
+ break
325
+ case '--api-key':
326
+ case '--api-url':
327
+ console.warn(` warn: ${head} is Maestro beta AI analysis; rnx ignores it`)
328
+ break
329
+ case '--screen-size':
330
+ console.warn(' warn: --screen-size is web-only; rnx ignores it')
331
+ break
287
332
  }
288
333
  continue
289
334
  }
@@ -299,22 +344,62 @@ export function extractMaestroCompatFlags(args: string[]): {
299
344
  rest.push('--device')
300
345
  continue
301
346
  }
302
- if (a === '--no-ansi') {
347
+ if (a === '--no-ansi' || a === '--no-color') {
303
348
  // trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
304
349
  continue
305
350
  }
306
- if (a === '--continuous') {
351
+ if (a === '--ansi' || a === '--color') {
352
+ console.warn(' warn: rnx prints no ANSI escapes; colored output is unavailable')
353
+ continue
354
+ }
355
+ if (a === '--continuous' || a === '-c') {
307
356
  console.warn(' warn: --continuous is not implemented in rnx — running once')
308
357
  continue
309
358
  }
359
+ if (a === '--flatten-debug-output') {
360
+ // rnx writes one <dir>/maestro.log with no per-run subfolders or
361
+ // timestamps, so the maestro layout flag is already satisfied.
362
+ continue
363
+ }
364
+ if (a === '--analyze') {
365
+ console.warn(' warn: --analyze is Maestro beta AI analysis; rnx ignores it')
366
+ continue
367
+ }
368
+ if (a === '-s') {
369
+ const count = args[i + 1]
370
+ if (!count || count.startsWith('-')) throw new Error('-s expects a value')
371
+ i += 1
372
+ console.warn(
373
+ ' warn: -s shards across Maestro devices; rnx runs the full selection on one sim',
374
+ )
375
+ continue
376
+ }
377
+ if (head === '-p') {
378
+ // -p is the rnx bridge-port short, not maestro's --platform short. a
379
+ // non-numeric value is a pasted maestro command line; fail loud with
380
+ // the mapping instead of pinning the bridge to a NaN port.
381
+ const value = eq > 0 ? a.slice(eq + 1) : args[i + 1]
382
+ if (value !== undefined && !value.startsWith('-') && !/^\d+$/.test(value)) {
383
+ throw new Error(
384
+ `-p is the rnx bridge-port short; use --platform for the Maestro platform (got ${JSON.stringify(value)})`,
385
+ )
386
+ }
387
+ rest.push(a)
388
+ continue
389
+ }
310
390
  rest.push(a)
311
391
  }
312
392
  if (
313
393
  compat.format !== null &&
314
- !['junit', 'html', 'html-detailed'].includes(compat.format)
394
+ !['junit', 'html', 'html-detailed', 'noop'].includes(compat.format)
315
395
  ) {
316
396
  throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`)
317
397
  }
398
+ if (compat.format === 'html' || compat.format === 'html-detailed') {
399
+ console.warn(
400
+ ' warn: --format html is not implemented in rnx — running without a report',
401
+ )
402
+ }
318
403
  return { rest, compat }
319
404
  }
320
405
 
@@ -705,7 +790,7 @@ export async function runMaestro(
705
790
  steps: ReturnType<typeof getLastFlowTraceSteps>
706
791
  frontmatter: FlowFrontmatter
707
792
  }> = []
708
- for (const file of files) {
793
+ for (const [flowIndex, file] of files.entries()) {
709
794
  console.log(`\n ▶ ${path.relative(cwd, file)}`)
710
795
  const playbackArgs = [file, ...forwardArgs]
711
796
  let flowTestDir: string | null = null
@@ -789,17 +874,20 @@ export async function runMaestro(
789
874
  }
790
875
 
791
876
  const upload = getLastFlowPreviewUploadResult()
792
- if (upload?.shareId || upload?.previewUrl) {
793
- try {
794
- fs.writeFileSync(
795
- path.join(cwd, 'preview-result.json'),
796
- JSON.stringify({
797
- previewId: upload.shareId,
798
- previewUrl: upload.previewUrl,
799
- }),
800
- )
801
- } catch {}
802
- } else if (args.includes('--preview')) {
877
+ // the receipt files are the per-flow manifest: always written, with
878
+ // null ids when the upload failed, so the flow never disappears from
879
+ // the collected entries.
880
+ const previewPayload = {
881
+ flow: path.relative(cwd, file) || file,
882
+ previewId: upload?.shareId ?? null,
883
+ previewUrl: upload?.previewUrl ?? null,
884
+ }
885
+ try {
886
+ for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
887
+ fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
888
+ }
889
+ } catch {}
890
+ if (!upload?.shareId && !upload?.previewUrl && args.includes('--preview')) {
803
891
  console.error(' error: required hosted recording upload failed')
804
892
  if (exit === 0) exit = 1
805
893
  if (worstExit === 0) worstExit = 1
@@ -822,21 +910,24 @@ export async function runMaestro(
822
910
  })
823
911
  if (run) {
824
912
  console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`)
825
- try {
826
- fs.writeFileSync(
827
- path.join(cwd, 'test-result.json'),
828
- JSON.stringify({
829
- runId: run.id,
830
- previewUrl: run.previewUrl,
831
- traceUrl: run.traceUrl,
832
- }),
833
- )
834
- } catch {}
835
913
  } else if (process.env.RNX_REQUIRE_REGISTRATION === 'true') {
836
914
  console.error(' error: required hosted run registration failed')
837
915
  if (exit === 0) exit = 1
838
916
  if (worstExit === 0) worstExit = 1
839
917
  }
918
+ // like the preview receipt above: always written, null ids when the
919
+ // registration failed, so the flow keeps its collected entry.
920
+ const testPayload = {
921
+ flow: path.relative(cwd, file) || file,
922
+ runId: run?.id ?? null,
923
+ previewUrl: run?.previewUrl ?? null,
924
+ traceUrl: run?.traceUrl ?? null,
925
+ }
926
+ try {
927
+ for (const name of cloudTestResultFileNames('test', flowIndex, files.length)) {
928
+ fs.writeFileSync(path.join(cwd, name), JSON.stringify(testPayload))
929
+ }
930
+ } catch {}
840
931
  }
841
932
  // artifact, upload, and registration failures above flip the local exit
842
933
  // after the result was pushed; the report below must see the final one.
@@ -865,6 +956,7 @@ export async function runMaestro(
865
956
  buildJUnitXml({
866
957
  device: deviceLabel,
867
958
  cases: results.map((r) => junitCaseForResult(cwd, r)),
959
+ ...(compat.testSuiteName ? { name: compat.testSuiteName } : {}),
868
960
  }),
869
961
  )
870
962
  console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`)
@@ -7,6 +7,7 @@ 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'
11
12
  import {
12
13
  parseCloudTestSidecarPorts,
@@ -58,8 +59,18 @@ const VALUE_FLAGS = new Set([
58
59
  '--test-output-dir',
59
60
  '--debug-output',
60
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',
61
71
  '-t',
62
72
  '--testNamePattern',
73
+ '--testPathPattern',
63
74
  '--testTimeout',
64
75
  '--grep',
65
76
  '--maxWorkers',
@@ -88,7 +99,10 @@ function targetIndexFromArgs(args: string[]): number {
88
99
  for (let index = 0; index < args.length; index++) {
89
100
  const arg = args[index]
90
101
  if (VALUE_FLAGS.has(arg)) {
91
- 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
92
106
  continue
93
107
  }
94
108
  if (arg.startsWith('--') && arg.includes('=')) {
@@ -104,6 +118,122 @@ function targetFromArgs(args: string[]): string | null {
104
118
  return index >= 0 ? args[index] : null
105
119
  }
106
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
233
+ }
234
+ return { testArgs, warnings }
235
+ }
236
+
107
237
  const APP_FLAGS = ['--app', '--bundle', '--bundle-url']
108
238
 
109
239
  function isAppFlag(arg: string): boolean {
@@ -564,8 +694,6 @@ export async function runCloudTest(
564
694
  }
565
695
 
566
696
  const localOnly = [
567
- '--include-tags',
568
- '--exclude-tags',
569
697
  '--format',
570
698
  '--output',
571
699
  '--test-output-dir',
@@ -577,6 +705,22 @@ export async function runCloudTest(
577
705
  )
578
706
  }
579
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
+
580
724
  const auth = resolveCliAuth()
581
725
  if (!auth) {
582
726
  console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
@@ -691,6 +835,7 @@ export async function runCloudTest(
691
835
  if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
692
836
  if (owner) submitPayload.owner = owner
693
837
  if (repo) submitPayload.repo = repo
838
+ if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
694
839
 
695
840
  try {
696
841
  const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
@@ -754,6 +899,12 @@ export async function runCloudTest(
754
899
  previewId?: string | null
755
900
  previewUrl?: string | null
756
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
757
908
  exitCode?: number | null
758
909
  failureReason?: string | null
759
910
  } | null
@@ -782,6 +933,13 @@ export async function runCloudTest(
782
933
  if (result?.runId) {
783
934
  console.log(` run: ${result.runId}`)
784
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
+ }
785
943
  if (result?.failureReason) {
786
944
  console.error(` failure: ${result.failureReason}`)
787
945
  }
@@ -34,6 +34,8 @@ export interface JUnitCaseInput {
34
34
  export interface JUnitSuiteInput {
35
35
  device: string
36
36
  cases: JUnitCaseInput[]
37
+ // --test-suite-name; absent keeps the existing "Test Suite" name.
38
+ name?: string
37
39
  }
38
40
 
39
41
  function formatJUnitTime(seconds: number): string {
@@ -49,7 +51,7 @@ export function buildJUnitXml(suite: JUnitSuiteInput): string {
49
51
  const lines = [
50
52
  '<?xml version="1.0" encoding="UTF-8"?>',
51
53
  '<testsuites>',
52
- ` <testsuite name="Test Suite" device="${escapeXml(suite.device)}" tests="${suite.cases.length}" failures="${failures}" time="${formatJUnitTime(totalTime)}">`,
54
+ ` <testsuite name="${escapeXml(suite.name ?? 'Test Suite')}" device="${escapeXml(suite.device)}" tests="${suite.cases.length}" failures="${failures}" time="${formatJUnitTime(totalTime)}">`,
53
55
  ]
54
56
  for (const c of suite.cases) {
55
57
  lines.push(