rnxsim 0.1.490 → 0.1.492
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.
- package/cli/app-state-reset.ts +11 -9
- package/cli/cloud-client.ts +43 -7
- package/cli/command-registry.ts +3 -0
- package/cli/commands/detox.ts +41 -11
- package/cli/commands/flow.ts +2 -1
- package/cli/commands/maestro.ts +30 -0
- package/cli/commands/platform.ts +15 -0
- package/cli/commands/preview.ts +104 -0
- package/cli/commands/test.ts +224 -0
- package/cli/main.ts +34 -0
- package/cli/outbound-endpoints.ts +5 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +22 -8
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +22 -8
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/skills/contrast/SKILL.md +3 -0
- package/src/bridge-contract.ts +4 -5
package/cli/app-state-reset.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
// resets the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// metro re-fetch of every lazy chunk, and the sim id does NOT rotate. when
|
|
5
|
-
// `resetStorage` is set it clears the guest app's tenant-scoped storage via the
|
|
6
|
-
// engine's own clear path (scoped localStorage + open-and-clear of the known
|
|
7
|
-
// tenant IndexedDBs / secure-store) — the maestro `clearState` contract.
|
|
1
|
+
// resets the guest app through the engine lifecycle bridge. a storage reset
|
|
2
|
+
// replaces the tenant worker before clearing its scoped stores, which is the
|
|
3
|
+
// maestro `clearState` contract. a plain reload keeps the faster reload path.
|
|
8
4
|
//
|
|
9
5
|
// this replaces a host-realm `localStorage.clear()` + `indexedDB.deleteDatabase`
|
|
10
6
|
// loop that was wrong twice: (1) `localStorage.clear()` wiped engine settings
|
|
@@ -17,8 +13,14 @@
|
|
|
17
13
|
export function resetGuestAppStateEval(resetStorage: boolean): string {
|
|
18
14
|
return `(async () => {
|
|
19
15
|
const bridge = globalThis.SootSim && globalThis.SootSim.bridges && globalThis.SootSim.bridges.hotRemount
|
|
20
|
-
if (!bridge
|
|
21
|
-
|
|
16
|
+
if (!bridge) return false
|
|
17
|
+
if (${resetStorage ? 'true' : 'false'}) {
|
|
18
|
+
if (typeof bridge.resetExternalApp !== 'function') return false
|
|
19
|
+
const result = await bridge.resetExternalApp({ strategy: 'full' })
|
|
20
|
+
return result.failures.length === 0 && result.relaunched
|
|
21
|
+
}
|
|
22
|
+
if (typeof bridge.reloadExternalApp !== 'function') return false
|
|
23
|
+
await bridge.reloadExternalApp()
|
|
22
24
|
return true
|
|
23
25
|
})()`
|
|
24
26
|
}
|
package/cli/cloud-client.ts
CHANGED
|
@@ -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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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 {
|
package/cli/command-registry.ts
CHANGED
|
@@ -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',
|
package/cli/commands/detox.ts
CHANGED
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
// moduleNameMapper for `^detox$`.
|
|
12
12
|
|
|
13
13
|
import { spawn } from 'child_process'
|
|
14
|
-
import {
|
|
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.
|
|
111
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
-
|
|
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[] = []
|
package/cli/commands/flow.ts
CHANGED
|
@@ -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
|
package/cli/commands/maestro.ts
CHANGED
|
@@ -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
|
}
|
package/cli/commands/platform.ts
CHANGED
|
@@ -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
|
+
}
|