rnxsim 0.1.538 → 0.1.540

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 (60) hide show
  1. package/cli/cloud-test-tunnel.ts +354 -0
  2. package/cli/commands/test.ts +333 -92
  3. package/cli/outbound-endpoints.ts +17 -1
  4. package/dist-lib/agent-daemon-client.cjs +1 -1
  5. package/dist-lib/agent-events.cjs +1 -1
  6. package/dist-lib/agent-identity.cjs +1 -1
  7. package/dist-lib/agent-sessions.cjs +1 -1
  8. package/dist-lib/attached-projects.cjs +1 -1
  9. package/dist-lib/auth/shared-session.cjs +1 -1
  10. package/dist-lib/backend-origin.cjs +1 -1
  11. package/dist-lib/beta.cjs +1 -1
  12. package/dist-lib/beta.mjs +1 -1
  13. package/dist-lib/bridge-constants.cjs +1 -1
  14. package/dist-lib/bridge-contract-input.cjs +1 -1
  15. package/dist-lib/bridge-contract-input.mjs +1 -1
  16. package/dist-lib/bridge-contract.cjs +1 -1
  17. package/dist-lib/bridge-contract.mjs +1 -1
  18. package/dist-lib/capture-contract.cjs +1 -1
  19. package/dist-lib/capture-contract.mjs +1 -1
  20. package/dist-lib/cli-constants.cjs +1 -1
  21. package/dist-lib/cloud-contract.cjs +1 -1
  22. package/dist-lib/cloud-contract.mjs +1 -1
  23. package/dist-lib/cloud.cjs +1 -1
  24. package/dist-lib/cloud.mjs +1 -1
  25. package/dist-lib/config.cjs +1 -1
  26. package/dist-lib/detox/index.cjs +1 -1
  27. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  28. package/dist-lib/home-paths.cjs +1 -1
  29. package/dist-lib/host/bridge-host.cjs +28 -2
  30. package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
  31. package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
  32. package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
  33. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  34. package/dist-lib/host/websocket-proxy.cjs +1 -1
  35. package/dist-lib/index.cjs +1 -1
  36. package/dist-lib/jump-to-source-babel.cjs +1 -1
  37. package/dist-lib/jump-to-source-native.cjs +1 -1
  38. package/dist-lib/menu.cjs +1 -1
  39. package/dist-lib/menu.mjs +1 -1
  40. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  41. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  42. package/dist-lib/metro-production-bundle.cjs +1 -1
  43. package/dist-lib/metro-production-bundle.mjs +1 -1
  44. package/dist-lib/metro.cjs +1 -1
  45. package/dist-lib/profiles.cjs +1 -1
  46. package/dist-lib/public-brand.cjs +1 -1
  47. package/dist-lib/react-native-host-modules.cjs +1 -1
  48. package/dist-lib/react-native-host-modules.mjs +1 -1
  49. package/dist-lib/render-mode.cjs +1 -1
  50. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  51. package/dist-lib/sdk.cjs +1 -1
  52. package/dist-lib/sdk.mjs +1 -1
  53. package/dist-lib/skills.cjs +29 -3
  54. package/dist-lib/swift-submit.cjs +1 -1
  55. package/dist-lib/swift-submit.mjs +1 -1
  56. package/dist-lib/swift.cjs +1 -1
  57. package/dist-lib/vite.cjs +1 -1
  58. package/package.json +1 -1
  59. package/src/host/fetch-proxy-handler.ts +2 -1
  60. package/src/host/fetch-proxy-overrides.ts +41 -0
@@ -2,12 +2,18 @@
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
18
  import { hoistLeadingSimFlag } from './flow'
13
19
  import { resolveDefaultUploadOrigin } from './upload'
@@ -48,6 +54,8 @@ const VALUE_FLAGS = new Set([
48
54
  '--repo',
49
55
  '--poll-interval',
50
56
  '--timeout',
57
+ '--sidecar-ports',
58
+ '--cancel',
51
59
  ])
52
60
 
53
61
  function valueAfter(args: string[], name: string): string | null {
@@ -202,6 +210,55 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
202
210
  return { command: 'detox', args: forwarded }
203
211
  }
204
212
 
