peachsim 0.1.527 → 0.1.571
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 +28 -28
- 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 +72 -12
- package/cli/cloud-client.ts +273 -6
- package/cli/cloud-dispatch.ts +23 -2
- package/cli/cloud-session.ts +60 -4
- package/cli/cloud-test-result-files.ts +97 -0
- 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 +582 -69
- 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 +600 -117
- 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 +180 -0
- package/cli/main.ts +1 -1
- package/cli/outbound-endpoints.ts +187 -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 +10 -1
- 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 +5 -5
- 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-test-args.cjs +116 -0
- package/dist-lib/cloud-test-args.mjs +86 -0
- 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 +5 -5
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +150 -65
- 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 +224 -41
- 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 +478 -266
- package/dist-lib/swift-compile.cjs +372 -0
- package/dist-lib/swift-compile.mjs +339 -0
- package/dist-lib/swift-package.cjs +78 -0
- package/dist-lib/swift-package.mjs +52 -0
- 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 +483 -150
- package/package.json +30 -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 +14 -14
- 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/cloud-test-args.ts +100 -0
- 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 +16 -12
- 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/swift-compile.ts +359 -0
- package/src/swift-package.ts +69 -0
- package/src/swift-submit.ts +3 -2
- package/src/vite-plugin-one.ts +1 -1
- package/src/vite-plugin-swift.ts +114 -113
- package/src/vite-plugin.ts +30 -17
- package/src/worklets-babel.ts +2 -2
package/cli/commands/test.ts
CHANGED
|
@@ -2,14 +2,21 @@
|
|
|
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
|
+
import { isCloudTestValueFlag, parseCloudTestArgs } from '../../src/cloud-test-args'
|
|
10
11
|
import { authHeaderValue, resolveCliAuth } from '../auth'
|
|
12
|
+
import {
|
|
13
|
+
parseCloudTestSidecarPorts,
|
|
14
|
+
parseLiveDevStackApp,
|
|
15
|
+
publishLiveDevStack,
|
|
16
|
+
} from '../cloud-test-tunnel'
|
|
17
|
+
import { parseFlowFile } from '../flow-file'
|
|
11
18
|
import { buildShellUrl, resolveShellBaseUrlForBridgePort } from './control'
|
|
12
|
-
import { hoistLeadingSimFlag } from './flow'
|
|
19
|
+
import { FLOW_ARG_VALUE_FLAGS, hoistLeadingSimFlag } from './flow'
|
|
13
20
|
import { resolveDefaultUploadOrigin } from './upload'
|
|
14
21
|
|
|
15
22
|
export interface RunTestOptions {
|
|
@@ -24,6 +31,11 @@ interface TestAppTarget {
|
|
|
24
31
|
close: () => Promise<void>
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
// every flag that swallows the token after it, so the positional scan finds
|
|
35
|
+
// the flow/suite target no matter where the flags sit (`rnx test
|
|
36
|
+
// --include-tags smoke .maestro/` must not take `smoke` as the target).
|
|
37
|
+
// maestro compat flags live here alongside the flow-runner vocabulary;
|
|
38
|
+
// without them a flag-first maestro command line mis-resolves its target.
|
|
27
39
|
const VALUE_FLAGS = new Set([
|
|
28
40
|
'--app',
|
|
29
41
|
'--bundle',
|
|
@@ -34,11 +46,32 @@ const VALUE_FLAGS = new Set([
|
|
|
34
46
|
'--tab',
|
|
35
47
|
'--port',
|
|
36
48
|
'--device',
|
|
49
|
+
'-d',
|
|
50
|
+
'--env',
|
|
51
|
+
'-e',
|
|
37
52
|
'--driver',
|
|
38
53
|
'--base-url',
|
|
39
54
|
'--url',
|
|
55
|
+
'--include-tags',
|
|
56
|
+
'--exclude-tags',
|
|
57
|
+
'--format',
|
|
58
|
+
'--output',
|
|
59
|
+
'--test-output-dir',
|
|
60
|
+
'--debug-output',
|
|
61
|
+
'--platform',
|
|
62
|
+
'--udid',
|
|
63
|
+
'--test-suite-name',
|
|
64
|
+
'--shards',
|
|
65
|
+
'-s',
|
|
66
|
+
'--shard-all',
|
|
67
|
+
'--shard-split',
|
|
68
|
+
'--api-key',
|
|
69
|
+
'--api-url',
|
|
70
|
+
'--screen-size',
|
|
40
71
|
'-t',
|
|
41
72
|
'--testNamePattern',
|
|
73
|
+
'--testPathPattern',
|
|
74
|
+
'--testTimeout',
|
|
42
75
|
'--grep',
|
|
43
76
|
'--maxWorkers',
|
|
44
77
|
'--shard',
|
|
@@ -48,6 +81,9 @@ const VALUE_FLAGS = new Set([
|
|
|
48
81
|
'--repo',
|
|
49
82
|
'--poll-interval',
|
|
50
83
|
'--timeout',
|
|
84
|
+
'--sidecar-ports',
|
|
85
|
+
'--cancel',
|
|
86
|
+
...FLOW_ARG_VALUE_FLAGS,
|
|
51
87
|
])
|
|
52
88
|
|
|
53
89
|
function valueAfter(args: string[], name: string): string | null {
|
|
@@ -59,30 +95,160 @@ function valueAfter(args: string[], name: string): string | null {
|
|
|
59
95
|
return value && !value.startsWith('-') ? value : null
|
|
60
96
|
}
|
|
61
97
|
|
|
62
|
-
function
|
|
98
|
+
function targetIndexFromArgs(args: string[]): number {
|
|
63
99
|
for (let index = 0; index < args.length; index++) {
|
|
64
100
|
const arg = args[index]
|
|
65
101
|
if (VALUE_FLAGS.has(arg)) {
|
|
66
|
-
|
|
102
|
+
// a missing value must not swallow the next flag: only skip a
|
|
103
|
+
// present, non-flag-shaped token, like valueAfter.
|
|
104
|
+
const next = args[index + 1]
|
|
105
|
+
if (next !== undefined && !next.startsWith('-')) index += 1
|
|
67
106
|
continue
|
|
68
107
|
}
|
|
69
108
|
if (arg.startsWith('--') && arg.includes('=')) {
|
|
70
109
|
continue
|
|
71
110
|
}
|
|
72
|
-
if (!arg.startsWith('-')) return
|
|
111
|
+
if (!arg.startsWith('-')) return index
|
|
112
|
+
}
|
|
113
|
+
return -1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function targetFromArgs(args: string[]): string | null {
|
|
117
|
+
const index = targetIndexFromArgs(args)
|
|
118
|
+
return index >= 0 ? args[index] : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// flags the cloud submitter consumes into the submission itself (app,
|
|
122
|
+
// origin, tunnel, polling): skipped silently by the testArgs walk, with
|
|
123
|
+
// their values. contract: harden owns the list's server half.
|
|
124
|
+
const CLOUD_CONSUMED_FLAGS = new Set([
|
|
125
|
+
'--app',
|
|
126
|
+
'--bundle',
|
|
127
|
+
'--bundle-url',
|
|
128
|
+
'--url',
|
|
129
|
+
'--origin',
|
|
130
|
+
'--owner',
|
|
131
|
+
'--repo',
|
|
132
|
+
'--config',
|
|
133
|
+
'--cancel',
|
|
134
|
+
'--poll-interval',
|
|
135
|
+
'--timeout',
|
|
136
|
+
'--sidecar-ports',
|
|
137
|
+
'--hosted',
|
|
138
|
+
'--cloud',
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
// execution-environment flags the hosted recorder owns: warned on every
|
|
142
|
+
// cloud submit, never forwarded.
|
|
143
|
+
const RECORDER_OWNED_FLAGS = new Set([
|
|
144
|
+
'--maxWorkers',
|
|
145
|
+
'--testTimeout',
|
|
146
|
+
'--shard',
|
|
147
|
+
'--device',
|
|
148
|
+
'--sim',
|
|
149
|
+
'--headless',
|
|
150
|
+
'--preview',
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
// report/output flags the local-only block already warned about: skipped
|
|
154
|
+
// silently here so one flag never warns twice.
|
|
155
|
+
const CLOUD_ALREADY_WARNED_FLAGS = new Set([
|
|
156
|
+
'--format',
|
|
157
|
+
'--output',
|
|
158
|
+
'--test-output-dir',
|
|
159
|
+
'--debug-output',
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
export interface CloudTestArgsWalk {
|
|
163
|
+
testArgs: string[]
|
|
164
|
+
warnings: string[]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// split the cloud argv into the testArgs slice the recorder appends
|
|
168
|
+
// verbatim plus one warning per dropped arg. pure: the caller prints
|
|
169
|
+
// the warnings and validates through parseCloudTestArgs, so a filter is
|
|
170
|
+
// never silently dropped and malformed selection fails pre-submit with
|
|
171
|
+
// the server's own message.
|
|
172
|
+
export function collectCloudTestArgs(forwarded: string[]): CloudTestArgsWalk {
|
|
173
|
+
const testArgs: string[] = []
|
|
174
|
+
const warnings: string[] = []
|
|
175
|
+
const targetIndex = targetIndexFromArgs(forwarded)
|
|
176
|
+
const takesValue = (name: string): boolean =>
|
|
177
|
+
isCloudTestValueFlag(name) || VALUE_FLAGS.has(name)
|
|
178
|
+
let i = 0
|
|
179
|
+
while (i < forwarded.length) {
|
|
180
|
+
const arg = forwarded[i]
|
|
181
|
+
if (i === targetIndex) {
|
|
182
|
+
i += 1
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
186
|
+
warnings.push(
|
|
187
|
+
`cloud ignores ${arg}: unexpected positional argument (one test target only)`,
|
|
188
|
+
)
|
|
189
|
+
i += 1
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const eq = arg.indexOf('=')
|
|
193
|
+
const head = eq > 0 ? arg.slice(0, eq) : arg
|
|
194
|
+
const inline = eq > 0
|
|
195
|
+
// a missing value must not swallow the next flag: only a present,
|
|
196
|
+
// non-flag-shaped token is consumed as a value.
|
|
197
|
+
const nextIsValue =
|
|
198
|
+
!inline &&
|
|
199
|
+
takesValue(head) &&
|
|
200
|
+
i + 1 < forwarded.length &&
|
|
201
|
+
!forwarded[i + 1].startsWith('-')
|
|
202
|
+
if (CLOUD_CONSUMED_FLAGS.has(head) || CLOUD_ALREADY_WARNED_FLAGS.has(head)) {
|
|
203
|
+
i += nextIsValue ? 2 : 1
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (isCloudTestValueFlag(head)) {
|
|
207
|
+
if (inline) {
|
|
208
|
+
testArgs.push(arg)
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
const value = forwarded[i + 1]
|
|
213
|
+
if (value !== undefined && !value.startsWith('-')) {
|
|
214
|
+
testArgs.push(arg, value)
|
|
215
|
+
i += 2
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
// a lone selection flag: emit it so the pre-submit parse names it
|
|
219
|
+
// (`<flag> expects a value`) instead of running unfiltered.
|
|
220
|
+
testArgs.push(arg)
|
|
221
|
+
i += 1
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (RECORDER_OWNED_FLAGS.has(head)) {
|
|
225
|
+
warnings.push(
|
|
226
|
+
`cloud ignores ${head}: the hosted recorder owns the execution environment`,
|
|
227
|
+
)
|
|
228
|
+
i += nextIsValue ? 2 : 1
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
warnings.push(`cloud ignores ${arg}: applies to local runs only`)
|
|
232
|
+
i += nextIsValue ? 2 : 1
|
|
73
233
|
}
|
|
74
|
-
return
|
|
234
|
+
return { testArgs, warnings }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const APP_FLAGS = ['--app', '--bundle', '--bundle-url']
|
|
238
|
+
|
|
239
|
+
function isAppFlag(arg: string): boolean {
|
|
240
|
+
return APP_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`))
|
|
75
241
|
}
|
|
76
242
|
|
|
77
243
|
function withoutApp(args: string[]): string[] {
|
|
78
244
|
const result: string[] = []
|
|
79
245
|
for (let i = 0; i < args.length; i++) {
|
|
80
246
|
const arg = args[i]
|
|
81
|
-
if (arg
|
|
247
|
+
if (APP_FLAGS.includes(arg)) {
|
|
82
248
|
i += 1
|
|
83
249
|
continue
|
|
84
250
|
}
|
|
85
|
-
if (arg
|
|
251
|
+
if (isAppFlag(arg)) {
|
|
86
252
|
continue
|
|
87
253
|
}
|
|
88
254
|
result.push(arg)
|
|
@@ -90,6 +256,25 @@ function withoutApp(args: string[]): string[] {
|
|
|
90
256
|
return result
|
|
91
257
|
}
|
|
92
258
|
|
|
259
|
+
// the maestro runner reads the app target as --url; rnx test spells it
|
|
260
|
+
// --app/--bundle/--bundle-url. map each spelling explicitly: a prefix
|
|
261
|
+
// replace would turn --bundle-url into --url-url.
|
|
262
|
+
function mapAppFlagToUrl(arg: string): string {
|
|
263
|
+
if (APP_FLAGS.includes(arg)) return '--url'
|
|
264
|
+
for (const flag of APP_FLAGS) {
|
|
265
|
+
if (arg.startsWith(`${flag}=`)) return `--url=${arg.slice(flag.length + 1)}`
|
|
266
|
+
}
|
|
267
|
+
return arg
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function appFromArgs(args: string[]): string | null {
|
|
271
|
+
return (
|
|
272
|
+
valueAfter(args, '--app') ??
|
|
273
|
+
valueAfter(args, '--bundle') ??
|
|
274
|
+
valueAfter(args, '--bundle-url')
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
93
278
|
function closeServer(server: Server): Promise<void> {
|
|
94
279
|
return new Promise((resolveClose, rejectClose) => {
|
|
95
280
|
server.close((error) => {
|
|
@@ -153,14 +338,30 @@ function restoreRnxUrl(previous: string | undefined): void {
|
|
|
153
338
|
}
|
|
154
339
|
}
|
|
155
340
|
|
|
341
|
+
// `--config` is claimed by both interpreters: a YAML value is a maestro
|
|
342
|
+
// workspace config, anything else is a detox jest config.
|
|
343
|
+
function isMaestroConfigValue(value: string | null): boolean {
|
|
344
|
+
return value !== null && /\.ya?ml$/i.test(value)
|
|
345
|
+
}
|
|
346
|
+
|
|
156
347
|
// dispatch only: each interpreter retains its own selection and execution rules.
|
|
157
348
|
export function resolveTestCommand(args: string[], cwd = process.cwd()) {
|
|
158
349
|
const forwarded = hoistLeadingSimFlag(args)
|
|
159
|
-
const
|
|
160
|
-
const config = forwarded.some(
|
|
350
|
+
const hasConfig = forwarded.some(
|
|
161
351
|
(arg) => arg === '--config' || arg.startsWith('--config='),
|
|
162
352
|
)
|
|
163
|
-
|
|
353
|
+
const maestroConfig =
|
|
354
|
+
hasConfig && isMaestroConfigValue(valueAfter(forwarded, '--config'))
|
|
355
|
+
if (hasConfig && !maestroConfig) return { command: 'detox', args: forwarded }
|
|
356
|
+
// the target is the first positional, wherever it sits: maestro users put
|
|
357
|
+
// flags before the path (`maestro test --include-tags smoke .maestro/`),
|
|
358
|
+
// so reading only argv[0] breaks the exact pasted command line.
|
|
359
|
+
let target = targetFromArgs(forwarded) ?? undefined
|
|
360
|
+
if (!target && maestroConfig) {
|
|
361
|
+
// `rnx test --config e2e/config.yaml`: the config describes a workspace,
|
|
362
|
+
// so run the directory it lives in.
|
|
363
|
+
target = dirname(resolve(cwd, valueAfter(forwarded, '--config') as string))
|
|
364
|
+
}
|
|
164
365
|
if (!target) {
|
|
165
366
|
throw new Error(
|
|
166
367
|
'pass a Maestro flow/directory, a Jest suite, or --config <jest-config>',
|
|
@@ -182,19 +383,22 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
|
|
|
182
383
|
['.maestro', 'maestro'].includes(basename(absolute))
|
|
183
384
|
: /\.ya?ml$/.test(extname(absolute))
|
|
184
385
|
if (maestro) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
386
|
+
// when the target names a workspace config file, run its directory.
|
|
387
|
+
// the replacement lands on the target positional by index: with
|
|
388
|
+
// flag-first argv the target is not argv[0], and a path can equal a
|
|
389
|
+
// flag value elsewhere in the line.
|
|
390
|
+
const targetIndex = targetIndexFromArgs(forwarded)
|
|
391
|
+
const runConfigDir = !directory && /^config\.ya?ml$/.test(basename(absolute))
|
|
392
|
+
const mapped = forwarded.map((arg, index) => {
|
|
393
|
+
if (index === targetIndex && runConfigDir) return dirname(absolute)
|
|
394
|
+
return mapAppFlagToUrl(arg)
|
|
395
|
+
})
|
|
396
|
+
if (targetIndex < 0) {
|
|
397
|
+
// synthesized target (`--config e2e/config.yaml` with no positional):
|
|
398
|
+
// name the workspace dir so maestro does not auto-discover cwd.
|
|
399
|
+
mapped.push(target)
|
|
197
400
|
}
|
|
401
|
+
return { command: 'maestro', args: ['test', ...mapped] }
|
|
198
402
|
}
|
|
199
403
|
if (!directory && !/\.[cm]?[jt]sx?$/.test(extname(absolute))) {
|
|
200
404
|
throw new Error(`expected Maestro YAML or a Detox/Jest suite: ${target}`)
|
|
@@ -202,6 +406,55 @@ export function resolveTestCommand(args: string[], cwd = process.cwd()) {
|
|
|
202
406
|
return { command: 'detox', args: forwarded }
|
|
203
407
|
}
|
|
204
408
|
|
|
409
|
+
// helper files a single flow pulls in through runFlow / runScript file
|
|
410
|
+
// refs, resolved relative to each referencing file exactly like the runner,
|
|
411
|
+
// transitively with a cycle guard. a ref that does not exist is the runner's
|
|
412
|
+
// loud failure, not the submitter's: warn and let the hosted run report it
|
|
413
|
+
// at its step, like a local run does.
|
|
414
|
+
function collectFlowHelperPaths(flowAbsPath: string): string[] {
|
|
415
|
+
if (!flowAbsPath.endsWith('.yml') && !flowAbsPath.endsWith('.yaml')) return []
|
|
416
|
+
const helpers: string[] = []
|
|
417
|
+
const visited = new Set<string>([resolve(flowAbsPath)])
|
|
418
|
+
const queue = [resolve(flowAbsPath)]
|
|
419
|
+
while (queue.length > 0) {
|
|
420
|
+
const current = queue.pop() as string
|
|
421
|
+
if (!current.endsWith('.yml') && !current.endsWith('.yaml')) continue
|
|
422
|
+
let steps: unknown[]
|
|
423
|
+
try {
|
|
424
|
+
steps = parseFlowFile(readFileSync(current, 'utf8')).steps
|
|
425
|
+
} catch (err) {
|
|
426
|
+
console.warn(
|
|
427
|
+
` warn: could not parse flow refs in ${current}: ${err instanceof Error ? err.message : String(err)}`,
|
|
428
|
+
)
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
for (const step of steps) {
|
|
432
|
+
if (typeof step !== 'object' || step === null) continue
|
|
433
|
+
for (const key of ['runFlow', 'runScript'] as const) {
|
|
434
|
+
const spec = (step as Record<string, unknown>)[key]
|
|
435
|
+
if (spec === undefined || spec === null) continue
|
|
436
|
+
const file =
|
|
437
|
+
typeof spec === 'string'
|
|
438
|
+
? spec
|
|
439
|
+
: typeof (spec as Record<string, unknown>).file === 'string'
|
|
440
|
+
? ((spec as Record<string, unknown>).file as string)
|
|
441
|
+
: null
|
|
442
|
+
if (!file) continue
|
|
443
|
+
const abs = resolve(dirname(current), file)
|
|
444
|
+
if (visited.has(abs)) continue
|
|
445
|
+
visited.add(abs)
|
|
446
|
+
if (existsSync(abs) && statSync(abs).isFile()) {
|
|
447
|
+
helpers.push(abs)
|
|
448
|
+
queue.push(abs)
|
|
449
|
+
} else {
|
|
450
|
+
console.warn(` warn: flow ref not found: ${file} (from ${current})`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return helpers
|
|
456
|
+
}
|
|
457
|
+
|
|
205
458
|
function collectTestFiles(
|
|
206
459
|
targetPath: string,
|
|
207
460
|
ignoredPath: string | null,
|
|
@@ -240,8 +493,48 @@ function collectTestFiles(
|
|
|
240
493
|
}
|
|
241
494
|
walk(targetPath, '')
|
|
242
495
|
} else if (stat.isFile()) {
|
|
243
|
-
add(targetPath, basename(targetPath))
|
|
244
496
|
const parent = dirname(targetPath)
|
|
497
|
+
const helperAbsPaths = collectFlowHelperPaths(targetPath)
|
|
498
|
+
if (helperAbsPaths.length === 0) {
|
|
499
|
+
add(targetPath, basename(targetPath))
|
|
500
|
+
} else {
|
|
501
|
+
// the flow references helpers by path relative to itself (runFlow /
|
|
502
|
+
// runScript file). key every file by its path under their common root
|
|
503
|
+
// so the recorder's unpack preserves those relative refs.
|
|
504
|
+
const all = [resolve(targetPath), ...helperAbsPaths]
|
|
505
|
+
const split = all.map((abs) => abs.split(sep))
|
|
506
|
+
const commonParts = split[0]?.slice(0) ?? []
|
|
507
|
+
while (
|
|
508
|
+
commonParts.length > 0 &&
|
|
509
|
+
!split.every(
|
|
510
|
+
(parts) =>
|
|
511
|
+
parts.length >= commonParts.length &&
|
|
512
|
+
commonParts.every((part, i) => parts[i] === part),
|
|
513
|
+
)
|
|
514
|
+
) {
|
|
515
|
+
commonParts.pop()
|
|
516
|
+
}
|
|
517
|
+
const common = commonParts.join(sep) || sep
|
|
518
|
+
const keys = all.map((abs) => relative(common, abs).split(sep).join('/'))
|
|
519
|
+
for (const [index, abs] of all.entries()) {
|
|
520
|
+
add(abs, keys[index] as string)
|
|
521
|
+
}
|
|
522
|
+
// the recorder runs an unpacked multi-file upload as a workspace,
|
|
523
|
+
// whose default glob only sees top-level flows: a nested entry would
|
|
524
|
+
// lose to a top-level helper. pin a config that selects the entry.
|
|
525
|
+
if (!keys.some((key) => /^config\.ya?ml$/.test(basename(key)))) {
|
|
526
|
+
if (Object.keys(files).length >= RNX_CLOUD_TEST_FILES_MAX_COUNT) {
|
|
527
|
+
throw new Error(
|
|
528
|
+
`cloud test suites may contain at most ${RNX_CLOUD_TEST_FILES_MAX_COUNT} files`,
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
const entryKey = keys[0] as string
|
|
532
|
+
const glob = entryKey.replace(/[\\*?[\]{}()!+@]/g, '\\$&')
|
|
533
|
+
files['config.yaml'] =
|
|
534
|
+
'# synthesized by rnx test --cloud: run exactly the submitted entry flow.\n' +
|
|
535
|
+
`flows:\n - ${JSON.stringify(glob)}\n`
|
|
536
|
+
}
|
|
537
|
+
}
|
|
245
538
|
for (const configName of [
|
|
246
539
|
'rnx-detox.config.cjs',
|
|
247
540
|
'jest.config.cjs',
|
|
@@ -262,6 +555,82 @@ function collectTestFiles(
|
|
|
262
555
|
return files
|
|
263
556
|
}
|
|
264
557
|
|
|
558
|
+
async function readJobStatus(
|
|
559
|
+
endpoint: string,
|
|
560
|
+
authorization: string,
|
|
561
|
+
): Promise<string | null> {
|
|
562
|
+
try {
|
|
563
|
+
const res = await fetch(endpoint, { headers: { authorization } })
|
|
564
|
+
if (!res.ok) return null
|
|
565
|
+
const json = (await res.json()) as { status?: unknown } | null
|
|
566
|
+
return json && typeof json.status === 'string' ? json.status : null
|
|
567
|
+
} catch {
|
|
568
|
+
return null
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function cancelCloudTest(jobId: string, origin: string): Promise<number> {
|
|
573
|
+
const auth = resolveCliAuth()
|
|
574
|
+
if (!auth) {
|
|
575
|
+
console.error(' error: cancelling a cloud test requires `rnx login` or RNX_API_KEY')
|
|
576
|
+
return 1
|
|
577
|
+
}
|
|
578
|
+
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(jobId)}`
|
|
579
|
+
let response: Response
|
|
580
|
+
try {
|
|
581
|
+
response = await fetch(endpoint, {
|
|
582
|
+
method: 'DELETE',
|
|
583
|
+
headers: { authorization: authHeaderValue(auth) },
|
|
584
|
+
})
|
|
585
|
+
} catch (err) {
|
|
586
|
+
console.error(
|
|
587
|
+
` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
588
|
+
)
|
|
589
|
+
return 1
|
|
590
|
+
}
|
|
591
|
+
if (response.status === 404) {
|
|
592
|
+
console.error(` error: cloud test job not found: ${jobId}`)
|
|
593
|
+
return 1
|
|
594
|
+
}
|
|
595
|
+
if (!response.ok) {
|
|
596
|
+
const body = await response.text()
|
|
597
|
+
let message = body
|
|
598
|
+
try {
|
|
599
|
+
const json = JSON.parse(body)
|
|
600
|
+
if (json.message) message = json.message
|
|
601
|
+
} catch {}
|
|
602
|
+
console.error(` error: cancel failed (${response.status}): ${message}`)
|
|
603
|
+
return 1
|
|
604
|
+
}
|
|
605
|
+
let status: string | null = null
|
|
606
|
+
try {
|
|
607
|
+
const json = (await response.json()) as { status?: unknown } | null
|
|
608
|
+
if (json && typeof json.status === 'string') status = json.status
|
|
609
|
+
} catch {
|
|
610
|
+
// an older server answers 2xx with no cancel body; fall through to the
|
|
611
|
+
// read-back below rather than claim anything.
|
|
612
|
+
}
|
|
613
|
+
if (status === null) {
|
|
614
|
+
status = await readJobStatus(endpoint, authHeaderValue(auth))
|
|
615
|
+
if (status === 'queued' || status === 'running') {
|
|
616
|
+
console.error(
|
|
617
|
+
` error: the server left ${jobId} ${status}; cancel needs a newer job api`,
|
|
618
|
+
)
|
|
619
|
+
return 1
|
|
620
|
+
}
|
|
621
|
+
if (status === null) {
|
|
622
|
+
console.error(` error: cancel sent but the state of ${jobId} is unknown`)
|
|
623
|
+
return 1
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (status === 'cancelled') {
|
|
627
|
+
console.log(` cloud test job cancelled: ${jobId}`)
|
|
628
|
+
} else {
|
|
629
|
+
console.log(` cloud test job already ${status}: ${jobId}`)
|
|
630
|
+
}
|
|
631
|
+
return 0
|
|
632
|
+
}
|
|
633
|
+
|
|
265
634
|
export async function runCloudTest(
|
|
266
635
|
args: string[],
|
|
267
636
|
opts: RunTestOptions = {},
|
|
@@ -269,6 +638,14 @@ export async function runCloudTest(
|
|
|
269
638
|
const forwarded = args.filter((a) => a !== '--cloud' && a !== '--hosted')
|
|
270
639
|
const originFlag = valueAfter(forwarded, '--origin')
|
|
271
640
|
const origin = await resolveDefaultUploadOrigin(originFlag ?? undefined)
|
|
641
|
+
const cancelId = valueAfter(forwarded, '--cancel')
|
|
642
|
+
if (forwarded.includes('--cancel') || cancelId !== null) {
|
|
643
|
+
if (!cancelId) {
|
|
644
|
+
console.error(' usage: rnx test --cancel <job-id>')
|
|
645
|
+
return 1
|
|
646
|
+
}
|
|
647
|
+
return cancelCloudTest(cancelId, origin)
|
|
648
|
+
}
|
|
272
649
|
const app =
|
|
273
650
|
valueAfter(forwarded, '--app') ||
|
|
274
651
|
valueAfter(forwarded, '--bundle') ||
|
|
@@ -284,7 +661,11 @@ export async function runCloudTest(
|
|
|
284
661
|
let target = targetFromArgs(forwarded)
|
|
285
662
|
const config = valueAfter(forwarded, '--config')
|
|
286
663
|
if (!target && config) {
|
|
287
|
-
|
|
664
|
+
// a maestro workspace config uploads its directory; a jest config is
|
|
665
|
+
// collected from its own dir by the path logic below.
|
|
666
|
+
target = isMaestroConfigValue(config)
|
|
667
|
+
? dirname(resolve(process.cwd(), config))
|
|
668
|
+
: config
|
|
288
669
|
}
|
|
289
670
|
if (!target) {
|
|
290
671
|
for (const cand of ['.maestro', 'maestro', 'flows', 'e2e']) {
|
|
@@ -307,11 +688,39 @@ export async function runCloudTest(
|
|
|
307
688
|
|
|
308
689
|
if (!app) {
|
|
309
690
|
console.error(
|
|
310
|
-
' error: rnx test --cloud requires --app <bundle.js|https://bundle-url>',
|
|
691
|
+
' error: rnx test --cloud requires --app <bundle.js|https://bundle-url|http://localhost:port>',
|
|
311
692
|
)
|
|
312
693
|
return 1
|
|
313
694
|
}
|
|
314
695
|
|
|
696
|
+
const localOnly = [
|
|
697
|
+
'--format',
|
|
698
|
+
'--output',
|
|
699
|
+
'--test-output-dir',
|
|
700
|
+
'--debug-output',
|
|
701
|
+
].filter((flag) => forwarded.some((arg) => arg === flag || arg.startsWith(`${flag}=`)))
|
|
702
|
+
if (localOnly.length > 0) {
|
|
703
|
+
console.warn(
|
|
704
|
+
` warn: ${localOnly.join(', ')} apply to local runs only and are ignored by --cloud`,
|
|
705
|
+
)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// selection flags ride testArgs to the recorder; everything else the
|
|
709
|
+
// walk drops gets one warning, never silence. validated pre-submit so
|
|
710
|
+
// malformed selection fails here with the server's own message,
|
|
711
|
+
// before any tunnel or upload side effect.
|
|
712
|
+
const walk = collectCloudTestArgs(forwarded)
|
|
713
|
+
for (const warning of walk.warnings) console.warn(` warn: ${warning}`)
|
|
714
|
+
let cloudTestArgs: string[] | null = null
|
|
715
|
+
if (walk.testArgs.length > 0) {
|
|
716
|
+
const parsed = parseCloudTestArgs(walk.testArgs)
|
|
717
|
+
if (!parsed.ok) {
|
|
718
|
+
console.error(` error: ${parsed.message}`)
|
|
719
|
+
return 1
|
|
720
|
+
}
|
|
721
|
+
cloudTestArgs = parsed.args
|
|
722
|
+
}
|
|
723
|
+
|
|
315
724
|
const auth = resolveCliAuth()
|
|
316
725
|
if (!auth) {
|
|
317
726
|
console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
|
|
@@ -336,18 +745,41 @@ export async function runCloudTest(
|
|
|
336
745
|
let bundleUrl: string | undefined
|
|
337
746
|
let bundle: string | undefined
|
|
338
747
|
let bundleShareId: string | undefined
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
748
|
+
let tunnelTargetUrl: string | undefined
|
|
749
|
+
let tunnelHostHeader: string | undefined
|
|
750
|
+
let tunnelJobToken: string | undefined
|
|
751
|
+
let tunnelSidecarPorts: string | undefined
|
|
752
|
+
let closeTunnel: (() => void) | null = null
|
|
753
|
+
|
|
754
|
+
const liveDevStack = parseLiveDevStackApp(app)
|
|
755
|
+
const sidecarFlag = valueAfter(forwarded, '--sidecar-ports')
|
|
756
|
+
let liveSidecarPorts: number[] | null = null
|
|
757
|
+
if (liveDevStack) {
|
|
758
|
+
const parsedSidecars = parseCloudTestSidecarPorts(sidecarFlag, liveDevStack.port)
|
|
759
|
+
if (!parsedSidecars.ok) {
|
|
760
|
+
console.error(` error: ${parsedSidecars.message}`)
|
|
761
|
+
return 1
|
|
762
|
+
}
|
|
763
|
+
liveSidecarPorts = parsedSidecars.ports
|
|
344
764
|
} else {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
765
|
+
if (sidecarFlag !== null) {
|
|
766
|
+
console.error(
|
|
767
|
+
' error: --sidecar-ports requires a live dev stack --app (http://localhost:<port>)',
|
|
768
|
+
)
|
|
348
769
|
return 1
|
|
349
770
|
}
|
|
350
|
-
|
|
771
|
+
if (app.startsWith('http://') || app.startsWith('https://')) {
|
|
772
|
+
bundleUrl = app
|
|
773
|
+
} else if (app.startsWith('srb_') || app.includes('/api/preview/share/bundle')) {
|
|
774
|
+
bundleShareId = app.replace(/^.*id=/, '')
|
|
775
|
+
} else {
|
|
776
|
+
const localBundle = resolve(process.cwd(), app)
|
|
777
|
+
if (!existsSync(localBundle) || !statSync(localBundle).isFile()) {
|
|
778
|
+
console.error(` error: --app bundle file not found: ${app}`)
|
|
779
|
+
return 1
|
|
780
|
+
}
|
|
781
|
+
bundle = readFileSync(localBundle, 'utf8')
|
|
782
|
+
}
|
|
351
783
|
}
|
|
352
784
|
|
|
353
785
|
const targetName = basename(targetPath)
|
|
@@ -370,6 +802,26 @@ export async function runCloudTest(
|
|
|
370
802
|
return 1
|
|
371
803
|
}
|
|
372
804
|
|
|
805
|
+
// the tunnel stays open from submit until the job reaches a terminal
|
|
806
|
+
// state; tearing it down early strands the recorder mid-run.
|
|
807
|
+
if (liveDevStack && liveSidecarPorts) {
|
|
808
|
+
try {
|
|
809
|
+
const published = await publishLiveDevStack({
|
|
810
|
+
devPort: liveDevStack.port,
|
|
811
|
+
sidecarPorts: liveSidecarPorts,
|
|
812
|
+
})
|
|
813
|
+
tunnelTargetUrl = published.targetUrl
|
|
814
|
+
tunnelHostHeader = published.hostHeader
|
|
815
|
+
tunnelJobToken = published.jobToken
|
|
816
|
+
tunnelSidecarPorts = published.sidecarPorts.join(',') || undefined
|
|
817
|
+
closeTunnel = published.close
|
|
818
|
+
console.log(` live dev stack published: ${published.targetUrl}`)
|
|
819
|
+
} catch (err) {
|
|
820
|
+
console.error(` error: ${err instanceof Error ? err.message : String(err)}`)
|
|
821
|
+
return 1
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
373
825
|
const submitPayload: Record<string, unknown> = {
|
|
374
826
|
kind: 'test',
|
|
375
827
|
testFiles,
|
|
@@ -377,114 +829,145 @@ export async function runCloudTest(
|
|
|
377
829
|
if (bundleUrl) submitPayload.bundleUrl = bundleUrl
|
|
378
830
|
if (bundle) submitPayload.bundle = bundle
|
|
379
831
|
if (bundleShareId) submitPayload.bundleShareId = bundleShareId
|
|
832
|
+
if (tunnelTargetUrl) submitPayload.targetUrl = tunnelTargetUrl
|
|
833
|
+
if (tunnelHostHeader) submitPayload.hostHeader = tunnelHostHeader
|
|
834
|
+
if (tunnelJobToken) submitPayload.jobToken = tunnelJobToken
|
|
835
|
+
if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
|
|
380
836
|
if (owner) submitPayload.owner = owner
|
|
381
837
|
if (repo) submitPayload.repo = repo
|
|
838
|
+
if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
|
|
382
839
|
|
|
383
|
-
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
384
|
-
let response: Response
|
|
385
840
|
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
|
|
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
|
|
841
|
+
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
842
|
+
let response: Response
|
|
423
843
|
try {
|
|
424
|
-
|
|
425
|
-
|
|
844
|
+
response = await fetch(endpoint, {
|
|
845
|
+
method: 'POST',
|
|
846
|
+
headers: {
|
|
847
|
+
'content-type': 'application/json',
|
|
848
|
+
authorization: authHeaderValue(auth),
|
|
849
|
+
},
|
|
850
|
+
body: JSON.stringify(submitPayload),
|
|
426
851
|
})
|
|
427
852
|
} catch (err) {
|
|
428
|
-
console.
|
|
429
|
-
`
|
|
853
|
+
console.error(
|
|
854
|
+
` error: could not connect to ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
430
855
|
)
|
|
431
|
-
|
|
856
|
+
return 1
|
|
432
857
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
858
|
+
|
|
859
|
+
if (!response.ok) {
|
|
860
|
+
const body = await response.text()
|
|
861
|
+
let message = body
|
|
862
|
+
try {
|
|
863
|
+
const json = JSON.parse(body)
|
|
864
|
+
if (json.message) message = json.message
|
|
865
|
+
} catch {}
|
|
866
|
+
console.error(
|
|
867
|
+
` error: cloud test submission failed (${response.status}): ${message}`,
|
|
868
|
+
)
|
|
869
|
+
return 1
|
|
436
870
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
|
|
455
|
-
console.log(` status: ${job.status}`)
|
|
456
|
-
console.log(` exit code: ${exitCode}`)
|
|
457
|
-
if (result?.previewUrl) {
|
|
458
|
-
console.log(` preview: ${result.previewUrl}`)
|
|
459
|
-
} else if (result?.previewId) {
|
|
460
|
-
console.log(
|
|
461
|
-
` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
|
|
871
|
+
|
|
872
|
+
const { id } = (await response.json()) as { id: string }
|
|
873
|
+
console.log(` cloud test job submitted: ${id}`)
|
|
874
|
+
|
|
875
|
+
const pollUrl = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs?id=${encodeURIComponent(id)}`
|
|
876
|
+
const startedAt = Date.now()
|
|
877
|
+
|
|
878
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
879
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
|
880
|
+
let pollRes: Response
|
|
881
|
+
try {
|
|
882
|
+
pollRes = await fetch(pollUrl, {
|
|
883
|
+
headers: { authorization: authHeaderValue(auth) },
|
|
884
|
+
})
|
|
885
|
+
} catch (err) {
|
|
886
|
+
console.warn(
|
|
887
|
+
` warn: error polling job: ${err instanceof Error ? err.message : String(err)}`,
|
|
462
888
|
)
|
|
889
|
+
continue
|
|
463
890
|
}
|
|
464
|
-
if (
|
|
465
|
-
console.
|
|
891
|
+
if (!pollRes.ok) {
|
|
892
|
+
console.warn(` warn: polling error: HTTP ${pollRes.status}`)
|
|
893
|
+
continue
|
|
466
894
|
}
|
|
467
|
-
|
|
468
|
-
|
|
895
|
+
const job = (await pollRes.json()) as {
|
|
896
|
+
id: string
|
|
897
|
+
status: string
|
|
898
|
+
result?: {
|
|
899
|
+
previewId?: string | null
|
|
900
|
+
previewUrl?: string | null
|
|
901
|
+
runId?: string | null
|
|
902
|
+
flows?: Array<{
|
|
903
|
+
flow?: string | null
|
|
904
|
+
previewId?: string | null
|
|
905
|
+
previewUrl?: string | null
|
|
906
|
+
runId?: string | null
|
|
907
|
+
}> | null
|
|
908
|
+
exitCode?: number | null
|
|
909
|
+
failureReason?: string | null
|
|
910
|
+
} | null
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (
|
|
914
|
+
job.status === 'done' ||
|
|
915
|
+
job.status === 'failed' ||
|
|
916
|
+
job.status === 'expired' ||
|
|
917
|
+
job.status === 'cancelled'
|
|
918
|
+
) {
|
|
919
|
+
const result = job.result
|
|
920
|
+
// a job the recorder failed after a passing suite (missing recording or
|
|
921
|
+
// registration) is still a failed run.
|
|
922
|
+
const exitCode =
|
|
923
|
+
job.status === 'done' ? (result?.exitCode ?? 0) : result?.exitCode || 1
|
|
924
|
+
console.log(` status: ${job.status}`)
|
|
925
|
+
console.log(` exit code: ${exitCode}`)
|
|
926
|
+
if (result?.previewUrl) {
|
|
927
|
+
console.log(` preview: ${result.previewUrl}`)
|
|
928
|
+
} else if (result?.previewId) {
|
|
929
|
+
console.log(
|
|
930
|
+
` preview: ${origin.replace(/\/$/, '')}/preview/${result.previewId}`,
|
|
931
|
+
)
|
|
932
|
+
}
|
|
933
|
+
if (result?.runId) {
|
|
934
|
+
console.log(` run: ${result.runId}`)
|
|
935
|
+
}
|
|
936
|
+
if (result?.flows && result.flows.length > 1) {
|
|
937
|
+
result.flows.forEach((flow, index) => {
|
|
938
|
+
console.log(
|
|
939
|
+
` flow ${index + 1}: ${flow.flow ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
|
|
940
|
+
)
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
if (result?.failureReason) {
|
|
944
|
+
console.error(` failure: ${result.failureReason}`)
|
|
945
|
+
}
|
|
946
|
+
return exitCode
|
|
469
947
|
}
|
|
470
|
-
return exitCode
|
|
471
948
|
}
|
|
472
|
-
}
|
|
473
949
|
|
|
474
|
-
|
|
475
|
-
|
|
950
|
+
console.error(` error: cloud test timed out after ${Math.round(timeoutMs / 1000)}s`)
|
|
951
|
+
return 1
|
|
952
|
+
} finally {
|
|
953
|
+
closeTunnel?.()
|
|
954
|
+
}
|
|
476
955
|
}
|
|
477
956
|
|
|
478
957
|
export async function runTest(
|
|
479
958
|
args: string[],
|
|
480
959
|
opts: RunTestOptions = {},
|
|
481
960
|
): Promise<number> {
|
|
482
|
-
if (
|
|
961
|
+
if (
|
|
962
|
+
args.includes('--cloud') ||
|
|
963
|
+
args.includes('--hosted') ||
|
|
964
|
+
args.some((arg) => arg === '--cancel' || arg.startsWith('--cancel='))
|
|
965
|
+
) {
|
|
483
966
|
return runCloudTest(args, opts)
|
|
484
967
|
}
|
|
485
968
|
|
|
486
969
|
const selected = resolveTestCommand(args)
|
|
487
|
-
const app =
|
|
970
|
+
const app = appFromArgs(args)
|
|
488
971
|
const appTarget = app
|
|
489
972
|
? await resolveTestAppTarget(app)
|
|
490
973
|
: { target: '', close: async () => {} }
|