rnxsim 0.1.490 → 0.1.491

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 (63) hide show
  1. package/cli/cloud-client.ts +43 -7
  2. package/cli/command-registry.ts +3 -0
  3. package/cli/commands/detox.ts +41 -11
  4. package/cli/commands/flow.ts +2 -1
  5. package/cli/commands/maestro.ts +30 -0
  6. package/cli/commands/platform.ts +15 -0
  7. package/cli/commands/preview.ts +104 -0
  8. package/cli/commands/test.ts +224 -0
  9. package/cli/main.ts +34 -0
  10. package/cli/outbound-endpoints.ts +5 -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.cjs +1 -1
  31. package/dist-lib/cloud.mjs +1 -1
  32. package/dist-lib/config.cjs +1 -1
  33. package/dist-lib/detox/index.cjs +1 -1
  34. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  35. package/dist-lib/home-paths.cjs +1 -1
  36. package/dist-lib/host/bridge-host.cjs +22 -8
  37. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  38. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  39. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  40. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  41. package/dist-lib/host/websocket-proxy.cjs +1 -1
  42. package/dist-lib/index.cjs +1 -1
  43. package/dist-lib/jump-to-source-babel.cjs +1 -1
  44. package/dist-lib/jump-to-source-native.cjs +1 -1
  45. package/dist-lib/menu.cjs +1 -1
  46. package/dist-lib/menu.mjs +1 -1
  47. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  48. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  49. package/dist-lib/metro-production-bundle.cjs +1 -1
  50. package/dist-lib/metro-production-bundle.mjs +1 -1
  51. package/dist-lib/metro.cjs +1 -1
  52. package/dist-lib/profiles.cjs +1 -1
  53. package/dist-lib/public-brand.cjs +1 -1
  54. package/dist-lib/react-native-host-modules.cjs +1 -1
  55. package/dist-lib/react-native-host-modules.mjs +1 -1
  56. package/dist-lib/render-mode.cjs +1 -1
  57. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  58. package/dist-lib/sdk.cjs +1 -1
  59. package/dist-lib/sdk.mjs +1 -1
  60. package/dist-lib/skills.cjs +22 -8
  61. package/dist-lib/vite.cjs +1 -1
  62. package/package.json +1 -1
  63. package/skills/contrast/SKILL.md +3 -0
@@ -59,6 +59,7 @@ export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
59
59
 
60
60
  // every message this module writes names the command through this constant.
61
61
  const REMOTE_COMMAND = 'rnx ios --remote'
62
+ const PREVIEW_COMMAND = 'rnx preview'
62
63
 
63
64
  // a Metro asset dest is a shallow tree of images. a walk that runs past this
64
65
  // is pointed at something else entirely, and its extra entries cannot resolve
