rnxsim 0.1.500 → 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.
- package/README.md +1 -1
- package/cli/cloud-client.ts +90 -7
- package/cli/cloud-dispatch.ts +27 -27
- package/cli/cloud-session.ts +61 -6
- package/cli/commands/control.ts +33 -31
- package/cli/commands/detox.ts +157 -159
- package/cli/commands/platform.ts +170 -48
- package/cli/commands/preview.ts +1 -1
- package/cli/commands/test.ts +403 -95
- package/cli/main.ts +11 -0
- package/cli/outbound-endpoints.ts +10 -0
- package/detox/jest-preset.cjs +1 -6
- 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 +7 -1
- package/dist-lib/cloud-contract.mjs +5 -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/detox/jest-preset.cjs +1 -6
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +54 -29
- 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 +39 -12
- 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 +6 -8
- package/dist-lib/sdk.mjs +6 -8
- package/dist-lib/skills.cjs +109 -51
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/skills/rnx-test/SKILL.md +2 -2
- package/src/cloud-contract.ts +2 -0
- package/src/sim-client.ts +8 -10
package/cli/commands/test.ts
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
|
-
// rnx test — choose
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
195
|
-
const
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
: [targetPath, ...forwarded]
|
|
510
|
+
const { runDetox } = await import('./detox')
|
|
511
|
+
const detoxArgs = withoutApp(selected.args)
|
|
208
512
|
const previousRnxUrl = process.env.RNX_URL
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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/detox/jest-preset.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// jest preset for running existing detox suites against sootsim.
|
|
2
2
|
//
|
|
3
|
-
// users drop `preset: '
|
|
3
|
+
// users drop `preset: 'rnxsim/detox'` into their jest config
|
|
4
4
|
// (or `rnx-detox.config.cjs`) and their `import { by, device, element,
|
|
5
5
|
// expect, waitFor } from 'detox'` calls resolve to the sootsim driver.
|
|
6
6
|
|
|
@@ -25,10 +25,5 @@ module.exports = {
|
|
|
25
25
|
},
|
|
26
26
|
moduleNameMapper: {
|
|
27
27
|
'^detox$': DRIVER_ENTRY,
|
|
28
|
-
// shared utils some external detox suites import relatively.
|
|
29
|
-
'^\\.\\./utils/navigation$': path.join(PACKAGE_ROOT, 'detox', 'navigation.ts'),
|
|
30
|
-
'^\\.\\./utils/colors$': path.join(PACKAGE_ROOT, 'detox', 'colors.ts'),
|
|
31
|
-
'^\\./(utils/navigation)$': path.join(PACKAGE_ROOT, 'detox', 'navigation.ts'),
|
|
32
|
-
'^\\./(utils/colors)$': path.join(PACKAGE_ROOT, 'detox', 'colors.ts'),
|
|
33
28
|
},
|
|
34
29
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.501 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.501 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.501 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.501 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.501 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! rnx v0.1.
|
|
1
|
+
/*! rnx v0.1.501 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
|
|
2
2
|
let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
|
|
3
3
|
"use strict";
|
|
4
4
|
var __create = Object.create;
|