213
+ // helper files a single flow pulls in through runFlow / runScript file
214
+ // refs, resolved relative to each referencing file exactly like the runner,
215
+ // transitively with a cycle guard. a ref that does not exist is the runner's
216
+ // loud failure, not the submitter's: warn and let the hosted run report it
217
+ // at its step, like a local run does.
218
+ function collectFlowHelperPaths(flowAbsPath: string): string[] {
219
+ if (!flowAbsPath.endsWith('.yml') && !flowAbsPath.endsWith('.yaml')) return []
220
+ const helpers: string[] = []
221
+ const visited = new Set<string>([resolve(flowAbsPath)])
222
+ const queue = [resolve(flowAbsPath)]
223
+ while (queue.length > 0) {
224
+ const current = queue.pop() as string
225
+ if (!current.endsWith('.yml') && !current.endsWith('.yaml')) continue
226
+ let steps: unknown[]
227
+ try {
228
+ steps = parseFlowFile(readFileSync(current, 'utf8')).steps
229
+ } catch (err) {
230
+ console.warn(
231
+ ` warn: could not parse flow refs in ${current}: ${err instanceof Error ? err.message : String(err)}`,
232
+ )
233
+ continue
234
+ }
235
+ for (const step of steps) {
236
+ if (typeof step !== 'object' || step === null) continue
237
+ for (const key of ['runFlow', 'runScript'] as const) {
238
+ const spec = (step as Record<string, unknown>)[key]
239
+ if (spec === undefined || spec === null) continue
240
+ const file =
241
+ typeof spec === 'string'
242
+ ? spec
243
+ : typeof (spec as Record<string, unknown>).file === 'string'
244
+ ? ((spec as Record<string, unknown>).file as string)
245
+ : null
246
+ if (!file) continue
247
+ const abs = resolve(dirname(current), file)
248
+ if (visited.has(abs)) continue
249
+ visited.add(abs)
250
+ if (existsSync(abs) && statSync(abs).isFile()) {
251
+ helpers.push(abs)
252
+ queue.push(abs)
253
+ } else {
254
+ console.warn(` warn: flow ref not found: ${file} (from ${current})`)
255
+ }
256
+ }
257
+ }
258
+ }
259
+ return helpers
260
+ }
261
+
205
262
  function collectTestFiles(
206
263
  targetPath: string,
207
264
  ignoredPath: string | null,
@@ -240,8 +297,48 @@ function collectTestFiles(
240
297
  }
241
298
  walk(targetPath, '')
242
299
  } else if (stat.isFile()) {
243
- add(targetPath, basename(targetPath))
244
300
  const parent = dirname(targetPath)
301
+ const helperAbsPaths = collectFlowHelperPaths(targetPath)
302
+ if (helperAbsPaths.length === 0) {
303
+ add(targetPath, basename(targetPath))
304
+ } else {
305
+ // the flow references helpers by path relative to itself (runFlow /
306
+ // runScript file). key every file by its path under their common root
307
+ // so the recorder's unpack preserves those relative refs.
308
+ const all = [resolve(targetPath), ...helperAbsPaths]
309
+ const split = all.map((abs) => abs.split(sep))
310
+ const commonParts = split[0]?.slice(0) ?? []
311
+ while (
312
+ commonParts.length > 0 &&
313
+ !split.every(
314
+ (parts) =>
315
+ parts.length >= commonParts.length &&
316
+ commonParts.every((part, i) => parts[i] === part),
317
+ )
318
+ ) {
319
+ commonParts.pop()
320
+ }
321
+ const common = commonParts.join(sep) || sep
322
+ const keys = all.map((abs) => relative(common, abs).split(sep).join('/'))
323
+ for (const [index, abs] of all.entries()) {
324
+ add(abs, keys[index] as string)
325
+ }
326
+ // the recorder runs an unpacked multi-file upload as a workspace,
327
+ // whose default glob only sees top-level flows: a nested entry would
328
+ // lose to a top-level helper. pin a config that selects the entry.
329
+ if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
330
+ if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
331
+ throw new Error(
332
+ `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
333
+ )
334
+ }
335
+ const entryKey = keys[0] as string
336
+ const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
337
+ files['config.yaml'] =
338
+ '# synthesized by rnx test --cloud: run exactly the submitted entry flow.\n' +
339
+ `flows:\n - ${JSON.stringify(glob)}\n`
340
+ }
341
+ }
245
342
  for (const configName of [
246
343
  'rnx-detox.config.cjs',
247
344
  'jest.config.cjs',
@@ -262,6 +359,82 @@ function collectTestFiles(
262
359
  return files
263
360
  }
264
361
 
362
+ async function readJobStatus(
363
+ endpoint: string,
364
+ authorization: string,
365
+ ): Promise<string | null> {
366
+ try {
367
+ const res = await fetch(endpoint, { headers: { authorization } })
368
+ if (!res.ok) return null
369
+ const json = (await res.json()) as { status?: unknown } | null
370
+ return json && typeof json.status === 'string' ? json.status : null
371
+ } catch {
372
+ return null
373
+ }
374
+ }
375
+
376
+ export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
377
+ const auth = resolveCliAuth()
378
+ if (!auth) {
379
+ console.error(' error: cancelling a cloud test requires `rnx login` or RNX_API_KEY')
380
+ return 1
381
+ }
382
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
383
+ let response: Response
384
+ try {
385
+ response = await fetch(endpoint, {
386
+ method: 'DELETE',
387
+ headers: { authorization: authHeaderValue(auth) },
388
+ })
389
+ } catch (err) {
390
+ console.error(
391
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
392
+ )
393
+ return 1
394
+ }
395
+ if (response.status === 404) {
396
+ console.error(` error: cloud test job not found: ${jobId}`)
397
+ return 1
398
+ }
399
+ if (!response.ok) {
400
+ const body = await response.text()
401
+ let message = body
402
+ try {
403
+ const json = JSON.parse(body)
404
+ if (json.message) message = json.message
405
+ } catch {}
406
+ console.error(` error: cancel failed (${response.status}): ${message}`)
407
+ return 1
408
+ }
409
+ let status: string | null = null
410
+ try {
411
+ const json = (await response.json()) as { status?: unknown } | null
412
+ if (json && typeof json.status === 'string') status = json.status
413
+ } catch {
414
+ // an older server answers 2xx with no cancel body; fall through to the
415
+ // read-back below rather than claim anything.
416
+ }
417
+ if (status === null) {
418
+ status = await readJobStatus(endpoint, authHeaderValue(auth))
419
+ if (status === 'queued' || status === 'running') {
420
+ console.error(
421
+ ` error: the server left ${jobId} ${status}; cancel needs a newer job api`,
422
+ )
423
+ return 1
424
+ }
425
+ if (status === null) {
426
+ console.error(` error: cancel sent but the state of ${jobId} is unknown`)
427
+ return 1
428
+ }
429
+ }
430
+ if (status === 'cancelled') {
431
+ console.log(` cloud test job cancelled: ${jobId}`)
432
+ } else {
433
+ console.log(` cloud test job already ${status}: ${jobId}`)
434
+ }
435
+ return 0
436
+ }
437
+
265
438
  export async function runCloudTest(
266
439
  args: string[],
267
440
  opts: RunTestOptions = {},
@@ -269,6 +442,14 @@ export async function runCloudTest(
269
442
  const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted')
270
443
  const originFlag = valueAfter(forwarded, '--origin')
271
444
  const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined)
445
+ const cancelId = valueAfter(forwarded, '--cancel')
446
+ if (forwarded.includes('--cancel') || cancelId !== null) {
447
+ if (!cancelId) {
448
+ console.error(' usage: rnx test --cancel <job-id>')
449
+ return 1
450
+ }
451
+ return cancelCloudTest(cancelId, origin)
452
+ }
272
453
  const app =
273
454
  valueAfter(forwarded, '--app') ||
274
455
  valueAfter(forwarded, '--bundle') ||
@@ -307,7 +488,7 @@ export async function runCloudTest(
307
488
 
308
489
  if (!app) {
309
490
  console.error(
310
- ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url>',
491
+ ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url|http://localhost:port>',
311
492
  )
312
493
  return 1
313
494
  }
@@ -336,18 +517,41 @@ export async function runCloudTest(
336
517
  let bundleUrl: string | undefined
337
518
  let bundle: string | undefined
338
519
  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=/, '')
520
+ let tunnelTargetUrl: string | undefined
521
+ let tunnelHostHeader: string | undefined
522
+ let tunnelJobToken: string | undefined
523
+ let tunnelSidecarPorts: string | undefined
524
+ let closeTunnel: (() => void) | null = null
525
+
526
+ const liveDevStack = parseLiveDevStackApp(app)
527
+ const sidecarFlag = valueAfter(forwarded, '--sidecar-ports')
528
+ let liveSidecarPorts: number[] | null = null
529
+ if (liveDevStack) {
530
+ const parsedSidecars = parseCloudTestSidecarPorts(sidecarFlag, liveDevStack.port)
531
+ if (!parsedSidecars.ok) {
532
+ console.error(` error: ${parsedSidecars.message}`)
533
+ return 1
534
+ }
535
+ liveSidecarPorts = parsedSidecars.ports
344
536
  } 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}`)
537
+ if (sidecarFlag !== null) {
538
+ console.error(
539
+ ' error: --sidecar-ports requires a live dev stack --app (http://localhost:<port>)',
540
+ )
348
541
  return 1
349
542
  }
350
- bundle = readFileSync(localBundle, 'utf8')
543
+ if (app.startsWith('http://') || app.startsWith('https://')) {
544
+ bundleUrl = app
545
+ } else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) {
546
+ bundleShareId = app.replace(/^.*id=/, '')
547
+ } else {
548
+ const localBundle = resolve(process.cwd(), app)
549
+ if (!existsSync(localBundle) || !statSync(localBundle).isFile()) {
550
+ console.error(` error: --app bundle file not found: ${app}`)
551
+ return 1
552
+ }
553
+ bundle = readFileSync(localBundle, 'utf8')
554
+ }
351
555
  }
