rnxsim 0.1.499 → 0.1.501
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/cli/bridge-flow-runner.ts +6 -2
- 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/inspect/core.ts +2 -0
- 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/cli/ws-bridge.ts +18 -19
- 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 +52 -22
- 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 +7 -8
- package/dist-lib/sdk.mjs +7 -8
- package/dist-lib/skills.cjs +128 -63
- 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/cli/ws-bridge.ts
CHANGED
|
@@ -324,9 +324,9 @@ export interface WsBridge {
|
|
|
324
324
|
opts?: { timeoutMs?: number },
|
|
325
325
|
): Promise<any>
|
|
326
326
|
listSims(): Promise<BridgeSimInfo[]>
|
|
327
|
-
// a guest reload retires the
|
|
328
|
-
//
|
|
329
|
-
// nothing proves what replaced it.
|
|
327
|
+
// a guest reload retires the socket and the same page reconnects under a new
|
|
328
|
+
// or restored id. a caller that pins an id resolves that target here; null
|
|
329
|
+
// means nothing proves what replaced it.
|
|
330
330
|
resolveReloadedSim(retiredSimId: string): Promise<string | null>
|
|
331
331
|
focusSim(simId?: string): Promise<any>
|
|
332
332
|
closeSim(simId?: string): Promise<any>
|
|
@@ -621,21 +621,20 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
|
|
|
621
621
|
const hostPidBySimId = new Map<string, number>()
|
|
622
622
|
const lineageLookupBySimId = new Map<string, Promise<void>>()
|
|
623
623
|
|
|
624
|
-
// a reload retires the
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
function
|
|
624
|
+
// a reload retires the socket but keeps the browser host. the replacement
|
|
625
|
+
// can receive a new sim id or restore the prior one from session storage.
|
|
626
|
+
// the restored id is definitive; a new id must be the only same-host match.
|
|
627
|
+
function pickReloadedTarget(sims: unknown, retiredSimId: string): string | null {
|
|
628
628
|
const pinnedHostPid = hostPidBySimId.get(retiredSimId)
|
|
629
629
|
if (pinnedHostPid === undefined) return null
|
|
630
|
-
const
|
|
631
|
-
(sim) =>
|
|
632
|
-
sim.id !== retiredSimId &&
|
|
633
|
-
sim.readyState === 'open' &&
|
|
634
|
-
sim.meta?.sootsimHostPid === pinnedHostPid,
|
|
630
|
+
const matches = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter(
|
|
631
|
+
(sim) => sim.readyState === 'open' && sim.meta?.sootsimHostPid === pinnedHostPid,
|
|
635
632
|
)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
return
|
|
633
|
+
const restored = matches.find((sim) => sim.id === retiredSimId)
|
|
634
|
+
if (restored) return restored.id
|
|
635
|
+
if (matches.length !== 1) return null
|
|
636
|
+
hostPidBySimId.set(matches[0].id, pinnedHostPid)
|
|
637
|
+
return matches[0].id
|
|
639
638
|
}
|
|
640
639
|
|
|
641
640
|
// print a one-line notice on first sim-scoped command so agents/users see
|
|
@@ -801,7 +800,7 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
|
|
|
801
800
|
// a reload retires the sim id but keeps the browser host, so the
|
|
802
801
|
// successor with the same host pid IS the same page lineage — not a
|
|
803
802
|
// guess. adopt only that, and only when it is unambiguous.
|
|
804
|
-
const successor =
|
|
803
|
+
const successor = pickReloadedTarget(
|
|
805
804
|
await sendOnce(undefined, { type: 'bridge:list-sims' }),
|
|
806
805
|
sentSimId,
|
|
807
806
|
)
|
|
@@ -831,10 +830,10 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
|
|
|
831
830
|
},
|
|
832
831
|
// a caller holding its own pinned sim id (a flow runner sets simId on every
|
|
833
832
|
// command, so it never reaches the pin-following path in send) asks here
|
|
834
|
-
// after a reload retires
|
|
835
|
-
//
|
|
833
|
+
// after a reload retires its socket. the page can reconnect under a new id
|
|
834
|
+
// or restore the old id; null means no unique same-host target exists.
|
|
836
835
|
async resolveReloadedSim(retiredSimId: string) {
|
|
837
|
-
return
|
|
836
|
+
return pickReloadedTarget(await this.listSims(), retiredSimId)
|
|
838
837
|
},
|
|
839
838
|
async focusSim(simId?: string) {
|
|
840
839
|
return this.send({ type: 'focus', simId })
|