peachsim 0.1.527 → 0.1.570
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 +26 -26
- package/cli/app-fonts.ts +1 -1
- package/cli/app-splash.ts +1 -1
- package/cli/bin.ts +1 -1
- package/cli/bridge-diagnostics.ts +1 -1
- package/cli/bridge-flow-runner.ts +57 -5
- package/cli/cloud-client.ts +273 -6
- package/cli/cloud-dispatch.ts +22 -2
- package/cli/cloud-session.ts +60 -4
- package/cli/cloud-test-tunnel.ts +354 -0
- package/cli/commands/box/bundle-plane.ts +2 -2
- package/cli/commands/box/checkout-plane.ts +1 -1
- package/cli/commands/box/rnx-command.ts +2 -2
- package/cli/commands/box/search-command.ts +2 -2
- package/cli/commands/box.ts +8 -8
- package/cli/commands/compat.ts +1 -1
- package/cli/commands/control.ts +22 -3
- package/cli/commands/detox.ts +1 -1
- package/cli/commands/device.ts +1 -1
- package/cli/commands/diagnose.ts +1 -1
- package/cli/commands/film.ts +1 -1
- package/cli/commands/flow.ts +60 -3
- package/cli/commands/inspect/actions.ts +1 -1
- package/cli/commands/inspect/core.ts +6 -7
- package/cli/commands/inspect/find.ts +2 -1
- package/cli/commands/inspect/resolve-target.ts +5 -3
- package/cli/commands/inspect/shared.ts +1 -1
- package/cli/commands/inspect/wait-event.ts +1 -1
- package/cli/commands/inspect/wait-selector.ts +15 -4
- package/cli/commands/inspect.ts +19 -14
- package/cli/commands/maestro.ts +464 -43
- package/cli/commands/permissions.ts +1 -1
- package/cli/commands/platform.ts +205 -8
- package/cli/commands/preview.ts +2 -2
- package/cli/commands/record.ts +2 -2
- package/cli/commands/report-issue.ts +2 -2
- package/cli/commands/screenshot-layers.ts +1 -1
- package/cli/commands/screenshot.ts +1 -1
- package/cli/commands/setup.ts +2 -2
- package/cli/commands/test.ts +440 -115
- package/cli/commands/three-mode.ts +1 -1
- package/cli/commands/upload.ts +5 -5
- package/cli/commands/what-happened.ts +1 -1
- package/cli/desktop-companion.ts +3 -3
- package/cli/drivers/playwright-sim-host.ts +2 -2
- package/cli/flow-file.ts +7 -0
- package/cli/flow-live-status.ts +65 -8
- package/cli/help-core.ts +3 -3
- package/cli/hidden-runtime-alias.ts +1 -1
- package/cli/maestro-report.ts +178 -0
- package/cli/main.ts +1 -1
- package/cli/outbound-endpoints.ts +185 -136
- package/cli/parse-args.ts +1 -1
- package/cli/registry.ts +1 -1
- package/cli/resolve-assets.ts +1 -1
- package/cli/run-registry.ts +8 -0
- package/cli/send-to-box.ts +4 -4
- package/cli/ws-bridge.ts +3 -5
- package/detox/expectations.ts +1 -1
- package/detox/gestures.ts +2 -2
- package/detox/index.ts +71 -29
- package/detox/jest-preset.cjs +3 -3
- package/detox/jest-setup-after-env.cjs +1 -1
- package/dist-lib/agent-daemon-client.cjs +2 -2
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +2 -2
- package/dist-lib/attached-projects.cjs +2 -2
- 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 +94 -3
- package/dist-lib/capture-contract.mjs +93 -3
- 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 +2 -2
- package/dist-lib/cloud.mjs +2 -2
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +106 -25
- package/dist-lib/detox/jest-preset.cjs +3 -3
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +137 -61
- package/dist-lib/host/fetch-proxy-handler.cjs +28 -2
- package/dist-lib/host/fetch-proxy-overrides.cjs +33 -3
- package/dist-lib/host/fetch-proxy-overrides.mjs +30 -2
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +209 -16
- 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 +2 -2
- package/dist-lib/menu.mjs +2 -2
- 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 +3 -3
- package/dist-lib/metro-production-bundle.mjs +3 -3
- package/dist-lib/metro.cjs +8 -8
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +2 -2
- 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 +31 -15
- package/dist-lib/sdk.cjs +3 -2
- package/dist-lib/sdk.mjs +3 -2
- package/dist-lib/skills.cjs +362 -163
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +583 -16
- package/dist-lib/vite.cjs +29 -14
- package/package.json +9 -9
- package/scripts/dev-server-scanner.ts +41 -14
- package/scripts/optional-demo-registry.ts +3 -3
- package/skills/contrast/SKILL.md +9 -9
- package/skills/rnx-debug/SKILL.md +14 -4
- package/skills/rnx-perf/SKILL.md +5 -5
- package/skills/rnx-setup/SKILL.md +11 -11
- package/skills/rnx-test/SKILL.md +11 -11
- package/skills/rnx-visual/SKILL.md +6 -6
- package/src/agent-sessions.ts +3 -3
- package/src/attached-projects.ts +1 -1
- package/src/bridge-contract-input.ts +1 -1
- package/src/bridge-contract.ts +23 -5
- package/src/capture-contract.ts +145 -3
- package/src/config.ts +2 -2
- package/src/connect.ts +2 -2
- package/src/home-paths.ts +3 -3
- package/src/host/bridge-host.ts +2 -2
- package/src/host/fetch-proxy-handler.ts +4 -3
- package/src/host/fetch-proxy-overrides.ts +41 -0
- package/src/host/plane-host.ts +2 -2
- package/src/index.ts +3 -0
- package/src/metro-plugin.ts +4 -4
- package/src/metro-production-bundle.ts +2 -2
- package/src/native-seam-manifest.ts +6 -6
- package/src/public-brand.ts +1 -1
- package/src/rolldown-plugin-swift.ts +8 -5
- package/src/runtime-assets.ts +8 -6
- package/src/screenshots/frame-compose.ts +24 -56
- package/src/screenshots/org-deck.ts +1 -1
- package/src/screenshots/registry.ts +4 -4
- package/src/screenshots/schema.ts +1 -1
- package/src/sdk.ts +2 -2
- package/src/settings-bundle.ts +1 -1
- package/src/sim-client.ts +2 -4
- package/src/skills/builtin/compat-check.ts +1 -1
- package/src/vite-plugin-one.ts +1 -1
- package/src/vite-plugin-swift.ts +11 -6
- package/src/vite-plugin.ts +30 -17
- package/src/worklets-babel.ts +2 -2
package/cli/commands/test.ts
CHANGED
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
4
4
|
import { createServer, type Server } from 'node:http'
|
|
5
|
-
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
5
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
|
|
6
6
|
import {
|
|
7
7
|
RNX_CLOUD_TEST_FILES_MAX_BYTES,
|
|
8
8
|
RNX_CLOUD_TEST_FILES_MAX_COUNT,
|
|
9
9
|
} from '../../src/cloud-contract'
|
|
10
10
|
import { authHeaderValue, resolveCliAuth } from '../auth'
|
|
11
|
+
import {
|
|
12
|
+
parseCloudTestSidecarPorts,
|
|
13
|
+
parseLiveDevStackApp,
|
|
14
|
+
publishLiveDevStack,
|
|
15
|
+
} from '../cloud-test-tunnel'
|
|
16
|
+
import { parseFlowFile } from '../flow-file'
|
|
11
17
|
import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
|
|
12
|
-
import { hoistLeadingSimFlag } from './flow'
|
|
18
|
+
import { FLOW_ARG_VALUE_FLAGS, hoistLeadingSimFlag } from './flow'
|
|
13
19
|
import { resolveDefaultUploadOrigin } from './upload'
|
|
14
20
|
|
|
15
21
|
export interface RunTestOptions {
|
|
@@ -24,6 +30,11 @@ interface TestAppTarget {
|
|
|
24
30
|
close: () => Promise<void>
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
// every flag that swallows the token after it, so the positional scan finds
|
|
34
|
+
// the flow/suite target no matter where the flags sit (`rnx test
|
|
35
|
+
// --include-tags smoke .maestro/` must not take `smoke` as the target).
|
|
36
|
+
// maestro compat flags live here alongside the flow-runner vocabulary;
|
|
37
|
+
// without them a flag-first maestro command line mis-resolves its target.
|
|
27
38
|
const VALUE_FLAGS = new Set([
|
|
28
39
|
'--app',
|
|
29
40
|
'--bundle',
|
|
@@ -34,11 +45,22 @@ const VALUE_FLAGS = new Set([
|
|
|
34
45
|
'--tab',
|
|
35
46
|
'--port',
|
|
36
47
|
'--device',
|
|
48
|
+
'-d',
|
|
49
|
+
'--env',
|
|
50
|
+
'-e',
|
|
37
51
|
'--driver',
|
|
38
52
|
'--base-url',
|
|
39
53
|
'--url',
|
|
54
|
+
'--include-tags',
|
|
55
|
+
'--exclude-tags',
|
|
56
|
+
'--format',
|
|
57
|
+
'--output',
|
|
58
|
+
'--test-output-dir',
|
|
59
|
+
'--debug-output',
|
|
60
|
+
'--platform',
|
|
40
61
|
'-t',
|
|
41
62
|
'--testNamePattern',
|
|
63
|
+
'--testTimeout',
|
|
42
64
|
'--grep',
|
|
43
65
|
'--maxWorkers',
|
|
44
66
|
'--shard',
|
|
@@ -48,6 +70,9 @@ const VALUE_FLAGS = new Set([
|
|
|
48
70
|
'--repo',
|
|
49
71
|
'--poll-interval',
|
|
50
72
|
'--timeout',
|
|
73
|
+
'--sidecar-ports',
|
|
74
|
+
'--cancel',
|
|
75
|
+
...FLOW_ARG_VALUE_FLAGS,
|
|
51
76
|
])
|
|
52
77
|
|
|
53
78
|
function valueAfter(args: string[], name: string): string | null {
|
|
@@ -59,7 +84,7 @@ function valueAfter(args: string[], name: string): string | null {
|
|
|
59
84
|
return value && !value.startsWith('-') ? value : null
|
|
60
85
|
}
|
|
61
86
|
|
|
62
|
-
function
|
|
87
|
+
function targetIndexFromArgs(args: string[]): number {
|
|
63
88
|
for (let index = 0; index < args.length; index++) {
|
|
64
89
|
const arg = args[index]
|
|
65
90
|
if (VALUE_FLAGS.has(arg)) {
|
|
@@ -69,20 +94,31 @@ function targetFromArgs(args: string[]): string | null {
|
|
|
69
94
|
if (arg.startsWith('--') && arg.includes('=')) {
|
|
70
95
|
continue
|
|
71
96
|
}
|
|
72
|
-
if (!arg.startsWith('-')) return
|
|
97
|
+
if (!arg.startsWith('-')) return index
|
|
73
98
|
}
|
|
74
|
-
return
|
|
99
|
+
return -1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function targetFromArgs(args: string[]): string | null {
|
|
103
|
+
const index = targetIndexFromArgs(args)
|
|
104
|
+
return index >= 0 ? args[index] : null
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const APP_FLAGS = ['--app', '--bundle', '--bundle-url']
|
|
108
|
+
|
|
109
|
+
function isAppFlag(arg: string): boolean {
|
|
110
|
+
return APP_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`))
|
|
75
111
|
}
|
|
76
112
|
|
|
77
113
|
function withoutApp(args: string[]): string[] {
|
|
78
114
|
const result: string[] = []
|
|
79
115
|
for (let i = 0; i < args.length; i++) {
|
|
80
116
|
const arg = args[i]
|
|
81
|
-
if (arg
|
|
117
|
+
if (APP_FLAGS.includes(arg)) {
|
|
82
118
|
i += 1
|
|
83
119
|
continue
|
|
84
120
|
}
|
|
85
|
-
if (arg
|
|
121
|
+
if (isAppFlag(arg)) {
|
|
86
122
|
continue
|
|
87
123
|
}
|
|
88
124
|
result.push(arg)
|
|
@@ -90,6 +126,25 @@ function withoutApp(args: string[]): string[] {
|
|
|
90
126
|
return result
|
|
91
127
|
}
|
|
92
128
|
|
|
129
|
+
// the maestro runner reads the app target as --url; rnx test spells it
|
|
130
|
+
// --app/--bundle/--bundle-url. map each spelling explicitly: a prefix
|
|
131
|
+
// replace would turn --bundle-url into --url-url.
|
|
132
|
+
function mapAppFlagToUrl(arg: string): string {
|
|
133
|
+
if (APP_FLAGS.includes(arg)) return '--url'
|
|
134
|
+
for (const flag of APP_FLAGS) {
|
|
135
|
+
if (arg.startsWith(`${flag}=`)) return `--url=${arg.slice(flag.length + 1)}`
|
|
136
|
+
}
|
|
137
|
+
return arg
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appFromArgs(args: string[]): string | null {
|
|
141
|
+
return (
|
|
142
|
+
valueAfter(args, '--app') ??
|
|
143
|
+
valueAfter(args, '--bundle') ??
|
|
144
|
+
valueAfter(args, '--bundle-url')
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
93
148
|
function closeServer(server: Server): Promise<void> {
|
|
94
149
|
return new Promise((resolveClose, rejectClose) => {
|
|
95
150
|
server.close((error) => {
|
|
@@ -153,14 +208,30 @@ function restoreRnxUrl(previous: string | undefined): void {
|
|
|
153
208
|
}
|
|
154
209
|
}
|
|
155
210
|
|
|
211
|
+
// `--config` is claimed by both interpreters: a YAML value is a maestro
|
|
212
|
+
// workspace config, anything else is a detox jest config.
|
|
213
|
+
function isMaestroConfigValue(value: string | null): boolean {
|
|
214
|
+
return value !== null && /\.ya?ml$/i.test(value)
|
|
215
|
+
}
|
|
216
|
+
|
|
156
217
|
// dispatch only: each interpreter retains its own selection and execution rules.
|
|
157
218
|
export function resolveTestCommand(args: string[], cwd = process.cwd()) {
|
|
158
219
|
const forwarded = hoistLeadingSimFlag(args)
|
|
159
|
-
const
|
|
160
|
-
const config = forwarded.some(
|
|
220
|
+
const hasConfig = forwarded.some(
|
|
161
221
|
(arg) => arg === '--config' || arg.startsWith('--config='),
|
|
162
222
|
)
|
|
163
|
-
|
|
223
|
+
const maestroConfig =
|
|
224
|
+
hasConfig && isMaestroConfigValue(valueAfter(forwarded, '--config'))
|
|
225
|
+
if (hasConfig && !maestroConfig) return { command: 'detox', args: forwarded }
|
|
226
|
+
// the target is the first positional, wherever it sits: maestro users put
|
|
227
|
+
// flags before the path (`maestro test --include-tags smoke .maestro/`),
|
|
228
|
+
// so reading only argv[0] breaks the exact pasted command line.
|
|
229
|
+
let target = targetFromArgs(forwarded) ?? undefined
|
|
230
|
+
if (!target && maestroConfig) {
|
|
231
|
+
// `rnx test --config e2e/config.yaml`: the config describes a workspace,
|
|
232
|
+
// so run the directory it lives in.
|
|
233
|
+
target = dirname(resolve(cwd, valueAfter(forwarded, '--config') as string))
|
|
234
|
+
}
|
|
164
235
|
if (!target) {
|
|
165
236
|
throw new Error(
|
|
166
237
|
'pass a Maestro flow/directory, a Jest suite, or --config <jest-config>',
|
|
@@ -182,19 +253,22 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
|
|
|
182
253
|
['.maestro', 'maestro'].includes(basename(absolute))
|
|
183
254
|
: /\.ya?ml$/.test(extname(absolute))
|
|
184
255
|
if (maestro) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
256
|
+
// when the target names a workspace config file, run its directory.
|
|
257
|
+
// the replacement lands on the target positional by index: with
|
|
258
|
+
// flag-first argv the target is not argv[0], and a path can equal a
|
|
259
|
+
// flag value elsewhere in the line.
|
|
260
|
+
const targetIndex = targetIndexFromArgs(forwarded)
|
|
261
|
+
const runConfigDir = !directory && /^config\.ya?ml$/.test(basename(absolute))
|
|
262
|
+
const mapped = forwarded.map((arg, index) => {
|
|
263
|
+
if (index === targetIndex && runConfigDir) return dirname(absolute)
|
|
264
|
+
return mapAppFlagToUrl(arg)
|
|
265
|
+
})
|
|
266
|
+
if (targetIndex < 0) {
|
|
267
|
+
// synthesized target (`--config e2e/config.yaml` with no positional):
|
|
268
|
+
// name the workspace dir so maestro does not auto-discover cwd.
|
|
269
|
+
mapped.push(target)
|
|
197
270
|
}
|
|
271
|
+
return { command: 'maestro', args: ['test', ...mapped] }
|
|
198
272
|
}
|
|
199
273
|
if (!directory && !/\.[cm]?[jt]sx?$/.test(extname(absolute))) {
|
|
200
274
|
throw new Error(`expected Maestro YAML or a Detox/Jest suite: ${target}`)
|
|
@@ -202,6 +276,55 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
|
|
|
202
276
|
return { command: 'detox', args: forwarded }
|
|
203
277
|
}
|
|
204
278
|
|
|
279
|
+
// helper files a single flow pulls in through runFlow / runScript file
|
|
280
|
+
// refs, resolved relative to each referencing file exactly like the runner,
|
|
281
|
+
// transitively with a cycle guard. a ref that does not exist is the runner's
|
|
282
|
+
// loud failure, not the submitter's: warn and let the hosted run report it
|
|
283
|
+
// at its step, like a local run does.
|
|
284
|
+
function collectFlowHelperPaths(flowAbsPath: string): string[] {
|
|
285
|
+
if (!flowAbsPath.endsWith('.yml') && !flowAbsPath.endsWith('.yaml')) return []
|
|
286
|
+
const helpers: string[] = []
|
|
287
|
+
const visited = new Set<string>([resolve(flowAbsPath)])
|
|
288
|
+
const queue = [resolve(flowAbsPath)]
|
|
289
|
+
while (queue.length > 0) {
|
|
290
|
+
const current = queue.pop() as string
|
|
291
|
+
if (!current.endsWith('.yml') && !current.endsWith('.yaml')) continue
|
|
292
|
+
let steps: unknown[]
|
|
293
|
+
try {
|
|
294
|
+
steps = parseFlowFile(readFileSync(current, 'utf8')).steps
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.warn(
|
|
297
|
+
` warn: could not parse flow refs in ${current}: ${err instanceof Error ? err.message : String(err)}`,
|
|
298
|
+
)
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
for (const step of steps) {
|
|
302
|
+
if (typeof step !== 'object' || step === null) continue
|
|
303
|
+
for (const key of ['runFlow', 'runScript'] as const) {
|
|
304
|
+
const spec = (step as Record<string, unknown>)[key]
|
|
305
|
+
if (spec === undefined || spec === null) continue
|
|
306
|
+
const file =
|
|
307
|
+
typeof spec === 'string'
|
|
308
|
+
? spec
|
|
309
|
+
: typeof (spec as Record<string, unknown>).file === 'string'
|
|
310
|
+
? ((spec as Record<string, unknown>).file as string)
|
|
311
|
+
: null
|
|
312
|
+
if (!file) continue
|
|
313
|
+
const abs = resolve(dirname(current), file)
|
|
314
|
+
if (visited.has(abs)) continue
|
|
315
|
+
visited.add(abs)
|
|
316
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
317
|
+
helpers.push(abs)
|
|
318
|
+
queue.push(abs)
|
|
319
|
+
} else {
|
|
320
|
+
console.warn(` warn: flow ref not found: ${file} (from ${current})`)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return helpers
|
|
326
|
+
}
|
|
327
|
+
|
|
205
328
|
function collectTestFiles(
|
|
206
329
|
targetPath: string,
|
|
207
330
|
ignoredPath: string | null,
|
|
@@ -240,8 +363,48 @@ function collectTestFiles(
|
|
|
240
363
|
}
|
|
241
364
|
walk(targetPath, '')
|
|
242
365
|
} else if (stat.isFile()) {
|
|
243
|
-
add(targetPath, basename(targetPath))
|
|
244
366
|
const parent = dirname(targetPath)
|
|
367
|
+
const helperAbsPaths = collectFlowHelperPaths(targetPath)
|
|
368
|
+
if (helperAbsPaths.length === 0) {
|
|
369
|
+
add(targetPath, basename(targetPath))
|
|
370
|
+
} else {
|
|
371
|
+
// the flow references helpers by path relative to itself (runFlow /
|
|
372
|
+
// runScript file). key every file by its path under their common root
|
|
373
|
+
// so the recorder's unpack preserves those relative refs.
|
|
374
|
+
const all = [resolve(targetPath), ...helperAbsPaths]
|
|
375
|
+
const split = all.map((abs) => abs.split(sep))
|
|
376
|
+
const commonParts = split[0]?.slice(0) ?? []
|
|
377
|
+
while (
|
|
378
|
+
commonParts.length > 0 &&
|
|
379
|
+
!split.every(
|
|
380
|
+
(parts) =>
|
|
381
|
+
parts.length >= commonParts.length &&
|
|
382
|
+
commonParts.every((part, i) => parts[i] === part),
|
|
383
|
+
)
|
|
384
|
+
) {
|
|
385
|
+
commonParts.pop()
|
|
386
|
+
}
|
|
387
|
+
const common = commonParts.join(sep) || sep
|
|
388
|
+
const keys = all.map((abs) => relative(common, abs).split(sep).join('/'))
|
|
389
|
+
for (const [index, abs] of all.entries()) {
|
|
390
|
+
add(abs, keys[index] as string)
|
|
391
|
+
}
|
|
392
|
+
// the recorder runs an unpacked multi-file upload as a workspace,
|
|
393
|
+
// whose default glob only sees top-level flows: a nested entry would
|
|
394
|
+
// lose to a top-level helper. pin a config that selects the entry.
|
|
395
|
+
if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
|
|
396
|
+
if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
const entryKey = keys[0] as string
|
|
402
|
+
const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
|
|
403
|
+
files['config.yaml'] =
|
|
404
|
+
'# synthesized by rnx test --cloud: run exactly the submitted entry flow.\n' +
|
|
405
|
+
`flows:\n - ${JSON.stringify(glob)}\n`
|
|
406
|
+
}
|
|
407
|
+
}
|
|
245
408
|
for (const configName of [
|
|
246
409
|
'rnx-detox.config.cjs',
|
|
247
410
|
'jest.config.cjs',
|
|
@@ -262,6 +425,82 @@ function collectTestFiles(
|
|
|
262
425
|
return files
|
|
263
426
|
}
|
|
264
427
|
|
|
428
|
+
async function readJobStatus(
|
|
429
|
+
endpoint: string,
|
|
430
|
+
authorization: string,
|
|
431
|
+
): Promise<string | null> {
|
|
432
|
+
try {
|
|
433
|
+
const res = await fetch(endpoint, { headers: { authorization } })
|
|
434
|
+
if (!res.ok) return null
|
|
435
|
+
const json = (await res.json()) as { status?: unknown } | null
|
|
436
|
+
return json && typeof json.status === 'string' ? json.status : null
|
|
437
|
+
} catch {
|
|
438
|
+
return null
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
|
|
443
|
+
const auth = resolveCliAuth()
|
|
444
|
+
if (!auth) {
|
|
445
|
+
console.error(' error: cancelling a cloud test requires `rnx login` or RNX_API_KEY')
|
|
446
|
+
return 1
|
|
447
|
+
}
|
|
448
|
+
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
|
|
449
|
+
let response: Response
|
|
450
|
+
try {
|
|
451
|
+
response = await fetch(endpoint, {
|
|
452
|
+
method: 'DELETE',
|
|
453
|
+
headers: { authorization: authHeaderValue(auth) },
|
|
454
|
+
})
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.error(
|
|
457
|
+
` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
458
|
+
)
|
|
459
|
+
return 1
|
|
460
|
+
}
|
|
461
|
+
if (response.status === 404) {
|
|
462
|
+
console.error(` error: cloud test job not found: ${jobId}`)
|
|
463
|
+
return 1
|
|
464
|
+
}
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
const body = await response.text()
|
|
467
|
+
let message = body
|
|
468
|
+
try {
|
|
469
|
+
const json = JSON.parse(body)
|
|
470
|
+
if (json.message) message = json.message
|
|
471
|
+
} catch {}
|
|
472
|
+
console.error(` error: cancel failed (${response.status}): ${message}`)
|
|
473
|
+
return 1
|
|
474
|
+
}
|
|
475
|
+
let status: string | null = null
|
|
476
|
+
try {
|
|
477
|
+
const json = (await response.json()) as { status?: unknown } | null
|
|
478
|
+
if (json && typeof json.status === 'string') status = json.status
|
|
479
|
+
} catch {
|
|
480
|
+
// an older server answers 2xx with no cancel body; fall through to the
|
|
481
|
+
// read-back below rather than claim anything.
|
|
482
|
+
}
|
|
483
|
+
if (status === null) {
|
|
484
|
+
status = await readJobStatus(endpoint, authHeaderValue(auth))
|
|
485
|
+
if (status === 'queued' || status === 'running') {
|
|
486
|
+
console.error(
|
|
487
|
+
` error: the server left ${jobId} ${status}; cancel needs a newer job api`,
|
|
488
|
+
)
|
|
489
|
+
return 1
|
|
490
|
+
}
|
|
491
|
+
if (status === null) {
|
|
492
|
+
console.error(` error: cancel sent but the state of ${jobId} is unknown`)
|
|
493
|
+
return 1
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (status === 'cancelled') {
|
|
497
|
+
console.log(` cloud test job cancelled: ${jobId}`)
|
|
498
|
+
} else {
|
|
499
|
+
console.log(` cloud test job already ${status}: ${jobId}`)
|
|
500
|
+
}
|
|
501
|
+
return 0
|
|
502
|
+
}
|
|
503
|
+
|
|
265
504
|
export async function runCloudTest(
|
|
266
505
|
args: string[],
|
|
267
506
|
opts: RunTestOptions = {},
|
|
@@ -269,6 +508,14 @@ export async function runCloudTest(
|
|
|
269
508
|
const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted')
|
|
270
509
|
const originFlag = valueAfter(forwarded, '--origin')
|
|
271
510
|
const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined)
|
|
511
|
+
const cancelId = valueAfter(forwarded, '--cancel')
|
|
512
|
+
if (forwarded.includes('--cancel') || cancelId !== null) {
|
|
513
|
+
if (!cancelId) {
|
|
514
|
+
console.error(' usage: rnx test --cancel <job-id>')
|
|
515
|
+
return 1
|
|
516
|
+
}
|
|
517
|
+
return cancelCloudTest(cancelId, origin)
|
|
518
|
+
}
|
|
272
519
|
const app =
|
|
273
520
|
valueAfter(forwarded, '--app') ||
|
|
274
521
|
valueAfter(forwarded, '--bundle') ||
|
|
@@ -284,7 +531,11 @@ export async function runCloudTest(
|
|
|
284
531
|
let target = targetFromArgs(forwarded)
|
|
285
532
|
const config = valueAfter(forwarded, '--config')
|
|
286
533
|
if (!target && config) {
|
|
287
|
-
|
|
534
|
+
// a maestro workspace config uploads its directory; a jest config is
|
|
535
|
+
// collected from its own dir by the path logic below.
|
|
536
|
+
target = isMaestroConfigValue(config)
|
|
537
|
+
? dirname(resolve(process.cwd(), config))
|
|
538
|
+
: config
|
|
288
539
|
}
|
|
289
540
|
if (!target) {
|
|
290
541
|
for (const cand of ['.maestro', 'maestro', 'flows', 'e2e']) {
|
|
@@ -307,11 +558,25 @@ export async function runCloudTest(
|
|
|
307
558
|
|
|
308
559
|
if (!app) {
|
|
309
560
|
console.error(
|
|
310
|
-
' error: rnx test --cloud requires --app <bundle.js|https://bundle-url>',
|
|
561
|
+
' error: rnx test --cloud requires --app <bundle.js|https://bundle-url|http://localhost:port>',
|
|
311
562
|
)
|
|
312
563
|
return 1
|
|
313
564
|
}
|
|
314
565
|
|
|
566
|
+
const localOnly = [
|
|
567
|
+
'--include-tags',
|
|
568
|
+
'--exclude-tags',
|
|
569
|
+
'--format',
|
|
570
|
+
'--output',
|
|
571
|
+
'--test-output-dir',
|
|
572
|
+
'--debug-output',
|
|
573
|
+
].filter((flag) => forwarded.some((arg) => arg === flag || arg.startsWith(`${flag}=`)))
|
|
574
|
+
if (localOnly.length > 0) {
|
|
575
|
+
console.warn(
|
|
576
|
+
` warn: ${localOnly.join(', ')} apply to local runs only and are ignored by --cloud`,
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
315
580
|
const auth = resolveCliAuth()
|
|
316
581
|
if (!auth) {
|
|
317
582
|
console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
|
|
@@ -336,18 +601,41 @@ export async function runCloudTest(
|
|
|
336
601
|
let bundleUrl: string | undefined
|
|
337
602
|
let bundle: string | undefined
|
|
338
603
|
let bundleShareId: string | undefined
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
604
|
+
let tunnelTargetUrl: string | undefined
|
|
605
|
+
let tunnelHostHeader: string | undefined
|
|
606
|
+
let tunnelJobToken: string | undefined
|
|
607
|
+
let tunnelSidecarPorts: string | undefined
|
|
608
|
+
let closeTunnel: (() => void) | null = null
|
|
609
|
+
|
|
610
|
+
const liveDevStack = parseLiveDevStackApp(app)
|
|
611
|
+
const sidecarFlag = valueAfter(forwarded, '--sidecar-ports')
|
|
612
|
+
let liveSidecarPorts: number[] | null = null
|
|
613
|
+
if (liveDevStack) {
|
|
614
|
+
const parsedSidecars = parseCloudTestSidecarPorts(sidecarFlag, liveDevStack.port)
|
|
615
|
+
if (!parsedSidecars.ok) {
|
|
616
|
+
console.error(` error: ${parsedSidecars.message}`)
|
|
617
|
+
return 1
|
|
618
|
+
}
|
|
619
|
+
liveSidecarPorts = parsedSidecars.ports
|
|
344
620
|
} else {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
621
|
+
if (sidecarFlag !== null) {
|
|
622
|
+
console.error(
|
|
623
|
+
' error: --sidecar-ports requires a live dev stack --app (http://localhost:<port>)',
|
|
624
|
+
)
|
|
348
625
|
return 1
|
|
349
626
|
}
|
|
350
|
-
|
|
627
|
+
if (app.startsWith('http://') || app.startsWith('https://')) {
|
|
628
|
+
bundleUrl = app
|
|
629
|
+
} else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) {
|
|
630
|
+
bundleShareId = app.replace(/^.*id=/, '')
|
|
631
|
+
} else {
|
|
632
|
+
const localBundle = resolve(process.cwd(), app)
|
|
633
|
+
if (!existsSync(localBundle) || !statSync(localBundle).isFile()) {
|
|
634
|
+
console.error(` error: --app bundle file not found: ${app}`)
|
|
635
|
+
return 1
|
|
636
|
+
}
|
|
637
|
+
bundle = readFileSync(localBundle, 'utf8')
|
|
638
|
+
}
|
|
351
639
|
}
|
|
352
640
|
|
|
353
641
|
const targetName = basename(targetPath)
|
|
@@ -370,6 +658,26 @@ export async function runCloudTest(
|
|
|
370
658
|
return 1
|
|
371
659
|
}
|
|
372
660
|
|
|
661
|
+
// the tunnel stays open from submit until the job reaches a terminal
|
|
662
|
+
// state; tearing it down early strands the recorder mid-run.
|
|
663
|
+
if (liveDevStack && liveSidecarPorts) {
|
|
664
|
+
try {
|
|
665
|
+
const published = await publishLiveDevStack({
|
|
666
|
+
devPort: liveDevStack.port,
|
|
667
|
+
sidecarPorts: liveSidecarPorts,
|
|
668
|
+
})
|
|
669
|
+
tunnelTargetUrl = published.targetUrl
|
|
670
|
+
tunnelHostHeader = published.hostHeader
|
|
671
|
+
tunnelJobToken = published.jobToken
|
|
672
|
+
tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
|
|
673
|
+
closeTunnel = published.close
|
|
674
|
+
console.log(` live dev stack published: ${published.targetUrl}`)
|
|
675
|
+
} catch (err) {
|
|
676
|
+
console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
|
|
677
|
+
return 1
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
373
681
|
const submitPayload: Record<string, unknown> = {
|
|
374
682
|
kind: 'test',
|
|
375
683
|
testFiles,
|
|
@@ -377,114 +685,131 @@ export async function runCloudTest(
|
|
|
377
685
|
if (bundleUrl) submitPayload.bundleUrl = bundleUrl
|
|
378
686
|
if (bundle) submitPayload.bundle = bundle
|
|
379
687
|
if (bundleShareId) submitPayload.bundleShareId = bundleShareId
|
|
688
|
+
if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
|
|
689
|
+
if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
|
|
690
|
+
if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
|
|
691
|
+
if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
|
|
380
692
|
if (owner) submitPayload.owner = owner
|
|
381
693
|
if (repo) submitPayload.repo = repo
|
|
382
694
|
|
|
383
|
-
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
384
|
-
let response: Response
|
|
385
695
|
try {
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
696
|
+
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
697
|
+
let response: Response
|
|
404
698
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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) },
|
|
699
|
+
response = await fetch(endpoint, {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
headers: {
|
|
702
|
+
'content-type': 'application/json',
|
|
703
|
+
authorization: authHeaderValue(auth),
|
|
704
|
+
},
|
|
705
|
+
body: JSON.stringify(submitPayload),
|
|
426
706
|
})
|
|
427
707
|
} catch (err) {
|
|
428
|
-
console.
|
|
429
|
-
`
|
|
708
|
+
console.error(
|
|
709
|
+
` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
430
710
|
)
|
|
431
|
-
|
|
432
|
-
}
|
|
433
|
-
if (!pollRes.ok) {
|
|
434
|
-
console.warn(` warn: polling error: HTTP ${pollRes.status}`)
|
|
435
|
-
continue
|
|
711
|
+
return 1
|
|
436
712
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
713
|
+
|
|
714
|
+
if (!response.ok) {
|
|
715
|
+
const body = await response.text()
|
|
716
|
+
let message = body
|
|
717
|
+
try {
|
|
718
|
+
const json = JSON.parse(body)
|
|
719
|
+
if (json.message) message = json.message
|
|
720
|
+
} catch {}
|
|
721
|
+
console.error(
|
|
722
|
+
` error: cloud test submission failed (${response.status}): ${message}`,
|
|
723
|
+
)
|
|
724
|
+
return 1
|
|
447
725
|
}
|
|
448
726
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
727
|
+
const { id } = (await response.json()) as { id: string }
|
|
728
|
+
console.log(` cloud test job submitted: ${id}`)
|
|
729
|
+
|
|
730
|
+
const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
|
|
731
|
+
const startedAt = Date.now()
|
|
732
|
+
|
|
733
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
734
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
|
735
|
+
let pollRes: Response
|
|
736
|
+
try {
|
|
737
|
+
pollRes = await fetch(pollUrl, {
|
|
738
|
+
headers: { authorization: authHeaderValue(auth) },
|
|
739
|
+
})
|
|
740
|
+
} catch (err) {
|
|
741
|
+
console.warn(
|
|
742
|
+
` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
|
|
462
743
|
)
|
|
744
|
+
continue
|
|
463
745
|
}
|
|
464
|
-
if (
|
|
465
|
-
console.
|
|
746
|
+
if (!pollRes.ok) {
|
|
747
|
+
console.warn(` warn: polling error: HTTP ${pollRes.status}`)
|
|
748
|
+
continue
|
|
466
749
|
}
|
|
467
|
-
|
|
468
|
-
|
|
750
|
+
const job = (await pollRes.json()) as {
|
|
751
|
+
id: string
|
|
752
|
+
status: string
|
|
753
|
+
result?: {
|
|
754
|
+
previewId?: string | null
|
|
755
|
+
previewUrl?: string | null
|
|
756
|
+
runId?: string | null
|
|
757
|
+
exitCode?: number | null
|
|
758
|
+
failureReason?: string | null
|
|
759
|
+
} | null
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (
|
|
763
|
+
job.status === 'done' ||
|
|
764
|
+
job.status === 'failed' ||
|
|
765
|
+
job.status === 'expired' ||
|
|
766
|
+
job.status === 'cancelled'
|
|
767
|
+
) {
|
|
768
|
+
const result = job.result
|
|
769
|
+
// a job the recorder failed after a passing suite (missing recording or
|
|
770
|
+
// registration) is still a failed run.
|
|
771
|
+
const exitCode =
|
|
772
|
+
job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
|
|
773
|
+
console.log(` status: ${job.status}`)
|
|
774
|
+
console.log(` exit code: ${exitCode}`)
|
|
775
|
+
if (result?.previewUrl) {
|
|
776
|
+
console.log(` preview: ${result.previewUrl}`)
|
|
777
|
+
} else if (result?.previewId) {
|
|
778
|
+
console.log(
|
|
779
|
+
` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
|
|
780
|
+
)
|
|
781
|
+
}
|
|
782
|
+
if (result?.runId) {
|
|
783
|
+
console.log(` run: ${result.runId}`)
|
|
784
|
+
}
|
|
785
|
+
if (result?.failureReason) {
|
|
786
|
+
console.error(` failure: ${result.failureReason}`)
|
|
787
|
+
}
|
|
788
|
+
return exitCode
|
|
469
789
|
}
|
|
470
|
-
return exitCode
|
|
471
790
|
}
|
|
472
|
-
}
|
|
473
791
|
|
|
474
|
-
|
|
475
|
-
|
|
792
|
+
console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
|
|
793
|
+
return 1
|
|
794
|
+
} finally {
|
|
795
|
+
closeTunnel?.()
|
|
796
|
+
}
|
|
476
797
|
}
|
|
477
798
|
|
|
478
799
|
export async function runTest(
|
|
479
800
|
args: string[],
|
|
480
801
|
opts: RunTestOptions = {},
|
|
481
802
|
): Promise<number> {
|
|
482
|
-
if (
|
|
803
|
+
if (
|
|
804
|
+
args.includes('--cloud') ||
|
|
805
|
+
args.includes('--hosted') ||
|
|
806
|
+
args.some((arg) => arg === '--cancel' || arg.startsWith('--cancel='))
|
|
807
|
+
) {
|
|
483
808
|
return runCloudTest(args, opts)
|
|
484
809
|
}
|
|
485
810
|
|
|
486
811
|
const selected = resolveTestCommand(args)
|
|
487
|
-
const app =
|
|
812
|
+
const app = appFromArgs(args)
|
|
488
813
|
const appTarget = app
|
|
489
814
|
? await resolveTestAppTarget(app)
|
|
490
815
|
: { target: '', close: async () => {} }
|