352
556
 
353
557
  const targetName = basename(targetPath)
@@ -370,6 +574,26 @@ export async function runCloudTest(
370
574
  return 1
371
575
  }
372
576
 
577
+ // the tunnel stays open from submit until the job reaches a terminal
578
+ // state; tearing it down early strands the recorder mid-run.
579
+ if (liveDevStack && liveSidecarPorts) {
580
+ try {
581
+ const published = await publishLiveDevStack({
582
+ devPort: liveDevStack.port,
583
+ sidecarPorts: liveSidecarPorts,
584
+ })
585
+ tunnelTargetUrl = published.targetUrl
586
+ tunnelHostHeader = published.hostHeader
587
+ tunnelJobToken = published.jobToken
588
+ tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
589
+ closeTunnel = published.close
590
+ console.log(` live dev stack published: ${published.targetUrl}`)
591
+ } catch (err) {
592
+ console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
593
+ return 1
594
+ }
595
+ }
596
+
373
597
  const submitPayload: Record<string, unknown> = {
374
598
  kind: 'test',
375
599
  testFiles,
@@ -377,109 +601,126 @@ export async function runCloudTest(
377
601
  if (bundleUrl) submitPayload.bundleUrl = bundleUrl
378
602
  if (bundle) submitPayload.bundle = bundle
379
603
  if (bundleShareId) submitPayload.bundleShareId = bundleShareId
604
+ if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
605
+ if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
606
+ if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
607
+ if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
380
608
  if (owner) submitPayload.owner = owner
381
609
  if (repo) submitPayload.repo = repo
382
610
 
383
- const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
384
- let response: Response
385
611
  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
612
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
613
+ let response: Response
404
614
  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) },
