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/maestro.ts
CHANGED
|
@@ -10,7 +10,16 @@ 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 { parseFlowFile } from '../flow-file'
|
|
13
|
+
import { parseFlowFile, type FlowFrontmatter } from '../flow-file'
|
|
14
|
+
import {
|
|
15
|
+
buildFlowCommandsJson,
|
|
16
|
+
buildFlowLogLines,
|
|
17
|
+
buildJUnitXml,
|
|
18
|
+
buildSessionLog,
|
|
19
|
+
failureFromTraceSteps,
|
|
20
|
+
type JUnitCaseInput,
|
|
21
|
+
type SessionLogFlow,
|
|
22
|
+
} from '../maestro-report'
|
|
14
23
|
import { registerRun, shouldRegisterRun, stepSummaryFromTrace } from '../run-registry'
|
|
15
24
|
import { RnxExit } from '../run-rnx'
|
|
16
25
|
import {
|
|
@@ -38,6 +47,7 @@ rnx maestro — author and run Maestro YAML flows against rnx
|
|
|
38
47
|
usage:
|
|
39
48
|
rnx maestro # discover .maestro/ or maestro/ in cwd, run all
|
|
40
49
|
rnx maestro test <flow-or-dir> # mirrors "maestro test"
|
|
50
|
+
rnx maestro load <flow> # load flow into runner without running (--dry-run)
|
|
41
51
|
rnx maestro test .maestro/ # every *.yaml / *.yml in a directory
|
|
42
52
|
rnx maestro generate "<goal>" # generate, run, and upload a Maestro flow
|
|
43
53
|
rnx maestro start|keep|end # author a flow from live CLI actions
|
|
@@ -46,9 +56,22 @@ usage:
|
|
|
46
56
|
rnx maestro --list-compat # print supported/unsupported verbs
|
|
47
57
|
|
|
48
58
|
options:
|
|
59
|
+
--dry-run, --no-run load flow into runner idle without auto-running
|
|
49
60
|
--env KEY=VALUE set env vars for \${KEY} interpolation (repeatable)
|
|
50
61
|
--continuous accepted for maestro cli compat; rnx runs the flow once
|
|
51
|
-
--
|
|
62
|
+
--include-tags <a,b> run only flows carrying one of these tags (comma-
|
|
63
|
+
separated; CLI wins over config.yaml includeTags)
|
|
64
|
+
--exclude-tags <a,b> skip flows carrying any of these tags
|
|
65
|
+
--config <path> read the workspace config (flows/tags) from this file
|
|
66
|
+
instead of <dir>/config.yaml
|
|
67
|
+
--format junit write a junit xml report (default ./report.xml)
|
|
68
|
+
--output <file> junit report path (needs --format junit)
|
|
69
|
+
--test-output-dir <dir>
|
|
70
|
+
per-flow screenshots, commands.json, and logs/maestro.log
|
|
71
|
+
(config key testOutputDir:; CLI wins over config)
|
|
72
|
+
--debug-output <dir> write a session maestro.log there
|
|
73
|
+
--platform ios accepted; any other platform is refused (rnx is iOS-only)
|
|
74
|
+
--no-ansi accepted; rnx output is already plain text
|
|
52
75
|
--device <model> override the flow frontmatter device for this run
|
|
53
76
|
--new force a fresh browser window for this run
|
|
54
77
|
--headed force a fresh, VISIBLE window (implies --new,
|
|
@@ -93,8 +116,19 @@ supported verbs:
|
|
|
93
116
|
(env vars as globals, output.*, maestro.*, \${EXPR || 'default'}).
|
|
94
117
|
|
|
95
118
|
workspace mode (directory runs):
|
|
96
|
-
config.yaml / config.yml discovery, flows: inclusion globs
|
|
97
|
-
|
|
119
|
+
config.yaml / config.yml discovery, flows: inclusion globs (with !
|
|
120
|
+
negation; at least one positive pattern), includeTags / excludeTags
|
|
121
|
+
against flow frontmatter tags.
|
|
122
|
+
|
|
123
|
+
cli flags (maestro test parity):
|
|
124
|
+
--include-tags / --exclude-tags (comma-separated, OR within a flag;
|
|
125
|
+
CLI wins over config.yaml), --config <path>, --format junit with
|
|
126
|
+
--output (default report.xml; flow name:/properties: shape the
|
|
127
|
+
testcase), --test-output-dir (config key testOutputDir:, CLI wins),
|
|
128
|
+
--debug-output, --platform ios (others refused), --no-ansi (rnx
|
|
129
|
+
output is already plain), -d for --device, -e for --env.
|
|
130
|
+
--continuous warns and runs once; --format html warns and runs
|
|
131
|
+
without a report.
|
|
98
132
|
|
|
99
133
|
launch behavior:
|
|
100
134
|
launchApp.arguments: a JSON object available before guest app entry through
|
|
@@ -148,19 +182,109 @@ function applyEnvFlags(args: string[]): string[] {
|
|
|
148
182
|
return out
|
|
149
183
|
}
|
|
150
184
|
|
|
151
|
-
// maestro-cli flags
|
|
152
|
-
// swap `maestro` for `rnx maestro` unedited. each one
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
185
|
+
// maestro-cli flags this command consumes itself, accepted so an existing
|
|
186
|
+
// script can swap `maestro test` for `rnx maestro test` unedited. each one
|
|
187
|
+
// either takes effect below or says out loud that it does not: an option
|
|
188
|
+
// that is quietly dropped is how a run reports success while doing something
|
|
189
|
+
// other than what was asked. `--origin` is consumed here because only this
|
|
190
|
+
// command reads it; everything this function leaves alone rides on to the
|
|
191
|
+
// flow runner, which rejects what it does not know.
|
|
192
|
+
//
|
|
193
|
+
// both `--flag value` and `--flag=value` spellings work: maestro's own docs
|
|
194
|
+
// use the `=` form for tags and output dirs, so rejecting it would break the
|
|
195
|
+
// exact command lines users paste from there.
|
|
196
|
+
export interface MaestroCompatOptions {
|
|
197
|
+
// null = flag absent. CLI lists take precedence over config.yaml (maestro
|
|
198
|
+
// documents CLI-always-wins), so absent must stay distinguishable from [].
|
|
199
|
+
includeTags: string[] | null
|
|
200
|
+
excludeTags: string[] | null
|
|
201
|
+
configPath: string | null
|
|
202
|
+
format: string | null
|
|
203
|
+
output: string | null
|
|
204
|
+
testOutputDir: string | null
|
|
205
|
+
debugOutput: string | null
|
|
206
|
+
platform: string | null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
|
|
210
|
+
'--include-tags',
|
|
211
|
+
'--exclude-tags',
|
|
212
|
+
'--config',
|
|
213
|
+
'--format',
|
|
214
|
+
'--output',
|
|
215
|
+
'--test-output-dir',
|
|
216
|
+
'--debug-output',
|
|
217
|
+
'--platform',
|
|
218
|
+
])
|
|
219
|
+
|
|
220
|
+
function splitTagList(value: string): string[] {
|
|
221
|
+
return value
|
|
222
|
+
.split(',')
|
|
223
|
+
.map((tag) => tag.trim())
|
|
224
|
+
.filter((tag) => tag.length > 0)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function extractMaestroCompatFlags(args: string[]): {
|
|
228
|
+
rest: string[]
|
|
229
|
+
compat: MaestroCompatOptions
|
|
230
|
+
} {
|
|
231
|
+
const compat: MaestroCompatOptions = {
|
|
232
|
+
includeTags: null,
|
|
233
|
+
excludeTags: null,
|
|
234
|
+
configPath: null,
|
|
235
|
+
format: null,
|
|
236
|
+
output: null,
|
|
237
|
+
testOutputDir: null,
|
|
238
|
+
debugOutput: null,
|
|
239
|
+
platform: null,
|
|
240
|
+
}
|
|
241
|
+
const rest: string[] = []
|
|
159
242
|
for (let i = 0; i < args.length; i++) {
|
|
160
243
|
const a = args[i]
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
244
|
+
const eq = a.indexOf('=')
|
|
245
|
+
const head = eq > 0 ? a.slice(0, eq) : a
|
|
246
|
+
if (MAESTRO_COMPAT_VALUE_FLAGS.has(head)) {
|
|
247
|
+
const inline = eq > 0 ? a.slice(eq + 1) : null
|
|
248
|
+
const next = args[i + 1]
|
|
249
|
+
const value =
|
|
250
|
+
inline !== null && inline.length > 0
|
|
251
|
+
? inline
|
|
252
|
+
: next !== undefined && !next.startsWith('-')
|
|
253
|
+
? next
|
|
254
|
+
: null
|
|
255
|
+
if (inline === null || inline.length === 0) i += 1
|
|
256
|
+
if (value === null || value.length === 0) {
|
|
257
|
+
throw new Error(`${head} expects a value`)
|
|
258
|
+
}
|
|
259
|
+
switch (head) {
|
|
260
|
+
case '--include-tags':
|
|
261
|
+
compat.includeTags = [...(compat.includeTags ?? []), ...splitTagList(value)]
|
|
262
|
+
break
|
|
263
|
+
case '--exclude-tags':
|
|
264
|
+
compat.excludeTags = [...(compat.excludeTags ?? []), ...splitTagList(value)]
|
|
265
|
+
break
|
|
266
|
+
case '--config':
|
|
267
|
+
compat.configPath = value
|
|
268
|
+
break
|
|
269
|
+
case '--format':
|
|
270
|
+
compat.format = value
|
|
271
|
+
break
|
|
272
|
+
case '--output':
|
|
273
|
+
compat.output = value
|
|
274
|
+
break
|
|
275
|
+
case '--test-output-dir':
|
|
276
|
+
compat.testOutputDir = value
|
|
277
|
+
break
|
|
278
|
+
case '--debug-output':
|
|
279
|
+
compat.debugOutput = value
|
|
280
|
+
break
|
|
281
|
+
case '--platform':
|
|
282
|
+
if (value.toLowerCase() !== 'ios') {
|
|
283
|
+
throw new Error(`rnx emulates iOS only; --platform ${value} is not supported`)
|
|
284
|
+
}
|
|
285
|
+
compat.platform = value
|
|
286
|
+
break
|
|
287
|
+
}
|
|
164
288
|
continue
|
|
165
289
|
}
|
|
166
290
|
if (a === '--origin') {
|
|
@@ -172,16 +296,26 @@ function stripMaestroCompatFlags(args: string[]): string[] {
|
|
|
172
296
|
if (a === '-d') {
|
|
173
297
|
// maestro's short form for --device. rnx's device flag names a device
|
|
174
298
|
// model, and the flow runner reads it under the long spelling only.
|
|
175
|
-
|
|
299
|
+
rest.push('--device')
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (a === '--no-ansi') {
|
|
303
|
+
// trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
|
|
176
304
|
continue
|
|
177
305
|
}
|
|
178
306
|
if (a === '--continuous') {
|
|
179
307
|
console.warn(' warn: --continuous is not implemented in rnx — running once')
|
|
180
308
|
continue
|
|
181
309
|
}
|
|
182
|
-
|
|
310
|
+
rest.push(a)
|
|
183
311
|
}
|
|
184
|
-
|
|
312
|
+
if (
|
|
313
|
+
compat.format !== null &&
|
|
314
|
+
!['junit', 'html', 'html-detailed'].includes(compat.format)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`)
|
|
317
|
+
}
|
|
318
|
+
return { rest, compat }
|
|
185
319
|
}
|
|
186
320
|
|
|
187
321
|
function findDefaultMaestroTarget(cwd: string): string | null {
|
|
@@ -196,36 +330,88 @@ interface MaestroWorkspaceConfig {
|
|
|
196
330
|
flows?: string[]
|
|
197
331
|
includeTags?: string[]
|
|
198
332
|
excludeTags?: string[]
|
|
333
|
+
testOutputDir?: string
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface CollectFlowFilesOptions {
|
|
337
|
+
// explicit `--config <path>`; globs still resolve under the target dir.
|
|
338
|
+
configPath?: string
|
|
339
|
+
// CLI tag lists; null/undefined falls back to the workspace config (maestro
|
|
340
|
+
// documents CLI-always-wins, never a union).
|
|
341
|
+
includeTags?: string[] | null
|
|
342
|
+
excludeTags?: string[] | null
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// read the workspace config for a directory target: an explicit --config
|
|
346
|
+
// path, else config.yaml/config.yml beside the flows, else empty policy.
|
|
347
|
+
export function loadMaestroWorkspaceConfig(
|
|
348
|
+
dir: string,
|
|
349
|
+
configPath?: string,
|
|
350
|
+
): { path: string | null; config: MaestroWorkspaceConfig } {
|
|
351
|
+
const found =
|
|
352
|
+
configPath ??
|
|
353
|
+
['config.yaml', 'config.yml']
|
|
354
|
+
.map((name) => path.join(dir, name))
|
|
355
|
+
.find((p) => fs.existsSync(p))
|
|
356
|
+
if (!found) return { path: null, config: {} }
|
|
357
|
+
if (!fs.existsSync(found)) {
|
|
358
|
+
throw new Error(`maestro config not found: ${configPath}`)
|
|
359
|
+
}
|
|
360
|
+
// yaml.parse returns any; shape-check the fields we use below instead
|
|
361
|
+
// of trusting the file
|
|
362
|
+
const parsed: unknown = yaml.parse(fs.readFileSync(found, 'utf8'))
|
|
363
|
+
if (!parsed || typeof parsed !== 'object') return { path: found, config: {} }
|
|
364
|
+
const raw = parsed as Record<string, unknown>
|
|
365
|
+
const config: MaestroWorkspaceConfig = {}
|
|
366
|
+
if (Array.isArray(raw.flows)) {
|
|
367
|
+
config.flows = raw.flows.filter((g): g is string => typeof g === 'string')
|
|
368
|
+
}
|
|
369
|
+
if (Array.isArray(raw.includeTags)) {
|
|
370
|
+
config.includeTags = raw.includeTags.filter((t): t is string => typeof t === 'string')
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(raw.excludeTags)) {
|
|
373
|
+
config.excludeTags = raw.excludeTags.filter((t): t is string => typeof t === 'string')
|
|
374
|
+
}
|
|
375
|
+
if (typeof raw.testOutputDir === 'string' && raw.testOutputDir.length > 0) {
|
|
376
|
+
config.testOutputDir = raw.testOutputDir
|
|
377
|
+
}
|
|
378
|
+
return { path: found, config }
|
|
199
379
|
}
|
|
200
380
|
|
|
201
381
|
// workspace planning for directory targets — mirrors maestro's
|
|
202
382
|
// WorkspaceExecutionPlanner: discover config.yaml/config.yml in the
|
|
203
383
|
// directory, walk it recursively for *.yaml/*.yml flow files (config files
|
|
204
384
|
// excluded), keep the ones matching the config's `flows:` globs (default
|
|
205
|
-
// `*` = top level only
|
|
206
|
-
// each flow's
|
|
207
|
-
|
|
385
|
+
// `*` = top level only; `!`-prefixed globs subtract from the positive
|
|
386
|
+
// matches), then filter by includeTags/excludeTags against each flow's
|
|
387
|
+
// frontmatter `tags:` (OR within a flag, include-then-exclude between them).
|
|
388
|
+
//
|
|
389
|
+
// an explicit file target always runs: maestro runs a named flow even when
|
|
390
|
+
// its tags would exclude it from a directory run.
|
|
391
|
+
export function collectFlowFiles(
|
|
392
|
+
target: string,
|
|
393
|
+
opts: CollectFlowFilesOptions = {},
|
|
394
|
+
): string[] {
|
|
208
395
|
const stat = fs.statSync(target)
|
|
209
396
|
if (stat.isFile()) return [target]
|
|
210
397
|
if (!stat.isDirectory()) {
|
|
211
398
|
throw new Error(`not a file or directory: ${target}`)
|
|
212
399
|
}
|
|
213
400
|
|
|
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
|
-
}
|
|
401
|
+
const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath)
|
|
226
402
|
|
|
227
403
|
const globs =
|
|
228
404
|
Array.isArray(config.flows) && config.flows.length > 0 ? config.flows : ['*']
|
|
405
|
+
const positive = globs.filter((glob) => !glob.startsWith('!'))
|
|
406
|
+
const negative = globs
|
|
407
|
+
.filter((glob) => glob.startsWith('!'))
|
|
408
|
+
.map((glob) => glob.slice(1))
|
|
409
|
+
.filter((glob) => glob.length > 0)
|
|
410
|
+
if (positive.length === 0) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`flows: needs at least one positive pattern in ${configPath ?? target} (got only exclusions)`,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
229
415
|
const isFlowFile = (rel: string) => {
|
|
230
416
|
const base = path.basename(rel)
|
|
231
417
|
if (base.startsWith('.')) return false
|
|
@@ -234,17 +420,22 @@ export function collectFlowFiles(target: string): string[] {
|
|
|
234
420
|
return true
|
|
235
421
|
}
|
|
236
422
|
const matched = new Set<string>()
|
|
237
|
-
for (const glob of
|
|
423
|
+
for (const glob of positive) {
|
|
238
424
|
for (const rel of fs.globSync(glob, { cwd: target })) {
|
|
239
425
|
if (!isFlowFile(rel)) continue
|
|
240
426
|
const abs = path.join(target, rel)
|
|
241
427
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs)
|
|
242
428
|
}
|
|
243
429
|
}
|
|
430
|
+
for (const glob of negative) {
|
|
431
|
+
for (const rel of fs.globSync(glob, { cwd: target })) {
|
|
432
|
+
matched.delete(path.join(target, rel))
|
|
433
|
+
}
|
|
434
|
+
}
|
|
244
435
|
let files = [...matched].sort()
|
|
245
436
|
|
|
246
|
-
const includeTags =
|
|
247
|
-
const excludeTags =
|
|
437
|
+
const includeTags = opts.includeTags ?? config.includeTags ?? []
|
|
438
|
+
const excludeTags = opts.excludeTags ?? config.excludeTags ?? []
|
|
248
439
|
if (includeTags.length > 0 || excludeTags.length > 0) {
|
|
249
440
|
files = files.filter((file) => {
|
|
250
441
|
const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8'))
|
|
@@ -259,6 +450,23 @@ export function collectFlowFiles(target: string): string[] {
|
|
|
259
450
|
return files
|
|
260
451
|
}
|
|
261
452
|
|
|
453
|
+
function flowFileStem(file: string): string {
|
|
454
|
+
return path.basename(file).replace(/\.(ya?ml)$/i, '')
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// a per-flow artifact dir under the test-output root. never reuses a dir,
|
|
458
|
+
// so a re-run cannot overwrite the previous run's evidence.
|
|
459
|
+
function uniqueFlowDir(root: string, file: string): string {
|
|
460
|
+
const slug = flowFileStem(file).replace(/[^A-Za-z0-9._-]+/g, '_') || 'flow'
|
|
461
|
+
let dir = path.join(root, slug)
|
|
462
|
+
let n = 2
|
|
463
|
+
while (fs.existsSync(dir)) {
|
|
464
|
+
dir = path.join(root, `${slug}-${n}`)
|
|
465
|
+
n += 1
|
|
466
|
+
}
|
|
467
|
+
return dir
|
|
468
|
+
}
|
|
469
|
+
|
|
262
470
|
async function runInit(cwd: string): Promise<number> {
|
|
263
471
|
const dir = path.join(cwd, '.maestro')
|
|
264
472
|
fs.mkdirSync(dir, { recursive: true })
|
|
@@ -316,7 +524,15 @@ export async function runMaestro(
|
|
|
316
524
|
remaining = hoistLeadingSimFlag(remaining)
|
|
317
525
|
|
|
318
526
|
const cwd = process.cwd()
|
|
319
|
-
|
|
527
|
+
let sub = remaining[0]
|
|
528
|
+
|
|
529
|
+
if (sub === 'load') {
|
|
530
|
+
remaining[0] = 'test'
|
|
531
|
+
sub = 'test'
|
|
532
|
+
if (!remaining.includes('--dry-run') && !remaining.includes('--no-run')) {
|
|
533
|
+
remaining.push('--dry-run')
|
|
534
|
+
}
|
|
535
|
+
}
|
|
320
536
|
|
|
321
537
|
if (sub === 'init') {
|
|
322
538
|
return runInit(cwd)
|
|
@@ -335,11 +551,27 @@ export async function runMaestro(
|
|
|
335
551
|
return runMaestroAuthoring(remaining)
|
|
336
552
|
}
|
|
337
553
|
// `--origin` names the Contrast instance that hosts the run row. this command
|
|
338
|
-
// is its only reader, so read it before
|
|
554
|
+
// is its only reader, so read it before the compat extractor consumes it.
|
|
339
555
|
const originFlagIndex = remaining.indexOf('--origin')
|
|
340
556
|
const originFlagValue =
|
|
341
557
|
originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined
|
|
342
|
-
|
|
558
|
+
let compat: MaestroCompatOptions
|
|
559
|
+
try {
|
|
560
|
+
const extracted = extractMaestroCompatFlags(remaining)
|
|
561
|
+
remaining = extracted.rest
|
|
562
|
+
compat = extracted.compat
|
|
563
|
+
} catch (err) {
|
|
564
|
+
console.error(` error: ${(err as Error).message}`)
|
|
565
|
+
return 1
|
|
566
|
+
}
|
|
567
|
+
if (compat.format === 'html' || compat.format === 'html-detailed') {
|
|
568
|
+
console.warn(
|
|
569
|
+
` warn: --format ${compat.format} reports are not implemented in rnx — running without writing a report`,
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
if (compat.output && compat.format !== 'junit') {
|
|
573
|
+
console.warn(' warn: --output is ignored without --format junit')
|
|
574
|
+
}
|
|
343
575
|
|
|
344
576
|
// resolve the target flow file or directory.
|
|
345
577
|
let target: string | null = null
|
|
@@ -358,6 +590,11 @@ export async function runMaestro(
|
|
|
358
590
|
if (positionalIndex >= 0) {
|
|
359
591
|
target = path.resolve(cwd, rest[positionalIndex])
|
|
360
592
|
forwardArgs = rest.filter((_, i) => i !== positionalIndex)
|
|
593
|
+
} else if (compat.configPath) {
|
|
594
|
+
// no positional with an explicit workspace config: run the directory
|
|
595
|
+
// the config lives in, like `rnx test --config` does.
|
|
596
|
+
target = path.resolve(cwd, path.dirname(compat.configPath))
|
|
597
|
+
forwardArgs = rest
|
|
361
598
|
} else {
|
|
362
599
|
target = findDefaultMaestroTarget(cwd)
|
|
363
600
|
forwardArgs = rest
|
|
@@ -401,18 +638,42 @@ export async function runMaestro(
|
|
|
401
638
|
}
|
|
402
639
|
|
|
403
640
|
let files: string[]
|
|
641
|
+
let workspaceConfig: MaestroWorkspaceConfig = {}
|
|
642
|
+
let workspaceConfigPath: string | null = null
|
|
404
643
|
try {
|
|
405
|
-
|
|
644
|
+
if (fs.statSync(target).isDirectory()) {
|
|
645
|
+
const loaded = loadMaestroWorkspaceConfig(target, compat.configPath ?? undefined)
|
|
646
|
+
workspaceConfig = loaded.config
|
|
647
|
+
workspaceConfigPath = loaded.path
|
|
648
|
+
}
|
|
649
|
+
files = collectFlowFiles(target, {
|
|
650
|
+
configPath: compat.configPath ?? undefined,
|
|
651
|
+
includeTags: compat.includeTags,
|
|
652
|
+
excludeTags: compat.excludeTags,
|
|
653
|
+
})
|
|
406
654
|
} catch (err) {
|
|
407
655
|
console.error(` error: ${(err as Error).message}`)
|
|
408
656
|
return 1
|
|
409
657
|
}
|
|
410
658
|
|
|
411
659
|
if (files.length === 0) {
|
|
660
|
+
if (compat.includeTags !== null || compat.excludeTags !== null) {
|
|
661
|
+
console.error(` error: no flows in ${target} match the requested tags`)
|
|
662
|
+
return 1
|
|
663
|
+
}
|
|
412
664
|
console.error(` error: no *.yaml or *.yml files found in ${target}`)
|
|
413
665
|
return 1
|
|
414
666
|
}
|
|
415
667
|
|
|
668
|
+
// maestro's priority rule: CLI flag, then config.yaml, then the default
|
|
669
|
+
// (which for rnx is no test-output routing at all). a config-relative dir
|
|
670
|
+
// resolves against the config file, a CLI dir against the invocation cwd.
|
|
671
|
+
const testOutputRoot = compat.testOutputDir
|
|
672
|
+
? path.resolve(cwd, compat.testOutputDir)
|
|
673
|
+
: workspaceConfig.testOutputDir && workspaceConfigPath
|
|
674
|
+
? path.resolve(path.dirname(workspaceConfigPath), workspaceConfig.testOutputDir)
|
|
675
|
+
: null
|
|
676
|
+
|
|
416
677
|
console.log(`
|
|
417
678
|
rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
|
|
418
679
|
root: ${path.relative(cwd, target) || '.'}
|
|
@@ -435,11 +696,36 @@ export async function runMaestro(
|
|
|
435
696
|
flagValue('--preview-origin') ?? originFlagValue,
|
|
436
697
|
))
|
|
437
698
|
|
|
699
|
+
const sessionStartedAt = new Date().toISOString()
|
|
438
700
|
let worstExit = 0
|
|
439
|
-
const results: Array<{
|
|
701
|
+
const results: Array<{
|
|
702
|
+
file: string
|
|
703
|
+
exit: number
|
|
704
|
+
durationMs: number
|
|
705
|
+
steps: ReturnType<typeof getLastFlowTraceSteps>
|
|
706
|
+
frontmatter: FlowFrontmatter
|
|
707
|
+
}> = []
|
|
440
708
|
for (const file of files) {
|
|
441
709
|
console.log(`\n ▶ ${path.relative(cwd, file)}`)
|
|
442
710
|
const playbackArgs = [file, ...forwardArgs]
|
|
711
|
+
let flowTestDir: string | null = null
|
|
712
|
+
if (testOutputRoot) {
|
|
713
|
+
flowTestDir = uniqueFlowDir(testOutputRoot, file)
|
|
714
|
+
try {
|
|
715
|
+
fs.mkdirSync(path.join(flowTestDir, 'screenshots'), { recursive: true })
|
|
716
|
+
fs.mkdirSync(path.join(flowTestDir, 'logs'), { recursive: true })
|
|
717
|
+
} catch (err) {
|
|
718
|
+
console.error(
|
|
719
|
+
` error: could not create test output dir: ${(err as Error).message}`,
|
|
720
|
+
)
|
|
721
|
+
results.push({ file, exit: 1, durationMs: 0, steps: [], frontmatter: {} })
|
|
722
|
+
if (worstExit === 0) worstExit = 1
|
|
723
|
+
continue
|
|
724
|
+
}
|
|
725
|
+
// an explicit --screenshots wins over this routing: flow.ts reads the
|
|
726
|
+
// FIRST occurrence, and the user's flag sits earlier in playbackArgs.
|
|
727
|
+
playbackArgs.push('--screenshots', path.join(flowTestDir, 'screenshots'))
|
|
728
|
+
}
|
|
443
729
|
// `--port` is the BRIDGE port everywhere else in the CLI, so forward it as
|
|
444
730
|
// one. sending it on as `--url` made it an app target instead, which left
|
|
445
731
|
// the playback bridge on whichever world resolveBridgeWorld picks by
|
|
@@ -466,9 +752,42 @@ export async function runMaestro(
|
|
|
466
752
|
exit = 1
|
|
467
753
|
}
|
|
468
754
|
}
|
|
469
|
-
|
|
755
|
+
const traceSteps = getLastFlowTraceSteps()
|
|
756
|
+
const durationMs = Date.now() - startedAt
|
|
757
|
+
let frontmatter: FlowFrontmatter = {}
|
|
758
|
+
try {
|
|
759
|
+
frontmatter = parseFlowFile(fs.readFileSync(file, 'utf8')).frontmatter
|
|
760
|
+
} catch {
|
|
761
|
+
frontmatter = {}
|
|
762
|
+
}
|
|
763
|
+
const result = { file, exit, durationMs, steps: traceSteps, frontmatter }
|
|
764
|
+
results.push(result)
|
|
470
765
|
if (exit !== 0 && worstExit === 0) worstExit = exit
|
|
471
766
|
|
|
767
|
+
if (flowTestDir) {
|
|
768
|
+
try {
|
|
769
|
+
fs.writeFileSync(
|
|
770
|
+
path.join(flowTestDir, 'commands.json'),
|
|
771
|
+
buildFlowCommandsJson(traceSteps),
|
|
772
|
+
)
|
|
773
|
+
fs.writeFileSync(
|
|
774
|
+
path.join(flowTestDir, 'logs', 'maestro.log'),
|
|
775
|
+
`${buildFlowLogLines({
|
|
776
|
+
file: path.relative(cwd, file) || file,
|
|
777
|
+
passed: exit === 0,
|
|
778
|
+
durationMs,
|
|
779
|
+
steps: traceSteps,
|
|
780
|
+
}).join('\n')}\n`,
|
|
781
|
+
)
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.error(
|
|
784
|
+
` error: could not write test artifacts: ${(err as Error).message}`,
|
|
785
|
+
)
|
|
786
|
+
if (exit === 0) exit = 1
|
|
787
|
+
if (worstExit === 0) worstExit = 1
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
472
791
|
const upload = getLastFlowPreviewUploadResult()
|
|
473
792
|
if (upload?.shareId || upload?.previewUrl) {
|
|
474
793
|
try {
|
|
@@ -487,7 +806,6 @@ export async function runMaestro(
|
|
|
487
806
|
}
|
|
488
807
|
|
|
489
808
|
if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
|
|
490
|
-
const traceSteps = getLastFlowTraceSteps()
|
|
491
809
|
const failedStep = traceSteps.find((step) => step.status === 'failure')
|
|
492
810
|
const run = await registerRun({
|
|
493
811
|
origin: await registerOrigin(),
|
|
@@ -520,6 +838,9 @@ export async function runMaestro(
|
|
|
520
838
|
if (worstExit === 0) worstExit = 1
|
|
521
839
|
}
|
|
522
840
|
}
|
|
841
|
+
// artifact, upload, and registration failures above flip the local exit
|
|
842
|
+
// after the result was pushed; the report below must see the final one.
|
|
843
|
+
result.exit = exit
|
|
523
844
|
}
|
|
524
845
|
|
|
525
846
|
if (files.length > 1) {
|
|
@@ -532,5 +853,105 @@ export async function runMaestro(
|
|
|
532
853
|
console.log(` ${passed}/${results.length} passed`)
|
|
533
854
|
}
|
|
534
855
|
|
|
856
|
+
// one device per invocation, like maestro: the explicit override, else the
|
|
857
|
+
// sim default. reports print it; playback already resolved it per flow.
|
|
858
|
+
const deviceLabel = flagValue('--device')?.trim() || 'iPhone 17 Pro'
|
|
859
|
+
if (compat.format === 'junit') {
|
|
860
|
+
const junitPath = path.resolve(cwd, compat.output ?? 'report.xml')
|
|
861
|
+
try {
|
|
862
|
+
fs.mkdirSync(path.dirname(junitPath), { recursive: true })
|
|
863
|
+
fs.writeFileSync(
|
|
864
|
+
junitPath,
|
|
865
|
+
buildJUnitXml({
|
|
866
|
+
device: deviceLabel,
|
|
867
|
+
cases: results.map((r) => junitCaseForResult(cwd, r)),
|
|
868
|
+
}),
|
|
869
|
+
)
|
|
870
|
+
console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`)
|
|
871
|
+
} catch (err) {
|
|
872
|
+
console.error(` error: could not write junit report: ${(err as Error).message}`)
|
|
873
|
+
if (worstExit === 0) worstExit = 1
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (compat.debugOutput) {
|
|
877
|
+
const logPath = path.resolve(cwd, compat.debugOutput, 'maestro.log')
|
|
878
|
+
try {
|
|
879
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
|
880
|
+
const sessionFlows: SessionLogFlow[] = results.map((r) => ({
|
|
881
|
+
file: path.relative(cwd, r.file) || r.file,
|
|
882
|
+
passed: r.exit === 0,
|
|
883
|
+
durationMs: r.durationMs,
|
|
884
|
+
steps: r.steps,
|
|
885
|
+
}))
|
|
886
|
+
fs.writeFileSync(
|
|
887
|
+
logPath,
|
|
888
|
+
buildSessionLog({
|
|
889
|
+
startedAtIso: sessionStartedAt,
|
|
890
|
+
root: path.relative(cwd, target) || '.',
|
|
891
|
+
device: deviceLabel,
|
|
892
|
+
flows: sessionFlows,
|
|
893
|
+
}),
|
|
894
|
+
)
|
|
895
|
+
console.log(` debug log: ${path.relative(cwd, logPath) || logPath}`)
|
|
896
|
+
} catch (err) {
|
|
897
|
+
console.error(` error: could not write debug log: ${(err as Error).message}`)
|
|
898
|
+
if (worstExit === 0) worstExit = 1
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
535
902
|
return worstExit
|
|
536
903
|
}
|
|
904
|
+
|
|
905
|
+
// a junit testcase from one flow result. yaml frontmatter is untrusted, so
|
|
906
|
+
// every field is shape-checked back to the documented scalar shapes.
|
|
907
|
+
function junitCaseForResult(
|
|
908
|
+
cwd: string,
|
|
909
|
+
result: {
|
|
910
|
+
file: string
|
|
911
|
+
exit: number
|
|
912
|
+
durationMs: number
|
|
913
|
+
steps: ReturnType<typeof getLastFlowTraceSteps>
|
|
914
|
+
frontmatter: FlowFrontmatter
|
|
915
|
+
},
|
|
916
|
+
): JUnitCaseInput {
|
|
917
|
+
const raw = result.frontmatter
|
|
918
|
+
const name =
|
|
919
|
+
typeof raw.name === 'string' && raw.name.trim().length > 0
|
|
920
|
+
? raw.name.trim()
|
|
921
|
+
: flowFileStem(result.file)
|
|
922
|
+
const custom: Record<string, string> = {}
|
|
923
|
+
let junitId: string | null = null
|
|
924
|
+
let junitClassname: string | null = null
|
|
925
|
+
const props: unknown = raw.properties
|
|
926
|
+
if (props && typeof props === 'object') {
|
|
927
|
+
for (const [key, value] of Object.entries(props)) {
|
|
928
|
+
if (
|
|
929
|
+
typeof value !== 'string' &&
|
|
930
|
+
typeof value !== 'number' &&
|
|
931
|
+
typeof value !== 'boolean'
|
|
932
|
+
) {
|
|
933
|
+
continue
|
|
934
|
+
}
|
|
935
|
+
const text = String(value)
|
|
936
|
+
if (key === 'junitId') junitId = text
|
|
937
|
+
else if (key === 'junitClassname') junitClassname = text
|
|
938
|
+
else custom[key] = text
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const tags = Array.isArray(raw.tags)
|
|
942
|
+
? raw.tags.filter((tag): tag is string => typeof tag === 'string')
|
|
943
|
+
: []
|
|
944
|
+
return {
|
|
945
|
+
file: path.relative(cwd, result.file) || result.file,
|
|
946
|
+
name,
|
|
947
|
+
id: junitId ?? name,
|
|
948
|
+
classname: junitClassname ?? name,
|
|
949
|
+
properties: custom,
|
|
950
|
+
tags,
|
|
951
|
+
timeSeconds: result.durationMs / 1000,
|
|
952
|
+
failure:
|
|
953
|
+
result.exit === 0
|
|
954
|
+
? null
|
|
955
|
+
: failureFromTraceSteps(result.steps, 'flow playback failed'),
|
|
956
|
+
}
|
|
957
|
+
}
|