@@ -656,6 +657,36 @@ export async function produceCloudArtifact(
656
657
  * the default; a box that accepts a larger artifact passes its own.
657
658
  */
658
659
  maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES,
660
+ ): Promise<CloudArtifactProduction> {
661
+ return produceArtifact(
662
+ bundlePath,
663
+ assetsPath,
664
+ maxBytes,
665
+ REMOTE_COMMAND,
666
+ 'allow-placeholders',
667
+ )
668
+ }
669
+
670
+ /** prepares a standalone preview whose bytes must represent every Metro asset. */
671
+ export async function producePreviewArtifact(
672
+ bundlePath: string,
673
+ assetsPath?: string,
674
+ ): Promise<CloudArtifactProduction> {
675
+ return produceArtifact(
676
+ bundlePath,
677
+ assetsPath,
678
+ Number.POSITIVE_INFINITY,
679
+ PREVIEW_COMMAND,
680
+ 'require-assets',
681
+ )
682
+ }
683
+
684
+ async function produceArtifact(
685
+ bundlePath: string,
686
+ assetsPath: string | undefined,
687
+ maxBytes: number,
688
+ command: string,
689
+ assetPolicy: 'allow-placeholders' | 'require-assets',
659
690
  ): Promise<CloudArtifactProduction> {
660
691
  const source = await loadBundleSource(bundlePath)
661
692
  const descriptors = extractAssetDescriptors(source)
@@ -680,7 +711,7 @@ export async function produceCloudArtifact(
680
711
  const placement = placements.get(label)
681
712
  if (placement?.mismatched && dest) {
682
713
  throw new Error(
683
- `${REMOTE_COMMAND} found ${variant.descriptor.name}.${variant.descriptor.type} in ${dest.directory}, but its bytes are not the ones this bundle was built against. Rebuild with \`react-native bundle --assets-dest ${dest.directory}\` so the bundle and the images match.`,
714
+ `${command} found ${variant.descriptor.name}.${variant.descriptor.type} in ${dest.directory}, but its bytes are not the ones this bundle was built against. Rebuild with \`react-native bundle --assets-dest ${dest.directory}\` so the bundle and the images match.`,
684
715
  )
685
716
  }
686
717
  const relative = placement?.files.get(variant.scale)
@@ -690,7 +721,7 @@ export async function produceCloudArtifact(
690
721
  if (placement && placement.files.size > 0) return null
691
722
  if (placement && placement.ambiguous.length > 0 && dest) {
692
723
  throw new Error(
693
- `${REMOTE_COMMAND} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
724
+ `${command} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
694
725
  )
695
726
  }
696
727
  // a stand-in is only honest when the asset is genuinely absent from a
@@ -700,12 +731,17 @@ export async function produceCloudArtifact(
700
731
  // chose.
701
732
  if (assetsPath) {
702
733
  throw new Error(
703
- `${REMOTE_COMMAND} could not find ${label} under --assets ${dest?.directory ?? resolve(assetsPath)}. Point --assets at the directory \`react-native bundle --assets-dest\` wrote, or drop the flag to let ${REMOTE_COMMAND} find it.`,
734
+ `${command} could not find ${label} under --assets ${dest?.directory ?? resolve(assetsPath)}. Point --assets at the directory \`react-native bundle --assets-dest\` wrote, or drop the flag to let ${command} find it.`,
704
735
  )
705
736
  }
706
737
  if (search.truncated) {
707
738
  throw new Error(
708
- `${REMOTE_COMMAND} stopped listing ${search.searched.join(', ')} after ${MAX_ASSET_DEST_FILES} files and never reached ${label}, so it cannot tell a missing asset from an unread one. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
739
+ `${command} stopped listing ${search.searched.join(', ')} after ${MAX_ASSET_DEST_FILES} files and never reached ${label}, so it cannot tell a missing asset from an unread one. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
740
+ )
741
+ }
742
+ if (assetPolicy === 'require-assets') {
743
+ throw new Error(
744
+ `${command} could not find ${label}. Pass --assets <dir> pointing at the directory \`react-native bundle --assets-dest <dir>\` wrote, or build with \`--assets-dest\`.`,
709
745
  )
710
746
  }
711
747
  // no scale of this asset exists anywhere the search looked. an image can
@@ -726,7 +762,7 @@ export async function produceCloudArtifact(
726
762
  metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
727
763
  filename
728
764
  throw new Error(
729
- `${REMOTE_COMMAND} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Build the bundle with --assets-dest, or pass --assets <dir>.`,
765
+ `${command} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Build the bundle with --assets-dest, or pass --assets <dir>.`,
730
766
  )
731
767
  })
732
768
  const artifact = await buildRnxCloudArtifact(embedded.code, {
@@ -742,14 +778,14 @@ export async function produceCloudArtifact(
742
778
  const warnings: string[] = []
743
779
  if (substituted.length > 0) {
744
780
  warnings.push(
745
- `${REMOTE_COMMAND} embedded a placeholder image for ${substituted.length} asset(s) no scale of which is in ${search.searched.join(', ') || 'any asset dest directory'}: ${substituted.join(', ')}. Build the bundle with --assets-dest, or pass --assets <dir>, to ship the real images.`,
781
+ `${command} embedded a placeholder image for ${substituted.length} asset(s) no scale of which is in ${search.searched.join(', ') || 'any asset dest directory'}: ${substituted.join(', ')}. Build the bundle with --assets-dest, or pass --assets <dir>, to ship the real images.`,
746
782
  )
747
783
  }
748
784
  // an artifact this close to the ceiling is one asset away from being refused,
749
785
  // and the producer knows which assets it would be.
750
786
  if (bytes.length >= Math.floor(maxBytes * 0.8)) {
751
787
  warnings.push(
752
- `${REMOTE_COMMAND} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
788
+ `${command} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
753
789
  )
754
790
  }
755
791
  return {
@@ -18,6 +18,7 @@ export interface RnxCommandAvailability {
18
18
  }
19
19
 
20
20
  const NODE_COMMAND_NAMES = [
21
+ 'test',
21
22
  'detox',
22
23
  'maestro',
23
24
  'record',
@@ -39,6 +40,8 @@ const NODE_COMMAND_NAMES = [
39
40
  'open',
40
41
  'ios',
41
42
  'android',
43
+ 'remote',
44
+ 'preview',
42
45
  'box',
43
46
  'list',
44
47
  'use',
@@ -11,7 +11,14 @@
11
11
  // moduleNameMapper for `^detox$`.
12
12
 
13
13
  import { spawn } from 'child_process'
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'fs'
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ readFileSync,
18
+ statSync,
19
+ writeFileSync,
20
+ unlinkSync,
21
+ } from 'fs'
15
22
  import { createRequire } from 'module'
16
23
  import { tmpdir } from 'os'
17
24
  import { dirname, resolve, join, relative } from 'path'
@@ -107,12 +114,20 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
107
114
  return []
108
115
  })
109
116
 
110
- // test dir discovery. external test suites set RNX_TEST_DIR; otherwise
111
- // we auto-detect the conventional locations.
117
+ // test dir discovery. an explicit suite owns its root; otherwise external
118
+ // suites set RNX_TEST_DIR and conventional locations remain the fallback.
112
119
  const detoxDirs = ['e2e', 'test/e2e', 'detox']
113
- let testDir: string | null = process.env.RNX_TEST_DIR
114
- ? resolve(process.cwd(), process.env.RNX_TEST_DIR)
115
- : null
120
+ const explicitTarget =
121
+ positional[0] && existsSync(resolve(process.cwd(), positional[0]))
122
+ ? resolve(process.cwd(), positional[0])
123
+ : null
124
+ let testDir: string | null = explicitTarget
125
+ ? statSync(explicitTarget).isDirectory()
126
+ ? explicitTarget
127
+ : dirname(explicitTarget)
128
+ : process.env.RNX_TEST_DIR
129
+ ? resolve(process.cwd(), process.env.RNX_TEST_DIR)
130
+ : null
116
131
  if (!testDir) {
117
132
  for (const dir of detoxDirs) {
118
133
  const full = resolve(process.cwd(), dir)
@@ -137,24 +152,39 @@ export async function runDetox(args: string[], opts: RunDetoxOpts = {}): Promise
137
152
  await ensureShellRunning(port)
138
153
  }
139
154
 
140
- // build the jest command. prefer the user's config if they passed --config
141
- // or if a rnx-detox.config.cjs exists in cwd. otherwise point at our
142
- // preset directly via --preset-free inline config.
155
+ // build the jest command. customer config remains authoritative for setup,
156
+ // transforms, roots, and timeouts; the generated wrapper adds only RNX's
157
+ // Detox module mapping and cleanup hook.
143
158
  const jestArgs: string[] = ['jest']
144
159
  const localConfig =
145
160
  configFlag ||
146
161
  firstExisting(['rnx-detox.config.cjs', 'jest.config.cjs', 'jest.config.js'])
147
162
 
148
163
  let tempConfig: string | null = null
164
+ const presetPath = resolve(resolveDetoxDir(), 'jest-preset.cjs')
149
165
  if (localConfig) {
150
- jestArgs.push('--config', resolve(process.cwd(), localConfig))
166
+ const localConfigPath = resolve(process.cwd(), localConfig)
167
+ // Keep the wrapper beside the customer config so a relative rootDir keeps
168
+ // resolving from the same directory Jest would have used directly.
169
+ tempConfig = join(dirname(localConfigPath), `rnx-detox-${process.pid}.config.cjs`)
170
+ writeFileSync(
171
+ tempConfig,
172
+ `const rnx = require(${JSON.stringify(presetPath)})\n` +
173
+ `const customer = require(${JSON.stringify(localConfigPath)})\n` +
174
+ `module.exports = {\n` +
175
+ ` ...rnx,\n` +
176
+ ` ...customer,\n` +
177
+ ` moduleNameMapper: { ...rnx.moduleNameMapper, ...customer.moduleNameMapper, '^detox$': rnx.moduleNameMapper['^detox$'] },\n` +
178
+ ` setupFilesAfterEnv: [...new Set([...(customer.setupFilesAfterEnv || []), ...(rnx.setupFilesAfterEnv || [])])],\n` +
179
+ `}\n`,
180
+ )
181
+ jestArgs.push('--config', tempConfig)
151
182
  } else {
152
183
  // no local config — write a temp config that pulls in our preset.
153
184
  // jest's --preset flag has quirky module resolution that breaks with
154
185
  // subpath exports, so we generate a real config file instead.
155
186
  // scope roots + testMatch to the discovered test dir so jest doesn't
156
187
  // scan the entire repo (which causes haste collisions in monorepos).
157
- const presetPath = resolve(resolveDetoxDir(), 'jest-preset.cjs')
158
188
  // an external suite may optionally declare its own ignore patterns via e2e/jest-ignore.cjs
159
189
  const suiteIgnoreFile = testDir ? join(testDir, 'e2e', 'jest-ignore.cjs') : null
160
190
  let suiteIgnorePatterns: string[] = []
@@ -61,6 +61,7 @@ const FLOW_BOOLEAN_FLAGS = [
61
61
  ]
62
62
 
63
63
  const FLOW_VALUE_FLAGS = [
64
+ '--app',
64
65
  '--out',
65
66
  '--base-url',
66
67
  '--device',
@@ -430,7 +431,7 @@ flow extension:
430
431
  const targetApp = targetAppResolution.target
431
432
 
432
433
  const parsedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
433
- const url = getFlag('--url') || ''
434
+ const url = getFlag('--app') || getFlag('--url') || ''
434
435
 
435
436
  // --base-url pins the run to one shell, so a caller can build an engine from
436
437
  // a known tree, serve it, and measure that build instead of whatever the
@@ -470,6 +470,22 @@ export async function runMaestro(
470
470
  if (exit !== 0 && worstExit === 0) worstExit = exit
471
471
 
472
472
  const upload = getLastFlowPreviewUploadResult()
473
+ if (upload?.shareId || upload?.previewUrl) {
474
+ try {
475
+ fs.writeFileSync(
476
+ path.join(cwd, 'preview-result.json'),
477
+ JSON.stringify({
478
+ previewId: upload.shareId,
479
+ previewUrl: upload.previewUrl,
480
+ }),
481
+ )
482
+ } catch {}
483
+ } else if (args.includes('--preview')) {
484
+ console.error(' error: required hosted recording upload failed')
485
+ if (exit === 0) exit = 1
486
+ if (worstExit === 0) worstExit = 1
487
+ }
488
+
473
489
  if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
474
490
  const traceSteps = getLastFlowTraceSteps()
475
491
  const failedStep = traceSteps.find((step) => step.status === 'failure')
@@ -488,6 +504,20 @@ export async function runMaestro(
488
504
  })
489
505
  if (run) {
490
506
  console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`)
507
+ try {
508
+ fs.writeFileSync(
509
+ path.join(cwd, 'test-result.json'),
510
+ JSON.stringify({
511
+ runId: run.id,
512
+ previewUrl: run.previewUrl,
513
+ traceUrl: run.traceUrl,
514
+ }),
515
+ )
516
+ } catch {}
517
+ } else if (process.env.RNX_REQUIRE_REGISTRATION === 'true') {
518
+ console.error(' error: required hosted run registration failed')
519
+ if (exit === 0) exit = 1
520
+ if (worstExit === 0) worstExit = 1
491
521
  }
492
522
  }
493
523
  }
@@ -258,6 +258,21 @@ export async function runPlatformCommand(
258
258
  return runOpenCommand(args, { port: opts.port })
259
259
  }
260
260
 
261
+ /** Runs the existing remote session path without changing the local platform commands. */
262
+ export function runRemotePlatformCommand(
263
+ platform: RuntimePlatform,
264
+ args: string[],
265
+ opts: { port?: number } = {},
266
+ ): Promise<number | undefined> {
267
+ if (platform === 'android') {
268
+ console.error(
269
+ ` ${rnxPublicBrand.commandName} remote android is not available: rnxsim/cloud only creates iOS simulators`,
270
+ )
271
+ return Promise.resolve(1)
272
+ }
273
+ return runPlatformCommand(platform, ['--remote', ...args], opts)
274
+ }
275
+
261
276
  /**
262
277
  * `rnx ios --remote` — the app runs in a Box.
263
278
  *
@@ -0,0 +1,104 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { brotliBundle } from 'sootsim-engine/preview/compress-node'
3
+ import {
4
+ runPresignedUpload,
5
+ type InitRequestBody,
6
+ } from 'sootsim-engine/preview/presigned-upload'
7
+ import { authHeaderValue, cloudAccountIdOrExit, resolveCliAuth } from '../auth'
8
+ import { producePreviewArtifact } from '../cloud-client'
9
+ import { openUrl } from '../open-url'
10
+ import { resolveDefaultUploadOrigin, resolvePublicPreviewOrigin } from './upload'
11
+
12
+ function takeValue(args: string[], flag: string): string | null {
13
+ const index = args.indexOf(flag)
14
+ if (index < 0) return null
15
+ const value = args[index + 1]
16
+ args.splice(index, 2)
17
+ if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value`)
18
+ return value
19
+ }
20
+
21
+ export async function runPreview(args: string[]): Promise<number> {
22
+ if (args.includes('--help') || args.includes('-h')) {
23
+ console.log(`
24
+ rnx preview — publish a prebuilt React Native bundle as a standalone share
25
+
26
+ usage:
27
+ rnx preview <bundle.js|https://bundle-url> [--assets <dir>] [--platform ios|android] [--open]
28
+
29
+ The share contains the immutable build and its Metro assets. It is not a recorded replay.
30
+ `)
31
+ return 0
32
+ }
33
+ try {
34
+ const input = [...args]
35
+ const assets = takeValue(input, '--assets')
36
+ const origin = await resolveDefaultUploadOrigin(
37
+ takeValue(input, '--origin') ?? undefined,
38
+ )
39
+ const publicOrigin = resolvePublicPreviewOrigin(
40
+ origin,
41
+ takeValue(input, '--public-origin') ?? undefined,
42
+ )
43
+ const platform = takeValue(input, '--platform') ?? 'ios'
44
+ const openIndex = input.indexOf('--open')
45
+ const open = openIndex >= 0
46
+ if (open) input.splice(openIndex, 1)
47
+ if (platform !== 'ios' && platform !== 'android') {
48
+ throw new Error('--platform must be ios or android')
49
+ }
50
+ if (input.length !== 1 || input[0]?.startsWith('-')) {
51
+ throw new Error('usage: rnx preview <bundle.js|https://bundle-url>')
52
+ }
53
+ const auth = resolveCliAuth()
54
+ if (!auth) throw new Error('rnx preview needs `rnx login` or RNX_API_KEY')
55
+ const accountId = cloudAccountIdOrExit(auth)
56
+
57
+ const artifact = await producePreviewArtifact(input[0], assets ?? undefined)
58
+ const bundle = brotliBundle(artifact.bytes)
59
+ const initBody: InitRequestBody = {
60
+ kind: 'build',
61
+ buildPlatform: platform,
62
+ contentHash: createHash('sha256').update(artifact.bytes).digest('hex'),
63
+ bundleSizeBytes: artifact.bytes.byteLength,
64
+ bundleContentType: 'application/javascript',
65
+ bundleEncoding: 'br',
66
+ bundleMinified: true,
67
+ bundleOrigin: null,
68
+ entry: null,
69
+ isTransformed: false,
70
+ deviceSpec:
71
+ platform === 'android'
72
+ ? { model: 'pixel-8', width: 412, height: 915 }
73
+ : { model: 'iphone-16', width: 402, height: 874 },
74
+ files: [],
75
+ }
76
+ const { finalize } = await runPresignedUpload({
77
+ originBase: origin,
78
+ endpoints: {
79
+ init: '/api/v1/previews',
80
+ finalize: '/api/v1/previews/finalize',
81
+ },
82
+ initBody,
83
+ bundleBytes: bundle,
84
+ filesByHash: new Map(),
85
+ authHeader: authHeaderValue(auth),
86
+ accountId,
87
+ })
88
+ const url = finalize.url.startsWith('http')
89
+ ? finalize.url
90
+ : `${publicOrigin}${finalize.url}`
91
+ console.log(` preview: ${url}`)
92
+ console.log(
93
+ ` artifact: sha256:${artifact.sha256} (${artifact.bytes.byteLength} bytes)`,
94
+ )
95
+ for (const warning of artifact.warnings) console.warn(` warning: ${warning}`)
96
+ if (open) await openUrl(url)
97
+ return 0
98
+ } catch (error) {
99
+ console.error(
100
+ ` rnx preview failed: ${error instanceof Error ? error.message : String(error)}`,
101
+ )
102
+ return 1
103
+ }
104
+ }
@@ -0,0 +1,224 @@
1
+ // rnx test — choose the existing Maestro or Detox interpreter from a test path.
2
+
3
+ import { existsSync, readFileSync, statSync } from 'fs'
4
+ import { createServer, type Server } from 'node:http'
5
+ import { basename, extname, join, resolve } from 'path'
6
+ import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
7
+ import { runDetox } from './detox'
8
+ import { runMaestro } from './maestro'
9
+
10
+ type TestRunner = 'maestro' | 'detox'
11
+
12
+ export interface RunTestOptions {
13
+ port?: number
14
+ verbose?: boolean
15
+ }
16
+
17
+ const MAESTRO_CONFIG_NAMES = new Set(['config.yaml', 'config.yml'])
18
+ const DETOX_CONFIG_NAME = /^jest\.config\.(?:[cm]?[jt]s)$/
19
+ const VALUE_FLAGS = new Set([
20
+ '--app',
21
+ '--config',
22
+ '--sim',
23
+ '--session',
24
+ '--tab',
25
+ '--port',
26
+ '--device',
27
+ '--driver',
28
+ '--base-url',
29
+ '--url',
30
+ '-t',
31
+ '--testNamePattern',
32
+ '--grep',
33
+ '--maxWorkers',
34
+ '--shard',
35
+ '--outputFile',
36
+ ])
37
+
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
+ function valueAfter(args: string[], name: string): string | null {
81
+ const index = args.indexOf(name)
82
+ if (index < 0) return null
83
+ const value = args[index + 1]
84
+ return value && !value.startsWith('-') ? value : null
85
+ }
86
+
87
+ function targetFromArgs(args: string[]): string | null {
88
+ for (let index = 0; index < args.length; index++) {
89
+ const arg = args[index]
90
+ if (VALUE_FLAGS.has(arg)) {
91
+ index += 1
92
+ continue
93
+ }
94
+ if (!arg.startsWith('-')) return arg
95
+ }
96
+ return null
97
+ }
98
+
99
+ 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
113
+ }
114
+ }
115
+
116
+ interface TestAppTarget {
117
+ target: string
118
+ close: () => Promise<void>
119
+ }
120
+
121
+ function closeServer(server: Server): Promise<void> {
122
+ return new Promise((resolveClose, rejectClose) => {
123
+ server.close((error) => {
124
+ if (error) rejectClose(error)
125
+ else resolveClose()
126
+ })
127
+ })
128
+ }
129
+
130
+ async function servePrebuiltBundle(path: string): Promise<TestAppTarget> {
131
+ const file = resolve(process.cwd(), path)
132
+ const stat = statSync(file)
133
+ if (!stat.isFile() || extname(file) !== '.js') {
134
+ throw new Error('--app must name a regular Metro .js bundle or a packager target')
135
+ }
136
+ const bundle = readFileSync(file)
137
+ if (bundle.length === 0) throw new Error('--app bundle is empty')
138
+
139
+ const server = createServer((request, response) => {
140
+ if (new URL(request.url || '/', 'http://127.0.0.1').pathname !== '/bundle.js') {
141
+ response.writeHead(404).end()
142
+ return
143
+ }
144
+ response.writeHead(200, {
145
+ 'access-control-allow-origin': '*',
146
+ 'cache-control': 'no-store',
147
+ 'content-type': 'application/javascript; charset=utf-8',
148
+ 'content-length': String(bundle.length),
149
+ })
150
+ response.end(bundle)
151
+ })
152
+ await new Promise<void>((resolveListen, rejectListen) => {
153
+ server.once('error', rejectListen)
154
+ server.listen(0, '127.0.0.1', () => {
155
+ server.off('error', rejectListen)
156
+ resolveListen()
157
+ })
158
+ })
159
+ const address = server.address()
160
+ if (!address || typeof address === 'string') {
161
+ await closeServer(server)
162
+ throw new Error('could not start the local prebuilt-bundle server')
163
+ }
164
+ return {
165
+ target: `http://127.0.0.1:${address.port}/bundle.js`,
166
+ close: () => closeServer(server),
167
+ }
168
+ }
169
+
170
+ async function resolveTestAppTarget(app: string): Promise<TestAppTarget> {
171
+ const localPath = resolve(process.cwd(), app)
172
+ if (existsSync(localPath)) return servePrebuiltBundle(localPath)
173
+ return { target: app, close: async () => {} }
174
+ }
175
+
176
+ export async function runTest(
177
+ args: string[],
178
+ opts: RunTestOptions = {},
179
+ ): 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>')
184
+ return 1
185
+ }
186
+
187
+ 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}`)
191
+ return 1
192
+ }
193
+
194
+ const forwarded = withoutApp(args).filter((arg) => arg !== target)
195
+ const appTarget = await resolveTestAppTarget(app)
196
+ try {
197
+ if (runner === 'maestro') {
198
+ const code = await runMaestro(
199
+ ['test', targetPath, '--url', appTarget.target, ...forwarded],
200
+ opts,
201
+ )
202
+ return typeof code === 'number' ? code : 0
203
+ }
204
+
205
+ const detoxArgs = isDetoxConfigPath(targetPath)
206
+ ? ['--config', targetPath, ...forwarded]
207
+ : [targetPath, ...forwarded]
208
+ 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
+ )
215
+ try {
216
+ await runDetox(detoxArgs, opts)
217
+ return 0
218
+ } finally {
219
+ restoreRnxUrl(previousRnxUrl)
220
+ }
221
+ } finally {
222
+ await appTarget.close()
223
+ }
224
+ }
package/cli/main.ts CHANGED
@@ -364,6 +364,11 @@ try {
364
364
  await runInspect([command, ...commandArgsWithSim], { port, verbose })
365
365
  } else {
366
366
  switch (command) {
367
+ case 'test': {
368
+ const { runTest } = await import('./commands/test')
369
+ await exitWithFlush(await runTest(commandArgsWithSim, { port, verbose }))
370
+ }
371
+
367
372
  case 'assert': {
368
373
  // stays outside the usual dispatcher — it spawns self to get the
369
374
  // inner verb's --json payload, so it never needs the shared bridge
@@ -483,6 +488,35 @@ try {
483
488
  await exitWithFlush(typeof code === 'number' ? code : 0)
484
489
  }
485
490
 
491
+ case 'remote': {
492
+ if (globalSimTarget) {
493
+ console.error(
494
+ ` ${rnxPublicBrand.commandName} remote creates a new simulator and cannot target --sim`,
495
+ )
496
+ await exitWithFlush(1)
497
+ }
498
+ const platform = parsed.commandArgs[0]
499
+ if (platform === 'ios' || platform === 'android') {
500
+ const { runRemotePlatformCommand } = await import('./commands/platform')
501
+ const code = await runRemotePlatformCommand(
502
+ platform,
503
+ parsed.commandArgs.slice(1),
504
+ { port },
505
+ )
506
+ await exitWithFlush(typeof code === 'number' ? code : 0)
507
+ } else {
508
+ console.error(
509
+ ` usage: ${rnxPublicBrand.commandName} remote ios|android <bundle.js|https://bundle-url>`,
510
+ )
511
+ await exitWithFlush(1)
512
+ }
513
+ }
514
+
515
+ case 'preview': {
516
+ const { runPreview } = await import('./commands/preview')
517
+ await exitWithFlush(await runPreview(parsed.commandArgs))
518
+ }
519
+
486
520
  case 'web': {
487
521
  const { getRnxCommandAvailability } = await import('./command-registry')
488
522
  const availability = getRnxCommandAvailability('web', parsed.commandArgs, 'node')