615
+ response = await fetch(endpoint, {
616
+ method: 'POST',
617
+ headers: {
618
+ 'content-type': 'application/json',
619
+ authorization: authHeaderValue(auth),
620
+ },
621
+ body: JSON.stringify(submitPayload),
426
622
  })
427
623
  } catch (err) {
428
- console.warn(
429
- ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
624
+ console.error(
625
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
430
626
  )
431
- continue
432
- }
433
- if (!pollRes.ok) {
434
- console.warn(` warn: polling error: HTTP ${pollRes.status}`)
435
- continue
627
+ return 1
436
628
  }
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
629
+
630
+ if (!response.ok) {
631
+ const body = await response.text()
632
+ let message = body
633
+ try {
634
+ const json = JSON.parse(body)
635
+ if (json.message) message = json.message
636
+ } catch {}
637
+ console.error(
638
+ ` error: cloud test submission failed (${response.status}): ${message}`,
639
+ )
640
+ return 1
447
641
  }
448
642
 
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}`,
643
+ const { id } = (await response.json()) as { id: string }
644
+ console.log(` cloud test job submitted: ${id}`)
645
+
646
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
647
+ const startedAt = Date.now()
648
+
649
+ while (Date.now() - startedAt < timeoutMs) {
650
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
651
+ let pollRes: Response
652
+ try {
653
+ pollRes = await fetch(pollUrl, {
654
+ headers: { authorization: authHeaderValue(auth) },
655
+ })
656
+ } catch (err) {
657
+ console.warn(
658
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
462
659
  )
660
+ continue
661
+ }
662
+ if (!pollRes.ok) {
663
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
664
+ continue
463
665
  }
464
- if (result?.runId) {
465
- console.log(` run: ${result.runId}`)
666
+ const job = (await pollRes.json()) as {
667
+ id: string
668
+ status: string
669
+ result?: {
670
+ previewId?: string | null
671
+ previewUrl?: string | null
672
+ runId?: string | null
673
+ exitCode?: number | null
674
+ failureReason?: string | null
675
+ } | null
466
676
  }
467
- if (result?.failureReason) {
468
- console.error(` failure: ${result.failureReason}`)
677
+
678
+ if (
679
+ job.status === 'done' ||
680
+ job.status === 'failed' ||
681
+ job.status === 'expired' ||
682
+ job.status === 'cancelled'
683
+ ) {
684
+ const result = job.result
685
+ // a job the recorder failed after a passing suite (missing recording or
686
+ // registration) is still a failed run.
687
+ const exitCode =
688
+ job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
689
+ console.log(` status: ${job.status}`)
690
+ console.log(` exit code: ${exitCode}`)
691
+ if (result?.previewUrl) {
692
+ console.log(` preview: ${result.previewUrl}`)
693
+ } else if (result?.previewId) {
694
+ console.log(
695
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
696
+ )
697
+ }
698
+ if (result?.runId) {
699
+ console.log(` run: ${result.runId}`)
700
+ }
701
+ if (result?.failureReason) {
702
+ console.error(` failure: ${result.failureReason}`)
703
+ }
704
+ return exitCode
469
705
  }
470
- return exitCode
471
706
  }
472
- }
473
707
 
474
- console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
475
- return 1
708
+ console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
709
+ return 1
710
+ } finally {
711
+ closeTunnel?.()
712
+ }
476
713
  }
477
714
 
478
715
  export async function runTest(
479
716
  args: string[],
480
717
  opts: RunTestOptions = {},
481
718
  ): Promise<number> {
482
- if (args.includes('--cloud') || args.includes('--hosted')) {
719
+ if (
720
+ args.includes('--cloud') ||
721
+ args.includes('--hosted') ||
722
+ args.some((arg) => arg === '--cancel' || arg.startsWith('--cancel='))
723
+ ) {
483
724
  return runCloudTest(args, opts)
484
725
  }
485
726
 
@@ -133,6 +133,13 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
133
133
  payload:
134
134
  'the Metro JavaScript bundle selected by rnx ios --remote, then structured simulator commands and lifecycle requests',
135
135
  },
136
+ tunnel_tool_download: {
137
+ category: 'explicit-user-action',
138
+ destination: 'https://github.com/cloudflare/cloudflared/releases/latest/download/',
139
+ preConsent: false,
140
+ payload:
141
+ 'platform and architecture only, carried in the release asset name; fires only for rnx test --cloud with a live dev stack when no cloudflared binary is installed',
142
+ },
136
143
  }
137
144
 
138
145
  export interface OutboundCallDeclaration {
@@ -192,14 +199,23 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
192
199
  category: 'guest_app_network',
193
200
  count: 1,
194
201
  },
202
+ // submitting a cloud test job (POST) and cancelling one (DELETE) share
203
+ // the jobs route: one destination, two call sites.
195
204
  'packages/sootsim/cli/commands/test.ts :: fetch :: endpoint': {
196
205
  category: 'contrast_user_action',
197
- count: 1,
206
+ count: 3,
198
207
  },
199
208
  'packages/sootsim/cli/commands/test.ts :: fetch :: pollUrl': {
200
209
  category: 'contrast_user_action',
201
210
  count: 1,
202
211
  },
212
+ // the live-dev-stack publisher fetches a cloudflared binary only when none
213
+ // is installed; the token proxy below it forwards to the user's own
214
+ // loopback dev server and sidecars, never anywhere remote.
215
+ 'packages/sootsim/cli/cloud-test-tunnel.ts :: fetch :: `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`':
216
+ { category: 'tunnel_tool_download', count: 1 },
217
+ "packages/sootsim/cli/cloud-test-tunnel.ts :: node-request :: { host: '127.0.0.1', port: selectedPort, path: req.url, method: req.method, headers, }":
218
+ { category: 'local_development', count: 1 },
203
219
  // compiling a project's .swift sources needs a swift toolchain, so the
204
220
  // bundler posts them to the app's own /api/swift-compile. it only fires with
205
221
  // a compile credential and only for a project that has .swift sources.
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
package/dist-lib/beta.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/beta.ts
4
4
  var IS_BETA = true;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var SIM_LONG_PRESS_MAX_MS = 5e3;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __defProp = Object.defineProperty;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.538 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.540 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
 
3
3
  // src/bridge-contract.ts
4
4
  var REFUSED_PERFORM_STEP_TYPES = [