rnxsim 0.1.499 → 0.1.501

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 (71) hide show
  1. package/README.md +1 -1
  2. package/cli/bridge-flow-runner.ts +6 -2
  3. package/cli/cloud-client.ts +90 -7
  4. package/cli/cloud-dispatch.ts +27 -27
  5. package/cli/cloud-session.ts +61 -6
  6. package/cli/commands/control.ts +33 -31
  7. package/cli/commands/detox.ts +157 -159
  8. package/cli/commands/inspect/core.ts +2 -0
  9. package/cli/commands/platform.ts +170 -48
  10. package/cli/commands/preview.ts +1 -1
  11. package/cli/commands/test.ts +403 -95
  12. package/cli/main.ts +11 -0
  13. package/cli/outbound-endpoints.ts +10 -0
  14. package/cli/ws-bridge.ts +18 -19
  15. package/detox/jest-preset.cjs +1 -6
  16. package/dist-lib/agent-daemon-client.cjs +1 -1
  17. package/dist-lib/agent-events.cjs +1 -1
  18. package/dist-lib/agent-identity.cjs +1 -1
  19. package/dist-lib/agent-sessions.cjs +1 -1
  20. package/dist-lib/attached-projects.cjs +1 -1
  21. package/dist-lib/auth/shared-session.cjs +1 -1
  22. package/dist-lib/backend-origin.cjs +1 -1
  23. package/dist-lib/beta.cjs +1 -1
  24. package/dist-lib/beta.mjs +1 -1
  25. package/dist-lib/bridge-constants.cjs +1 -1
  26. package/dist-lib/bridge-contract-input.cjs +1 -1
  27. package/dist-lib/bridge-contract-input.mjs +1 -1
  28. package/dist-lib/bridge-contract.cjs +1 -1
  29. package/dist-lib/bridge-contract.mjs +1 -1
  30. package/dist-lib/capture-contract.cjs +1 -1
  31. package/dist-lib/capture-contract.mjs +1 -1
  32. package/dist-lib/cli-constants.cjs +1 -1
  33. package/dist-lib/cloud-contract.cjs +7 -1
  34. package/dist-lib/cloud-contract.mjs +5 -1
  35. package/dist-lib/cloud.cjs +1 -1
  36. package/dist-lib/cloud.mjs +1 -1
  37. package/dist-lib/config.cjs +1 -1
  38. package/dist-lib/detox/index.cjs +1 -1
  39. package/dist-lib/detox/jest-preset.cjs +1 -6
  40. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  41. package/dist-lib/home-paths.cjs +1 -1
  42. package/dist-lib/host/bridge-host.cjs +54 -29
  43. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  46. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  47. package/dist-lib/host/websocket-proxy.cjs +1 -1
  48. package/dist-lib/index.cjs +52 -22
  49. package/dist-lib/jump-to-source-babel.cjs +1 -1
  50. package/dist-lib/jump-to-source-native.cjs +1 -1
  51. package/dist-lib/menu.cjs +1 -1
  52. package/dist-lib/menu.mjs +1 -1
  53. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  54. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  55. package/dist-lib/metro-production-bundle.cjs +1 -1
  56. package/dist-lib/metro-production-bundle.mjs +1 -1
  57. package/dist-lib/metro.cjs +1 -1
  58. package/dist-lib/profiles.cjs +1 -1
  59. package/dist-lib/public-brand.cjs +1 -1
  60. package/dist-lib/react-native-host-modules.cjs +1 -1
  61. package/dist-lib/react-native-host-modules.mjs +1 -1
  62. package/dist-lib/render-mode.cjs +1 -1
  63. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  64. package/dist-lib/sdk.cjs +7 -8
  65. package/dist-lib/sdk.mjs +7 -8
  66. package/dist-lib/skills.cjs +128 -63
  67. package/dist-lib/vite.cjs +1 -1
  68. package/package.json +1 -1
  69. package/skills/rnx-test/SKILL.md +2 -2
  70. package/src/cloud-contract.ts +2 -0
  71. package/src/sim-client.ts +8 -10
