rnxsim 0.1.549 → 0.1.551
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/cli/bridge-flow-runner.ts +26 -0
- package/cli/commands/maestro.ts +453 -42
- package/cli/commands/test.ts +107 -23
- package/cli/flow-file.ts +7 -0
- package/cli/maestro-report.ts +178 -0
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +8 -6
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +28 -7
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +23 -5
- package/dist-lib/vite.cjs +24 -11
- package/package.json +1 -1
- package/src/rolldown-plugin-swift.ts +8 -5
- package/src/vite-plugin-swift.ts +8 -5
|
@@ -2027,7 +2027,33 @@ export class SootSimBridgeFlowRunner {
|
|
|
2027
2027
|
// begin its pixels only after launch has mounted and settled, leaving the
|
|
2028
2028
|
// loading prelude outside the playable video while its trace stays visible.
|
|
2029
2029
|
await this.waitForVisualSettle()
|
|
2030
|
+
const videoAlreadyStarted = this.recordingStartedAtMs != null
|
|
2030
2031
|
await this.ensureRecordingStarted()
|
|
2032
|
+
if (!videoAlreadyStarted && this.recordingStartedAtMs != null) {
|
|
2033
|
+
// the preview event recorder has been running since before launch,
|
|
2034
|
+
// so its clock includes the boot prelude the video just skipped.
|
|
2035
|
+
// rebase it to the video's first frame — without this every event
|
|
2036
|
+
// timestamp overshoots the playable video and preview playback can
|
|
2037
|
+
// never align (play stuck disabled). no-op unless the preview
|
|
2038
|
+
// event recorder is running.
|
|
2039
|
+
await this.rebaseEventRecorderToVideoStart(this.recordingStartedAtMs)
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) {
|
|
2045
|
+
try {
|
|
2046
|
+
await this.evaluate(
|
|
2047
|
+
`(() => {
|
|
2048
|
+
const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
2049
|
+
if (!recorder?.isRecording?.()) return 'not-recording'
|
|
2050
|
+
return recorder.markVisibleStart?.(${JSON.stringify(videoStartedAtMs)}) ?? 'no-mark'
|
|
2051
|
+
})()`,
|
|
2052
|
+
)
|
|
2053
|
+
} catch (err) {
|
|
2054
|
+
console.warn(
|
|
2055
|
+
` warn: event-recorder rebase to video start failed: ${err instanceof Error ? err.message : err}`,
|
|
2056
|
+
)
|
|
2031
2057
|
}
|
|
2032
2058
|
}
|
|
2033
2059
|
|
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 {
|
|
@@ -50,7 +59,19 @@ options:
|
|
|
50
59
|
--dry-run, --no-run load flow into runner idle without auto-running
|
|
51
60
|
--env KEY=VALUE set env vars for \${KEY} interpolation (repeatable)
|
|
52
61
|
--continuous accepted for maestro cli compat; rnx runs the flow once
|
|
53
|
-
--
|
|
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
|
|
54
75
|
--device <model> override the flow frontmatter device for this run
|
|
55
76
|
--new force a fresh browser window for this run
|
|
56
77
|
--headed force a fresh, VISIBLE window (implies --new,
|
|
@@ -95,8 +116,19 @@ supported verbs:
|
|
|
95
116
|
(env vars as globals, output.*, maestro.*, \${EXPR || 'default'}).
|
|
96
117
|
|
|
97
118
|
workspace mode (directory runs):
|
|
98
|
-
config.yaml / config.yml discovery, flows: inclusion globs
|
|
99
|
-
|
|
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.
|
|
100
132
|
|
|
101
133
|
launch behavior:
|
|
102
134
|
launchApp.arguments: a JSON object available before guest app entry through
|
|
@@ -150,19 +182,109 @@ function applyEnvFlags(args: string[]): string[] {
|
|
|
150
182
|
return out
|
|
151
183
|
}
|
|
152
184
|
|
|
153
|
-
// maestro-cli flags
|
|
154
|
-
// swap `maestro` for `rnx maestro` unedited. each one
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
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[] = []
|
|
161
242
|
for (let i = 0; i < args.length; i++) {
|
|
162
243
|
const a = args[i]
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
}
|
|
166
288
|
continue
|
|
167
289
|
}
|
|
168
290
|
if (a === '--origin') {
|
|
@@ -174,16 +296,26 @@ function stripMaestroCompatFlags(args: string[]): string[] {
|
|
|
174
296
|
if (a === '-d') {
|
|
175
297
|
// maestro's short form for --device. rnx's device flag names a device
|
|
176
298
|
// model, and the flow runner reads it under the long spelling only.
|
|
177
|
-
|
|
299
|
+
rest.push('--device')
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (a === '--no-ansi') {
|
|
303
|
+
// trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
|
|
178
304
|
continue
|
|
179
305
|
}
|
|
180
306
|
if (a === '--continuous') {
|
|
181
307
|
console.warn(' warn: --continuous is not implemented in rnx — running once')
|
|
182
308
|
continue
|
|
183
309
|
}
|
|
184
|
-
|
|
310
|
+
rest.push(a)
|
|
185
311
|
}
|
|
186
|
-
|
|
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 }
|
|
187
319
|
}
|
|
188
320
|
|
|
189
321
|
function findDefaultMaestroTarget(cwd: string): string | null {
|
|
@@ -198,36 +330,88 @@ interface MaestroWorkspaceConfig {
|
|
|
198
330
|
flows?: string[]
|
|
199
331
|
includeTags?: string[]
|
|
200
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 }
|
|
201
379
|
}
|
|
202
380
|
|
|
203
381
|
// workspace planning for directory targets — mirrors maestro's
|
|
204
382
|
// WorkspaceExecutionPlanner: discover config.yaml/config.yml in the
|
|
205
383
|
// directory, walk it recursively for *.yaml/*.yml flow files (config files
|
|
206
384
|
// excluded), keep the ones matching the config's `flows:` globs (default
|
|
207
|
-
// `*` = top level only
|
|
208
|
-
// each flow's
|
|
209
|
-
|
|
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[] {
|
|
210
395
|
const stat = fs.statSync(target)
|
|
211
396
|
if (stat.isFile()) return [target]
|
|
212
397
|
if (!stat.isDirectory()) {
|
|
213
398
|
throw new Error(`not a file or directory: ${target}`)
|
|
214
399
|
}
|
|
215
400
|
|
|
216
|
-
|
|
217
|
-
const configPath = ['config.yaml', 'config.yml']
|
|
218
|
-
.map((name) => path.join(target, name))
|
|
219
|
-
.find((p) => fs.existsSync(p))
|
|
220
|
-
if (configPath) {
|
|
221
|
-
// yaml.parse returns any; shape-check the fields we use below instead
|
|
222
|
-
// of trusting the file
|
|
223
|
-
const parsed = yaml.parse(fs.readFileSync(configPath, 'utf8'))
|
|
224
|
-
if (parsed && typeof parsed === 'object') {
|
|
225
|
-
config = parsed
|
|
226
|
-
}
|
|
227
|
-
}
|
|
401
|
+
const { path: configPath, config } = loadMaestroWorkspaceConfig(target, opts.configPath)
|
|
228
402
|
|
|
229
403
|
const globs =
|
|
230
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
|
+
}
|
|
231
415
|
const isFlowFile = (rel: string) => {
|
|
232
416
|
const base = path.basename(rel)
|
|
233
417
|
if (base.startsWith('.')) return false
|
|
@@ -236,17 +420,22 @@ export function collectFlowFiles(target: string): string[] {
|
|
|
236
420
|
return true
|
|
237
421
|
}
|
|
238
422
|
const matched = new Set<string>()
|
|
239
|
-
for (const glob of
|
|
423
|
+
for (const glob of positive) {
|
|
240
424
|
for (const rel of fs.globSync(glob, { cwd: target })) {
|
|
241
425
|
if (!isFlowFile(rel)) continue
|
|
242
426
|
const abs = path.join(target, rel)
|
|
243
427
|
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) matched.add(abs)
|
|
244
428
|
}
|
|
245
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
|
+
}
|
|
246
435
|
let files = [...matched].sort()
|
|
247
436
|
|
|
248
|
-
const includeTags =
|
|
249
|
-
const excludeTags =
|
|
437
|
+
const includeTags = opts.includeTags ?? config.includeTags ?? []
|
|
438
|
+
const excludeTags = opts.excludeTags ?? config.excludeTags ?? []
|
|
250
439
|
if (includeTags.length > 0 || excludeTags.length > 0) {
|
|
251
440
|
files = files.filter((file) => {
|
|
252
441
|
const { frontmatter } = parseFlowFile(fs.readFileSync(file, 'utf8'))
|
|
@@ -261,6 +450,23 @@ export function collectFlowFiles(target: string): string[] {
|
|
|
261
450
|
return files
|
|
262
451
|
}
|
|
263
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
|
+
|
|
264
470
|
async function runInit(cwd: string): Promise<number> {
|
|
265
471
|
const dir = path.join(cwd, '.maestro')
|
|
266
472
|
fs.mkdirSync(dir, { recursive: true })
|
|
@@ -345,11 +551,27 @@ export async function runMaestro(
|
|
|
345
551
|
return runMaestroAuthoring(remaining)
|
|
346
552
|
}
|
|
347
553
|
// `--origin` names the Contrast instance that hosts the run row. this command
|
|
348
|
-
// is its only reader, so read it before
|
|
554
|
+
// is its only reader, so read it before the compat extractor consumes it.
|
|
349
555
|
const originFlagIndex = remaining.indexOf('--origin')
|
|
350
556
|
const originFlagValue =
|
|
351
557
|
originFlagIndex >= 0 ? remaining[originFlagIndex + 1] : undefined
|
|
352
|
-
|
|
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
|
+
}
|
|
353
575
|
|
|
354
576
|
// resolve the target flow file or directory.
|
|
355
577
|
let target: string | null = null
|
|
@@ -368,6 +590,11 @@ export async function runMaestro(
|
|
|
368
590
|
if (positionalIndex >= 0) {
|
|
369
591
|
target = path.resolve(cwd, rest[positionalIndex])
|
|
370
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
|
|
371
598
|
} else {
|
|
372
599
|
target = findDefaultMaestroTarget(cwd)
|
|
373
600
|
forwardArgs = rest
|
|
@@ -411,18 +638,42 @@ export async function runMaestro(
|
|
|
411
638
|
}
|
|
412
639
|
|
|
413
640
|
let files: string[]
|
|
641
|
+
let workspaceConfig: MaestroWorkspaceConfig = {}
|
|
642
|
+
let workspaceConfigPath: string | null = null
|
|
414
643
|
try {
|
|
415
|
-
|
|
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
|
+
})
|
|
416
654
|
} catch (err) {
|
|
417
655
|
console.error(` error: ${(err as Error).message}`)
|
|
418
656
|
return 1
|
|
419
657
|
}
|
|
420
658
|
|
|
421
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
|
+
}
|
|
422
664
|
console.error(` error: no *.yaml or *.yml files found in ${target}`)
|
|
423
665
|
return 1
|
|
424
666
|
}
|
|
425
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
|
+
|
|
426
677
|
console.log(`
|
|
427
678
|
rnx maestro — ${files.length} flow${files.length === 1 ? '' : 's'}
|
|
428
679
|
root: ${path.relative(cwd, target) || '.'}
|
|
@@ -445,11 +696,36 @@ export async function runMaestro(
|
|
|
445
696
|
flagValue('--preview-origin') ?? originFlagValue,
|
|
446
697
|
))
|
|
447
698
|
|
|
699
|
+
const sessionStartedAt = new Date().toISOString()
|
|
448
700
|
let worstExit = 0
|
|
449
|
-
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
|
+
}> = []
|
|
450
708
|
for (const file of files) {
|
|
451
709
|
console.log(`\n ▶ ${path.relative(cwd, file)}`)
|
|
452
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
|
+
}
|
|
453
729
|
// `--port` is the BRIDGE port everywhere else in the CLI, so forward it as
|
|
454
730
|
// one. sending it on as `--url` made it an app target instead, which left
|
|
455
731
|
// the playback bridge on whichever world resolveBridgeWorld picks by
|
|
@@ -476,9 +752,42 @@ export async function runMaestro(
|
|
|
476
752
|
exit = 1
|
|
477
753
|
}
|
|
478
754
|
}
|
|
479
|
-
|
|
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)
|
|
480
765
|
if (exit !== 0 && worstExit === 0) worstExit = exit
|
|
481
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
|
+
|
|
482
791
|
const upload = getLastFlowPreviewUploadResult()
|
|
483
792
|
if (upload?.shareId || upload?.previewUrl) {
|
|
484
793
|
try {
|
|
@@ -497,7 +806,6 @@ export async function runMaestro(
|
|
|
497
806
|
}
|
|
498
807
|
|
|
499
808
|
if (shouldRegisterRun({ uploadedShare: Boolean(upload), auth })) {
|
|
500
|
-
const traceSteps = getLastFlowTraceSteps()
|
|
501
809
|
const failedStep = traceSteps.find((step) => step.status === 'failure')
|
|
502
810
|
const run = await registerRun({
|
|
503
811
|
origin: await registerOrigin(),
|
|
@@ -530,6 +838,9 @@ export async function runMaestro(
|
|
|
530
838
|
if (worstExit === 0) worstExit = 1
|
|
531
839
|
}
|
|
532
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
|
|
533
844
|
}
|
|
534
845
|
|
|
535
846
|
if (files.length > 1) {
|
|
@@ -542,5 +853,105 @@ export async function runMaestro(
|
|
|
542
853
|
console.log(` ${passed}/${results.length} passed`)
|
|
543
854
|
}
|
|
544
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
|
+
|
|
545
902
|
return worstExit
|
|
546
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
|
+
}
|