peachsim 0.1.527
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/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
// shared Maestro YAML playback and live-authoring implementation.
|
|
2
|
+
// discovers an rnx target when needed, then reuses the shared bridge action layer
|
|
3
|
+
|
|
4
|
+
import * as fs from 'fs'
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
6
|
+
import { tmpdir } from 'os'
|
|
7
|
+
import * as path from 'path'
|
|
8
|
+
import { scanDevServers } from '../../scripts/dev-server-scanner'
|
|
9
|
+
import { loadOptionalDemoApps } from '../../scripts/optional-demo-registry'
|
|
10
|
+
import { applyRNXConfigToUrl, type RNXConfig } from '../../src/config'
|
|
11
|
+
import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
|
|
12
|
+
import {
|
|
13
|
+
SootSimBridgeFlowRunner,
|
|
14
|
+
type SootSimFlowProfileResult,
|
|
15
|
+
type SootSimFlowTraceStep,
|
|
16
|
+
} from '../bridge-flow-runner'
|
|
17
|
+
import { readCurrentSimId, saveCurrentSimId } from '../current-sim'
|
|
18
|
+
import { findDesktopCompanion } from '../desktop-companion'
|
|
19
|
+
import { exportFlowSession } from '../flow-export'
|
|
20
|
+
import { parseFlowFile, validateFlowFile } from '../flow-file'
|
|
21
|
+
import { FlowLiveStatusReporter } from '../flow-live-status'
|
|
22
|
+
import { finalizeFlowSession, keepFlowCandidate, startFlowSession } from '../flow-session'
|
|
23
|
+
import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args'
|
|
24
|
+
import { rnxExit } from '../run-rnx'
|
|
25
|
+
import {
|
|
26
|
+
BRIDGE_VALUE_FLAGS,
|
|
27
|
+
createBridge,
|
|
28
|
+
createBridgeFromParsed,
|
|
29
|
+
parseBridgeCliArgs,
|
|
30
|
+
resolveBridgePortForPin,
|
|
31
|
+
} from '../ws-bridge'
|
|
32
|
+
import {
|
|
33
|
+
buildShellUrl,
|
|
34
|
+
closeSimsBulk,
|
|
35
|
+
resolveShellBaseUrlForBridgePort,
|
|
36
|
+
runOpenCommand,
|
|
37
|
+
terminatePlaywrightHostsForSims,
|
|
38
|
+
} from './control'
|
|
39
|
+
import { printShellPerfReport } from './inspect'
|
|
40
|
+
import { resolveDefaultUploadOrigin } from './upload'
|
|
41
|
+
import type { UploadResult } from './upload'
|
|
42
|
+
|
|
43
|
+
export { parseFlowFile, validateFlowFile } from '../flow-file'
|
|
44
|
+
|
|
45
|
+
// the complete flag vocabulary of `runFlowPlayback`. it is both the strip list
|
|
46
|
+
// handed to parseBridgeCliArgs and the vocabulary the unknown-argument check
|
|
47
|
+
// enforces, so a flag added to the runner and left out of here gets rejected
|
|
48
|
+
// rather than silently dropped.
|
|
49
|
+
const FLOW_BOOLEAN_FLAGS = [
|
|
50
|
+
'--record',
|
|
51
|
+
'--profile',
|
|
52
|
+
'--electron',
|
|
53
|
+
'--new',
|
|
54
|
+
'--preview',
|
|
55
|
+
'--preview-open',
|
|
56
|
+
'--headless',
|
|
57
|
+
'--headed',
|
|
58
|
+
'--proof',
|
|
59
|
+
'--no-shell',
|
|
60
|
+
'--shell-only',
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const FLOW_VALUE_FLAGS = [
|
|
64
|
+
'--app',
|
|
65
|
+
'--out',
|
|
66
|
+
'--base-url',
|
|
67
|
+
'--device',
|
|
68
|
+
'--theme',
|
|
69
|
+
'--slow',
|
|
70
|
+
'--tail-wait',
|
|
71
|
+
'--url',
|
|
72
|
+
'--replace',
|
|
73
|
+
'--remap',
|
|
74
|
+
'--screenshots',
|
|
75
|
+
'--screenshot-paths',
|
|
76
|
+
'--preview-origin',
|
|
77
|
+
'--preview-public-origin',
|
|
78
|
+
'--driver',
|
|
79
|
+
'--billing-kind',
|
|
80
|
+
'--owner',
|
|
81
|
+
'--repo',
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
// the strip lists parseBridgeCliArgs needs to see a flow argv the way the
|
|
85
|
+
// runner does. exported so a command that pre-parses argv before handing it to
|
|
86
|
+
// runFlowPlayback reads the same vocabulary instead of keeping its own copy.
|
|
87
|
+
export const FLOW_BRIDGE_ARG_OPTIONS = {
|
|
88
|
+
stripBooleanFlags: FLOW_BOOLEAN_FLAGS,
|
|
89
|
+
stripValueFlags: FLOW_VALUE_FLAGS,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// every flag that swallows the token after it on a flow argv. a caller picking
|
|
93
|
+
// the positional flow path out of raw argv has to skip those tokens, or
|
|
94
|
+
// `maestro test --sim a2 login.yaml` resolves `a2` as the flow file.
|
|
95
|
+
export const FLOW_ARG_VALUE_FLAGS = [...FLOW_VALUE_FLAGS, ...BRIDGE_VALUE_FLAGS]
|
|
96
|
+
|
|
97
|
+
export interface UnexpectedFlowArg {
|
|
98
|
+
arg: string
|
|
99
|
+
message: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// runFlowPlayback is the end of the line for argv: everything below it is built
|
|
103
|
+
// from named values, never forwarded raw. so an argument it does not read is
|
|
104
|
+
// read by nobody, and dropping one is how a run reports success while doing
|
|
105
|
+
// something other than what was asked — a stale build took --proof, discarded
|
|
106
|
+
// it, and produced non-proof screenshots under a proof-mode label.
|
|
107
|
+
//
|
|
108
|
+
// parseBridgeCliArgs already removes every flag this command understands and
|
|
109
|
+
// every flag the bridge owns, each with its value, so whatever it leaves behind
|
|
110
|
+
// past the flow path at args[0] is an argument nothing here consumes.
|
|
111
|
+
export function findUnexpectedFlowArgs(args: string[]): UnexpectedFlowArg[] {
|
|
112
|
+
const known = new Set([...FLOW_BOOLEAN_FLAGS, ...FLOW_VALUE_FLAGS])
|
|
113
|
+
const problems: UnexpectedFlowArg[] = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
|
|
114
|
+
.positional.slice(1)
|
|
115
|
+
.map((arg) => {
|
|
116
|
+
const eq = arg.indexOf('=')
|
|
117
|
+
const head = eq > 0 ? arg.slice(0, eq) : ''
|
|
118
|
+
if (known.has(head)) {
|
|
119
|
+
return {
|
|
120
|
+
arg,
|
|
121
|
+
message: `${head} takes its value as a separate argument: ${head} ${arg.slice(eq + 1)}`,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const globalTakesValue = GLOBAL_FLAG_TAKES_VALUE.get(arg)
|
|
125
|
+
if (globalTakesValue !== undefined) {
|
|
126
|
+
return {
|
|
127
|
+
arg,
|
|
128
|
+
message: `${arg} is a global rnx flag and goes before the command: rnx ${arg}${
|
|
129
|
+
globalTakesValue ? ' <value>' : ''
|
|
130
|
+
} <command>`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (arg.startsWith('-')) return { arg, message: `unknown flag: ${arg}` }
|
|
134
|
+
return { arg, message: `unexpected argument: ${arg}` }
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// a value flag left without a value keeps the runner's default instead of
|
|
138
|
+
// what the caller meant to set, which is the same silent substitution.
|
|
139
|
+
for (let i = 0; i < args.length; i++) {
|
|
140
|
+
if (!FLOW_VALUE_FLAGS.includes(args[i])) continue
|
|
141
|
+
const value = args[i + 1]
|
|
142
|
+
if (value === undefined || value.startsWith('-')) {
|
|
143
|
+
problems.push({ arg: args[i], message: `${args[i]} expects a value` })
|
|
144
|
+
}
|
|
145
|
+
i++
|
|
146
|
+
}
|
|
147
|
+
return problems
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let lastPreviewUploadResult: UploadResult | null = null
|
|
151
|
+
|
|
152
|
+
export function getLastFlowPreviewUploadResult(): UploadResult | null {
|
|
153
|
+
return lastPreviewUploadResult
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// per-step trace of the most recent playback, preview or not — callers
|
|
157
|
+
// Maestro callers fold this into their run registration.
|
|
158
|
+
let lastFlowTraceSteps: SootSimFlowTraceStep[] = []
|
|
159
|
+
|
|
160
|
+
export function getLastFlowTraceSteps(): SootSimFlowTraceStep[] {
|
|
161
|
+
return lastFlowTraceSteps
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// resolve a frontmatter `app:` value that may be a port, a URL, or a
|
|
165
|
+
// registered demo name (`bluesky`, `3pc`, `uniswap`, …). ports and URLs pass
|
|
166
|
+
// through untouched. a registered name also carries the launcher's preferred
|
|
167
|
+
// port and runtime config into the open command.
|
|
168
|
+
export async function resolveAppFrontmatterTarget(
|
|
169
|
+
raw: string,
|
|
170
|
+
): Promise<{ target: string; runtimeConfig?: RNXConfig }> {
|
|
171
|
+
if (!raw) return { target: '' }
|
|
172
|
+
const trimmed = raw.trim()
|
|
173
|
+
if (!trimmed) return { target: '' }
|
|
174
|
+
if (/^\d+$/.test(trimmed)) return { target: trimmed }
|
|
175
|
+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
176
|
+
return { target: trimmed }
|
|
177
|
+
}
|
|
178
|
+
// try a case-insensitive name match against the OPTIONAL internal demo
|
|
179
|
+
// registry (in-repo only; empty on a published install). unknown strings
|
|
180
|
+
// fall through unchanged so other targets (host:port, raw bundle URLs
|
|
181
|
+
// without a scheme) still reach the legacy resolver.
|
|
182
|
+
const apps = await loadOptionalDemoApps()
|
|
183
|
+
const match = apps.find((app) => app.name.toLowerCase() === trimmed.toLowerCase())
|
|
184
|
+
if (match) {
|
|
185
|
+
return {
|
|
186
|
+
target: String(match.preferredPort),
|
|
187
|
+
runtimeConfig: match.runtimeConfig,
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { target: trimmed }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// discover an rnx shell URL — prefer patched servers, fall back to any match.
|
|
194
|
+
// exported so `detox.ts` and `maestro.ts` can reuse the same discovery logic.
|
|
195
|
+
export async function discoverSootsimUrl(): Promise<string | null> {
|
|
196
|
+
const servers = await scanDevServers()
|
|
197
|
+
const patched = servers.find((s) => s.patched)
|
|
198
|
+
if (patched) return `http://localhost:${patched.port}/__soot/`
|
|
199
|
+
const any = servers[0]
|
|
200
|
+
if (any) return `http://localhost:${any.port}/__soot/`
|
|
201
|
+
return null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function runFlowPlayback(args: string[]): Promise<number> {
|
|
205
|
+
lastPreviewUploadResult = null
|
|
206
|
+
lastFlowTraceSteps = []
|
|
207
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
208
|
+
console.log(`
|
|
209
|
+
rnx maestro — author and run Maestro YAML flows
|
|
210
|
+
|
|
211
|
+
usage:
|
|
212
|
+
rnx maestro test <flow.yaml> [options]
|
|
213
|
+
rnx maestro start
|
|
214
|
+
rnx maestro keep
|
|
215
|
+
rnx maestro end [--output <path>] [--validate] [--video]
|
|
216
|
+
rnx maestro validate <path>
|
|
217
|
+
|
|
218
|
+
options:
|
|
219
|
+
--record record a webm artifact while the flow runs
|
|
220
|
+
--profile capture perf stats while the flow runs
|
|
221
|
+
--out <dir> output directory (default: /tmp/rnx-recordings)
|
|
222
|
+
--device <model> override the flow frontmatter device for this run
|
|
223
|
+
--theme <scheme> override the flow frontmatter theme (light or dark) for
|
|
224
|
+
this run
|
|
225
|
+
--slow <ms> delay between steps for natural pacing
|
|
226
|
+
--tail-wait <ms> hold the recording open after the last step so the
|
|
227
|
+
final UI state is captured (default: smart idle up to
|
|
228
|
+
6000ms for --preview, fixed 2000ms for --record, 0 otherwise)
|
|
229
|
+
--url <url> load this target before running the flow
|
|
230
|
+
--base-url <url> run the flow against the shell at this URL instead of
|
|
231
|
+
the discovered one, and poll that shell's own bridge.
|
|
232
|
+
errors when no live rnx world serves it, so a pinned run
|
|
233
|
+
can never quietly measure the default shell instead
|
|
234
|
+
--screenshot-paths <mode>
|
|
235
|
+
screenshot path mode: dir (default) or flow
|
|
236
|
+
--no-shell capture every screenshot tenant-only (no status bar,
|
|
237
|
+
keyboard, notification center, or other shell overlays).
|
|
238
|
+
use for clean demo asset sets. per-step \`layers:\`
|
|
239
|
+
still wins over this default.
|
|
240
|
+
--shell-only inverse — capture shell chrome only
|
|
241
|
+
--replace <m>=<f> replace a Metro module with a local file for this run
|
|
242
|
+
--new open a fresh sim before running the flow
|
|
243
|
+
--driver <id> when paired with --new, use playwright or electron;
|
|
244
|
+
browser sims default to isolated playwright
|
|
245
|
+
--headless pass headless=true to the launch driver (useful
|
|
246
|
+
with --driver playwright)
|
|
247
|
+
--headed force a fresh, VISIBLE window for this run (implies
|
|
248
|
+
--new; overrides --headless). use when you want to
|
|
249
|
+
watch the flow run live even if the current sim is
|
|
250
|
+
headless or stale
|
|
251
|
+
--proof deterministic-capture mode: pin the TextInput caret
|
|
252
|
+
visible instead of blinking, stop notifications
|
|
253
|
+
auto-dismissing, and leave the device corners unclipped
|
|
254
|
+
to match a native simctl framebuffer. a screen holding a
|
|
255
|
+
focused text field cannot reach three identical idle
|
|
256
|
+
frames without this, so its screenshot fails after 12
|
|
257
|
+
tries. use it for any run whose screenshots get
|
|
258
|
+
compared to anything
|
|
259
|
+
--electron prefer opening the target in the desktop companion
|
|
260
|
+
--sim <id> target a specific current sim
|
|
261
|
+
--validate replay the drafted flow before clearing it
|
|
262
|
+
--video replay and record the drafted flow before clearing it
|
|
263
|
+
--preview record events+video, upload, and print /preview/<id>
|
|
264
|
+
--preview-open open the uploaded preview link after a successful run
|
|
265
|
+
--preview-origin <url>
|
|
266
|
+
upload target for --preview (default: auto)
|
|
267
|
+
--preview-public-origin <url>
|
|
268
|
+
public link origin for --preview (default: rnxsim.com
|
|
269
|
+
for prod uploads)
|
|
270
|
+
--billing-kind test_run
|
|
271
|
+
meter the chained preview upload as a test run
|
|
272
|
+
--owner <org> associate preview upload with a linked org repo
|
|
273
|
+
--repo <repo> repo name for --owner; user must belong to the team
|
|
274
|
+
|
|
275
|
+
frontmatter (top of flow.yaml between --- markers):
|
|
276
|
+
app: 8081 load this target before the flow starts
|
|
277
|
+
device: iphone-16 set the live device model for this flow run
|
|
278
|
+
electron: true prefer the desktop companion sim for this flow
|
|
279
|
+
|
|
280
|
+
default runtime:
|
|
281
|
+
bridge-backed flow playback against the current sim
|
|
282
|
+
if no current sim exists, flow opens the discovered target first
|
|
283
|
+
recording and profiling both run on the same bridge-backed path
|
|
284
|
+
|
|
285
|
+
examples:
|
|
286
|
+
rnx maestro test login.yaml
|
|
287
|
+
rnx maestro test demo.yaml --url 8081 --record --slow 500 --out ./recordings
|
|
288
|
+
rnx maestro test demo.yaml --sim a2
|
|
289
|
+
rnx maestro start
|
|
290
|
+
rnx maestro keep
|
|
291
|
+
rnx maestro end --output ./.maestro/draft.yaml
|
|
292
|
+
rnx maestro end --output ./.maestro/draft.yaml --validate
|
|
293
|
+
rnx maestro end --output ./.maestro/draft.yaml --video
|
|
294
|
+
rnx maestro validate ./.maestro/draft.yaml
|
|
295
|
+
|
|
296
|
+
flow extension:
|
|
297
|
+
takeScreenshot: hero
|
|
298
|
+
takeScreenshot:
|
|
299
|
+
path: marketing/hero
|
|
300
|
+
withFrame: true
|
|
301
|
+
`)
|
|
302
|
+
rnxExit(0)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const unexpected = findUnexpectedFlowArgs(args)
|
|
306
|
+
if (unexpected.length > 0) {
|
|
307
|
+
for (const problem of unexpected) {
|
|
308
|
+
console.error(` error: ${problem.message}`)
|
|
309
|
+
}
|
|
310
|
+
console.error('\n run `rnx maestro --help` for the flags this command accepts')
|
|
311
|
+
rnxExit(1)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const flowPath = args[0]
|
|
315
|
+
if (!fs.existsSync(flowPath)) {
|
|
316
|
+
console.error(` error: ${flowPath} not found`)
|
|
317
|
+
rnxExit(1)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name)
|
|
321
|
+
const profile = args.includes('--profile')
|
|
322
|
+
// --headed forces a fresh visible window: implies --new and overrides
|
|
323
|
+
// --headless (you asked to watch it run).
|
|
324
|
+
const headed = args.includes('--headed')
|
|
325
|
+
// deterministic-capture mode. pins everything that would otherwise change
|
|
326
|
+
// between two frames of an idle screen, so `takeScreenshot` reaches its
|
|
327
|
+
// three identical frames on the first try instead of hunting for them.
|
|
328
|
+
const proofMode = args.includes('--proof')
|
|
329
|
+
const openInNewSim = args.includes('--new') || headed
|
|
330
|
+
const outDir = getFlag('--out') || '/tmp/rnx-recordings'
|
|
331
|
+
// when --screenshots isn't explicitly set, default to the --out directory.
|
|
332
|
+
// this matches what most users expect: one flag controls the whole output
|
|
333
|
+
// bundle (recording + screenshots) for a demo run.
|
|
334
|
+
// previously, screenshots silently landed in /tmp/rnx-flow even when
|
|
335
|
+
// --out was set, requiring a second --screenshots flag to redirect them.
|
|
336
|
+
const screenshotDir =
|
|
337
|
+
getFlag('--screenshots') || (getFlag('--out') ? outDir : '/tmp/rnx-flow')
|
|
338
|
+
const screenshotPathMode =
|
|
339
|
+
getFlag('--screenshot-paths') === 'flow' ? 'flow' : ('dir' as const)
|
|
340
|
+
// --no-shell makes every takeScreenshot in the flow capture tenant-only
|
|
341
|
+
// (no status bar, keyboard, notification center). useful for demo asset
|
|
342
|
+
// sets where shell chrome would obscure the app or stale overlays would
|
|
343
|
+
// pollute the result. per-step `layers:` still wins over this default.
|
|
344
|
+
const screenshotLayers: 'full' | 'tenant' | 'shell' | undefined = args.includes(
|
|
345
|
+
'--no-shell',
|
|
346
|
+
)
|
|
347
|
+
? 'tenant'
|
|
348
|
+
: args.includes('--shell-only')
|
|
349
|
+
? 'shell'
|
|
350
|
+
: undefined
|
|
351
|
+
const deviceOverride = getFlag('--device')?.trim() || ''
|
|
352
|
+
// --device and --theme are also global flags, but a global sets the settings
|
|
353
|
+
// store inside THIS process and the sim renders in the shell's browser page,
|
|
354
|
+
// so `rnx --theme light maestro test` reaches nothing. both have to be read
|
|
355
|
+
// here and pushed over the bridge, and they are read the same way so a caller
|
|
356
|
+
// that gets one right cannot get the other silently ignored.
|
|
357
|
+
const themeOverride = getFlag('--theme')?.trim() || ''
|
|
358
|
+
const slow = args.includes('--slow') ? +(getFlag('--slow') || '500') : 0
|
|
359
|
+
const cliElectron = args.includes('--electron')
|
|
360
|
+
const preview = args.includes('--preview')
|
|
361
|
+
// only --preview reads this (start event, entitlement origin, uploader).
|
|
362
|
+
// resolving it unconditionally probed https://contrast.localhost:3000 and
|
|
363
|
+
// charged every non-preview flow the probe's full 2s timeout whenever no
|
|
364
|
+
// local Contrast stack was listening.
|
|
365
|
+
const previewOrigin = preview
|
|
366
|
+
? await resolveDefaultUploadOrigin(getFlag('--preview-origin'))
|
|
367
|
+
: ''
|
|
368
|
+
const previewPublicOrigin = getFlag('--preview-public-origin')
|
|
369
|
+
const previewOpen = args.includes('--preview-open')
|
|
370
|
+
const previewOwner = getFlag('--owner')?.trim() || ''
|
|
371
|
+
const previewRepo = getFlag('--repo')?.trim() || ''
|
|
372
|
+
const previewBillingKind = getFlag('--billing-kind')?.trim() || ''
|
|
373
|
+
const previewRunScope = preview
|
|
374
|
+
? `maestro:${
|
|
375
|
+
process.env.GITHUB_RUN_ID
|
|
376
|
+
? `github:${process.env.GITHUB_RUN_ID}:attempt:${process.env.GITHUB_RUN_ATTEMPT || '1'}:job:${process.env.GITHUB_JOB || 'preview'}`
|
|
377
|
+
: `local:${randomUUID()}`
|
|
378
|
+
}:flow:${createHash('sha256')
|
|
379
|
+
.update(path.relative(process.cwd(), path.resolve(flowPath)))
|
|
380
|
+
.digest('hex')
|
|
381
|
+
.slice(0, 16)}`
|
|
382
|
+
: ''
|
|
383
|
+
if (previewBillingKind && previewBillingKind !== 'test_run') {
|
|
384
|
+
console.error(` error: invalid --billing-kind: ${previewBillingKind}`)
|
|
385
|
+
rnxExit(1)
|
|
386
|
+
}
|
|
387
|
+
const replaceArgs = args.flatMap((arg, index) =>
|
|
388
|
+
args[index - 1] === '--replace' ? ['--replace', arg] : [],
|
|
389
|
+
)
|
|
390
|
+
// forward --remap <host:port>=<host:port> to the open command so the sim
|
|
391
|
+
// boots with the guest-fetch network remap (config.network.remap).
|
|
392
|
+
const remapArgs = args.flatMap((arg, index) =>
|
|
393
|
+
args[index - 1] === '--remap' ? ['--remap', arg] : [],
|
|
394
|
+
)
|
|
395
|
+
// --preview implies --record: the preview page layers the mp4 over
|
|
396
|
+
// the canvas during scrub so the viewer sees smooth motion while the
|
|
397
|
+
// real app is catching up. upload chains the video in automatically.
|
|
398
|
+
const record = args.includes('--record') || preview
|
|
399
|
+
// hold the final UI state in the recording so viewers see the result
|
|
400
|
+
// of the flow, not just a frame mid-transition. short flows otherwise
|
|
401
|
+
// cut off ~600ms in. caller can override with --tail-wait <ms>.
|
|
402
|
+
const tailWaitMs = args.includes('--tail-wait')
|
|
403
|
+
? Math.max(0, +(getFlag('--tail-wait') || '2000'))
|
|
404
|
+
: record
|
|
405
|
+
? preview
|
|
406
|
+
? 6000
|
|
407
|
+
: 2000
|
|
408
|
+
: 0
|
|
409
|
+
|
|
410
|
+
// parse flow file (frontmatter + steps). `${...}` templates stay verbatim
|
|
411
|
+
// here — the runner interpolates per step at execution time.
|
|
412
|
+
const flowContent = fs.readFileSync(flowPath, 'utf8')
|
|
413
|
+
const { frontmatter, steps } = parseFlowFile(flowContent)
|
|
414
|
+
if (steps.length === 0) {
|
|
415
|
+
console.error(' error: flow file must contain a YAML array of steps')
|
|
416
|
+
rnxExit(1)
|
|
417
|
+
}
|
|
418
|
+
const targetDevice = deviceOverride || frontmatter.device || ''
|
|
419
|
+
const targetTheme = themeOverride || frontmatter.theme || ''
|
|
420
|
+
|
|
421
|
+
const wantElectron = cliElectron || frontmatter.electron === true
|
|
422
|
+
const rawTargetApp =
|
|
423
|
+
frontmatter.app === undefined || frontmatter.app === null
|
|
424
|
+
? ''
|
|
425
|
+
: typeof frontmatter.app === 'number'
|
|
426
|
+
? String(frontmatter.app)
|
|
427
|
+
: frontmatter.app
|
|
428
|
+
// accept `app: <demo-name>` (e.g. `app: 3pc`) in addition to ports/URLs.
|
|
429
|
+
// resolve via APPS — the launcher's preferredPort is the explicit target.
|
|
430
|
+
const targetAppResolution = await resolveAppFrontmatterTarget(rawTargetApp)
|
|
431
|
+
const targetApp = targetAppResolution.target
|
|
432
|
+
|
|
433
|
+
const parsedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
|
|
434
|
+
const url = getFlag('--app') || getFlag('--url') || ''
|
|
435
|
+
|
|
436
|
+
// --base-url pins the run to one shell, so a caller can build an engine from
|
|
437
|
+
// a known tree, serve it, and measure that build instead of whatever the
|
|
438
|
+
// discovered shell happens to hold. the page and the bridge have to stay in
|
|
439
|
+
// one world: without this retarget the flow would open the pinned shell and
|
|
440
|
+
// then poll the default world's bridge, waiting out its timeout with no sign
|
|
441
|
+
// of which shell it actually ran against.
|
|
442
|
+
const pinnedShellBaseUrl = getFlag('--base-url') || ''
|
|
443
|
+
if (pinnedShellBaseUrl) {
|
|
444
|
+
const pinned = resolveBridgePortForPin({
|
|
445
|
+
baseUrl: pinnedShellBaseUrl,
|
|
446
|
+
wsPort: parsedBridgeArgs.wsPort,
|
|
447
|
+
explicitPort: parsedBridgeArgs.explicitPort,
|
|
448
|
+
})
|
|
449
|
+
if (pinned.error !== undefined) {
|
|
450
|
+
console.error(` error: ${pinned.error}`)
|
|
451
|
+
rnxExit(1)
|
|
452
|
+
} else {
|
|
453
|
+
parsedBridgeArgs.wsPort = pinned.port
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (wantElectron && !findDesktopCompanion()) {
|
|
457
|
+
console.error(
|
|
458
|
+
' error: desktop companion not found. install or build it first with `bun run build:electron`',
|
|
459
|
+
)
|
|
460
|
+
rnxExit(1)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const mode = wantElectron ? 'desktop companion' : 'bridge'
|
|
464
|
+
const appLabel = targetApp ? ` | target: ${targetApp}` : ''
|
|
465
|
+
console.log(`
|
|
466
|
+
rnx maestro — ${path.basename(flowPath)}
|
|
467
|
+
${steps.length} steps | ${mode}${appLabel}${record ? ' | recording' : ''}${profile ? ' | profiling' : ''}${slow ? ` | ${slow}ms delay` : ''}
|
|
468
|
+
`)
|
|
469
|
+
|
|
470
|
+
let openTarget = url || targetApp || ''
|
|
471
|
+
const effectiveOpenInNewSim =
|
|
472
|
+
openInNewSim || (wantElectron && parsedBridgeArgs.simIdSource !== 'flag')
|
|
473
|
+
const driverFlag = getFlag('--driver') || ''
|
|
474
|
+
let closeFlowOwnedSimAfterRun = false
|
|
475
|
+
const ownedSimIds = new Set<string>()
|
|
476
|
+
|
|
477
|
+
// if we weren't told exactly where to go, and there's already a primary
|
|
478
|
+
// open tab, just reuse it. opening a fresh tab that auto-discovers some
|
|
479
|
+
// other dev server on the host is almost never what the user wants — they
|
|
480
|
+
// already pointed the current tab at the app they want to drive.
|
|
481
|
+
let reuseActivePrimary = false
|
|
482
|
+
if (!openTarget && !effectiveOpenInNewSim && parsedBridgeArgs.simIdSource === 'none') {
|
|
483
|
+
const probe = createBridgeFromParsed(parsedBridgeArgs)
|
|
484
|
+
try {
|
|
485
|
+
const sims = await probe.listSims()
|
|
486
|
+
const primary = sims.find((b) => b.isPrimary && b.readyState === 'open')
|
|
487
|
+
const firstOpen = sims.find((b) => b.readyState === 'open')
|
|
488
|
+
const active = primary ?? firstOpen
|
|
489
|
+
if (active) {
|
|
490
|
+
reuseActivePrimary = true
|
|
491
|
+
console.log(` reusing active sim: ${active.id}`)
|
|
492
|
+
}
|
|
493
|
+
} catch {
|
|
494
|
+
// bridge not reachable — fall through to discovery
|
|
495
|
+
} finally {
|
|
496
|
+
probe.close()
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (
|
|
501
|
+
!openTarget &&
|
|
502
|
+
!reuseActivePrimary &&
|
|
503
|
+
(effectiveOpenInNewSim || parsedBridgeArgs.simIdSource === 'none')
|
|
504
|
+
) {
|
|
505
|
+
const discovered = await discoverSootsimUrl()
|
|
506
|
+
if (!discovered) {
|
|
507
|
+
console.error(' error: no current sim and no rnx target found')
|
|
508
|
+
rnxExit(1)
|
|
509
|
+
}
|
|
510
|
+
openTarget = discovered
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (openTarget && targetDevice) {
|
|
514
|
+
const shellBaseUrl = applyRNXConfigToUrl(
|
|
515
|
+
pinnedShellBaseUrl || resolveShellBaseUrlForBridgePort(parsedBridgeArgs.wsPort),
|
|
516
|
+
targetAppResolution.runtimeConfig,
|
|
517
|
+
)
|
|
518
|
+
const url = new URL(
|
|
519
|
+
await buildShellUrl(openTarget, shellBaseUrl, { proof: proofMode }),
|
|
520
|
+
)
|
|
521
|
+
url.searchParams.set('device', targetDevice)
|
|
522
|
+
openTarget = url.toString()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (openTarget) {
|
|
526
|
+
const openArgs = [openTarget]
|
|
527
|
+
if (pinnedShellBaseUrl) openArgs.push('--base-url', pinnedShellBaseUrl)
|
|
528
|
+
openArgs.push(...replaceArgs)
|
|
529
|
+
openArgs.push(...remapArgs)
|
|
530
|
+
if (effectiveOpenInNewSim) {
|
|
531
|
+
openArgs.push('--new')
|
|
532
|
+
}
|
|
533
|
+
openArgs.push('--no-describe')
|
|
534
|
+
if (parsedBridgeArgs.simIdSource === 'flag' && parsedBridgeArgs.simId) {
|
|
535
|
+
openArgs.push('--sim', parsedBridgeArgs.simId)
|
|
536
|
+
}
|
|
537
|
+
if (driverFlag) {
|
|
538
|
+
openArgs.push('--driver', driverFlag)
|
|
539
|
+
}
|
|
540
|
+
if (args.includes('--headless') && !headed) {
|
|
541
|
+
openArgs.push('--headless')
|
|
542
|
+
}
|
|
543
|
+
if (proofMode) openArgs.push('--proof')
|
|
544
|
+
const opened = await runOpenCommand(openArgs, {
|
|
545
|
+
port: parsedBridgeArgs.wsPort,
|
|
546
|
+
timeoutMs: parsedBridgeArgs.commandTimeoutMs,
|
|
547
|
+
runtimeConfig: targetAppResolution.runtimeConfig,
|
|
548
|
+
})
|
|
549
|
+
// a flow that launched its own sim owns it, and owning it means closing it.
|
|
550
|
+
// the detached host's watchdog polls the *top* ancestor
|
|
551
|
+
// (getStableOwnerPid -> findTopAncestor), which is the shell, agent
|
|
552
|
+
// session, or runner daemon, not the run. that pid can live for days, so
|
|
553
|
+
// a launched sim nobody closes stays open and keeps its flow lock.
|
|
554
|
+
closeFlowOwnedSimAfterRun = opened?.launched === true || !!driverFlag
|
|
555
|
+
if (closeFlowOwnedSimAfterRun && opened) ownedSimIds.add(opened.simId)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const resolvedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
|
|
559
|
+
const pinnedSimId =
|
|
560
|
+
resolvedBridgeArgs.simIdSource === 'flag'
|
|
561
|
+
? resolvedBridgeArgs.simId
|
|
562
|
+
: readCurrentSimId() || resolvedBridgeArgs.simId
|
|
563
|
+
// carry the source through so the target-sim notice reads `via --sim`
|
|
564
|
+
// (not the misleading `primary fallback — no sim pinned`) when the flow
|
|
565
|
+
// was actually pinned with --sim or a `rnx use` selection.
|
|
566
|
+
const pinnedSimIdSource: typeof resolvedBridgeArgs.simIdSource =
|
|
567
|
+
resolvedBridgeArgs.simIdSource === 'flag' ? 'flag' : pinnedSimId ? 'saved' : 'none'
|
|
568
|
+
const bridge = createBridge(resolvedBridgeArgs.wsPort, {
|
|
569
|
+
commandTimeoutMs: resolvedBridgeArgs.commandTimeoutMs,
|
|
570
|
+
simId: pinnedSimId,
|
|
571
|
+
simIdSource: pinnedSimIdSource,
|
|
572
|
+
// the shell prints `lockedBy` over the device while the flow drives it.
|
|
573
|
+
// without a label that falls back to the cli identity key, which is an
|
|
574
|
+
// internal `ENV_VAR:value` string rather than something a viewer reads.
|
|
575
|
+
cliLabel: 'maestro test',
|
|
576
|
+
})
|
|
577
|
+
let driver: SootSimBridgeFlowRunner
|
|
578
|
+
const applySimSettings = async () => {
|
|
579
|
+
const statusBarTime = frontmatter.env?.RNX_STATUS_BAR_TIME
|
|
580
|
+
if (statusBarTime) {
|
|
581
|
+
if (!/^(?:[1-9]|1[0-2]):[0-5][0-9]$/.test(statusBarTime)) {
|
|
582
|
+
throw new Error('RNX_STATUS_BAR_TIME must be a 12-hour time such as 9:41')
|
|
583
|
+
}
|
|
584
|
+
await bridge.send({
|
|
585
|
+
type: 'evaluate',
|
|
586
|
+
simId: driver.simId,
|
|
587
|
+
code: `window.dispatchEvent(new CustomEvent('sootsim:statusBarOverride', { detail: { time: ${JSON.stringify(statusBarTime)} } }))`,
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
if (targetTheme) {
|
|
591
|
+
await bridge.send({
|
|
592
|
+
type: 'call',
|
|
593
|
+
simId: driver.simId,
|
|
594
|
+
path: 'SootSim.bridges.settings.set',
|
|
595
|
+
args: ['colorScheme', targetTheme],
|
|
596
|
+
})
|
|
597
|
+
}
|
|
598
|
+
if (!targetDevice) return false
|
|
599
|
+
return Boolean(
|
|
600
|
+
await bridge.send({
|
|
601
|
+
type: 'call',
|
|
602
|
+
simId: driver.simId,
|
|
603
|
+
path: 'SootSim.bridges.settings.set',
|
|
604
|
+
args: ['deviceModel', targetDevice],
|
|
605
|
+
}),
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
driver = new SootSimBridgeFlowRunner(bridge, {
|
|
609
|
+
screenshotDir,
|
|
610
|
+
flowDir: path.dirname(path.resolve(flowPath)),
|
|
611
|
+
screenshotPathMode,
|
|
612
|
+
screenshotLayers,
|
|
613
|
+
simId: pinnedSimId,
|
|
614
|
+
recordingOutputDir: record ? outDir : undefined,
|
|
615
|
+
recordingFormat: preview ? 'mp4' : 'webm',
|
|
616
|
+
// preview mode uploads to previewOrigin; entitlement check must
|
|
617
|
+
// talk to the same origin, not whatever the shared desktop sim
|
|
618
|
+
// happens to have cached.
|
|
619
|
+
billingOriginOverride: preview ? previewOrigin : undefined,
|
|
620
|
+
// any recording reached via a github installation token is a ci /
|
|
621
|
+
// pr-preview run, so allow it for preview uploads. local flow recording
|
|
622
|
+
// writes a file and matches `rnx record --mode video`.
|
|
623
|
+
allowGitHubRecording: preview,
|
|
624
|
+
requireRecordingEntitlement: preview,
|
|
625
|
+
// re-arm the event recorder after every launchApp-with-reload. the
|
|
626
|
+
// recorder lives in-page state and gets wiped by window.location.reload.
|
|
627
|
+
onAfterLaunch: async (simId) => {
|
|
628
|
+
if (simId) saveCurrentSimId(simId)
|
|
629
|
+
const deviceChanged = await applySimSettings()
|
|
630
|
+
if (deviceChanged) await driver.waitForTree(120000)
|
|
631
|
+
if (!preview) return
|
|
632
|
+
await bridge.send({
|
|
633
|
+
type: 'evaluate',
|
|
634
|
+
simId: driver.simId,
|
|
635
|
+
code: `(() => {
|
|
636
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
637
|
+
return r?.start?.() ?? false
|
|
638
|
+
})()`,
|
|
639
|
+
})
|
|
640
|
+
},
|
|
641
|
+
})
|
|
642
|
+
if (slow) driver.stepDelay = slow
|
|
643
|
+
// live step rail + pause gate in the sim devtools "test" tab, named by the
|
|
644
|
+
// flow file so the rail identifies what is running.
|
|
645
|
+
driver.liveStatus = new FlowLiveStatusReporter(
|
|
646
|
+
bridge,
|
|
647
|
+
path.basename(flowPath),
|
|
648
|
+
flowContent,
|
|
649
|
+
() => driver.simId,
|
|
650
|
+
)
|
|
651
|
+
// maestro default vars + flow-level `env:` frontmatter, defined into the
|
|
652
|
+
// flow's JS context before the first step (upstream withDefaultEnvVars +
|
|
653
|
+
// DefineVariablesCommand).
|
|
654
|
+
driver.js.putEnv('MAESTRO_FILENAME', path.basename(flowPath, path.extname(flowPath)))
|
|
655
|
+
if (frontmatter.env && typeof frontmatter.env === 'object') {
|
|
656
|
+
for (const [key, value] of Object.entries(frontmatter.env)) {
|
|
657
|
+
driver.js.putEnv(key, value)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let profileResult: SootSimFlowProfileResult | null = null
|
|
662
|
+
let videoPath: string | null = null
|
|
663
|
+
let videoDurationMs: number | null = null
|
|
664
|
+
let capturedEvents: Array<Record<string, any>> | null = null
|
|
665
|
+
let previewScreenshotPath: string | null = null
|
|
666
|
+
let failureBundleDir: string | null = null
|
|
667
|
+
let exitCode = 0
|
|
668
|
+
const startsWithLaunchApp = !!steps[0]?.launchApp
|
|
669
|
+
try {
|
|
670
|
+
if (!startsWithLaunchApp) {
|
|
671
|
+
await driver.waitForTree(120000)
|
|
672
|
+
}
|
|
673
|
+
const deviceChanged = await applySimSettings()
|
|
674
|
+
if (deviceChanged && !startsWithLaunchApp) await driver.waitForTree(120000)
|
|
675
|
+
// preview replay must reproduce the SAME initial conditions the agent
|
|
676
|
+
// explored from. the explore session runs on a fresh ephemeral profile
|
|
677
|
+
// (cold, logged-out), so its recorded steps assume that cold start —
|
|
678
|
+
// e.g. step 1 taps a dev/login button. but the replay tab reuses the
|
|
679
|
+
// explore tab's now-dirtied storage (persisted auth/session), so without
|
|
680
|
+
// a reset the app boots already-authenticated and the recorded login
|
|
681
|
+
// step targets an element that no longer exists — the flow hangs on
|
|
682
|
+
// step 1 ("made no progress … bridge or bundle probably hung"). flows
|
|
683
|
+
// that open with an explicit launchApp manage their own state; every
|
|
684
|
+
// other preview flow gets a clean cold launch here so the recording
|
|
685
|
+
// captures the same journey from the same starting point. app-agnostic,
|
|
686
|
+
// no per-repo configuration — the bot just figures it out.
|
|
687
|
+
// skip when the caller already established a clean cold state (e.g. the
|
|
688
|
+
// contrastbot run.sh pre-replay reset reopens an --ephemeral sim: fresh
|
|
689
|
+
// profile, logged-out, brought to readiness). a second clearState+reload
|
|
690
|
+
// here is redundant and triggers a sim-id rotation on the reload that
|
|
691
|
+
// routes the flow to a stale sim, hanging step 1 ("made no progress …
|
|
692
|
+
// bridge or bundle probably hung"). one reset is enough — the bot
|
|
693
|
+
// signals it did one via RNX_PREVIEW_RESET_DONE.
|
|
694
|
+
if (preview && !startsWithLaunchApp && !process.env.RNX_PREVIEW_RESET_DONE) {
|
|
695
|
+
await driver.launchApp({ clearState: true })
|
|
696
|
+
}
|
|
697
|
+
if (record) {
|
|
698
|
+
if (startsWithLaunchApp) {
|
|
699
|
+
driver.prepareRecording()
|
|
700
|
+
} else {
|
|
701
|
+
await driver.startRecording()
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (profile) {
|
|
705
|
+
await driver.startProfile()
|
|
706
|
+
}
|
|
707
|
+
if (preview) {
|
|
708
|
+
// start the preview event recorder on the tab so agent-driven taps
|
|
709
|
+
// (via sootsim:agentAction) land in the capture stream for replay.
|
|
710
|
+
// some shells expose the recorder via window.SootSim, others via
|
|
711
|
+
// the legacy alias only — try both.
|
|
712
|
+
const started = await bridge.send({
|
|
713
|
+
type: 'evaluate',
|
|
714
|
+
simId: driver.simId,
|
|
715
|
+
code: `(() => {
|
|
716
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
717
|
+
return r?.start?.() ?? false
|
|
718
|
+
})()`,
|
|
719
|
+
})
|
|
720
|
+
if (!started) {
|
|
721
|
+
console.warn(
|
|
722
|
+
' warn: eventRecorder.start() returned false — preview will upload bundle without replay events',
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
await driver.runFlow(steps)
|
|
727
|
+
if (profile) {
|
|
728
|
+
profileResult = await driver.stopProfile()
|
|
729
|
+
}
|
|
730
|
+
await driver.waitForRecordingTail({ maxMs: tailWaitMs, smart: preview })
|
|
731
|
+
if (record) {
|
|
732
|
+
videoPath = await driver.stopRecording()
|
|
733
|
+
videoDurationMs = driver.getLastRecordingDurationMs()
|
|
734
|
+
}
|
|
735
|
+
if (preview) {
|
|
736
|
+
capturedEvents = (await bridge.send({
|
|
737
|
+
type: 'evaluate',
|
|
738
|
+
simId: driver.simId,
|
|
739
|
+
code: `(() => {
|
|
740
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
741
|
+
return r?.stop?.() ?? []
|
|
742
|
+
})()`,
|
|
743
|
+
})) as Array<Record<string, any>>
|
|
744
|
+
console.log(` captured ${capturedEvents.length} replay events`)
|
|
745
|
+
previewScreenshotPath = path.join(screenshotDir, 'preview-final.png')
|
|
746
|
+
await driver.captureScreenshot(previewScreenshotPath)
|
|
747
|
+
console.log(` final screenshot: ${previewScreenshotPath}`)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const screenshotSteps = steps.filter(
|
|
751
|
+
(s: any) => s && typeof s === 'object' && 'takeScreenshot' in s,
|
|
752
|
+
).length
|
|
753
|
+
console.log(`\n + completed (${steps.length} steps)`)
|
|
754
|
+
if (screenshotSteps > 0) {
|
|
755
|
+
// surface where the assets actually landed so the user doesn't have to
|
|
756
|
+
// grep through verbose [flow] step output for paths
|
|
757
|
+
console.log(` screenshots: ${screenshotSteps} → ${screenshotDir}`)
|
|
758
|
+
}
|
|
759
|
+
if (profileResult) {
|
|
760
|
+
printFlowProfile(profileResult)
|
|
761
|
+
}
|
|
762
|
+
if (videoPath) {
|
|
763
|
+
console.log(` video: ${videoPath}`)
|
|
764
|
+
}
|
|
765
|
+
console.log()
|
|
766
|
+
} catch (err: any) {
|
|
767
|
+
if (profile && !profileResult) {
|
|
768
|
+
try {
|
|
769
|
+
profileResult = await driver.stopProfile()
|
|
770
|
+
} catch {
|
|
771
|
+
// ignore profiling teardown failures while already handling the main error
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (record && !videoPath) {
|
|
775
|
+
try {
|
|
776
|
+
videoPath = await driver.stopRecording()
|
|
777
|
+
videoDurationMs = driver.getLastRecordingDurationMs()
|
|
778
|
+
} catch (recordErr: any) {
|
|
779
|
+
console.warn(
|
|
780
|
+
` recording stop failed: ${recordErr?.message || String(recordErr)}`,
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (preview && !capturedEvents) {
|
|
785
|
+
try {
|
|
786
|
+
capturedEvents = (await bridge.send({
|
|
787
|
+
type: 'evaluate',
|
|
788
|
+
simId: driver.simId,
|
|
789
|
+
code: `(() => {
|
|
790
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
791
|
+
return r?.stop?.() ?? []
|
|
792
|
+
})()`,
|
|
793
|
+
})) as Array<Record<string, any>>
|
|
794
|
+
console.log(` captured ${capturedEvents.length} partial replay events`)
|
|
795
|
+
} catch (eventErr: any) {
|
|
796
|
+
console.warn(
|
|
797
|
+
` event recorder stop failed: ${eventErr?.message || String(eventErr)}`,
|
|
798
|
+
)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
console.error(`\n x failed: ${err.message}\n`)
|
|
802
|
+
if (profileResult) {
|
|
803
|
+
console.log(' partial profile:')
|
|
804
|
+
printFlowProfile(profileResult)
|
|
805
|
+
}
|
|
806
|
+
if (videoPath) {
|
|
807
|
+
console.log(` partial video: ${videoPath}`)
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
// rich per-failure bundle: screenshot + describe.json + a11y +
|
|
811
|
+
// tree + console + failed-requests. dir is named by the failing
|
|
812
|
+
// step so consecutive failing runs don't overwrite each other.
|
|
813
|
+
const failedStep = driver.lastFailedStep
|
|
814
|
+
const bundleName = failedStep
|
|
815
|
+
? `step-${String(failedStep.index + 1).padStart(2, '0')}-${failedStep.kind}`
|
|
816
|
+
: 'unstaged'
|
|
817
|
+
const bundleDir = path.join(screenshotDir, bundleName)
|
|
818
|
+
await driver.captureFailureBundle(bundleDir, {
|
|
819
|
+
error: err,
|
|
820
|
+
stepIndex: failedStep?.index,
|
|
821
|
+
stepKind: failedStep?.kind,
|
|
822
|
+
stepTarget: failedStep?.target,
|
|
823
|
+
})
|
|
824
|
+
failureBundleDir = bundleDir
|
|
825
|
+
console.log(` failure bundle: ${bundleDir}`)
|
|
826
|
+
console.log(
|
|
827
|
+
` (contents: screenshot.png, describe.json, a11y.txt, tree.txt, console.json, error.json)`,
|
|
828
|
+
)
|
|
829
|
+
} catch (bundleErr) {
|
|
830
|
+
// fall back to the bare screenshot if the full bundle blew up —
|
|
831
|
+
// at least the viewer has one thing to look at.
|
|
832
|
+
try {
|
|
833
|
+
const errShot = path.join(screenshotDir, 'error.png')
|
|
834
|
+
await driver.captureScreenshot(errShot)
|
|
835
|
+
console.log(` error screenshot: ${errShot}`)
|
|
836
|
+
} catch {}
|
|
837
|
+
console.log(
|
|
838
|
+
` (failure bundle capture failed: ${
|
|
839
|
+
bundleErr instanceof Error ? bundleErr.message : String(bundleErr)
|
|
840
|
+
})`,
|
|
841
|
+
)
|
|
842
|
+
}
|
|
843
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
844
|
+
errorsCommand: `rnx get errors 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
|
|
845
|
+
warningsCommand: `rnx get warnings 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
|
|
846
|
+
requestsCommand: `rnx get requests 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
|
|
847
|
+
})
|
|
848
|
+
exitCode = 1
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
await driver.liveStatus?.end(exitCode === 0 ? 'passed' : 'failed')
|
|
852
|
+
|
|
853
|
+
// under --preview, kick the captured events into the preview uploader so the viewer
|
|
854
|
+
// gets a real /preview/<id> with scrubbable replay. failed flows upload the
|
|
855
|
+
// partial recording when available so dashboard runs can link to the trace.
|
|
856
|
+
if (preview && (exitCode === 0 || videoPath || capturedEvents)) {
|
|
857
|
+
try {
|
|
858
|
+
const eventsJsonlGz = await prepareEventsFile(capturedEvents ?? [])
|
|
859
|
+
const uploadArgs = ['--origin', previewOrigin, '--events', eventsJsonlGz]
|
|
860
|
+
if (previewPublicOrigin) {
|
|
861
|
+
uploadArgs.push('--public-origin', previewPublicOrigin)
|
|
862
|
+
}
|
|
863
|
+
// chain the recorded mp4 so preview-page can layer it over the
|
|
864
|
+
// canvas during scrub for smooth motion while the real app
|
|
865
|
+
// catches up.
|
|
866
|
+
if (videoPath) uploadArgs.push('--video', videoPath)
|
|
867
|
+
if (previewScreenshotPath) {
|
|
868
|
+
uploadArgs.push('--screenshot', previewScreenshotPath)
|
|
869
|
+
}
|
|
870
|
+
if (videoPath && videoDurationMs && Number.isFinite(videoDurationMs)) {
|
|
871
|
+
uploadArgs.push('--video-duration-ms', String(Math.round(videoDurationMs)))
|
|
872
|
+
uploadArgs.push('--recorded-duration-ms', String(Math.round(videoDurationMs)))
|
|
873
|
+
}
|
|
874
|
+
const recordingStartedAtMs = driver.getLastRecordingStartedAtMs()
|
|
875
|
+
if (recordingStartedAtMs && Number.isFinite(recordingStartedAtMs)) {
|
|
876
|
+
uploadArgs.push(
|
|
877
|
+
'--recording-started-at-ms',
|
|
878
|
+
String(Math.round(recordingStartedAtMs)),
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
if (parsedBridgeArgs.wsPort) {
|
|
882
|
+
uploadArgs.push('--port', String(parsedBridgeArgs.wsPort))
|
|
883
|
+
}
|
|
884
|
+
if (driver.simId) {
|
|
885
|
+
uploadArgs.push('--sim', driver.simId)
|
|
886
|
+
}
|
|
887
|
+
if (previewOpen) uploadArgs.push('--open')
|
|
888
|
+
if (previewOwner) uploadArgs.push('--owner', previewOwner)
|
|
889
|
+
if (previewRepo) uploadArgs.push('--repo', previewRepo)
|
|
890
|
+
uploadArgs.push('--run-scope', previewRunScope)
|
|
891
|
+
if (previewBillingKind) {
|
|
892
|
+
uploadArgs.push('--billing-kind', previewBillingKind)
|
|
893
|
+
}
|
|
894
|
+
console.log(`\n preparing preview upload…`)
|
|
895
|
+
const flowTraceSteps = driver.getFlowTraceSteps()
|
|
896
|
+
if (flowTraceSteps.length > 0) {
|
|
897
|
+
uploadArgs.push(
|
|
898
|
+
'--timeline-events',
|
|
899
|
+
await prepareFlowStepTimelineFile(flowTraceSteps),
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
const snapshotManifest = await prepareFlowSnapshotManifest(
|
|
903
|
+
flowTraceSteps,
|
|
904
|
+
recordingStartedAtMs,
|
|
905
|
+
)
|
|
906
|
+
if (snapshotManifest) {
|
|
907
|
+
uploadArgs.push('--snapshot-manifest', snapshotManifest)
|
|
908
|
+
}
|
|
909
|
+
if (failureBundleDir) {
|
|
910
|
+
// failed runs ship the per-step debug bundle with the share so the
|
|
911
|
+
// dashboard's run row can show the failing screen + full context.
|
|
912
|
+
uploadArgs.push('--failure-bundle', failureBundleDir)
|
|
913
|
+
}
|
|
914
|
+
const frameStats = driver.getLastRecordingFrameStats()
|
|
915
|
+
if (frameStats) {
|
|
916
|
+
uploadArgs.push('--trace-frame-stats', await prepareFrameStatsFile(frameStats))
|
|
917
|
+
}
|
|
918
|
+
const { runUpload } = await import('./upload')
|
|
919
|
+
lastPreviewUploadResult = await runUpload(uploadArgs, {})
|
|
920
|
+
} catch (err: any) {
|
|
921
|
+
console.error(` preview upload failed: ${err?.message || err}`)
|
|
922
|
+
exitCode = 1
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
lastFlowTraceSteps = driver.getFlowTraceSteps()
|
|
927
|
+
|
|
928
|
+
bridge.close()
|
|
929
|
+
|
|
930
|
+
if (closeFlowOwnedSimAfterRun) {
|
|
931
|
+
if (driver.simId) ownedSimIds.add(driver.simId)
|
|
932
|
+
await closeFlowOwnedSims(parsedBridgeArgs.wsPort, parsedBridgeArgs.commandTimeoutMs, [
|
|
933
|
+
...ownedSimIds,
|
|
934
|
+
])
|
|
935
|
+
} else if (driver.simId && driver.simId !== pinnedSimId) {
|
|
936
|
+
// `launchApp clearState` rotates the sim id mid-flow (the shell reconnects
|
|
937
|
+
// under a fresh id). the runner tracks that in-memory, but a directory run
|
|
938
|
+
// (`rnx maestro test <dir>`) spawns the next flow fresh — it re-reads
|
|
939
|
+
// the saved sim id, which would still point at the pre-rotation sim and
|
|
940
|
+
// fail with "sim not responding / stale orphan". persist the rotated id so
|
|
941
|
+
// the next flow reuses the live sim. only when we're NOT closing it.
|
|
942
|
+
saveCurrentSimId(driver.simId)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return exitCode
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function closeFlowOwnedSims(
|
|
949
|
+
wsPort: number,
|
|
950
|
+
commandTimeoutMs: number,
|
|
951
|
+
simIds: string[],
|
|
952
|
+
): Promise<void> {
|
|
953
|
+
const ids = [...new Set(simIds.filter(Boolean))]
|
|
954
|
+
if (ids.length === 0) return
|
|
955
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
956
|
+
try {
|
|
957
|
+
const sims = await bridge.listSims()
|
|
958
|
+
const openIds = ids.filter((id) =>
|
|
959
|
+
sims.some((sim) => sim.id === id && sim.readyState === 'open'),
|
|
960
|
+
)
|
|
961
|
+
if (openIds.length === 0) return
|
|
962
|
+
const result = await closeSimsBulk(bridge, wsPort, commandTimeoutMs, openIds)
|
|
963
|
+
await terminatePlaywrightHostsForSims(sims, openIds)
|
|
964
|
+
if (result.closed.length > 0) {
|
|
965
|
+
console.log(` closed flow sim(s): ${result.closed.join(', ')}`)
|
|
966
|
+
}
|
|
967
|
+
if (result.remaining.length > 0) {
|
|
968
|
+
console.warn(
|
|
969
|
+
` warn: flow sim(s) still connected after close: ${result.remaining.join(', ')}`,
|
|
970
|
+
)
|
|
971
|
+
}
|
|
972
|
+
} catch (err) {
|
|
973
|
+
console.warn(
|
|
974
|
+
` warn: failed to close flow sim(s): ${
|
|
975
|
+
err instanceof Error ? err.message : String(err)
|
|
976
|
+
}`,
|
|
977
|
+
)
|
|
978
|
+
} finally {
|
|
979
|
+
bridge.close()
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// write the captured event stream to a gzipped jsonl file in a temp dir.
|
|
984
|
+
// returns the absolute path for the upload command to pick up.
|
|
985
|
+
async function prepareEventsFile(events: Array<Record<string, any>>): Promise<string> {
|
|
986
|
+
const { gzipSync } = await import('zlib')
|
|
987
|
+
const lines =
|
|
988
|
+
events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')
|
|
989
|
+
const gz = gzipSync(Buffer.from(lines, 'utf8'))
|
|
990
|
+
const outPath = path.join(tmpdir(), `rnx-events-${Date.now()}.jsonl.gz`)
|
|
991
|
+
fs.writeFileSync(outPath, gz)
|
|
992
|
+
console.log(` events: ${events.length} written to ${outPath} (${gz.length} bytes gz)`)
|
|
993
|
+
return outPath
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
async function prepareFlowStepTimelineFile(
|
|
997
|
+
steps: SootSimFlowTraceStep[],
|
|
998
|
+
): Promise<string> {
|
|
999
|
+
const { gzipSync } = await import('zlib')
|
|
1000
|
+
const events = steps.map((step, index) => ({
|
|
1001
|
+
schemaVersion: 1,
|
|
1002
|
+
t: step.endedAtMs,
|
|
1003
|
+
seq: 1_000_000 + index,
|
|
1004
|
+
context: 'host',
|
|
1005
|
+
kind: 'flow-step',
|
|
1006
|
+
id: `flow-step-${step.stepIndex}`,
|
|
1007
|
+
data: {
|
|
1008
|
+
stepIndex: step.stepIndex,
|
|
1009
|
+
stepNumber: step.stepIndex + 1,
|
|
1010
|
+
stepName: step.stepName,
|
|
1011
|
+
...(step.targetLabel ? { targetLabel: step.targetLabel } : {}),
|
|
1012
|
+
status: step.status,
|
|
1013
|
+
durationMs: step.durationMs,
|
|
1014
|
+
startedAtMs: step.startedAtMs,
|
|
1015
|
+
endedAtMs: step.endedAtMs,
|
|
1016
|
+
...(step.error ? { error: step.error } : {}),
|
|
1017
|
+
},
|
|
1018
|
+
}))
|
|
1019
|
+
const lines = events.map((event) => JSON.stringify(event)).join('\n') + '\n'
|
|
1020
|
+
const gz = gzipSync(Buffer.from(lines, 'utf8'))
|
|
1021
|
+
const outPath = path.join(tmpdir(), `rnx-flow-steps-${Date.now()}.jsonl.gz`)
|
|
1022
|
+
fs.writeFileSync(outPath, gz)
|
|
1023
|
+
console.log(
|
|
1024
|
+
` flow trace: ${events.length} step events written to ${outPath} (${gz.length} bytes gz)`,
|
|
1025
|
+
)
|
|
1026
|
+
return outPath
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function prepareFlowSnapshotManifest(
|
|
1030
|
+
steps: SootSimFlowTraceStep[],
|
|
1031
|
+
recordingStartedAtMs: number | null,
|
|
1032
|
+
): Promise<string | null> {
|
|
1033
|
+
if (!recordingStartedAtMs) return null
|
|
1034
|
+
const snapshots = steps.flatMap((step) =>
|
|
1035
|
+
step.screenshotPath
|
|
1036
|
+
? [
|
|
1037
|
+
{
|
|
1038
|
+
id: `flow-step-${step.stepIndex}`,
|
|
1039
|
+
label: step.targetLabel ?? `Step ${step.stepIndex + 1}`,
|
|
1040
|
+
kind: 'interaction',
|
|
1041
|
+
t: Math.max(0, step.endedAtMs - recordingStartedAtMs),
|
|
1042
|
+
path: step.screenshotPath,
|
|
1043
|
+
},
|
|
1044
|
+
]
|
|
1045
|
+
: [],
|
|
1046
|
+
)
|
|
1047
|
+
if (snapshots.length === 0) return null
|
|
1048
|
+
const outPath = path.join(tmpdir(), `rnx-flow-snapshots-${Date.now()}.json`)
|
|
1049
|
+
fs.writeFileSync(outPath, JSON.stringify(snapshots))
|
|
1050
|
+
console.log(` flow snapshots: ${snapshots.length} written to ${outPath}`)
|
|
1051
|
+
return outPath
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
async function prepareFrameStatsFile(frameStats: unknown): Promise<string> {
|
|
1055
|
+
const outPath = path.join(tmpdir(), `rnx-frame-stats-${Date.now()}.json`)
|
|
1056
|
+
fs.writeFileSync(outPath, JSON.stringify(frameStats))
|
|
1057
|
+
console.log(` frame stats: written to ${outPath}`)
|
|
1058
|
+
return outPath
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function runFlowSession(args: string[]) {
|
|
1062
|
+
const subcommand = args[0]
|
|
1063
|
+
const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name)
|
|
1064
|
+
|
|
1065
|
+
switch (subcommand) {
|
|
1066
|
+
case 'start': {
|
|
1067
|
+
const { path, state } = startFlowSession()
|
|
1068
|
+
console.log(` flow draft started`)
|
|
1069
|
+
console.log(` session: ${path}`)
|
|
1070
|
+
console.log(` steps: ${state.steps.length}`)
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
case 'keep': {
|
|
1075
|
+
const result = keepFlowCandidate()
|
|
1076
|
+
if (!result.active) {
|
|
1077
|
+
console.error(' no active flow draft — run `rnx maestro start` first')
|
|
1078
|
+
rnxExit(1)
|
|
1079
|
+
}
|
|
1080
|
+
if (!result.kept) {
|
|
1081
|
+
console.log(' no pending action to keep')
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
console.log(` kept: ${result.candidate.summary}`)
|
|
1085
|
+
console.log(` steps: ${result.stepCount}`)
|
|
1086
|
+
return
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
case 'end': {
|
|
1090
|
+
const outputPath =
|
|
1091
|
+
getFlag('--output') || (args[1] && !args[1].startsWith('-') ? args[1] : undefined)
|
|
1092
|
+
const validate = args.includes('--validate') || args.includes('--video')
|
|
1093
|
+
const video = args.includes('--video')
|
|
1094
|
+
// ending the draft commits the trailing pending action. the last
|
|
1095
|
+
// successful `do` is presumably one the user wants; without this,
|
|
1096
|
+
// `maestro start → do → maestro end` (no explicit keep) exported an empty
|
|
1097
|
+
// [] and silently dropped the work (F13-4).
|
|
1098
|
+
const autoKept = keepFlowCandidate()
|
|
1099
|
+
if (autoKept.active && autoKept.kept) {
|
|
1100
|
+
console.log(` auto-kept trailing action: ${autoKept.candidate.summary}`)
|
|
1101
|
+
}
|
|
1102
|
+
const result = exportFlowSession(
|
|
1103
|
+
outputPath || (validate ? createDraftValidationPath() : undefined),
|
|
1104
|
+
)
|
|
1105
|
+
if (!result.active) {
|
|
1106
|
+
console.error(' no active flow draft — run `rnx maestro start` first')
|
|
1107
|
+
rnxExit(1)
|
|
1108
|
+
}
|
|
1109
|
+
if (!result.valid) {
|
|
1110
|
+
console.error(' flow draft is not valid:')
|
|
1111
|
+
for (const issue of result.issues) console.error(` - ${issue}`)
|
|
1112
|
+
console.error(
|
|
1113
|
+
' draft preserved — keep at least one real interaction or run `rnx maestro start` to reset',
|
|
1114
|
+
)
|
|
1115
|
+
rnxExit(1)
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (validate) {
|
|
1119
|
+
const playbackArgs = buildFlowValidationArgs(args, result.outputPath)
|
|
1120
|
+
const exitCode = await runFlowPlayback(playbackArgs)
|
|
1121
|
+
if (exitCode !== 0) {
|
|
1122
|
+
console.error(
|
|
1123
|
+
'\n validation failed — draft preserved so you can keep iterating',
|
|
1124
|
+
)
|
|
1125
|
+
process.exitCode = exitCode
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
finalizeFlowSession()
|
|
1130
|
+
console.log(
|
|
1131
|
+
` flow draft validated (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`,
|
|
1132
|
+
)
|
|
1133
|
+
if (result.outputPath) {
|
|
1134
|
+
console.log(` saved: ${result.outputPath}`)
|
|
1135
|
+
}
|
|
1136
|
+
if (video) {
|
|
1137
|
+
console.log(' video: recorded during validation run')
|
|
1138
|
+
}
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
finalizeFlowSession()
|
|
1143
|
+
console.log(
|
|
1144
|
+
` flow draft ended (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`,
|
|
1145
|
+
)
|
|
1146
|
+
if (result.outputPath) {
|
|
1147
|
+
console.log(` saved: ${result.outputPath}`)
|
|
1148
|
+
console.log(` next: rnx maestro test ${result.outputPath} --record`)
|
|
1149
|
+
return
|
|
1150
|
+
}
|
|
1151
|
+
console.log('')
|
|
1152
|
+
process.stdout.write(result.yaml)
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
case 'validate': {
|
|
1157
|
+
const flowPath = args[1]
|
|
1158
|
+
if (!flowPath || flowPath.startsWith('-')) {
|
|
1159
|
+
console.error(' usage: rnx maestro validate <path>')
|
|
1160
|
+
rnxExit(1)
|
|
1161
|
+
}
|
|
1162
|
+
const issues = validateFlowFile(flowPath)
|
|
1163
|
+
if (issues.length > 0) {
|
|
1164
|
+
console.error(` x ${flowPath} failed validation:`)
|
|
1165
|
+
for (const issue of issues) console.error(` - ${issue}`)
|
|
1166
|
+
rnxExit(1)
|
|
1167
|
+
}
|
|
1168
|
+
console.log(` + ${flowPath} looks valid`)
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// the subcommand / flow-file path is the first positional arg, but a
|
|
1175
|
+
// `--sim <id>` pair can sit in front of it: bin.ts prepends the global
|
|
1176
|
+
// `--sim` to every command's args, and `rnx maestro --sim a2 start` is a
|
|
1177
|
+
// documented form. hoist a leading `--sim <value>` to the end so `args[0]`
|
|
1178
|
+
// is the real subcommand again (downstream getFlag / parseBridgeCliArgs
|
|
1179
|
+
// still find `--sim` wherever it lands). without this, `--sim 6a5 flow
|
|
1180
|
+
// start` parsed `--sim` as the flow path and failed `--sim not found`.
|
|
1181
|
+
export function hoistLeadingSimFlag(args: string[]): string[] {
|
|
1182
|
+
if (args[0] === '--sim' && args.length >= 2) {
|
|
1183
|
+
return [...args.slice(2), '--sim', args[1]]
|
|
1184
|
+
}
|
|
1185
|
+
return args
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export async function runMaestroAuthoring(rawArgs: string[]): Promise<number> {
|
|
1189
|
+
const args = hoistLeadingSimFlag(rawArgs)
|
|
1190
|
+
const subcommand = args[0]
|
|
1191
|
+
if (
|
|
1192
|
+
subcommand === 'start' ||
|
|
1193
|
+
subcommand === 'keep' ||
|
|
1194
|
+
subcommand === 'good' ||
|
|
1195
|
+
subcommand === 'end' ||
|
|
1196
|
+
subcommand === 'validate'
|
|
1197
|
+
) {
|
|
1198
|
+
await runFlowSession(args)
|
|
1199
|
+
return 0
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const exitCode = await runFlowPlayback(args)
|
|
1203
|
+
if (exitCode !== 0) {
|
|
1204
|
+
process.exitCode = exitCode
|
|
1205
|
+
}
|
|
1206
|
+
return exitCode
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function printFlowProfile(result: SootSimFlowProfileResult) {
|
|
1210
|
+
console.log('')
|
|
1211
|
+
printShellPerfReport(result)
|
|
1212
|
+
}
|
|
1213
|
+
function createDraftValidationPath() {
|
|
1214
|
+
return path.join(tmpdir(), `rnx-flow-draft-${Date.now()}.yaml`)
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function buildFlowValidationArgs(args: string[], outputPath: string | null) {
|
|
1218
|
+
if (!outputPath) {
|
|
1219
|
+
throw new Error('validated flow draft requires an output path')
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const nextArgs = [outputPath]
|
|
1223
|
+
for (let i = 1; i < args.length; i++) {
|
|
1224
|
+
const arg = args[i]
|
|
1225
|
+
if (arg === '--validate' || arg === '--video') continue
|
|
1226
|
+
if (arg === '--output') {
|
|
1227
|
+
i += 1
|
|
1228
|
+
continue
|
|
1229
|
+
}
|
|
1230
|
+
if (i === 1 && !arg.startsWith('-')) {
|
|
1231
|
+
continue
|
|
1232
|
+
}
|
|
1233
|
+
nextArgs.push(arg)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (!nextArgs.includes('--record') && args.includes('--video')) {
|
|
1237
|
+
nextArgs.push('--record')
|
|
1238
|
+
}
|
|
1239
|
+
return nextArgs
|
|
1240
|
+
}
|