peachsim 0.1.570 → 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 +2 -2
- package/cli/bridge-flow-runner.ts +15 -7
- package/cli/cloud-dispatch.ts +1 -0
- package/cli/cloud-test-result-files.ts +97 -0
- package/cli/commands/maestro.ts +123 -31
- package/cli/commands/test.ts +161 -3
- package/cli/maestro-report.ts +3 -1
- package/cli/outbound-endpoints.ts +2 -0
- package/cli/run-registry.ts +2 -1
- package/detox/jest-preset.cjs +2 -2
- 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-test-args.cjs +116 -0
- package/dist-lib/cloud-test-args.mjs +86 -0
- 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/detox/jest-preset.cjs +2 -2
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +16 -7
- 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 +16 -26
- 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 +119 -106
- 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 +1 -1
- package/dist-lib/vite.cjs +456 -138
- package/package.json +22 -1
- package/skills/rnx-test/SKILL.md +3 -3
- package/src/cloud-test-args.ts +100 -0
- package/src/rolldown-plugin-swift.ts +8 -7
- 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-swift.ts +103 -107
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ What this workspace gives you:
|
|
|
33
33
|
- `rnxsim/jump-to-source-babel` — an optional Babel plugin that annotates JSX
|
|
34
34
|
with source locations so inspect mode can open the selected element in your
|
|
35
35
|
editor
|
|
36
|
-
- `
|
|
36
|
+
- `peachsim/detox` — a drop-in Detox driver + jest preset
|
|
37
37
|
- `rnxsim/sdk` — the inspect/interact verbs as a programmatic API
|
|
38
38
|
- `rnxsim/bridge-contract-input` — parsers turning untrusted JSON into
|
|
39
39
|
bridge-contract arguments
|
|
@@ -445,7 +445,7 @@ no code changes — just add one line to your jest config:
|
|
|
445
445
|
|
|
446
446
|
```js
|
|
447
447
|
// jest config
|
|
448
|
-
{ preset: '
|
|
448
|
+
{ preset: 'peachsim/detox' }
|
|
449
449
|
```
|
|
450
450
|
|
|
451
451
|
Or use the CLI directly:
|
|
@@ -1163,7 +1163,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
1163
1163
|
|
|
1164
1164
|
async assertVisible(target: string | { id?: string; text?: string }) {
|
|
1165
1165
|
const opts = typeof target === 'string' ? { text: target } : target
|
|
1166
|
-
for
|
|
1166
|
+
// maestro auto-retries assertions for up to 7 seconds before failing; a
|
|
1167
|
+
// shorter window fails flows real maestro passes.
|
|
1168
|
+
for (let attempt = 0; attempt < 35; attempt++) {
|
|
1167
1169
|
if (await this.isElementVisible(opts)) {
|
|
1168
1170
|
return
|
|
1169
1171
|
}
|
|
@@ -1184,13 +1186,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1184
1186
|
|
|
1185
1187
|
async assertNotVisible(target: string | { id?: string; text?: string }) {
|
|
1186
1188
|
const opts = typeof target === 'string' ? { text: target } : target
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1189
|
+
// like assertVisible, maestro waits up to 7 seconds for the element to
|
|
1190
|
+
// disappear rather than failing on the first visible sample.
|
|
1191
|
+
for (let attempt = 0; attempt < 35; attempt++) {
|
|
1192
|
+
if (!(await this.isElementVisible(opts))) {
|
|
1193
|
+
return
|
|
1194
|
+
}
|
|
1195
|
+
await sleep(200)
|
|
1193
1196
|
}
|
|
1197
|
+
const found = await this.findElement(opts)
|
|
1198
|
+
const where = found
|
|
1199
|
+
? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
|
|
1200
|
+
: ''
|
|
1201
|
+
throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
|
|
1194
1202
|
}
|
|
1195
1203
|
|
|
1196
1204
|
// visibility is the engine's call, not a layout-bounds guess. the old test
|
package/cli/cloud-dispatch.ts
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// shared cloud-test result-file contract between the test interpreters and
|
|
2
|
+
// the hosted recorder.
|
|
3
|
+
//
|
|
4
|
+
// each maestro flow writes its recording + registration ids where the
|
|
5
|
+
// recorder collects them. single-flow jobs write only the bare
|
|
6
|
+
// preview-result.json / test-result.json; a multi-flow run additionally
|
|
7
|
+
// writes one numbered pair per flow so no flow's ids overwrite another's.
|
|
8
|
+
// the bare file stays the last flow's, so older readers keep working.
|
|
9
|
+
// every flow writes its files unconditionally: a failed upload or
|
|
10
|
+
// registration leaves null ids, never a missing entry, so the collector
|
|
11
|
+
// always sees one entry per flow in run order.
|
|
12
|
+
|
|
13
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export interface CloudTestFlowResult {
|
|
17
|
+
flow: string | null
|
|
18
|
+
previewId: string | null
|
|
19
|
+
previewUrl: string | null
|
|
20
|
+
runId: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// every file one flow's ids land in: the bare file always (last flow wins,
|
|
24
|
+
// as before), plus the flow's own numbered file on multi-flow runs.
|
|
25
|
+
export function cloudTestResultFileNames(
|
|
26
|
+
kind: 'preview' | 'test',
|
|
27
|
+
flowIndex: number,
|
|
28
|
+
flowCount: number,
|
|
29
|
+
): string[] {
|
|
30
|
+
const base = kind === 'preview' ? 'preview-result' : 'test-result'
|
|
31
|
+
if (flowCount < 2) return [`${base}.json`]
|
|
32
|
+
return [`${base}.json`, `${base}-${flowIndex + 1}.json`]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readResultJson(workDir: string, name: string): Record<string, unknown> | null {
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(readFileSync(join(workDir, name), 'utf8'))
|
|
38
|
+
return parsed && typeof parsed === 'object'
|
|
39
|
+
? (parsed as Record<string, unknown>)
|
|
40
|
+
: null
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stringField(obj: Record<string, unknown> | null, name: string): string | null {
|
|
47
|
+
const value = obj?.[name]
|
|
48
|
+
return typeof value === 'string' ? value : null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function previewIdFrom(preview: Record<string, unknown> | null): string | null {
|
|
52
|
+
return (
|
|
53
|
+
stringField(preview, 'previewId') ??
|
|
54
|
+
stringField(preview, 'previewUrl')?.match(/\/preview\/([a-z0-9_-]+)/i)?.[1] ??
|
|
55
|
+
null
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// every flow's ids in run order: the numbered files when the runner wrote
|
|
60
|
+
// them, else the single bare pair. missing or unparseable files read as
|
|
61
|
+
// null ids, never throw. discovery unions both sides: an upload failure
|
|
62
|
+
// leaves no preview file and a registration failure leaves no test file,
|
|
63
|
+
// and either way the flow keeps its entry with null ids instead of
|
|
64
|
+
// disappearing and letting a later flow pose as the last.
|
|
65
|
+
export function collectCloudTestFlowResults(
|
|
66
|
+
workDir: string,
|
|
67
|
+
cloudOrigin: string,
|
|
68
|
+
): CloudTestFlowResult[] {
|
|
69
|
+
const one = (previewName: string, testName: string): CloudTestFlowResult => {
|
|
70
|
+
const preview = readResultJson(workDir, previewName)
|
|
71
|
+
const test = readResultJson(workDir, testName)
|
|
72
|
+
const previewId = previewIdFrom(preview)
|
|
73
|
+
return {
|
|
74
|
+
flow: stringField(preview, 'flow') ?? stringField(test, 'flow'),
|
|
75
|
+
previewId,
|
|
76
|
+
previewUrl:
|
|
77
|
+
stringField(preview, 'previewUrl') ??
|
|
78
|
+
(previewId ? `${cloudOrigin.replace(/\/$/, '')}/preview/${previewId}` : null),
|
|
79
|
+
runId: stringField(test, 'runId'),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let numbered: number[] = []
|
|
83
|
+
try {
|
|
84
|
+
const indices = new Set<number>()
|
|
85
|
+
for (const name of readdirSync(workDir)) {
|
|
86
|
+
const match = name.match(/^(?:preview|test)-result-(\d+)\.json$/)
|
|
87
|
+
if (match) indices.add(Number(match[1]))
|
|
88
|
+
}
|
|
89
|
+
numbered = [...indices].sort((a, b) => a - b)
|
|
90
|
+
} catch {
|
|
91
|
+
numbered = []
|
|
92
|
+
}
|
|
93
|
+
if (numbered.length > 0) {
|
|
94
|
+
return numbered.map((n) => one(`preview-result-${n}.json`, `test-result-${n}.json`))
|
|
95
|
+
}
|
|
96
|
+
return [one('preview-result.json', 'test-result.json')]
|
|
97
|
+
}
|
package/cli/commands/maestro.ts
CHANGED
|
@@ -10,6 +10,7 @@ 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 { cloudTestResultFileNames } from '../cloud-test-result-files'
|
|
13
14
|
import { parseFlowFile, type FlowFrontmatter } from '../flow-file'
|
|
14
15
|
import {
|
|
15
16
|
buildFlowCommandsJson,
|
|
@@ -65,13 +66,22 @@ options:
|
|
|
65
66
|
--config <path> read the workspace config (flows/tags) from this file
|
|
66
67
|
instead of <dir>/config.yaml
|
|
67
68
|
--format junit write a junit xml report (default ./report.xml)
|
|
69
|
+
JUNIT, NOOP also accepted (case-insensitive)
|
|
68
70
|
--output <file> junit report path (needs --format junit)
|
|
69
71
|
--test-output-dir <dir>
|
|
70
72
|
per-flow screenshots, commands.json, and logs/maestro.log
|
|
71
73
|
(config key testOutputDir:; CLI wins over config)
|
|
72
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")
|
|
73
77
|
--platform ios accepted; any other platform is refused (rnx is iOS-only)
|
|
78
|
+
(-p stays the rnx bridge-port short)
|
|
74
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
|
|
75
85
|
--device <model> override the flow frontmatter device for this run
|
|
76
86
|
--new force a fresh browser window for this run
|
|
77
87
|
--headed force a fresh, VISIBLE window (implies --new,
|
|
@@ -162,11 +172,13 @@ function looksLikeFlag(s: string): boolean {
|
|
|
162
172
|
// extract --env KEY=VALUE pairs from argv and apply to process.env for the
|
|
163
173
|
// duration of the run. returns argv with the consumed flags removed so the
|
|
164
174
|
// rest can be forwarded to runFlowPlayback verbatim.
|
|
165
|
-
function applyEnvFlags(args: string[]): string[] {
|
|
175
|
+
export function applyEnvFlags(args: string[]): string[] {
|
|
166
176
|
const out: string[] = []
|
|
167
177
|
for (let i = 0; i < args.length; i++) {
|
|
168
|
-
|
|
169
|
-
|
|
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]
|
|
170
182
|
if (!kv || !kv.includes('=')) {
|
|
171
183
|
throw new Error(`--env expects KEY=VALUE (got ${JSON.stringify(kv ?? '')})`)
|
|
172
184
|
}
|
|
@@ -174,10 +186,10 @@ function applyEnvFlags(args: string[]): string[] {
|
|
|
174
186
|
const key = kv.slice(0, eq)
|
|
175
187
|
const value = kv.slice(eq + 1)
|
|
176
188
|
process.env[key] = value
|
|
177
|
-
i += 1
|
|
189
|
+
if (inline === null) i += 1
|
|
178
190
|
continue
|
|
179
191
|
}
|
|
180
|
-
out.push(
|
|
192
|
+
out.push(arg)
|
|
181
193
|
}
|
|
182
194
|
return out
|
|
183
195
|
}
|
|
@@ -204,6 +216,7 @@ export interface MaestroCompatOptions {
|
|
|
204
216
|
testOutputDir: string | null
|
|
205
217
|
debugOutput: string | null
|
|
206
218
|
platform: string | null
|
|
219
|
+
testSuiteName: string | null
|
|
207
220
|
}
|
|
208
221
|
|
|
209
222
|
const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
|
|
@@ -215,6 +228,14 @@ const MAESTRO_COMPAT_VALUE_FLAGS = new Set([
|
|
|
215
228
|
'--test-output-dir',
|
|
216
229
|
'--debug-output',
|
|
217
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',
|
|
218
239
|
])
|
|
219
240
|
|
|
220
241
|
function splitTagList(value: string): string[] {
|
|
@@ -237,6 +258,7 @@ export function extractMaestroCompatFlags(args: string[]): {
|
|
|
237
258
|
testOutputDir: null,
|
|
238
259
|
debugOutput: null,
|
|
239
260
|
platform: null,
|
|
261
|
+
testSuiteName: null,
|
|
240
262
|
}
|
|
241
263
|
const rest: string[] = []
|
|
242
264
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -267,7 +289,8 @@ export function extractMaestroCompatFlags(args: string[]): {
|
|
|
267
289
|
compat.configPath = value
|
|
268
290
|
break
|
|
269
291
|
case '--format':
|
|
270
|
-
|
|
292
|
+
// maestro spells it JUNIT; normalize so either case works.
|
|
293
|
+
compat.format = value.toLowerCase()
|
|
271
294
|
break
|
|
272
295
|
case '--output':
|
|
273
296
|
compat.output = value
|
|
@@ -284,6 +307,28 @@ export function extractMaestroCompatFlags(args: string[]): {
|
|
|
284
307
|
}
|
|
285
308
|
compat.platform = value
|
|
286
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
|
|
287
332
|
}
|
|
288
333
|
continue
|
|
289
334
|
}
|
|
@@ -299,22 +344,62 @@ export function extractMaestroCompatFlags(args: string[]): {
|
|
|
299
344
|
rest.push('--device')
|
|
300
345
|
continue
|
|
301
346
|
}
|
|
302
|
-
if (a === '--no-ansi') {
|
|
347
|
+
if (a === '--no-ansi' || a === '--no-color') {
|
|
303
348
|
// trivially satisfied: rnx prints no ANSI escapes anywhere on this path.
|
|
304
349
|
continue
|
|
305
350
|
}
|
|
306
|
-
if (a === '--
|
|
351
|
+
if (a === '--ansi' || a === '--color') {
|
|
352
|
+
console.warn(' warn: rnx prints no ANSI escapes; colored output is unavailable')
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
if (a === '--continuous' || a === '-c') {
|
|
307
356
|
console.warn(' warn: --continuous is not implemented in rnx — running once')
|
|
308
357
|
continue
|
|
309
358
|
}
|
|
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
|
+
}
|
|
310
390
|
rest.push(a)
|
|
311
391
|
}
|
|
312
392
|
if (
|
|
313
393
|
compat.format !== null &&
|
|
314
|
-
!['junit', 'html', 'html-detailed'].includes(compat.format)
|
|
394
|
+
!['junit', 'html', 'html-detailed', 'noop'].includes(compat.format)
|
|
315
395
|
) {
|
|
316
396
|
throw new Error(`unsupported --format: ${compat.format} (rnx writes junit reports)`)
|
|
317
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
|
+
}
|
|
318
403
|
return { rest, compat }
|
|
319
404
|
}
|
|
320
405
|
|
|
@@ -705,7 +790,7 @@ export async function runMaestro(
|
|
|
705
790
|
steps: ReturnType<typeof getLastFlowTraceSteps>
|
|
706
791
|
frontmatter: FlowFrontmatter
|
|
707
792
|
}> = []
|
|
708
|
-
for (const file of files) {
|
|
793
|
+
for (const [flowIndex, file] of files.entries()) {
|
|
709
794
|
console.log(`\n ▶ ${path.relative(cwd, file)}`)
|
|
710
795
|
const playbackArgs = [file, ...forwardArgs]
|
|
711
796
|
let flowTestDir: string | null = null
|
|
@@ -789,17 +874,20 @@ export async function runMaestro(
|
|
|
789
874
|
}
|
|
790
875
|
|
|
791
876
|
const upload = getLastFlowPreviewUploadResult()
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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')) {
|
|
803
891
|
console.error(' error: required hosted recording upload failed')
|
|
804
892
|
if (exit === 0) exit = 1
|
|
805
893
|
if (worstExit === 0) worstExit = 1
|
|
@@ -822,21 +910,24 @@ export async function runMaestro(
|
|
|
822
910
|
})
|
|
823
911
|
if (run) {
|
|
824
912
|
console.log(` run: ${run.id}${run.traceUrl ? ` · replay: ${run.traceUrl}` : ''}`)
|
|
825
|
-
try {
|
|
826
|
-
fs.writeFileSync(
|
|
827
|
-
path.join(cwd, 'test-result.json'),
|
|
828
|
-
JSON.stringify({
|
|
829
|
-
runId: run.id,
|
|
830
|
-
previewUrl: run.previewUrl,
|
|
831
|
-
traceUrl: run.traceUrl,
|
|
832
|
-
}),
|
|
833
|
-
)
|
|
834
|
-
} catch {}
|
|
835
913
|
} else if (process.env.RNX_REQUIRE_REGISTRATION === 'true') {
|
|
836
914
|
console.error(' error: required hosted run registration failed')
|
|
837
915
|
if (exit === 0) exit = 1
|
|
838
916
|
if (worstExit === 0) worstExit = 1
|
|
839
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 {}
|
|
840
931
|
}
|
|
841
932
|
// artifact, upload, and registration failures above flip the local exit
|
|
842
933
|
// after the result was pushed; the report below must see the final one.
|
|
@@ -865,6 +956,7 @@ export async function runMaestro(
|
|
|
865
956
|
buildJUnitXml({
|
|
866
957
|
device: deviceLabel,
|
|
867
958
|
cases: results.map((r) => junitCaseForResult(cwd, r)),
|
|
959
|
+
...(compat.testSuiteName ? { name: compat.testSuiteName } : {}),
|
|
868
960
|
}),
|
|
869
961
|
)
|
|
870
962
|
console.log(` junit: ${path.relative(cwd, junitPath) || junitPath}`)
|
package/cli/commands/test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RNX_CLOUD_TEST_FILES_MAX_BYTES,
|
|
8
8
|
RNX_CLOUD_TEST_FILES_MAX_COUNT,
|
|
9
9
|
} from '../../src/cloud-contract'
|
|
10
|
+
import { isCloudTestValueFlag, parseCloudTestArgs } from '../../src/cloud-test-args'
|
|
10
11
|
import { authHeaderValue, resolveCliAuth } from '../auth'
|
|
11
12
|
import {
|
|
12
13
|
parseCloudTestSidecarPorts,
|
|
@@ -58,8 +59,18 @@ const VALUE_FLAGS = new Set([
|
|
|
58
59
|
'--test-output-dir',
|
|
59
60
|
'--debug-output',
|
|
60
61
|
'--platform',
|
|
62
|
+
'--udid',
|
|
63
|
+
'--test-suite-name',
|
|
64
|
+
'--shards',
|
|
65
|
+
'-s',
|
|
66
|
+
'--shard-all',
|
|
67
|
+
'--shard-split',
|
|
68
|
+
'--api-key',
|
|
69
|
+
'--api-url',
|
|
70
|
+
'--screen-size',
|
|
61
71
|
'-t',
|
|
62
72
|
'--testNamePattern',
|
|
73
|
+
'--testPathPattern',
|
|
63
74
|
'--testTimeout',
|
|
64
75
|
'--grep',
|
|
65
76
|
'--maxWorkers',
|
|
@@ -88,7 +99,10 @@ function targetIndexFromArgs(args: string[]): number {
|
|
|
88
99
|
for (let index = 0; index < args.length; index++) {
|
|
89
100
|
const arg = args[index]
|
|
90
101
|
if (VALUE_FLAGS.has(arg)) {
|
|
91
|
-
|
|
102
|
+
// a missing value must not swallow the next flag: only skip a
|
|
103
|
+
// present, non-flag-shaped token, like valueAfter.
|
|
104
|
+
const next = args[index + 1]
|
|
105
|
+
if (next !== undefined && !next.startsWith('-')) index += 1
|
|
92
106
|
continue
|
|
93
107
|
}
|
|
94
108
|
if (arg.startsWith('--') && arg.includes('=')) {
|
|
@@ -104,6 +118,122 @@ function targetFromArgs(args: string[]): string | null {
|
|
|
104
118
|
return index >= 0 ? args[index] : null
|
|
105
119
|
}
|
|
106
120
|
|
|
121
|
+
// flags the cloud submitter consumes into the submission itself (app,
|
|
122
|
+
// origin, tunnel, polling): skipped silently by the testArgs walk, with
|
|
123
|
+
// their values. contract: harden owns the list's server half.
|
|
124
|
+
const CLOUD_CONSUMED_FLAGS = new Set([
|
|
125
|
+
'--app',
|
|
126
|
+
'--bundle',
|
|
127
|
+
'--bundle-url',
|
|
128
|
+
'--url',
|
|
129
|
+
'--origin',
|
|
130
|
+
'--owner',
|
|
131
|
+
'--repo',
|
|
132
|
+
'--config',
|
|
133
|
+
'--cancel',
|
|
134
|
+
'--poll-interval',
|
|
135
|
+
'--timeout',
|
|
136
|
+
'--sidecar-ports',
|
|
137
|
+
'--hosted',
|
|
138
|
+
'--cloud',
|
|
139
|
+
])
|
|
140
|
+
|
|
141
|
+
// execution-environment flags the hosted recorder owns: warned on every
|
|
142
|
+
// cloud submit, never forwarded.
|
|
143
|
+
const RECORDER_OWNED_FLAGS = new Set([
|
|
144
|
+
'--maxWorkers',
|
|
145
|
+
'--testTimeout',
|
|
146
|
+
'--shard',
|
|
147
|
+
'--device',
|
|
148
|
+
'--sim',
|
|
149
|
+
'--headless',
|
|
150
|
+
'--preview',
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
// report/output flags the local-only block already warned about: skipped
|
|
154
|
+
// silently here so one flag never warns twice.
|
|
155
|
+
const CLOUD_ALREADY_WARNED_FLAGS = new Set([
|
|
156
|
+
'--format',
|
|
157
|
+
'--output',
|
|
158
|
+
'--test-output-dir',
|
|
159
|
+
'--debug-output',
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
export interface CloudTestArgsWalk {
|
|
163
|
+
testArgs: string[]
|
|
164
|
+
warnings: string[]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// split the cloud argv into the testArgs slice the recorder appends
|
|
168
|
+
// verbatim plus one warning per dropped arg. pure: the caller prints
|
|
169
|
+
// the warnings and validates through parseCloudTestArgs, so a filter is
|
|
170
|
+
// never silently dropped and malformed selection fails pre-submit with
|
|
171
|
+
// the server's own message.
|
|
172
|
+
export function collectCloudTestArgs(forwarded: string[]): CloudTestArgsWalk {
|
|
173
|
+
const testArgs: string[] = []
|
|
174
|
+
const warnings: string[] = []
|
|
175
|
+
const targetIndex = targetIndexFromArgs(forwarded)
|
|
176
|
+
const takesValue = (name: string): boolean =>
|
|
177
|
+
isCloudTestValueFlag(name) || VALUE_FLAGS.has(name)
|
|
178
|
+
let i = 0
|
|
179
|
+
while (i < forwarded.length) {
|
|
180
|
+
const arg = forwarded[i]
|
|
181
|
+
if (i === targetIndex) {
|
|
182
|
+
i += 1
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
186
|
+
warnings.push(
|
|
187
|
+
`cloud ignores ${arg}: unexpected positional argument (one test target only)`,
|
|
188
|
+
)
|
|
189
|
+
i += 1
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
const eq = arg.indexOf('=')
|
|
193
|
+
const head = eq > 0 ? arg.slice(0, eq) : arg
|
|
194
|
+
const inline = eq > 0
|
|
195
|
+
// a missing value must not swallow the next flag: only a present,
|
|
196
|
+
// non-flag-shaped token is consumed as a value.
|
|
197
|
+
const nextIsValue =
|
|
198
|
+
!inline &&
|
|
199
|
+
takesValue(head) &&
|
|
200
|
+
i + 1 < forwarded.length &&
|
|
201
|
+
!forwarded[i + 1].startsWith('-')
|
|
202
|
+
if (CLOUD_CONSUMED_FLAGS.has(head) || CLOUD_ALREADY_WARNED_FLAGS.has(head)) {
|
|
203
|
+
i += nextIsValue ? 2 : 1
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (isCloudTestValueFlag(head)) {
|
|
207
|
+
if (inline) {
|
|
208
|
+
testArgs.push(arg)
|
|
209
|
+
i += 1
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
const value = forwarded[i + 1]
|
|
213
|
+
if (value !== undefined && !value.startsWith('-')) {
|
|
214
|
+
testArgs.push(arg, value)
|
|
215
|
+
i += 2
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
// a lone selection flag: emit it so the pre-submit parse names it
|
|
219
|
+
// (`<flag> expects a value`) instead of running unfiltered.
|
|
220
|
+
testArgs.push(arg)
|
|
221
|
+
i += 1
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
if (RECORDER_OWNED_FLAGS.has(head)) {
|
|
225
|
+
warnings.push(
|
|
226
|
+
`cloud ignores ${head}: the hosted recorder owns the execution environment`,
|
|
227
|
+
)
|
|
228
|
+
i += nextIsValue ? 2 : 1
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
warnings.push(`cloud ignores ${arg}: applies to local runs only`)
|
|
232
|
+
i += nextIsValue ? 2 : 1
|
|
233
|
+
}
|
|
234
|
+
return { testArgs, warnings }
|
|
235
|
+
}
|
|
236
|
+
|
|
107
237
|
const APP_FLAGS = ['--app', '--bundle', '--bundle-url']
|
|
108
238
|
|
|
109
239
|
function isAppFlag(arg: string): boolean {
|
|
@@ -564,8 +694,6 @@ export async function runCloudTest(
|
|
|
564
694
|
}
|
|
565
695
|
|
|
566
696
|
const localOnly = [
|
|
567
|
-
'--include-tags',
|
|
568
|
-
'--exclude-tags',
|
|
569
697
|
'--format',
|
|
570
698
|
'--output',
|
|
571
699
|
'--test-output-dir',
|
|
@@ -577,6 +705,22 @@ export async function runCloudTest(
|
|
|
577
705
|
)
|
|
578
706
|
}
|
|
579
707
|
|
|
708
|
+
// selection flags ride testArgs to the recorder; everything else the
|
|
709
|
+
// walk drops gets one warning, never silence. validated pre-submit so
|
|
710
|
+
// malformed selection fails here with the server's own message,
|
|
711
|
+
// before any tunnel or upload side effect.
|
|
712
|
+
const walk = collectCloudTestArgs(forwarded)
|
|
713
|
+
for (const warning of walk.warnings) console.warn(` warn: ${warning}`)
|
|
714
|
+
let cloudTestArgs: string[] | null = null
|
|
715
|
+
if (walk.testArgs.length > 0) {
|
|
716
|
+
const parsed = parseCloudTestArgs(walk.testArgs)
|
|
717
|
+
if (!parsed.ok) {
|
|
718
|
+
console.error(` error: ${parsed.message}`)
|
|
719
|
+
return 1
|
|
720
|
+
}
|
|
721
|
+
cloudTestArgs = parsed.args
|
|
722
|
+
}
|
|
723
|
+
|
|
580
724
|
const auth = resolveCliAuth()
|
|
581
725
|
if (!auth) {
|
|
582
726
|
console.error(' error: rnx test --cloud requires `rnx login` or RNX_API_KEY')
|
|
@@ -691,6 +835,7 @@ export async function runCloudTest(
|
|
|
691
835
|
if (tunnelSidecarPorts) submitPayload.sidecarPorts = tunnelSidecarPorts
|
|
692
836
|
if (owner) submitPayload.owner = owner
|
|
693
837
|
if (repo) submitPayload.repo = repo
|
|
838
|
+
if (cloudTestArgs) submitPayload.testArgs = cloudTestArgs
|
|
694
839
|
|
|
695
840
|
try {
|
|
696
841
|
const endpoint = `${origin.replace(/\/$/, '')}/api/sootsim/runner/jobs`
|
|
@@ -754,6 +899,12 @@ export async function runCloudTest(
|
|
|
754
899
|
previewId?: string | null
|
|
755
900
|
previewUrl?: string | null
|
|
756
901
|
runId?: string | null
|
|
902
|
+
flows?: Array<{
|
|
903
|
+
flow?: string | null
|
|
904
|
+
previewId?: string | null
|
|
905
|
+
previewUrl?: string | null
|
|
906
|
+
runId?: string | null
|
|
907
|
+
}> | null
|
|
757
908
|
exitCode?: number | null
|
|
758
909
|
failureReason?: string | null
|
|
759
910
|
} | null
|
|
@@ -782,6 +933,13 @@ export async function runCloudTest(
|
|
|
782
933
|
if (result?.runId) {
|
|
783
934
|
console.log(` run: ${result.runId}`)
|
|
784
935
|
}
|
|
936
|
+
if (result?.flows && result.flows.length > 1) {
|
|
937
|
+
result.flows.forEach((flow, index) => {
|
|
938
|
+
console.log(
|
|
939
|
+
` flow ${index + 1}: ${flow.flow ?? '(unknown flow)'} preview=${flow.previewUrl ?? flow.previewId ?? 'none'} run=${flow.runId ?? 'none'}`,
|
|
940
|
+
)
|
|
941
|
+
})
|
|
942
|
+
}
|
|
785
943
|
if (result?.failureReason) {
|
|
786
944
|
console.error(` failure: ${result.failureReason}`)
|
|
787
945
|
}
|
package/cli/maestro-report.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface JUnitCaseInput {
|
|
|
34
34
|
export interface JUnitSuiteInput {
|
|
35
35
|
device: string
|
|
36
36
|
cases: JUnitCaseInput[]
|
|
37
|
+
// --test-suite-name; absent keeps the existing "Test Suite" name.
|
|
38
|
+
name?: string
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function formatJUnitTime(seconds: number): string {
|
|
@@ -49,7 +51,7 @@ export function buildJUnitXml(suite: JUnitSuiteInput): string {
|
|
|
49
51
|
const lines = [
|
|
50
52
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
51
53
|
'<testsuites>',
|
|
52
|
-
` <testsuite name="Test Suite" device="${escapeXml(suite.device)}" tests="${suite.cases.length}" failures="${failures}" time="${formatJUnitTime(totalTime)}">`,
|
|
54
|
+
` <testsuite name="${escapeXml(suite.name ?? 'Test Suite')}" device="${escapeXml(suite.device)}" tests="${suite.cases.length}" failures="${failures}" time="${formatJUnitTime(totalTime)}">`,
|
|
53
55
|
]
|
|
54
56
|
for (const c of suite.cases) {
|
|
55
57
|
lines.push(
|