@@ -1,23 +1,33 @@
1
- // rnx test — choose the existing Maestro or Detox interpreter from a test path.
1
+ // rnx test — choose Maestro or Detox, support local bundle loopback and standalone cloud execution.
2
2
 
3
- import { existsSync, readFileSync, statSync } from 'fs'
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
4
4
  import { createServer, type Server } from 'node:http'
5
- import { basename, extname, join, resolve } from 'path'
5
+ import { basename, dirname, extname, join, resolve } from 'node:path'
6
+ import {
7
+ RNX_CLOUD_TEST_FILES_MAX_BYTES,
8
+ RNX_CLOUD_TEST_FILES_MAX_COUNT,
9
+ } from '../../src/cloud-contract'
10
+ import { authHeaderValue, resolveCliAuth } from '../auth'
6
11
  import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
7
- import { runDetox } from './detox'
8
- import { runMaestro } from './maestro'
9
-
10
- type TestRunner = 'maestro' | 'detox'
12
+ import { hoistLeadingSimFlag } from './flow'
13
+ import { resolveDefaultUploadOrigin } from './upload'
11
14
 
12
15
  export interface RunTestOptions {
13
16
  port?: number
14
17
  verbose?: boolean
18
+ pollIntervalMs?: number
19
+ timeoutMs?: number
20
+ }
21
+
22
+ interface TestAppTarget {
23
+ target: string
24
+ close: () => Promise<void>
15
25
  }
16
26
 
