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/maestro.ts
CHANGED
|
@@ -10,7 +10,17 @@ import * as fs from 'fs'
|
|
|
10
10
|
import * as path from 'path'
|
|
11
11
|
import yaml from 'yaml'
|
|
12
12
|
import { resolveCliAuth } from '../auth'
|
|
13
|
-
import {
|
|
13
|
+
import { cloudTestResultFileNames } from '../cloud-test-result-files'
|
|
14
|
+
import { parseFlowFile, type FlowFrontmatter } from '../flow-file'
|
|
15
|
+
import {
|
|
16
|
+
buildFlowCommandsJson,
|
|
17
|
+
buildFlowLogLines,
|
|
18
|
+
buildJUnitXml,
|
|
19
|
+
buildSessionLog,
|
|
20
|
+
failureFromTraceSteps,
|
|
21
|
+
type JUnitCaseInput,
|
|
22
|
+
type SessionLogFlow,
|
|
23
|
+
} from '../maestro-report'
|
|
14
24
|
import { registerRun, shouldRegisterRun, stepSummaryFromTrace } from '../run-registry'
|
|
15
25
|
import { RnxExit } from '../run-rnx'
|
|
16
26
|
import {
|
|
@@ -38,6 +48,7 @@ rnx maestro — author and run Maestro YAML flows against rnx
|
|
|
38
48
|
usage:
|
|
39
49
|
rnx maestro # discover .maestro/ or maestro/ in cwd, run all
|
|
40
50
|
rnx maestro test <flow-or-dir> # mirrors "maestro test"
|
|
51
|
+
rnx maestro load <flow> # load flow into runner without running (--dry-run)
|
|
41
52
|
rnx maestro test .maestro/ # every *.yaml / *.yml in a directory
|
|
42
53
|
rnx maestro generate "<goal>" # generate, run, and upload a Maestro flow
|
|
43
54
|
rnx maestro start|keep|end # author a flow from live CLI actions
|
|
@@ -46,9 +57,31 @@ usage:
|
|
|
46
57
|
rnx maestro --list-compat # print supported/unsupported verbs
|
|
47
58
|
|
|
48
59
|
options:
|
|
60
|
+
--dry-run, --no-run load flow into runner idle without auto-running
|
|
49
61
|
--env KEY=VALUE set env vars for \${KEY} interpolation (repeatable)
|
|
50
62
|
--continuous accepted for maestro cli compat; rnx runs the flow once
|
|
51
|
-
--
|
|
63
|
+
--include-tags <a,b> run only flows carrying one of these tags (comma-
|
|
64
|
+
separated; CLI wins over config.yaml includeTags)
|
|
65
|
+
--exclude-tags <a,b> skip flows carrying any of these tags
|
|
66
|
+
--config <path> read the workspace config (flows/tags) from this file
|
|
67
|
+
instead of <dir>/config.yaml
|
|
68
|
+
--format junit write a junit xml report (default ./report.xml)
|
|
69
|
+
JUNIT, NOOP also accepted (case-insensitive)
|
|
70
|
+
--output <file> junit report path (needs --format junit)
|
|
71
|
+
--test-output-dir <dir>
|
|
72
|
+
per-flow screenshots, commands.json, and logs/maestro.log
|
|
73
|
+
(config key testOutputDir:; CLI wins over config)
|
|
74
|
+
--debug-output <dir> write a session maestro.log there
|
|
75
|
+
--flatten-debug-output accepted; rnx output is already flat
|
|
76
|
+
--test-suite-name <n> junit testsuite name (default "Test Suite")
|
|
77
|
+
--platform ios accepted; any other platform is refused (rnx is iOS-only)
|
|
78
|
+
(-p stays the rnx bridge-port short)
|
|
79
|
+
--no-ansi accepted; rnx output is already plain text
|
|
80
|
+
--udid <id> warned; rnx manages its own sim (pin one with --sim)
|
|
81
|
+
-s, --shards <n> warned; rnx runs the full selection on one sim
|
|
82
|
+
--shard-all, --shard-split <spec>
|
|
83
|
+
warned; rnx runs the full selection on one sim
|
|
84
|
+
-c warned; --continuous runs once under rnx
|
|
52
85
|
--device <model> override the flow frontmatter device for this run
|
|
53
86
|
--new force a fresh browser window for this run
|
|
54
87
|
--headed force a fresh, VISIBLE window (implies --new,
|
|
@@ -93,8 +126,19 @@ supported verbs:
|
|
|
93
126
|
(env vars as globals, output.*, maestro.*, \${EXPR || 'default'}).
|
|
94
127
|
|
|
95
128
|
workspace mode (directory runs):
|
|
96
|
-
config.yaml / config.yml discovery, flows: inclusion globs
|
|
97
|
-
|
|
129
|
+
config.yaml / config.yml discovery, flows: inclusion globs (with !
|
|
130
|
+
negation; at least one positive pattern), includeTags / excludeTags
|
|
131
|
+
against flow frontmatter tags.
|
|
132
|
+
|
|
133
|
+
cli flags (maestro test parity):
|
|
134
|
+
--include-tags / --exclude-tags (comma-separated, OR within a flag;
|
|
135
|
+
CLI wins over config.yaml), --config <path>, --format junit with
|
|
136
|
+
--output (default report.xml; flow name:/properties: shape the
|
|
137
|
+
testcase), --test-output-dir (config key testOutputDir:, CLI wins),
|
|
138
|
+
--debug-output, --platform ios (others refused), --no-ansi (rnx
|
|
139
|
+
output is already plain), -d for --device, -e for --env.
|
|
140
|
+
--continuous warns and runs once; --format html warns and runs
|
|
141
|
+
without a report.
|
|
98
142
|
|
|
99
143
|
launch behavior:
|
|
100
144
|
launchApp.arguments: a JSON object available before guest app entry through
|
|
@@ -128,11 +172,13 @@ function looksLikeFlag(s: string): boolean {
|
|
|
128
172
|
// extract --env KEY=VALUE pairs from argv and apply to process.env for the
|
|
129
173
|
// duration of the run. returns argv with the consumed flags removed so the
|
|
130
174
|
// rest can be forwarded to runFlowPlayback verbatim.
|
|
131
|
-
function applyEnvFlags(args: string[]): string[] {
|
|
175
|
+
export function applyEnvFlags(args: string[]): string[] {
|
|
132
176
|
const out: string[] = []
|
|
133
177
|
for (let i = 0; i < args.length; i++) {
|
|
134
|
-
|
|
135
|
-
|
|
178
|
+
const arg = args[i] as string
|
|
179
|
+
const inline = arg.startsWith('--env=') ? arg.slice('--env='.length) : null
|
|
180
|
+
if (arg === '--env' || arg === '-e' || inline !== null) {
|
|
181
|
+
const kv = inline ?? args[i + 1]
|
|
136
182
|
if (!kv || !kv.includes('=')) {
|
|
137
183
|
throw new Error(`--env expects KEY=VALUE (got ${JSON.stringify(kv ?? '')})`)
|
|
138
184
|
}
|
|
@@ -140,27 +186,150 @@ function applyEnvFlags(args: string[]): string[] {
|
|
|
140
186
|
const key = kv.slice(0, eq)
|
|
141
187
|
const value = kv.slice(eq + 1)
|
|
142
188
|
process.env[key] = value
|
|
143
|
-
i += 1
|
|
189
|
+
if (inline === null) i += 1
|
|
144
190
|
continue
|
|
145
191
|
}
|
|
146
|
-
out.push(
|
|
192
|
+
out.push(arg)
|
|
147
193
|
}
|
|
148
194
|
return out
|
|
149
195
|
}
|
|
150
196
|
|
|
151
|
-
// maestro-cli flags
|
|
152
|
-
// swap `maestro` for `rnx maestro` unedited. each one
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
197
|
+
// maestro-cli flags this command consumes itself, accepted so an existing
|
|
198
|
+
// script can swap `maestro test` for `rnx maestro test` unedited. each one
|
|
199
|
+
// either takes effect below or says out loud that it does not: an option
|
|
200
|
+
// that is quietly dropped is how a run reports success while doing something
|
|
201
|
+
// other than what was asked. `--origin` is consumed here because only this
|
|
202
|
+
// command reads it; everything this function leaves alone rides on to the
|
|
203
|
+
// flow runner, which rejects what it does not know.
|
|
204
|
+
//
|
|
205
|
+
// both `--flag value` and `--flag=value` spellings work: maestro's own docs
|
|
206
|
+
// use the `=` form for tags and output dirs, so rejecting it would break the
|
|
207
|
+
// exact command lines users paste from there.
|
|
208
|
+
export interface MaestroCompatOptions {
|
|
209
|
+
// null = flag absent. CLI lists take precedence over config.yaml (maestro
|
|
210
|
+
// documents CLI-always-wins), so absent must stay distinguishable from [].
|
|
211
|
+
includeTags: string[] | null
|
|
212
|
+
excludeTags: string[] | null
|
|
213
|
+
configPath: string | null
|
|
214
|
+
format: string | null
|
|
215
|
+
output: string | null
|
|
216
|
+
testOutputDir: string | null
|
|
217
|
+
debugOutput: string | null
|
|
218
|
+
platform: string | null
|
|
219
|
+
testSuiteName: string | null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
|
|
223
|
+
'--include-tags',
|
|
224
|
+
'--exclude-tags',
|
|
225
|
+
'--config',
|
|
226
|
+
'--format',
|
|
227
|
+
'--output',
|
|
228
|
+
'--test-output-dir',
|
|
229
|
+
'--debug-output',
|
|
230
|
+
'--platform',
|
|
231
|
+
'--udid',
|
|
232
|
+
'--test-suite-name',
|
|
233
|
+
'--shards',
|
|
234
|
+
'--shard-all',
|
|
235
|
+
'--shard-split',
|
|
236
|
+
'--api-key',
|
|
237
|
+
'--api-url',
|
|
238
|
+
'--screen-size',
|
|
239
|
+
])
|
|
240
|
+
|
|
241
|
+
function splitTagList(value: string): string[] {
|
|
242
|
+
return value
|
|
243
|
+
.split(',')
|
|
244
|
+
.map((tag) => tag.trim())
|
|
245
|
+
.filter((tag) => tag.length > 0)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function extractMaestroCompatFlags(args: string[]): {
|
|
249
|
+
rest: string[]
|
|
250
|
+
compat: MaestroCompatOptions
|
|
251
|
+
} {
|
|
252
|
+
const compat: MaestroCompatOptions = {
|
|
253
|
+
includeTags: null,
|
|
254
|
+
excludeTags: null,
|
|
255
|
+
configPath: null,
|
|
256
|
+
format: null,
|
|
257
|
+
output: null,
|
|
258
|
+
testOutputDir: null,
|
|
259
|
+
debugOutput: null,
|
|
260
|
+
platform: null,
|
|
261
|
+
testSuiteName: null,
|
|
262
|
+
}
|
|
263
|
+
const rest: string[] = []
|
|
159
264
|
for (let i = 0; i < args.length; i++) {
|
|
160
265
|
const a = args[i]
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
266
|
+
const eq = a.indexOf('=')
|
|
267
|
+
const head = eq > 0 ? a.slice(0, eq) : a
|
|
268
|
+
if (MAESTRO_COMPAT_VALUE_FLAGS.has(head)) {
|
|
269
|
+
const inline = eq > 0 ? a.slice(eq + 1) : null
|
|
270
|
+
const next = args[i + 1]
|
|
271
|
+
const value =
|
|
272
|
+
inline !== null && inline.length > 0
|
|
273
|
+
? inline
|
|
274
|
+
: next !== undefined && !next.startsWith('-')
|
|
275
|
+
? next
|
|
276
|
+
: null
|
|
277
|
+
if (inline === null || inline.length === 0) i += 1
|
|
278
|
+
if (value === null || value.length === 0) {
|
|
279
|
+
throw new Error(`${head} expects a value`)
|
|
280
|
+
}
|
|
281
|
+
switch (head) {
|
|
282
|
+
case '--include-tags':
|
|
283
|
+
compat.includeTags = [...(compat.includeTags ?? []), ...splitTagList(value)]
|
|
284
|
+
break
|
|
285
|
+
case '--exclude-tags':
|
|
286
|
+
compat.excludeTags = [...(compat.excludeTags ?? []), ...splitTagList(value)]
|
|
287
|
+
break
|
|
288
|
+
case '--config':
|
|
289
|
+
compat.configPath = value
|
|
290
|
+
break
|
|
291
|
+
case '--format':
|
|
292
|
+
// maestro spells it JUNIT; normalize so either case works.
|
|
293
|
+
compat.format = value.toLowerCase()
|
|
294
|
+
break
|
|
295
|
+
case '--output':
|
|
296
|
+
compat.output = value
|
|
297
|
+
break
|
|
298
|
+
case '--test-output-dir':
|
|
299
|
+
compat.testOutputDir = value
|
|
300
|
+
break
|
|
301
|
+
case '--debug-output':
|
|
302
|
+
compat.debugOutput = value
|
|
303
|
+
break
|
|
304
|
+
case '--platform':
|
|
305
|
+
if (value.toLowerCase() !== 'ios') {
|
|
306
|
+
throw new Error(`rnx emulates iOS only; --platform ${value} is not supported`)
|
|
307
|
+
}
|
|
308
|
+
compat.platform = value
|
|
309
|
+
break
|
|
310
|
+
case '--udid':
|
|
311
|
+
console.warn(
|
|
312
|
+
' warn: --udid selects a Maestro simulator; rnx manages its own sim — pin one with --sim instead',
|
|
313
|
+
)
|
|
314
|
+
break
|
|
315
|
+
case '--test-suite-name':
|
|
316
|
+
compat.testSuiteName = value
|
|
317
|
+
break
|
|
318
|
+
case '--shards':
|
|
319
|
+
case '--shard-all':
|
|
320
|
+
case '--shard-split':
|
|
321
|
+
console.warn(
|
|
322
|
+
` warn: ${head} shards across Maestro devices; rnx runs the full selection on one sim`,
|
|
323
|
+
)
|
|
324
|
+
break
|
|
325
|
+
case '--api-key':
|
|
326
|
+
case '--api-url':
|
|
327
|
+
console.warn(` warn: ${head} is Maestro beta AI analysis; rnx ignores it`)
|
|
328
|
+
break
|
|
329
|
+
case '--screen-size':
|
|
330
|
+
console.warn(' warn: --screen-size is web-only; rnx ignores it')
|
|
331
|
+
break
|
|
332
|
+
}
|
|
164
333
|
continue
|
|
165
334
|
}
|
|
166
335
|
if (a === '--origin') {
|
|
@@ -172,16 +341,66 @@ function stripMaestroCompatFlags(args: string[]): string[] {
|
|
|
172
341
|
if (a === '-d') {
|
|
173
342
|
// maestro's short form for --device. rnx's device flag names a device
|
|
174
343
|
// model, and the flow runner reads it under the long spelling only.
|
|
175
|
-
|
|
344
|
+
rest.push('--device')
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
if (a === '--no-ansi' || a === '--no-color') {
|
|
348
|
+
// trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
if (a === '--ansi' || a === '--color') {
|
|
352
|
+
console.warn(' warn: rnx prints no ANSI escapes; colored output is unavailable')
|
|
176
353
|
continue
|
|
177
354
|
}
|
|
178
|
-
if (a === '--continuous') {
|
|
355
|
+
if (a === '--continuous' || a === '-c') {
|
|
179
356
|
console.warn(' warn: --continuous is not implemented in rnx — running once')
|
|
180
357
|
continue
|
|
181
358
|
}
|
|
182
|
-
|
|
359
|
+
if (a === '--flatten-debug-output') {
|
|
360
|
+
// rnx writes one <dir>/maestro.log with no per-run subfolders or
|
|
361
|
+
// timestamps, so the maestro layout flag is already satisfied.
|
|
362
|
+
continue
|
|
363
|
+
}
|
|
364
|
+
if (a === '--analyze') {
|
|
365
|
+
console.warn(' warn: --analyze is Maestro beta AI analysis; rnx ignores it')
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
if (a === '-s') {
|
|
369
|
+
const count = args[i + 1]
|
|
370
|
+
if (!count || count.startsWith('-')) throw new Error('-s expects a value')
|
|
371
|
+
i += 1
|
|
372
|
+
console.warn(
|
|
373
|
+
' warn: -s shards across Maestro devices; rnx runs the full selection on one sim',
|
|
374
|
+
)
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
if (head === '-p') {
|
|
378
|
+
// -p is the rnx bridge-port short, not maestro's --platform short. a
|
|
379
|
+
// non-numeric value is a pasted maestro command line; fail loud with
|
|
380
|
+
// the mapping instead of pinning the bridge to a NaN port.
|
|
381
|
+
const value = eq > 0 ? a.slice(eq + 1) : args[i + 1]
|
|
382
|
+
if (value !== undefined && !value.startsWith('-') && !/^\d+$/.test(value)) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`-p is the rnx bridge-port short; use --platform for the Maestro platform (got ${JSON.stringify(value)})`,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
rest.push(a)
|
|
388
|
+
continue
|
|
389
|
+
}
|
|
390
|
+
rest.push(a)
|
|
183
391
|
}
|
|
184
|
-
|
|
392
|
+
if (
|
|
393
|
+
compat.format !== null &&
|
|
394
|
+
!['junit', 'html', 'html-detailed', 'noop'].includes(compat.format)
|
|
395
|
+
) {
|
|
396
|
+
throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`)
|
|
397
|
+
}
|
|
398
|
+
if (compat.format === 'html' || compat.format === 'html-detailed') {
|
|
399
|
+
console.warn(
|
|
400
|
+
' warn: --format html is not implemented in rnx — running without a report',
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
return { rest, compat }
|
|
185
404
|
}
|
|
186
405
|
|
|
187
406
|
function findDefaultMaestroTarget(cwd: string): string | null {
|
|
@@ -196,36 +415,88 @@ interface MaestroWorkspaceConfig {
|
|
|
196
415
|
flows?: string[]
|
|
197
416
|
includeTags?: string[]
|
|
198
417
|
excludeTags?: string[]
|
|
418
|
+
testOutputDir?: string
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export interface CollectFlowFilesOptions {
|
|
422
|
+
// explicit `--config <path>`; globs still resolve under the target dir.
|
|
423
|
+
configPath?: string
|
|
424
|
+
// CLI tag lists; null/undefined falls back to the workspace config (maestro
|
|
425
|
+
// documents CLI-always-wins, never a union).
|
|
426
|
+
includeTags?: string[] | null
|
|
427
|
+
excludeTags?: string[] | null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// read the workspace config for a directory target: an explicit --config
|
|
431
|
+
// path, else config.yaml/config.yml beside the flows, else empty policy.
|
|
432
|
+
export function loadMaestroWorkspaceConfig(
|
|
433
|
+
dir: string,
|
|
434
|
+
configPath?: string,
|
|
435
|
+
): { path: string | null; config: MaestroWorkspaceConfig } {
|
|
436
|
+
const found =
|
|
437
|
+
configPath ??
|
|
438
|
+
['config.yaml', 'config.yml']
|
|
439
|
+
.map((name) => path.join(dir, name))
|
|
440
|
+
.find((p) => fs.existsSync(p))
|
|
441
|
+
if (!found) return { path: null, config: {} }
|
|
442
|
+
if (!fs.existsSync(found)) {
|
|
443
|
+
throw new Error(`maestro config not found: ${configPath}`)
|
|
444
|
+
}
|
|
445
|
+
// yaml.parse returns any; shape-check the fields we use below instead
|
|
446
|
+
// of trusting the file
|
|
447
|
+
const parsed: unknown = yaml.parse(fs.readFileSync(found, 'utf8'))
|
|
448
|
+
if (!parsed || typeof parsed !== 'object') return { path: found, config: {} }
|
|
449
|
+
const raw = parsed as Record<string, unknown>
|
|
450
|
+
const config: MaestroWorkspaceConfig = {}
|
|
451
|
+
if (Array.isArray(raw.flows)) {
|
|
452
|
+
config.flows = raw.flows.filter((g): g is string => typeof g === 'string')
|
|
453
|
+
}
|
|
454
|
+
if (Array.isArray(raw.includeTags)) {
|
|
455
|
+
config.includeTags = raw.includeTags.filter((t): t is string => typeof t === 'string')
|
|
456
|
+
}
|
|
457
|
+
if (Array.isArray(raw.excludeTags)) {
|
|
458
|
+
config.excludeTags = raw.excludeTags.filter((t): t is string => typeof t === 'string')
|
|
459
|
+
}
|
|
460
|
+
if (typeof raw.testOutputDir === 'string' && raw.testOutputDir.length > 0) {
|
|
461
|
+
config.testOutputDir = raw.testOutputDir
|
|
462
|
+
}
|
|
463
|
+
return { path: found, config }
|
|
199
464
|
}
|
|
200
465
|
|
|
201
466
|
// workspace planning for directory targets — mirrors maestro's
|
|
202
467
|
// WorkspaceExecutionPlanner: discover config.yaml/config.yml in the
|
|
203
468
|
// directory, walk it recursively for *.yaml/*.yml flow files (config files
|
|
204
469
|
// excluded), keep the ones matching the config's `flows:` globs (default
|
|
205
|
-
// `*` = top level only
|
|
206
|
-
// each flow's
|
|
207
|
-
|
|
470
|
+
// `*` = top level only; `!`-prefixed globs subtract from the positive
|
|
471
|
+
// matches), then filter by includeTags/excludeTags against each flow's
|
|
472
|
+
// frontmatter `tags:` (OR within a flag, include-then-exclude between them).
|
|
473
|
+
//
|
|
474
|
+
// an explicit file target always runs: maestro runs a named flow even when
|
|
475
|
+
// its tags would exclude it from a directory run.
|
|
476
|
+
export function collectFlowFiles(
|
|
477
|
+
target: string,
|
|
478
|
+
opts: CollectFlowFilesOptions = {},
|
|
479
|
+
): string[] {
|
|
208
480
|
const stat = fs.statSync(target)
|
|
209
481
|
if (stat.isFile()) return [target]
|
|
210
482
|
if (!stat.isDirectory()) {
|
|
211
483
|
throw new Error(`not a file or directory: ${target}`)
|
|
212
484
|
}
|
|
213
485
|
|
|
214
|
-
|
|
215
|
-
const configPath = ['config.yaml', 'config.yml']
|
|
216
|
-
.map((name) => path.join(target, name))
|
|
217
|
-
.find((p) => fs.existsSync(p))
|
|
218
|
-
if (configPath) {
|
|
219
|
-
// yaml.parse returns any; shape-check the fields we use below instead
|
|
220
|
-
// of trusting the file
|
|
221
|
-
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf8'))
|
|
222
|
-
if (parsed && typeof parsed === 'object') {
|
|
223
|
-
config = parsed
|
|
224
|
-
}
|
|
225
|
-
}
|
|
486
|
+
const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath)
|
|
226
487
|
|
|
227
488
|
const globs =
|
|
228
489
|
Array.isArray(config.flows) && config.flows.length > 0 ? config.flows : ['*']
|
|
490
|
+
const positive = globs.filter((glob) => !glob.startsWith('!'))
|
|
491
|
+
const negative = globs
|
|
492
|
+
.filter((glob) => glob.startsWith('!'))
|
|
493
|
+
.map((glob) => glob.slice(1))
|
|
494
|
+
.filter((glob) => glob.length > 0)
|
|
495
|
+
if (positive.length === 0) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`flows: needs at least one positive pattern in ${configPath ?? target} (got only exclusions)`,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
229
500
|
const isFlowFile = (rel: string) => {
|
|
230
501
|
const base = path.basename(rel)
|
|
231
502
|
if (base.startsWith('.')) return false
|
|
@@ -234,17 +505,22 @@ export function collectFlowFiles(target: string): string[] {
|
|
|
234
505
|
return true
|
|
235
506
|
}
|
|
236
507
|
const matched = new Set<string>()
|
|
237
|
-
for (const glob of
|
|
508
|
+
for (const glob of positive) {
|
|
238
509
|
for (const rel of fs.globSync(glob, { cwd: target })) {
|
|
239
510
|
if (!isFlowFile(rel)) continue
|
|
240
511
|
const abs = path.join(target, rel)
|
|
241
512
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs)
|
|
242
513
|
}
|
|
243
514
|
}
|
|
515
|
+
for (const glob of negative) {
|
|
516
|
+
for (const rel of fs.globSync(glob, { cwd: target })) {
|
|
517
|
+
matched.delete(path.join(target, rel))
|
|
518
|
+
}
|
|
519
|
+
}
|
|
244
520
|
let files = [...matched].sort()
|
|
245
521
|
|
|
246
|
-
const includeTags =
|
|
247
|
-
const excludeTags =
|
|
522
|
+
const includeTags = opts.includeTags ?? config.includeTags ?? []
|
|
523
|
+
const excludeTags = opts.excludeTags ?? config.excludeTags ?? []
|
|
248
524
|
if (includeTags.length > 0 || excludeTags.length > 0) {
|
|
249
525
|
files = files.filter((file) => {
|
|
250
526
|
const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8'))
|
|
@@ -259,6 +535,23 @@ export function collectFlowFiles(target: string): string[] {
|
|
|
259
535
|
return files
|
|
260
536
|
}
|
|
261
537
|
|
|
538
|
+
function flowFileStem(file: string): string {
|
|
539
|
+
return path.basename(file).replace(/\.(ya?ml)$/i, '')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// a per-flow artifact dir under the test-output root. never reuses a dir,
|
|
543
|
+
// so a re-run cannot overwrite the previous run's evidence.
|
|
544
|
+
function uniqueFlowDir(root: string, file: string): string {
|
|
545
|
+
const slug = flowFileStem(file).replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'
|
|
546
|
+
let dir = path.join(root, slug)
|
|
547
|
+
let n = 2
|
|
548
|
+
while (fs.existsSync(dir)) {
|
|
549
|
+
dir = path.join(root, `${slug}-${n}`)
|
|
550
|
+
n += 1
|
|
551
|
+
}
|
|
552
|
+
return dir
|
|
553
|
+
}
|
|
554
|
+
|
|
262
555
|
async function runInit(cwd: string): Promise<number> {
|
|
263
556
|
const dir = path.join(cwd, '.maestro')
|
|
264
557
|
fs.mkdirSync(dir, { recursive: true })
|
|
@@ -316,7 +609,15 @@ export async function runMaestro(
|
|
|
316
609
|
remaining = hoistLeadingSimFlag(remaining)
|
|
317
610
|
|
|
318
611
|
const cwd = process.cwd()
|
|
319
|
-
|
|
612
|
+
let sub = remaining[0]
|
|
613
|
+
|
|
614
|
+
if (sub === 'load') {
|
|
615
|
+
remaining[0] = 'test'
|
|
616
|
+
sub = 'test'
|
|
617
|
+
if (!remaining.includes('--dry-run') && !remaining.includes('--no-run')) {
|
|
618
|
+
remaining.push('--dry-run')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
320
621
|
|
|
321
622
|
if (sub === 'init') {
|
|
322
623
|
return runInit(cwd)
|
|
@@ -335,11 +636,27 @@ export async function runMaestro(
|
|
|
335
636
|
return runMaestroAuthoring(remaining)
|
|
336
637
|
}
|
|
337
638
|
// `--origin` names the Contrast instance that hosts the run row. this command
|
|
338
|
-
// is its only reader, so read it before
|
|
639
|
+
// is its only reader, so read it before the compat extractor consumes it.
|
|
339
640
|
const originFlagIndex = remaining.indexOf('--origin')
|
|
340
641
|
const originFlagValue =
|
|
341
642
|
originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined
|
|
342
|
-
|
|
643
|
+
let compat: MaestroCompatOptions
|
|
644
|
+
try {
|
|
645
|
+
const extracted = extractMaestroCompatFlags(remaining)
|
|
646
|
+
remaining = extracted.rest
|
|
647
|
+
compat = extracted.compat
|
|
648
|
+
} catch (err) {
|
|
649
|
+
console.error(` error: ${(err as Error).message}`)
|
|
650
|
+
return 1
|
|
651
|
+
}
|
|
652
|
+
if (compat.format === 'html' || compat.format === 'html-detailed') {
|
|
653
|
+
console.warn(
|
|
654
|
+
` warn: --format ${compat.format} reports are not implemented in rnx — running without writing a report`,
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
if (compat.output && compat.format !== 'junit') {
|
|
658
|
+
console.warn(' warn: --output is ignored without --format junit')
|
|
659
|
+
}
|
|
343
660
|
|
|
344
661
|
// resolve the target flow file or directory.
|
|
345
662
|
let target: string | null = null
|
|
@@ -358,6 +675,11 @@ export async function runMaestro(
|
|
|
358
675
|
if (positionalIndex >= 0) {
|
|
359
676
|
target = path.resolve(cwd, rest[positionalIndex])
|
|
360
677
|
forwardArgs = rest.filter((_, i) => i !== positionalIndex)
|
|
678
|
+
} else if (compat.configPath) {
|
|
679
|
+
// no positional with an explicit workspace config: run the directory
|
|
680
|
+
// the config lives in, like `rnx test --config` does.
|
|
681
|
+
target = path.resolve(cwd, path.dirname(compat.configPath))
|
|
682
|
+
forwardArgs = rest
|
|
361
683
|
} else {
|
|
362
684
|
target = findDefaultMaestroTarget(cwd)
|
|
363
685
|
forwardArgs = rest
|
|
@@ -401,18 +723,42 @@ export async function runMaestro(
|
|
|
401
723
|
}
|
|
402
724
|
|
|
403
725
|
let files: string[]
|
|
726
|
+
let workspaceConfig: MaestroWorkspaceConfig = {}
|
|
727
|
+
let workspaceConfigPath: string | null = null
|
|
404
728
|
try {
|
|
405
|
-
|
|
729
|
+
if (fs.statSync(target).isDirectory()) {
|
|
730
|
+
const loaded = loadMaestroWorkspaceConfig(target, compat.configPath ?? undefined)
|
|
731
|
+
workspaceConfig = loaded.config
|
|
732
|
+
workspaceConfigPath = loaded.path
|
|
733
|
+
}
|
|
734
|
+
files = collectFlowFiles(target, {
|
|
735
|
+
configPath: compat.configPath ?? undefined,
|
|
736
|
+
includeTags: compat.includeTags,
|
|
737
|
+
excludeTags: compat.excludeTags,
|
|
738
|
+
})
|
|
406
739
|
} catch (err) {
|
|
407
740
|
console.error(` error: ${(err as Error).message}`)
|
|
408
741
|
return 1
|
|
409
742
|
}
|
|
410
743
|
|
|
411
744
|
if (files.length === 0) {
|
|
745
|
+
if (compat.includeTags !== null || compat.excludeTags !== null) {
|
|
746
|
+
console.error(` error: no flows in ${target} match the requested tags`)
|
|
747
|
+
return 1
|
|
748
|
+
}
|
|
412
749
|
console.error(` error: no *.yaml or *.yml files found in ${target}`)
|
|
413
750
|
return 1
|
|
414
751
|
}
|
|
415
752
|
|
|
753
|
+
// maestro's priority rule: CLI flag, then config.yaml, then the default
|
|
754
|
+
// (which for rnx is no test-output routing at all). a config-relative dir
|
|
755
|
+
// resolves against the config file, a CLI dir against the invocation cwd.
|
|
756
|
+
const testOutputRoot = compat.testOutputDir
|
|
757
|
+
? path.resolve(cwd, compat.testOutputDir)
|
|
758
|
+
: workspaceConfig.testOutputDir && workspaceConfigPath
|
|
759
|
+
? path.resolve(path.dirname(workspaceConfigPath), workspaceConfig.testOutputDir)
|
|
760
|
+
: null
|
|
761
|
+
|
|
416
762
|
console.log(`
|
|
417
763
|
rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
|
|
418
764
|
root: ${path.relative(cwd, target) || '.'}
|
|
@@ -435,11 +781,36 @@ export async function runMaestro(
|
|
|
435
781
|
flagValue('--preview-origin') ?? originFlagValue,
|
|
436
782
|
))
|
|
437
783
|
|
|
784
|
+
const sessionStartedAt = new Date().toISOString()
|
|
438
785
|
let worstExit = 0
|
|
439
|
-
const results: Array<{
|
|
440
|
-
|
|
786
|
+
const results: Array<{
|
|
787
|
+
file: string
|
|
788
|
+
exit: number
|
|
789
|
+
durationMs: number
|
|
790
|
+
steps: ReturnType<typeof getLastFlowTraceSteps>
|
|
791
|
+
frontmatter: FlowFrontmatter
|
|
792
|
+
}> = []
|
|
793
|
+
for (const [flowIndex, file] of files.entries()) {
|
|
441
794
|
console.log(`\n ▶ ${path.relative(cwd, file)}`)
|
|
442
795
|
const playbackArgs = [file, ...forwardArgs]
|
|
796
|
+
let flowTestDir: string | null = null
|
|
797
|
+
if (testOutputRoot) {
|
|
798
|
+
flowTestDir = uniqueFlowDir(testOutputRoot, file)
|
|
799
|
+
try {
|
|
800
|
+
fs.mkdirSync(path.join(flowTestDir, 'screenshots'), { recursive: true })
|
|
801
|
+
fs.mkdirSync(path.join(flowTestDir, 'logs'), { recursive: true })
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.error(
|
|
804
|
+
` error: could not create test output dir: ${(err as Error).message}`,
|
|
805
|
+
)
|
|
806
|
+
results.push({ file, exit: 1, durationMs: 0, steps: [], frontmatter: {} })
|
|
807
|
+
if (worstExit === 0) worstExit = 1
|
|
808
|
+
continue
|
|
809
|
+
}
|
|
810
|
+
// an explicit --screenshots wins over this routing: flow.ts reads the
|
|
811
|
+
// FIRST occurrence, and the user's flag sits earlier in playbackArgs.
|
|
812
|
+
playbackArgs.push('--screenshots', path.join(flowTestDir, 'screenshots'))
|
|
813
|
+
}
|
|
443
814
|
// `--port` is the BRIDGE port everywhere else in the CLI, so forward it as
|
|
444
815
|
// one. sending it on as `--url` made it an app target instead, which left
|
|
445
816
|
// the playback bridge on whichever world resolveBridgeWorld picks by
|
|
@@ -466,28 +837,63 @@ export async function runMaestro(
|
|
|
466
837
|
exit = 1
|
|
467
838
|
}
|
|
468
839
|
}
|
|
469
|
-
|
|
840
|
+
const traceSteps = getLastFlowTraceSteps()
|
|
841
|
+
const durationMs = Date.now() - startedAt
|
|
842
|
+
let frontmatter: FlowFrontmatter = {}
|
|
843
|
+
try {
|
|
844
|
+
frontmatter = parseFlowFile(fs.readFileSync(file, 'utf8')).frontmatter
|
|
845
|
+
} catch {
|
|
846
|
+
frontmatter = {}
|
|
847
|
+
}
|
|
848
|
+
const result = { file, exit, durationMs, steps: traceSteps, frontmatter }
|
|
849
|
+
results.push(result)
|
|
470
850
|
if (exit !== 0 && worstExit === 0) worstExit = exit
|
|
471
851
|
|
|
472
|
-
|
|
473
|
-
if (upload?.shareId || upload?.previewUrl) {
|
|
852
|
+
if (flowTestDir) {
|
|
474
853
|
try {
|
|
475
854
|
fs.writeFileSync(
|
|
476
|
-
path.join(
|
|
477
|
-
|
|
478
|
-
previewId: upload.shareId,
|
|
479
|
-
previewUrl: upload.previewUrl,
|
|
480
|
-
}),
|
|
855
|
+
path.join(flowTestDir, 'commands.json'),
|
|
856
|
+
buildFlowCommandsJson(traceSteps),
|
|
481
857
|
)
|
|
482
|
-
|
|
483
|
-
|
|
858
|
+
fs.writeFileSync(
|
|
859
|
+
path.join(flowTestDir, 'logs', 'maestro.log'),
|
|
860
|
+
`${buildFlowLogLines({
|
|
861
|
+
file: path.relative(cwd, file) || file,
|
|
862
|
+
passed: exit === 0,
|
|
863
|
+
durationMs,
|
|
864
|
+
steps: traceSteps,
|
|
865
|
+
}).join('\n')}\n`,
|
|
866
|
+
)
|
|
867
|
+
} catch (err) {
|
|
868
|
+
console.error(
|
|
869
|
+
` error: could not write test artifacts: ${(err as Error).message}`,
|
|
870
|
+
)
|
|
871
|
+
if (exit === 0) exit = 1
|
|
872
|
+
if (worstExit === 0) worstExit = 1
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const upload = getLastFlowPreviewUploadResult()
|
|
877
|
+
// the receipt files are the per-flow manifest: always written, with
|
|
878
|
+
// null ids when the upload failed, so the flow never disappears from
|
|
879
|
+
// the collected entries.
|
|
880
|
+
const previewPayload = {
|
|
881
|
+
flow: path.relative(cwd, file) || file,
|
|
882
|
+
previewId: upload?.shareId ?? null,
|
|
883
|
+
previewUrl: upload?.previewUrl ?? null,
|
|
884
|
+
}
|
|
885
|
+
try {
|
|
886
|
+
for (const name of cloudTestResultFileNames('preview', flowIndex, files.length)) {
|
|
887
|
+
fs.writeFileSync(path.join(cwd, name), JSON.stringify(previewPayload))
|
|
888
|
+
}
|
|
889
|
+
} catch {}
|
|
890
|
+
if (!upload?.shareId && !upload?.previewUrl && args.includes('--preview')) {
|
|
484
891
|
console.error(' error: required hosted recording upload failed')
|
|
485
892
|
if (exit === 0) exit = 1
|
|
486
893
|
if (worstExit === 0) worstExit = 1
|
|
487
894
|
}
|
|
488
895
|
|
|
489
896
|
if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
|
|
490
|
-
const traceSteps = getLastFlowTraceSteps()
|
|
491
897
|
const failedStep = traceSteps.find((step) => step.status === 'failure')
|
|
492
898
|
const run = await registerRun({
|
|
493
899
|
origin: await registerOrigin(),
|
|
@@ -504,22 +910,28 @@ export async function runMaestro(
|
|
|
504
910
|
})
|
|
505
911
|
if (run) {
|
|
506
912
|
console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`)
|
|
507
|
-
try {
|
|
508
|
-
fs.writeFileSync(
|
|
509
|
-
path.join(cwd, 'test-result.json'),
|
|
510
|
-
JSON.stringify({
|
|
511
|
-
runId: run.id,
|
|
512
|
-
previewUrl: run.previewUrl,
|
|
513
|
-
traceUrl: run.traceUrl,
|
|
514
|
-
}),
|
|
515
|
-
)
|
|
516
|
-
} catch {}
|
|
517
913
|
} else if (process.env.RNX_REQUIRE_REGISTRATION === 'true') {
|
|
518
914
|
console.error(' error: required hosted run registration failed')
|
|
519
915
|
if (exit === 0) exit = 1
|
|
520
916
|
if (worstExit === 0) worstExit = 1
|
|
521
917
|
}
|
|
918
|
+
// like the preview receipt above: always written, null ids when the
|
|
919
|
+
// registration failed, so the flow keeps its collected entry.
|
|
920
|
+
const testPayload = {
|
|
921
|
+
flow: path.relative(cwd, file) || file,
|
|
922
|
+
runId: run?.id ?? null,
|
|
923
|
+
previewUrl: run?.previewUrl ?? null,
|
|
924
|
+
traceUrl: run?.traceUrl ?? null,
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
for (const name of cloudTestResultFileNames('test', flowIndex, files.length)) {
|
|
928
|
+
fs.writeFileSync(path.join(cwd, name), JSON.stringify(testPayload))
|
|
929
|
+
}
|
|
930
|
+
} catch {}
|
|
522
931
|
}
|
|
932
|
+
// artifact, upload, and registration failures above flip the local exit
|
|
933
|
+
// after the result was pushed; the report below must see the final one.
|
|
934
|
+
result.exit = exit
|
|
523
935
|
}
|
|
524
936
|
|
|
525
937
|
if (files.length > 1) {
|
|
@@ -532,5 +944,106 @@ export async function runMaestro(
|
|
|
532
944
|
console.log(` ${passed}/${results.length} passed`)
|
|
533
945
|
}
|
|
534
946
|
|
|
947
|
+
// one device per invocation, like maestro: the explicit override, else the
|
|
948
|
+
// sim default. reports print it; playback already resolved it per flow.
|
|
949
|
+
const deviceLabel = flagValue('--device')?.trim() || 'iPhone 17 Pro'
|
|
950
|
+
if (compat.format === 'junit') {
|
|
951
|
+
const junitPath = path.resolve(cwd, compat.output ?? 'report.xml')
|
|
952
|
+
try {
|
|
953
|
+
fs.mkdirSync(path.dirname(junitPath), { recursive: true })
|
|
954
|
+
fs.writeFileSync(
|
|
955
|
+
junitPath,
|
|
956
|
+
buildJUnitXml({
|
|
957
|
+
device: deviceLabel,
|
|
958
|
+
cases: results.map((r) => junitCaseForResult(cwd, r)),
|
|
959
|
+
...(compat.testSuiteName ? { name: compat.testSuiteName } : {}),
|
|
960
|
+
}),
|
|
961
|
+
)
|
|
962
|
+
console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`)
|
|
963
|
+
} catch (err) {
|
|
964
|
+
console.error(` error: could not write junit report: ${(err as Error).message}`)
|
|
965
|
+
if (worstExit === 0) worstExit = 1
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (compat.debugOutput) {
|
|
969
|
+
const logPath = path.resolve(cwd, compat.debugOutput, 'maestro.log')
|
|
970
|
+
try {
|
|
971
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
|
972
|
+
const sessionFlows: SessionLogFlow[] = results.map((r) => ({
|
|
973
|
+
file: path.relative(cwd, r.file) || r.file,
|
|
974
|
+
passed: r.exit === 0,
|
|
975
|
+
durationMs: r.durationMs,
|
|
976
|
+
steps: r.steps,
|
|
977
|
+
}))
|
|
978
|
+
fs.writeFileSync(
|
|
979
|
+
logPath,
|
|
980
|
+
buildSessionLog({
|
|
981
|
+
startedAtIso: sessionStartedAt,
|
|
982
|
+
root: path.relative(cwd, target) || '.',
|
|
983
|
+
device: deviceLabel,
|
|
984
|
+
flows: sessionFlows,
|
|
985
|
+
}),
|
|
986
|
+
)
|
|
987
|
+
console.log(` debug log: ${path.relative(cwd, logPath) || logPath}`)
|
|
988
|
+
} catch (err) {
|
|
989
|
+
console.error(` error: could not write debug log: ${(err as Error).message}`)
|
|
990
|
+
if (worstExit === 0) worstExit = 1
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
535
994
|
return worstExit
|
|
536
995
|
}
|
|
996
|
+
|
|
997
|
+
// a junit testcase from one flow result. yaml frontmatter is untrusted, so
|
|
998
|
+
// every field is shape-checked back to the documented scalar shapes.
|
|
999
|
+
function junitCaseForResult(
|
|
1000
|
+
cwd: string,
|
|
1001
|
+
result: {
|
|
1002
|
+
file: string
|
|
1003
|
+
exit: number
|
|
1004
|
+
durationMs: number
|
|
1005
|
+
steps: ReturnType<typeof getLastFlowTraceSteps>
|
|
1006
|
+
frontmatter: FlowFrontmatter
|
|
1007
|
+
},
|
|
1008
|
+
): JUnitCaseInput {
|
|
1009
|
+
const raw = result.frontmatter
|
|
1010
|
+
const name =
|
|
1011
|
+
typeof raw.name === 'string' && raw.name.trim().length > 0
|
|
1012
|
+
? raw.name.trim()
|
|
1013
|
+
: flowFileStem(result.file)
|
|
1014
|
+
const custom: Record<string, string> = {}
|
|
1015
|
+
let junitId: string | null = null
|
|
1016
|
+
let junitClassname: string | null = null
|
|
1017
|
+
const props: unknown = raw.properties
|
|
1018
|
+
if (props && typeof props === 'object') {
|
|
1019
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1020
|
+
if (
|
|
1021
|
+
typeof value !== 'string' &&
|
|
1022
|
+
typeof value !== 'number' &&
|
|
1023
|
+
typeof value !== 'boolean'
|
|
1024
|
+
) {
|
|
1025
|
+
continue
|
|
1026
|
+
}
|
|
1027
|
+
const text = String(value)
|
|
1028
|
+
if (key === 'junitId') junitId = text
|
|
1029
|
+
else if (key === 'junitClassname') junitClassname = text
|
|
1030
|
+
else custom[key] = text
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const tags = Array.isArray(raw.tags)
|
|
1034
|
+
? raw.tags.filter((tag): tag is string => typeof tag === 'string')
|
|
1035
|
+
: []
|
|
1036
|
+
return {
|
|
1037
|
+
file: path.relative(cwd, result.file) || result.file,
|
|
1038
|
+
name,
|
|
1039
|
+
id: junitId ?? name,
|
|
1040
|
+
classname: junitClassname ?? name,
|
|
1041
|
+
properties: custom,
|
|
1042
|
+
tags,
|
|
1043
|
+
timeSeconds: result.durationMs / 1000,
|
|
1044
|
+
failure:
|
|
1045
|
+
result.exit === 0
|
|
1046
|
+
? null
|
|
1047
|
+
: failureFromTraceSteps(result.steps, 'flow playback failed'),
|
|
1048
|
+
}
|
|
1049
|
+
}
|