17
- const MAESTRO_CONFIG_NAMES = new Set(['config.yaml', 'config.yml'])
18
- const DETOX_CONFIG_NAME = /^jest\.config\.(?:[cm]?[jt]s)$/
19
27
  const VALUE_FLAGS = new Set([
20
28
  '--app',
29
+ '--bundle',
30
+ '--bundle-url',
21
31
  '--config',
22
32
  '--sim',
23
33
  '--session',
@@ -33,51 +43,16 @@ const VALUE_FLAGS = new Set([
33
43
  '--maxWorkers',
34
44
  '--shard',
35
45
  '--outputFile',
46
+ '--origin',
47
+ '--owner',
48
+ '--repo',
49
+ '--poll-interval',
50
+ '--timeout',
36
51
  ])
37
52
 
38
- function isDetoxPackageConfig(path: string): boolean {
39
- if (basename(path) !== 'package.json') return false
40
- try {
41
- const value: unknown = JSON.parse(readFileSync(path, 'utf8'))
42
- if (!value || typeof value !== 'object' || Array.isArray(value)) return false
43
- return 'detox' in value || 'jest' in value
44
- } catch {
45
- return false
46
- }
47
- }
48
-
49
- function classifyTestPath(path: string): TestRunner | null {
50
- const name = basename(path)
51
- const extension = extname(path).toLowerCase()
52
- if (MAESTRO_CONFIG_NAMES.has(name) || extension === '.yaml' || extension === '.yml') {
53
- return 'maestro'
54
- }
55
- if (DETOX_CONFIG_NAME.test(name) || isDetoxPackageConfig(path)) return 'detox'
56
- if (
57
- ['.js', '.cjs', '.mjs', '.ts', '.cts', '.mts', '.tsx', '.jsx'].includes(extension)
58
- ) {
59
- return 'detox'
60
- }
61
- if (!existsSync(path) || !statSync(path).isDirectory()) return null
62
- if (existsSync(join(path, 'config.yaml')) || existsSync(join(path, 'config.yml'))) {
63
- return 'maestro'
64
- }
65
- if (existsSync(join(path, '.maestro')) || basename(path) === '.maestro')
66
- return 'maestro'
67
- if (
68
- existsSync(join(path, 'rnx-detox.config.cjs')) ||
69
- existsSync(join(path, 'jest.config.cjs')) ||
70
- existsSync(join(path, 'jest.config.js')) ||
71
- isDetoxPackageConfig(join(path, 'package.json')) ||
72
- existsSync(join(path, 'e2e')) ||
73
- existsSync(join(path, 'detox'))
74
- ) {
75
- return 'detox'
76
- }
77
- return null
78
- }
79
-
80
53
  function valueAfter(args: string[], name: string): string | null {
54
+ const inline = args.find((arg) => arg.startsWith(`${name}=`))
55
+ if (inline) return inline.slice(name.length + 1) || null
81
56
  const index = args.indexOf(name)
82
57
  if (index < 0) return null
83
58
  const value = args[index + 1]
@@ -91,31 +66,28 @@ function targetFromArgs(args: string[]): string | null {
91
66
  index += 1
92
67
  continue
93
68
  }
69
+ if (arg.startsWith('--') && arg.includes('=')) {
70
+ continue
71
+ }
94
72
  if (!arg.startsWith('-')) return arg
95
73
  }
96
74
  return null
97
75
  }
98
76
 
99
77
  function withoutApp(args: string[]): string[] {
100
- const appIndex = args.indexOf('--app')
101
- return args.filter((_, index) => index !== appIndex && index !== appIndex + 1)
102
- }
103
-
104
- function isDetoxConfigPath(path: string): boolean {
105
- return DETOX_CONFIG_NAME.test(basename(path)) || isDetoxPackageConfig(path)
106
- }
107
-
108
- function restoreRnxUrl(previous: string | undefined): void {
109
- if (previous === undefined) {
110
- delete process.env.RNX_URL
111
- } else {
112
- process.env.RNX_URL = previous
78
+ const result: string[] = []
79
+ for (let i = 0; i < args.length; i++) {
80
+ const arg = args[i]
81
+ if (arg === '--app') {
82
+ i += 1
83
+ continue
84
+ }
85
+ if (arg.startsWith('--app=')) {
86
+ continue
87
+ }
88
+ result.push(arg)
113
89
  }
114
- }
115
-
116
- interface TestAppTarget {
117
- target: string
118
- close: () => Promise<void>
90
+ return result
119
91
  }
120
92
 
121
93
  function closeServer(server: Server): Promise<void> {
@@ -127,7 +99,7 @@ function closeServer(server: Server): Promise<void> {
127
99
  })
128
100
  }
129
101
 
130
- async function servePrebuiltBundle(path: string): Promise<TestAppTarget> {
102
+ export async function servePrebuiltBundle(path: string): Promise<TestAppTarget> {
131
103
  const file = resolve(process.cwd(), path)
132
104
  const stat = statSync(file)
133
105
  if (!stat.isFile() || extname(file) !== '.js') {
@@ -167,56 +139,392 @@ async function servePrebuiltBundle(path: string): Promise<TestAppTarget> {
167
139
  }
168
140
  }
169
141
 
170
- async function resolveTestAppTarget(app: string): Promise<TestAppTarget> {
142
+ export async function resolveTestAppTarget(app: string): Promise<TestAppTarget> {
171
143
  const localPath = resolve(process.cwd(), app)
172
144
  if (existsSync(localPath)) return servePrebuiltBundle(localPath)
173
145
  return { target: app, close: async () => {} }
174
146
  }
175
147
 
176
- export async function runTest(
148
+ function restoreRnxUrl(previous: string | undefined): void {
149
+ if (previous === undefined) {
150
+ delete process.env.RNX_URL
151
+ } else {
152
+ process.env.RNX_URL = previous
153
+ }
154
+ }
155
+
156
+ // dispatch only: each interpreter retains its own selection and execution rules.
157
+ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
158
+ const forwarded = hoistLeadingSimFlag(args)
159
+ const target = forwarded[0]?.startsWith('-') ? undefined : forwarded[0]
160
+ const config = forwarded.some(
161
+ (arg) => arg === '--config' || arg.startsWith('--config='),
162
+ )
163
+ if (config) return { command: 'detox', args: forwarded }
164
+ if (!target) {
165
+ throw new Error(
166
+ 'pass a Maestro flow/directory, a Jest suite, or --config <jest-config>',
167
+ )
168
+ }
169
+ const absolute = resolve(cwd, target)
170
+ if (!existsSync(absolute)) throw new Error(`test target not found: ${target}`)
171
+ const directory = statSync(absolute).isDirectory()
172
+ if (
173
+ !directory &&
174
+ /^(?:jest|rnx-detox)\.config\.[cm]?[jt]s$|^jest\.config\.json$/.test(
175
+ basename(absolute),
176
+ )
177
+ ) {
178
+ return { command: 'detox', args: ['--config', target, ...forwarded.slice(1)] }
179
+ }
180
+ const maestro = directory
181
+ ? readdirSync(absolute).some((name) => /\.ya?ml$/.test(name)) ||
182
+ ['.maestro', 'maestro'].includes(basename(absolute))
183
+ : /\.ya?ml$/.test(extname(absolute))
184
+ 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
+ ],
197
+ }
198
+ }
199
+ if (!directory && !/\.[cm]?[jt]sx?$/.test(extname(absolute))) {
200
+ throw new Error(`expected Maestro YAML or a Detox/Jest suite: ${target}`)
201
+ }
202
+ return { command: 'detox', args: forwarded }
203
+ }
204
+
205
+ function collectTestFiles(
206
+ targetPath: string,
207
+ ignoredPath: string | null,
208
+ ): Record<string, string> {
209
+ const files: Record<string, string> = {}
210
+ let sourceBytes = 0
211
+ const add = (path: string, relativePath: string) => {
212
+ if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
213
+ throw new Error(
214
+ `cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
215
+ )
216
+ }
217
+ sourceBytes += statSync(path).size
218
+ if (sourceBytes > RNX_CLOUD_TEST_FILES_MAX_BYTES) {
219
+ throw new Error(
220
+ `cloud test files may contain at most ${RNX_CLOUD_TEST_FILES_MAX_BYTES} bytes`,
221
+ )
222
+ }
223
+ files[relativePath] = readFileSync(path, 'utf8')
224
+ }
225
+ const stat = statSync(targetPath)
226
+ if (stat.isDirectory()) {
227
+ function walk(dir: string, base: string) {
228
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
229
+ if (entry.name.startsWith('.') && entry.name !== '.maestro') continue
230
+ if (['node_modules', 'dist', 'build', '.git'].includes(entry.name)) continue
231
+ const full = join(dir, entry.name)
232
+ if (ignoredPath && resolve(full) === ignoredPath) continue
233
+ const rel = base ? `${base}/${entry.name}` : entry.name
234
+ if (entry.isDirectory()) {
235
+ walk(full, rel)
236
+ } else if (entry.isFile()) {
237
+ add(full, rel)
238
+ }
239
+ }
240
+ }
241
+ walk(targetPath, '')
242
+ } else if (stat.isFile()) {
243
+ add(targetPath, basename(targetPath))
244
+ const parent = dirname(targetPath)
245
+ for (const configName of [
246
+ 'rnx-detox.config.cjs',
247
+ 'jest.config.cjs',
248
+ 'jest.config.js',
249
+ ]) {
250
+ const configPath = join(parent, configName)
251
+ if (existsSync(configPath) && !files[configName]) {
252
+ add(configPath, configName)
253
+ }
254
+ }
255
+ }
256
+ const serializedBytes = Buffer.byteLength(JSON.stringify(files))
257
+ if (serializedBytes > RNX_CLOUD_TEST_FILES_MAX_BYTES) {
258
+ throw new Error(
259
+ `cloud test files serialize to ${serializedBytes} bytes; the limit is ${RNX_CLOUD_TEST_FILES_MAX_BYTES}`,
260
+ )
261
+ }
262
+ return files
263
+ }
264
+
265
+ export async function runCloudTest(
177
266
  args: string[],
178
267
  opts: RunTestOptions = {},
179
268
  ): Promise<number> {
180
- const target = targetFromArgs(args)
181
- const app = valueAfter(args, '--app')
182
- if (!target || !app) {
183
- console.error(' usage: rnx test <flow-or-suite> --app <bundle>')
269
+ const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted')
270
+ const originFlag = valueAfter(forwarded, '--origin')
271
+ const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined)
272
+ const app =
273
+ valueAfter(forwarded, '--app') ||
274
+ valueAfter(forwarded, '--bundle') ||
275
+ valueAfter(forwarded, '--bundle-url') ||
276
+ valueAfter(forwarded, '--url') ||
277
+ forwarded.find((a) => a.startsWith('--app='))?.split('=')[1] ||
278
+ forwarded.find((a) => a.startsWith('--bundle='))?.split('=')[1] ||
279
+ forwarded.find((a) => a.startsWith('--url='))?.split('=')[1]
280
+
281
+ const owner = valueAfter(forwarded, '--owner')
282
+ const repo = valueAfter(forwarded, '--repo')
283
+
284
+ let target = targetFromArgs(forwarded)
285
+ const config = valueAfter(forwarded, '--config')
286
+ if (!target && config) {
287
+ target = config
288
+ }
289
+ if (!target) {
290
+ for (const cand of ['.maestro', 'maestro', 'flows', 'e2e']) {
291
+ if (existsSync(resolve(process.cwd(), cand))) {
292
+ target = cand
293
+ break
294
+ }
295
+ }
296
+ }
297
+ if (!target) {
298
+ console.error(' usage: rnx test <flow-or-suite> --app <bundle> --cloud')
184
299
  return 1
185
300
  }
186
301
 
187
302
  const targetPath = resolve(process.cwd(), target)
188
- const runner = classifyTestPath(targetPath)
189
- if (!runner) {
190
- console.error(` error: could not detect a Maestro flow or Detox suite at ${target}`)
303
+ if (!existsSync(targetPath)) {
304
+ console.error(` error: test target not found: ${target}`)
305
+ return 1
306
+ }
307
+
308
+ if (!app) {
309
+ console.error(
310
+ ' error: rnx test --cloud requires --app <bundle.js|https://bundle-url>',
311
+ )
312
+ return 1
313
+ }
314
+
315
+ const auth = resolveCliAuth()
316
+ if (!auth) {
317
+ console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
191
318
  return 1
192
319
  }
193
320
 
194
- const forwarded = withoutApp(args).filter((arg) => arg !== target)
195
- const appTarget = await resolveTestAppTarget(app)
321
+ const pollIntervalFlag = valueAfter(forwarded, '--poll-interval')
322
+ const timeoutFlag = valueAfter(forwarded, '--timeout')
323
+ const pollIntervalMs =
324
+ pollIntervalFlag === null ? (opts.pollIntervalMs ?? 1000) : Number(pollIntervalFlag)
325
+ const timeoutMs =
326
+ timeoutFlag === null ? (opts.timeoutMs ?? 20 * 60 * 1000) : Number(timeoutFlag)
327
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 1) {
328
+ console.error(' error: --poll-interval must be a positive number of milliseconds')
329
+ return 1
330
+ }
331
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
332
+ console.error(' error: --timeout must be a positive number of milliseconds')
333
+ return 1
334
+ }
335
+
336
+ let bundleUrl: string | undefined
337
+ let bundle: string | undefined
338
+ 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=/, '')
344
+ } 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}`)
348
+ return 1
349
+ }
350
+ bundle = readFileSync(localBundle, 'utf8')
351
+ }
352
+
353
+ const targetName = basename(targetPath)
354
+ const collectionPath =
355
+ statSync(targetPath).isFile() &&
356
+ (/^(?:jest|rnx-detox)\.config\.[cm]?[jt]s$|^jest\.config\.json$/.test(targetName) ||
357
+ /\.[cm]?[jt]sx?$/.test(extname(targetPath)))
358
+ ? dirname(targetPath)
359
+ : targetPath
360
+ const localBundlePath =
361
+ app.startsWith('http://') ||
362
+ app.startsWith('https://') ||
363
+ app.startsWith('srb_') ||
364
+ app.includes('/api/preview/share/bundle')
365
+ ? null
366
+ : resolve(process.cwd(), app)
367
+ const testFiles = collectTestFiles(collectionPath, localBundlePath)
368
+ if (Object.keys(testFiles).length === 0) {
369
+ console.error(` error: no test files found at ${target}`)
370
+ return 1
371
+ }
372
+
373
+ const submitPayload: Record<string, unknown> = {
374
+ kind: 'test',
375
+ testFiles,
376
+ }
377
+ if (bundleUrl) submitPayload.bundleUrl = bundleUrl
378
+ if (bundle) submitPayload.bundle = bundle
379
+ if (bundleShareId) submitPayload.bundleShareId = bundleShareId
380
+ if (owner) submitPayload.owner = owner
381
+ if (repo) submitPayload.repo = repo
382
+
383
+ const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
384
+ let response: Response
196
385
  try {
197
- if (runner === 'maestro') {
198
- const code = await runMaestro(
199
- ['test', targetPath, '--url', appTarget.target, ...forwarded],
200
- opts,
386
+ response = await fetch(endpoint, {
387
+ method: 'POST',
388
+ headers: {
389
+ 'content-type': 'application/json',
390
+ authorization: authHeaderValue(auth),
391
+ },
392
+ body: JSON.stringify(submitPayload),
393
+ })
394
+ } catch (err) {
395
+ console.error(
396
+ ` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
397
+ )
398
+ return 1
399
+ }
400
+
401
+ if (!response.ok) {
402
+ const body = await response.text()
403
+ let message = body
404
+ try {
405
+ const json = JSON.parse(body)
406
+ if (json.message) message = json.message
407
+ } catch {}
408
+ console.error(
409
+ ` error: cloud test submission failed (${response.status}): ${message}`,
410
+ )
411
+ return 1
412
+ }
413
+
414
+ const { id } = (await response.json()) as { id: string }
415
+ console.log(` cloud test job submitted: ${id}`)
416
+
417
+ const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
418
+ const startedAt = Date.now()
419
+
420
+ while (Date.now() - startedAt < timeoutMs) {
421
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
422
+ let pollRes: Response
423
+ try {
424
+ pollRes = await fetch(pollUrl, {
425
+ headers: { authorization: authHeaderValue(auth) },
426
+ })
427
+ } catch (err) {
428
+ console.warn(
429
+ ` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
201
430
  )
431
+ continue
432
+ }
433
+ if (!pollRes.ok) {
434
+ console.warn(` warn: polling error: HTTP ${pollRes.status}`)
435
+ continue
436
+ }
437
+ const job = (await pollRes.json()) as {
438
+ id: string
439
+ status: string
440
+ result?: {
441
+ previewId?: string | null
442
+ previewUrl?: string | null
443
+ runId?: string | null
444
+ exitCode?: number | null
445
+ failureReason?: string | null
446
+ } | null
447
+ }
448
+
449
+ if (job.status === 'done' || job.status === 'failed' || job.status === 'expired') {
450
+ const result = job.result
451
+ const exitCode = result?.exitCode ?? (job.status === 'done' ? 0 : 1)
452
+ console.log(` status: ${job.status}`)
453
+ console.log(` exit code: ${exitCode}`)
454
+ if (result?.previewUrl) {
455
+ console.log(` preview: ${result.previewUrl}`)
456
+ } else if (result?.previewId) {
457
+ console.log(
458
+ ` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
459
+ )
460
+ }
461
+ if (result?.runId) {
462
+ console.log(` run: ${result.runId}`)
463
+ }
464
+ if (result?.failureReason) {
465
+ console.error(` failure: ${result.failureReason}`)
466
+ }
467
+ return exitCode
468
+ }
469
+ }
470
+
471
+ console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
472
+ return 1
473
+ }
474
+
475
+ export async function runTest(
476
+ args: string[],
477
+ opts: RunTestOptions = {},
478
+ ): Promise<number> {
479
+ if (args.includes('--cloud') || args.includes('--hosted')) {
480
+ return runCloudTest(args, opts)
481
+ }
482
+
483
+ const selected = resolveTestCommand(args)
484
+ const app = valueAfter(args, '--app')
485
+ const appTarget = app
486
+ ? await resolveTestAppTarget(app)
487
+ : { target: '', close: async () => {} }
488
+
489
+ try {
490
+ if (selected.command === 'maestro') {
491
+ const { flowTimeoutScale } = await import('../../src/flow-timeout-scale')
492
+ if (flowTimeoutScale() !== 1) {
493
+ throw new Error(
494
+ 'rnx test requires original flow timeouts; unset SOOTSIM_FLOW_TIMEOUT_SCALE',
495
+ )
496
+ }
497
+ const { runMaestro } = await import('./maestro')
498
+ const maestroArgs = appTarget.target
499
+ ? selected.args.map((arg, idx) => {
500
+ const prev = selected.args[idx - 1]
501
+ if (prev === '--url') return appTarget.target
502
+ if (arg.startsWith('--url=')) return `--url=${appTarget.target}`
503
+ return arg
504
+ })
505
+ : selected.args
506
+ const code = await runMaestro(maestroArgs, opts)
202
507
  return typeof code === 'number' ? code : 0
203
508
  }
204
509
 
205
- const detoxArgs = isDetoxConfigPath(targetPath)
206
- ? ['--config', targetPath, ...forwarded]
207
- : [targetPath, ...forwarded]
510
+ const { runDetox } = await import('./detox')
511
+ const detoxArgs = withoutApp(selected.args)
208
512
  const previousRnxUrl = process.env.RNX_URL
209
- process.env.RNX_URL = await buildShellUrl(
210
- appTarget.target,
211
- resolveShellBaseUrlForBridgePort(
212
- opts.port ?? (Number(process.env.SOOTSIM_PORT) || 5173),
213
- ),
214
- )
513
+ if (appTarget.target) {
514
+ process.env.RNX_URL = await buildShellUrl(
515
+ appTarget.target,
516
+ resolveShellBaseUrlForBridgePort(
517
+ opts.port ?? (Number(process.env.SOOTSIM_PORT) || 5173),
518
+ ),
519
+ )
520
+ }
215
521
  try {
216
522
  await runDetox(detoxArgs, opts)
217
523
  return 0
218
524
  } finally {
219
- restoreRnxUrl(previousRnxUrl)
525
+ if (appTarget.target) {
526
+ restoreRnxUrl(previousRnxUrl)
527
+ }
220
528
  }
221
529
  } finally {
222
530
  await appTarget.close()
package/cli/main.ts CHANGED
@@ -496,6 +496,17 @@ try {
496
496
  await exitWithFlush(1)
497
497
  }
498
498
  const platform = parsed.commandArgs[0]
499
+ if (platform === 'close') {
500
+ const { dispatchCloudBoundary } = await import('./cloud-dispatch')
501
+ const result = await dispatchCloudBoundary({
502
+ command: 'close',
503
+ args: parsed.commandArgs.slice(1),
504
+ })
505
+ if (result.handled) {
506
+ await exitWithFlush(result.code)
507
+ }
508
+ await exitWithFlush(0)
509
+ }
499
510
  if (platform === 'ios' || platform === 'android') {
500
511
  const { runRemotePlatformCommand } = await import('./commands/platform')
501
512
  const code = await runRemotePlatformCommand(
@@ -146,6 +146,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
146
146
  { category: 'rnx_cloud_sim', count: 1 },
147
147
  'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}`':
148
148
  { category: 'rnx_cloud_sim', count: 1 },
149
+ 'packages/sootsim/cli/cloud-client.ts :: fetch :: `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/stop`':
150
+ { category: 'rnx_cloud_sim', count: 1 },
149
151
  'packages/sootsim/cli/cloud-client.ts :: fetch :: `${this.session.apiOrigin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`':
150
152
  { category: 'rnx_cloud_sim', count: 1 },
151
153
  'packages/sootsim/cli/cloud-client.ts :: fetch :: `${this.session.apiOrigin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/commands`':
@@ -154,6 +156,14 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
154
156
  category: 'guest_app_network',
155
157
  count: 1,
156
158
  },
159
+ 'packages/sootsim/cli/commands/test.ts :: fetch :: endpoint': {
160
+ category: 'contrast_user_action',
161
+ count: 1,
162
+ },
163
+ 'packages/sootsim/cli/commands/test.ts :: fetch :: pollUrl': {
164
+ category: 'contrast_user_action',
165
+ count: 1,
166
+ },
157
167
  'packages/sootsim/src/cloud.ts :: fetch :: `${origin}/v1/boxes`': {
158
168
  category: 'rnx_cloud_sim',
159
169
  count: 1,
package/cli/ws-bridge.ts CHANGED
@@ -324,9 +324,9 @@ export interface WsBridge {
324
324
  opts?: { timeoutMs?: number },
325
325
  ): Promise<any>
326
326
  listSims(): Promise<BridgeSimInfo[]>
327
- // a guest reload retires the sim id and re-registers the same page under a
328
- // new one. a caller that pins an id resolves the successor here; null means
329
- // nothing proves what replaced it.
327
+ // a guest reload retires the socket and the same page reconnects under a new
328
+ // or restored id. a caller that pins an id resolves that target here; null
329
+ // means nothing proves what replaced it.
330
330
  resolveReloadedSim(retiredSimId: string): Promise<string | null>
331
331
  focusSim(simId?: string): Promise<any>
332
332
  closeSim(simId?: string): Promise<any>
@@ -621,21 +621,20 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
621
621
  const hostPidBySimId = new Map<string, number>()
622
622
  const lineageLookupBySimId = new Map<string, Promise<void>>()
623
623
 
624
- // a reload retires the sim id but keeps the browser host, so a successor
625
- // sharing that host pid IS the same page lineage rather than a guess. only
626
- // an unambiguous match counts; callers fail closed on null.
627
- function pickReloadedSuccessor(sims: unknown, retiredSimId: string): string | null {
624
+ // a reload retires the socket but keeps the browser host. the replacement
625
+ // can receive a new sim id or restore the prior one from session storage.
626
+ // the restored id is definitive; a new id must be the only same-host match.
627
+ function pickReloadedTarget(sims: unknown, retiredSimId: string): string | null {
628
628
  const pinnedHostPid = hostPidBySimId.get(retiredSimId)
629
629
  if (pinnedHostPid === undefined) return null
630
- const successors = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter(
631
- (sim) =>
632
- sim.id !== retiredSimId &&
633
- sim.readyState === 'open' &&
634
- sim.meta?.sootsimHostPid === pinnedHostPid,
630
+ const matches = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter(
631
+ (sim) => sim.readyState === 'open' && sim.meta?.sootsimHostPid === pinnedHostPid,
635
632
  )
636
- if (successors.length !== 1) return null
637
- hostPidBySimId.set(successors[0].id, pinnedHostPid)
638
- return successors[0].id
633
+ const restored = matches.find((sim) => sim.id === retiredSimId)
634
+ if (restored) return restored.id
635
+ if (matches.length !== 1) return null
636
+ hostPidBySimId.set(matches[0].id, pinnedHostPid)
637
+ return matches[0].id
639
638
  }
640
639
 
641
640
  // print a one-line notice on first sim-scoped command so agents/users see
@@ -801,7 +800,7 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
801
800
  // a reload retires the sim id but keeps the browser host, so the
802
801
  // successor with the same host pid IS the same page lineage — not a
803
802
  // guess. adopt only that, and only when it is unambiguous.
804
- const successor = pickReloadedSuccessor(
803
+ const successor = pickReloadedTarget(
805
804
  await sendOnce(undefined, { type: 'bridge:list-sims' }),
806
805
  sentSimId,
807
806
  )
@@ -831,10 +830,10 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
831
830
  },
832
831
  // a caller holding its own pinned sim id (a flow runner sets simId on every
833
832
  // command, so it never reaches the pin-following path in send) asks here
834
- // after a reload retires that id. null means nothing proves what replaced
835
- // it, and the caller must fail rather than guess.
833
+ // after a reload retires its socket. the page can reconnect under a new id
834
+ // or restore the old id; null means no unique same-host target exists.
836
835
  async resolveReloadedSim(retiredSimId: string) {
837
- return pickReloadedSuccessor(await this.listSims(), retiredSimId)
836
+ return pickReloadedTarget(await this.listSims(), retiredSimId)
838
837
  },
839
838
  async focusSim(simId?: string) {
840
839
  return this.send({ type: 'focus', simId })