rnxsim 0.1.313 → 0.1.314
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 +11 -10
- package/README.md +5 -0
- package/cli/app-config.ts +65 -0
- package/cli/app-fonts.ts +408 -0
- package/cli/app-project.ts +231 -0
- package/cli/app-splash.ts +185 -0
- package/cli/app-state-reset.ts +24 -0
- package/cli/auth.ts +155 -0
- package/cli/bin.ts +594 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2830 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/commands/agent-wrapper.ts +986 -0
- package/cli/commands/agent.ts +423 -0
- package/cli/commands/app-fonts.ts +98 -0
- package/cli/commands/assert.ts +541 -0
- package/cli/commands/auth.ts +59 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +87 -0
- package/cli/commands/config.ts +32 -0
- package/cli/commands/control.ts +2142 -0
- package/cli/commands/cpu-profile.ts +269 -0
- package/cli/commands/daemon-mac-app.ts +169 -0
- package/cli/commands/daemon.ts +874 -0
- package/cli/commands/debug.ts +719 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +197 -0
- package/cli/commands/detox.ts +385 -0
- package/cli/commands/device.ts +133 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/electron.ts +95 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1124 -0
- package/cli/commands/inspect/actions.ts +622 -0
- package/cli/commands/inspect/core.ts +2405 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +192 -0
- package/cli/commands/inspect/env.ts +23 -0
- package/cli/commands/inspect/find.ts +171 -0
- package/cli/commands/inspect/get-layout.ts +39 -0
- package/cli/commands/inspect/keyboard.ts +52 -0
- package/cli/commands/inspect/list.ts +58 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +82 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +22 -0
- package/cli/commands/inspect/settling.ts +158 -0
- package/cli/commands/inspect/shared.ts +353 -0
- package/cli/commands/inspect/sleep.ts +14 -0
- package/cli/commands/inspect/tree.ts +32 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +210 -0
- package/cli/commands/inspect/wait-idle.ts +24 -0
- package/cli/commands/inspect/wait-ready.ts +74 -0
- package/cli/commands/inspect/wait-selector.ts +54 -0
- package/cli/commands/inspect/wait.ts +31 -0
- package/cli/commands/inspect.ts +4519 -0
- package/cli/commands/install-desktop.ts +351 -0
- package/cli/commands/login.ts +331 -0
- package/cli/commands/logout.ts +31 -0
- package/cli/commands/maestro-generate.ts +361 -0
- package/cli/commands/maestro.ts +453 -0
- package/cli/commands/mode.ts +57 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +66 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/profile.ts +108 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1434 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +85 -0
- package/cli/commands/runtime.ts +351 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +143 -0
- package/cli/commands/screenshot-mode.ts +37 -0
- package/cli/commands/screenshot.ts +488 -0
- package/cli/commands/screenshots-capture.ts +607 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +168 -0
- package/cli/commands/setup.ts +545 -0
- package/cli/commands/shell-boolean-mode.ts +81 -0
- package/cli/commands/skills.ts +467 -0
- package/cli/commands/slides.ts +361 -0
- package/cli/commands/state.ts +87 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +54 -0
- package/cli/commands/three-mode.ts +763 -0
- package/cli/commands/timeline.ts +122 -0
- package/cli/commands/upgrade.ts +208 -0
- package/cli/commands/upload.ts +1225 -0
- package/cli/commands/version.ts +54 -0
- package/cli/commands/what-happened.ts +327 -0
- package/cli/current-sim.ts +204 -0
- package/cli/desktop-companion.ts +300 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +180 -0
- package/cli/drivers/playwright.ts +698 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +120 -0
- package/cli/flow-session.ts +187 -0
- package/cli/help.ts +80 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +216 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/maestro-js.ts +334 -0
- package/cli/open-url.ts +8 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +211 -0
- package/cli/prompt.ts +51 -0
- package/cli/recording-access.ts +107 -0
- package/cli/registry.ts +1 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/run-registry.ts +226 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/setup-repository.ts +187 -0
- package/cli/telemetry.ts +187 -0
- package/cli/ws-bridge.ts +798 -0
- package/dist-cli/bin.js +5 -5
- package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
- package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
- package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
- package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
- package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
- package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
- package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
- package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
- package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
- package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
- package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
- package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
- package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
- package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
- package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
- package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
- package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
- package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
- package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
- package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
- package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
- package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
- package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
- package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
- package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
- package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
- package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
- package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
- package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
- package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
- package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
- package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
- package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
- package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
- package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
- package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
- package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
- package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
- package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
- package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
- package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
- package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
- package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
- package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
- package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
- package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
- package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
- package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
- package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
- package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
- package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-WGGRJDRE.js} +1 -1
- package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
- package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
- package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
- package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
- package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
- package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
- package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
- package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
- package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
- package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
- package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
- package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
- package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
- package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
- package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
- package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
- package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
- package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
- package/dist-cli/chunks/{film-BJGTBYZB.js → film-GDMR33VO.js} +3 -3
- package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
- package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
- package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
- package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
- package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
- package/dist-cli/chunks/{login-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
- package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
- package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
- package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
- package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
- package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
- package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
- package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-TAI6DYZO.js} +2 -2
- package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
- package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
- package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
- package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
- package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
- package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
- package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
- package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
- package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
- package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-OYC57C4X.js} +6 -6
- package/dist-cli/chunks/store-32HSPZHI.js +4 -0
- package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
- package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
- package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-OEFNZRIP.js} +2 -2
- package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
- package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
- package/dist-cli/chunks/{web-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
- package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-YNHRFUDX.js} +3 -3
- 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.cjs +20 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +67 -28
- package/dist-lib/host/bridge-host.cjs +140 -12
- 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/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +2815 -40
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.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/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +2549 -2061
- package/dist-lib/sdk.mjs +2543 -2061
- package/dist-lib/skills.cjs +480 -280
- package/dist-lib/vite.cjs +1 -1
- package/package.json +8 -2
- package/src/bridge-constants.ts +3 -4
- package/src/bridge-contract.ts +251 -0
- package/src/connect.ts +83 -0
- package/src/disk-cleanup.ts +30 -0
- package/src/home-paths.ts +81 -38
- package/src/host/bridge-host.ts +134 -6
- package/src/index.ts +27 -1
- package/src/sdk.ts +8 -0
- package/src/sim-client.ts +660 -0
- package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
- package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
- package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
- package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
- package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
- package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
- package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
- package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
- package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
- package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
- package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
- package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
- package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
- package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
- package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
- package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
- package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
- package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
- package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
- package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
- package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
- package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
|
@@ -0,0 +1,1124 @@
|
|
|
1
|
+
// shared Maestro YAML playback and live-authoring implementation.
|
|
2
|
+
// discovers a sootsim 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 { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
|
|
11
|
+
import {
|
|
12
|
+
SootSimBridgeFlowRunner,
|
|
13
|
+
type SootSimFlowProfileResult,
|
|
14
|
+
type SootSimFlowTraceStep,
|
|
15
|
+
} from '../bridge-flow-runner'
|
|
16
|
+
import { readCurrentSimId, saveCurrentSimId } from '../current-sim'
|
|
17
|
+
import { findDesktopCompanion } from '../desktop-companion'
|
|
18
|
+
import { parseFlowFile, validateFlowFile } from '../flow-file'
|
|
19
|
+
import { FlowLiveStatusReporter } from '../flow-live-status'
|
|
20
|
+
import {
|
|
21
|
+
exportFlowSession,
|
|
22
|
+
finalizeFlowSession,
|
|
23
|
+
keepFlowCandidate,
|
|
24
|
+
startFlowSession,
|
|
25
|
+
} from '../flow-session'
|
|
26
|
+
import { flushCliTelemetry, trackCliEvent } from '../telemetry'
|
|
27
|
+
import { createBridge, createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
|
|
28
|
+
import {
|
|
29
|
+
buildShellUrl,
|
|
30
|
+
closeSimsBulk,
|
|
31
|
+
resolveShellBaseUrlForBridgePort,
|
|
32
|
+
runOpenCommand,
|
|
33
|
+
terminatePlaywrightHostsForSims,
|
|
34
|
+
} from './control'
|
|
35
|
+
import { printShellPerfReport } from './inspect'
|
|
36
|
+
import { resolveDefaultUploadOrigin } from './upload'
|
|
37
|
+
import type { UploadResult } from './upload'
|
|
38
|
+
|
|
39
|
+
export { parseFlowFile, validateFlowFile } from '../flow-file'
|
|
40
|
+
|
|
41
|
+
let lastPreviewUploadResult: UploadResult | null = null
|
|
42
|
+
|
|
43
|
+
export function getLastFlowPreviewUploadResult(): UploadResult | null {
|
|
44
|
+
return lastPreviewUploadResult
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// per-step trace of the most recent playback, preview or not — callers
|
|
48
|
+
// Maestro callers fold this into their run registration.
|
|
49
|
+
let lastFlowTraceSteps: SootSimFlowTraceStep[] = []
|
|
50
|
+
|
|
51
|
+
export function getLastFlowTraceSteps(): SootSimFlowTraceStep[] {
|
|
52
|
+
return lastFlowTraceSteps
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// resolve a frontmatter `app:` value that may be a port, a URL, or a
|
|
56
|
+
// registered demo name (`bluesky`, `3pc`, `uniswap`, …). returns the
|
|
57
|
+
// raw string for ports/URLs untouched; for a registered name, returns
|
|
58
|
+
// the launcher's preferredPort.
|
|
59
|
+
async function resolveAppFrontmatterTarget(raw: string): Promise<string> {
|
|
60
|
+
if (!raw) return ''
|
|
61
|
+
const trimmed = raw.trim()
|
|
62
|
+
if (!trimmed) return ''
|
|
63
|
+
if (/^\d+$/.test(trimmed)) return trimmed
|
|
64
|
+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed
|
|
65
|
+
// try a case-insensitive name match against the OPTIONAL internal demo
|
|
66
|
+
// registry (in-repo only; empty on a published install). unknown strings
|
|
67
|
+
// fall through unchanged so other targets (host:port, raw bundle URLs
|
|
68
|
+
// without a scheme) still reach the legacy resolver.
|
|
69
|
+
const apps = await loadOptionalDemoApps()
|
|
70
|
+
const match = apps.find((app) => app.name.toLowerCase() === trimmed.toLowerCase())
|
|
71
|
+
if (match) return String(match.preferredPort)
|
|
72
|
+
return trimmed
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// discover a sootsim shell URL — prefer patched servers, fall back to any match.
|
|
76
|
+
// exported so `detox.ts` and `maestro.ts` can reuse the same discovery logic.
|
|
77
|
+
export async function discoverSootsimUrl(): Promise<string | null> {
|
|
78
|
+
const servers = await scanDevServers()
|
|
79
|
+
const patched = servers.find((s) => s.patched)
|
|
80
|
+
if (patched) return `http://localhost:${patched.port}/__soot/`
|
|
81
|
+
const any = servers[0]
|
|
82
|
+
if (any) return `http://localhost:${any.port}/__soot/`
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runFlowPlayback(args: string[]): Promise<number> {
|
|
87
|
+
lastPreviewUploadResult = null
|
|
88
|
+
lastFlowTraceSteps = []
|
|
89
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
90
|
+
console.log(`
|
|
91
|
+
sootsim maestro — author and run Maestro YAML flows
|
|
92
|
+
|
|
93
|
+
usage:
|
|
94
|
+
sootsim maestro test <flow.yaml> [options]
|
|
95
|
+
sootsim maestro start
|
|
96
|
+
sootsim maestro keep
|
|
97
|
+
sootsim maestro end [--output <path>] [--validate] [--video]
|
|
98
|
+
sootsim maestro validate <path>
|
|
99
|
+
|
|
100
|
+
options:
|
|
101
|
+
--record record a webm artifact while the flow runs
|
|
102
|
+
--profile capture perf stats while the flow runs
|
|
103
|
+
--out <dir> output directory (default: /tmp/sootsim-recordings)
|
|
104
|
+
--device <model> override the flow frontmatter device for this run
|
|
105
|
+
--slow <ms> delay between steps for natural pacing
|
|
106
|
+
--tail-wait <ms> hold the recording open after the last step so the
|
|
107
|
+
final UI state is captured (default: smart idle up to
|
|
108
|
+
6000ms for --preview, fixed 2000ms for --record, 0 otherwise)
|
|
109
|
+
--url <url> load this target before running the flow
|
|
110
|
+
--screenshot-paths <mode>
|
|
111
|
+
screenshot path mode: dir (default) or flow
|
|
112
|
+
--no-shell capture every screenshot tenant-only (no status bar,
|
|
113
|
+
keyboard, notification center, or other shell overlays).
|
|
114
|
+
use for clean demo asset sets. per-step \`layers:\`
|
|
115
|
+
still wins over this default.
|
|
116
|
+
--shell-only inverse — capture shell chrome only
|
|
117
|
+
--replace <m>=<f> replace a Metro module with a local file for this run
|
|
118
|
+
--new open a fresh sim before running the flow
|
|
119
|
+
--driver <id> when paired with --new, use playwright or electron;
|
|
120
|
+
browser sims default to isolated playwright
|
|
121
|
+
--headless pass headless=true to the launch driver (useful
|
|
122
|
+
with --driver playwright)
|
|
123
|
+
--headed force a fresh, VISIBLE window for this run (implies
|
|
124
|
+
--new; overrides --headless). use when you want to
|
|
125
|
+
watch the flow run live even if the current sim is
|
|
126
|
+
headless or stale
|
|
127
|
+
--electron prefer opening the target in the desktop companion
|
|
128
|
+
--sim <id> target a specific current sim
|
|
129
|
+
--validate replay the drafted flow before clearing it
|
|
130
|
+
--video replay and record the drafted flow before clearing it
|
|
131
|
+
--preview record events+video, upload, and print /preview/<id>
|
|
132
|
+
--preview-open open the uploaded preview link after a successful run
|
|
133
|
+
--preview-origin <url>
|
|
134
|
+
upload target for --preview (default: auto)
|
|
135
|
+
--preview-public-origin <url>
|
|
136
|
+
public link origin for --preview (default: sootsim.com
|
|
137
|
+
for prod uploads)
|
|
138
|
+
--billing-kind test_run
|
|
139
|
+
meter the chained preview upload as a test run
|
|
140
|
+
--owner <org> associate preview upload with a linked org repo
|
|
141
|
+
--repo <repo> repo name for --owner; user must belong to the team
|
|
142
|
+
|
|
143
|
+
frontmatter (top of flow.yaml between --- markers):
|
|
144
|
+
app: 8081 load this target before the flow starts
|
|
145
|
+
device: iphone-14 set the live device model for this flow run
|
|
146
|
+
electron: true prefer the desktop companion sim for this flow
|
|
147
|
+
|
|
148
|
+
default runtime:
|
|
149
|
+
bridge-backed flow playback against the current sim
|
|
150
|
+
if no current sim exists, flow opens the discovered target first
|
|
151
|
+
recording and profiling both run on the same bridge-backed path
|
|
152
|
+
|
|
153
|
+
examples:
|
|
154
|
+
sootsim maestro test login.yaml
|
|
155
|
+
sootsim maestro test demo.yaml --url 8081 --record --slow 500 --out ./recordings
|
|
156
|
+
sootsim maestro test demo.yaml --sim a2
|
|
157
|
+
sootsim maestro start
|
|
158
|
+
sootsim maestro keep
|
|
159
|
+
sootsim maestro end --output ./.maestro/draft.yaml
|
|
160
|
+
sootsim maestro end --output ./.maestro/draft.yaml --validate
|
|
161
|
+
sootsim maestro end --output ./.maestro/draft.yaml --video
|
|
162
|
+
sootsim maestro validate ./.maestro/draft.yaml
|
|
163
|
+
|
|
164
|
+
flow extension:
|
|
165
|
+
takeScreenshot: hero
|
|
166
|
+
takeScreenshot:
|
|
167
|
+
path: marketing/hero
|
|
168
|
+
withFrame: true
|
|
169
|
+
`)
|
|
170
|
+
process.exit(0)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const flowPath = args[0]
|
|
174
|
+
if (!fs.existsSync(flowPath)) {
|
|
175
|
+
console.error(` error: ${flowPath} not found`)
|
|
176
|
+
process.exit(1)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name)
|
|
180
|
+
const profile = args.includes('--profile')
|
|
181
|
+
// --headed forces a fresh visible window: implies --new and overrides
|
|
182
|
+
// --headless (you asked to watch it run).
|
|
183
|
+
const headed = args.includes('--headed')
|
|
184
|
+
const openInNewSim = args.includes('--new') || headed
|
|
185
|
+
const outDir = getFlag('--out') || '/tmp/sootsim-recordings'
|
|
186
|
+
// when --screenshots isn't explicitly set, default to the --out directory.
|
|
187
|
+
// this matches what most users expect: one flag controls the whole output
|
|
188
|
+
// bundle (recording + screenshots) for a demo run.
|
|
189
|
+
// previously, screenshots silently landed in /tmp/sootsim-flow even when
|
|
190
|
+
// --out was set, requiring a second --screenshots flag to redirect them.
|
|
191
|
+
const screenshotDir =
|
|
192
|
+
getFlag('--screenshots') || (getFlag('--out') ? outDir : '/tmp/sootsim-flow')
|
|
193
|
+
const screenshotPathMode =
|
|
194
|
+
getFlag('--screenshot-paths') === 'flow' ? 'flow' : ('dir' as const)
|
|
195
|
+
// --no-shell makes every takeScreenshot in the flow capture tenant-only
|
|
196
|
+
// (no status bar, keyboard, notification center). useful for demo asset
|
|
197
|
+
// sets where shell chrome would obscure the app or stale overlays would
|
|
198
|
+
// pollute the result. per-step `layers:` still wins over this default.
|
|
199
|
+
const screenshotLayers: 'full' | 'tenant' | 'shell' | undefined = args.includes(
|
|
200
|
+
'--no-shell',
|
|
201
|
+
)
|
|
202
|
+
? 'tenant'
|
|
203
|
+
: args.includes('--shell-only')
|
|
204
|
+
? 'shell'
|
|
205
|
+
: undefined
|
|
206
|
+
const deviceOverride = getFlag('--device')?.trim() || ''
|
|
207
|
+
const slow = args.includes('--slow') ? +(getFlag('--slow') || '500') : 0
|
|
208
|
+
const cliElectron = args.includes('--electron')
|
|
209
|
+
const preview = args.includes('--preview')
|
|
210
|
+
// only --preview reads this (start event, entitlement origin, uploader).
|
|
211
|
+
// resolving it unconditionally probed https://contrast.localhost:3000 and
|
|
212
|
+
// charged every non-preview flow the probe's full 2s timeout whenever no
|
|
213
|
+
// local Contrast stack was listening.
|
|
214
|
+
const previewOrigin = preview
|
|
215
|
+
? await resolveDefaultUploadOrigin(getFlag('--preview-origin'))
|
|
216
|
+
: ''
|
|
217
|
+
const previewPublicOrigin = getFlag('--preview-public-origin')
|
|
218
|
+
const previewOpen = args.includes('--preview-open')
|
|
219
|
+
const previewOwner = getFlag('--owner')?.trim() || ''
|
|
220
|
+
const previewRepo = getFlag('--repo')?.trim() || ''
|
|
221
|
+
const previewBillingKind = getFlag('--billing-kind')?.trim() || ''
|
|
222
|
+
const previewRunScope = preview
|
|
223
|
+
? `maestro:${
|
|
224
|
+
process.env.GITHUB_RUN_ID
|
|
225
|
+
? `github:${process.env.GITHUB_RUN_ID}:attempt:${process.env.GITHUB_RUN_ATTEMPT || '1'}:job:${process.env.GITHUB_JOB || 'preview'}`
|
|
226
|
+
: `local:${randomUUID()}`
|
|
227
|
+
}:flow:${createHash('sha256')
|
|
228
|
+
.update(path.relative(process.cwd(), path.resolve(flowPath)))
|
|
229
|
+
.digest('hex')
|
|
230
|
+
.slice(0, 16)}`
|
|
231
|
+
: ''
|
|
232
|
+
if (previewBillingKind && previewBillingKind !== 'test_run') {
|
|
233
|
+
console.error(` error: invalid --billing-kind: ${previewBillingKind}`)
|
|
234
|
+
process.exit(1)
|
|
235
|
+
}
|
|
236
|
+
const replaceArgs = args.flatMap((arg, index) =>
|
|
237
|
+
args[index - 1] === '--replace' ? ['--replace', arg] : [],
|
|
238
|
+
)
|
|
239
|
+
// forward --remap <host:port>=<host:port> to the open command so the sim
|
|
240
|
+
// boots with the guest-fetch network remap (config.network.remap).
|
|
241
|
+
const remapArgs = args.flatMap((arg, index) =>
|
|
242
|
+
args[index - 1] === '--remap' ? ['--remap', arg] : [],
|
|
243
|
+
)
|
|
244
|
+
// --preview implies --record: the preview page layers the mp4 over
|
|
245
|
+
// the canvas during scrub so the viewer sees smooth motion while the
|
|
246
|
+
// real app is catching up. upload chains the video in automatically.
|
|
247
|
+
const record = args.includes('--record') || preview
|
|
248
|
+
// hold the final UI state in the recording so viewers see the result
|
|
249
|
+
// of the flow, not just a frame mid-transition. short flows otherwise
|
|
250
|
+
// cut off ~600ms in. caller can override with --tail-wait <ms>.
|
|
251
|
+
const tailWaitMs = args.includes('--tail-wait')
|
|
252
|
+
? Math.max(0, +(getFlag('--tail-wait') || '2000'))
|
|
253
|
+
: record
|
|
254
|
+
? preview
|
|
255
|
+
? 6000
|
|
256
|
+
: 2000
|
|
257
|
+
: 0
|
|
258
|
+
|
|
259
|
+
// parse flow file (frontmatter + steps). `${...}` templates stay verbatim
|
|
260
|
+
// here — the runner interpolates per step at execution time.
|
|
261
|
+
const flowContent = fs.readFileSync(flowPath, 'utf8')
|
|
262
|
+
const { frontmatter, steps } = parseFlowFile(flowContent)
|
|
263
|
+
if (steps.length === 0) {
|
|
264
|
+
console.error(' error: flow file must contain a YAML array of steps')
|
|
265
|
+
process.exit(1)
|
|
266
|
+
}
|
|
267
|
+
const targetDevice = deviceOverride || frontmatter.device || ''
|
|
268
|
+
const targetTheme = frontmatter.theme || ''
|
|
269
|
+
|
|
270
|
+
const wantElectron = cliElectron || frontmatter.electron === true
|
|
271
|
+
const rawTargetApp =
|
|
272
|
+
frontmatter.app === undefined || frontmatter.app === null
|
|
273
|
+
? ''
|
|
274
|
+
: typeof frontmatter.app === 'number'
|
|
275
|
+
? String(frontmatter.app)
|
|
276
|
+
: frontmatter.app
|
|
277
|
+
// accept `app: <demo-name>` (e.g. `app: 3pc`) in addition to ports/URLs.
|
|
278
|
+
// resolve via APPS — the launcher's preferredPort is the explicit target.
|
|
279
|
+
const targetApp = await resolveAppFrontmatterTarget(rawTargetApp)
|
|
280
|
+
|
|
281
|
+
const bridgeArgOptions = {
|
|
282
|
+
stripBooleanFlags: [
|
|
283
|
+
'--record',
|
|
284
|
+
'--profile',
|
|
285
|
+
'--electron',
|
|
286
|
+
'--new',
|
|
287
|
+
'--preview',
|
|
288
|
+
'--preview-open',
|
|
289
|
+
'--headless',
|
|
290
|
+
'--headed',
|
|
291
|
+
],
|
|
292
|
+
stripValueFlags: [
|
|
293
|
+
'--out',
|
|
294
|
+
'--device',
|
|
295
|
+
'--slow',
|
|
296
|
+
'--tail-wait',
|
|
297
|
+
'--url',
|
|
298
|
+
'--replace',
|
|
299
|
+
'--remap',
|
|
300
|
+
'--screenshots',
|
|
301
|
+
'--screenshot-paths',
|
|
302
|
+
'--preview-origin',
|
|
303
|
+
'--preview-public-origin',
|
|
304
|
+
'--driver',
|
|
305
|
+
'--billing-kind',
|
|
306
|
+
'--owner',
|
|
307
|
+
'--repo',
|
|
308
|
+
],
|
|
309
|
+
}
|
|
310
|
+
const parsedBridgeArgs = parseBridgeCliArgs(args, bridgeArgOptions)
|
|
311
|
+
const url = getFlag('--url') || ''
|
|
312
|
+
if (wantElectron && !findDesktopCompanion()) {
|
|
313
|
+
console.error(
|
|
314
|
+
' error: desktop companion not found. install or build it first with `bun run build:electron`',
|
|
315
|
+
)
|
|
316
|
+
process.exit(1)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const mode = wantElectron ? 'desktop companion' : 'bridge'
|
|
320
|
+
const appLabel = targetApp ? ` | target: ${targetApp}` : ''
|
|
321
|
+
console.log(`
|
|
322
|
+
sootsim maestro — ${path.basename(flowPath)}
|
|
323
|
+
${steps.length} steps | ${mode}${appLabel}${record ? ' | recording' : ''}${profile ? ' | profiling' : ''}${slow ? ` | ${slow}ms delay` : ''}
|
|
324
|
+
`)
|
|
325
|
+
|
|
326
|
+
// only meter flows that will result in a cloud preview upload. plain
|
|
327
|
+
// flow playback + local recording stay completely unmetered per the
|
|
328
|
+
// free/pro/enterprise beta plan (local usage is free). server emits
|
|
329
|
+
// the authoritative preview_upload events when the chained upload
|
|
330
|
+
// actually lands.
|
|
331
|
+
if (preview) {
|
|
332
|
+
trackCliEvent({
|
|
333
|
+
event: 'preview_flow_started',
|
|
334
|
+
properties: {
|
|
335
|
+
flowName: path.basename(flowPath),
|
|
336
|
+
stepCount: steps.length,
|
|
337
|
+
mode,
|
|
338
|
+
electron: wantElectron,
|
|
339
|
+
record,
|
|
340
|
+
profile,
|
|
341
|
+
previewOrigin,
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let openTarget = url || targetApp || ''
|
|
347
|
+
const effectiveOpenInNewSim =
|
|
348
|
+
openInNewSim || (wantElectron && parsedBridgeArgs.simIdSource !== 'flag')
|
|
349
|
+
const driverFlag = getFlag('--driver') || ''
|
|
350
|
+
let closeManagedDriverSimAfterRun = false
|
|
351
|
+
const ownedSimIds = new Set<string>()
|
|
352
|
+
|
|
353
|
+
// if we weren't told exactly where to go, and there's already a primary
|
|
354
|
+
// open tab, just reuse it. opening a fresh tab that auto-discovers some
|
|
355
|
+
// other dev server on the host is almost never what the user wants — they
|
|
356
|
+
// already pointed the current tab at the app they want to drive.
|
|
357
|
+
let reuseActivePrimary = false
|
|
358
|
+
if (!openTarget && !effectiveOpenInNewSim && parsedBridgeArgs.simIdSource === 'none') {
|
|
359
|
+
const probe = createBridgeFromParsed(parsedBridgeArgs)
|
|
360
|
+
try {
|
|
361
|
+
const sims = await probe.listSims()
|
|
362
|
+
const primary = sims.find((b) => b.isPrimary && b.readyState === 'open')
|
|
363
|
+
const firstOpen = sims.find((b) => b.readyState === 'open')
|
|
364
|
+
const active = primary ?? firstOpen
|
|
365
|
+
if (active) {
|
|
366
|
+
reuseActivePrimary = true
|
|
367
|
+
console.log(` reusing active sim: ${active.id}`)
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
// bridge not reachable — fall through to discovery
|
|
371
|
+
} finally {
|
|
372
|
+
probe.close()
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (
|
|
377
|
+
!openTarget &&
|
|
378
|
+
!reuseActivePrimary &&
|
|
379
|
+
(effectiveOpenInNewSim || parsedBridgeArgs.simIdSource === 'none')
|
|
380
|
+
) {
|
|
381
|
+
const discovered = await discoverSootsimUrl()
|
|
382
|
+
if (!discovered) {
|
|
383
|
+
console.error(' error: no current sim and no sootsim target found')
|
|
384
|
+
await flushCliTelemetry()
|
|
385
|
+
process.exit(1)
|
|
386
|
+
}
|
|
387
|
+
openTarget = discovered
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (openTarget && targetDevice) {
|
|
391
|
+
// build against the shell that owns the bridge port this run will poll.
|
|
392
|
+
// resolve the shell from the already-selected bridge port so the flow
|
|
393
|
+
// cannot drive a sim in another live development stack.
|
|
394
|
+
const url = new URL(
|
|
395
|
+
await buildShellUrl(
|
|
396
|
+
openTarget,
|
|
397
|
+
resolveShellBaseUrlForBridgePort(parsedBridgeArgs.wsPort),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
url.searchParams.set('device', targetDevice)
|
|
401
|
+
openTarget = url.toString()
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (openTarget) {
|
|
405
|
+
const openArgs = [openTarget]
|
|
406
|
+
openArgs.push(...replaceArgs)
|
|
407
|
+
openArgs.push(...remapArgs)
|
|
408
|
+
if (effectiveOpenInNewSim) {
|
|
409
|
+
openArgs.push('--new')
|
|
410
|
+
}
|
|
411
|
+
openArgs.push('--no-describe')
|
|
412
|
+
if (parsedBridgeArgs.simIdSource === 'flag' && parsedBridgeArgs.simId) {
|
|
413
|
+
openArgs.push('--sim', parsedBridgeArgs.simId)
|
|
414
|
+
}
|
|
415
|
+
if (driverFlag) {
|
|
416
|
+
openArgs.push('--driver', driverFlag)
|
|
417
|
+
closeManagedDriverSimAfterRun = true
|
|
418
|
+
}
|
|
419
|
+
if (args.includes('--headless') && !headed) {
|
|
420
|
+
openArgs.push('--headless')
|
|
421
|
+
}
|
|
422
|
+
await runOpenCommand(openArgs, {
|
|
423
|
+
port: parsedBridgeArgs.wsPort,
|
|
424
|
+
timeoutMs: parsedBridgeArgs.commandTimeoutMs,
|
|
425
|
+
})
|
|
426
|
+
if (closeManagedDriverSimAfterRun) {
|
|
427
|
+
const openedSimId = readCurrentSimId()
|
|
428
|
+
if (openedSimId) ownedSimIds.add(openedSimId)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const resolvedBridgeArgs = parseBridgeCliArgs(args, bridgeArgOptions)
|
|
433
|
+
const pinnedSimId =
|
|
434
|
+
resolvedBridgeArgs.simIdSource === 'flag'
|
|
435
|
+
? resolvedBridgeArgs.simId
|
|
436
|
+
: readCurrentSimId() || resolvedBridgeArgs.simId
|
|
437
|
+
// carry the source through so the target-sim notice reads `via --sim`
|
|
438
|
+
// (not the misleading `primary fallback — no sim pinned`) when the flow
|
|
439
|
+
// was actually pinned with --sim or a `sootsim use` selection.
|
|
440
|
+
const pinnedSimIdSource: typeof resolvedBridgeArgs.simIdSource =
|
|
441
|
+
resolvedBridgeArgs.simIdSource === 'flag' ? 'flag' : pinnedSimId ? 'saved' : 'none'
|
|
442
|
+
const bridge = createBridge(resolvedBridgeArgs.wsPort, {
|
|
443
|
+
commandTimeoutMs: resolvedBridgeArgs.commandTimeoutMs,
|
|
444
|
+
simId: pinnedSimId,
|
|
445
|
+
simIdSource: pinnedSimIdSource,
|
|
446
|
+
})
|
|
447
|
+
let driver: SootSimBridgeFlowRunner
|
|
448
|
+
const applyFlowFrontmatter = async () => {
|
|
449
|
+
const statusBarTime = frontmatter.env?.SOOTSIM_STATUS_BAR_TIME
|
|
450
|
+
if (statusBarTime) {
|
|
451
|
+
if (!/^(?:[1-9]|1[0-2]):[0-5][0-9]$/.test(statusBarTime)) {
|
|
452
|
+
throw new Error('SOOTSIM_STATUS_BAR_TIME must be a 12-hour time such as 9:41')
|
|
453
|
+
}
|
|
454
|
+
await bridge.send({
|
|
455
|
+
type: 'evaluate',
|
|
456
|
+
simId: driver.simId,
|
|
457
|
+
code: `window.dispatchEvent(new CustomEvent('sootsim:statusBarOverride', { detail: { time: ${JSON.stringify(statusBarTime)} } }))`,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
if (targetTheme) {
|
|
461
|
+
await bridge.send({
|
|
462
|
+
type: 'call',
|
|
463
|
+
simId: driver.simId,
|
|
464
|
+
path: 'SootSim.bridges.settings.set',
|
|
465
|
+
args: ['colorScheme', targetTheme],
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
if (!targetDevice) return false
|
|
469
|
+
const currentUrl = await bridge.send({
|
|
470
|
+
type: 'evaluate',
|
|
471
|
+
simId: driver.simId,
|
|
472
|
+
code: 'window.location.href',
|
|
473
|
+
})
|
|
474
|
+
if (typeof currentUrl === 'string' && currentUrl.length > 0) {
|
|
475
|
+
const nextUrl = new URL(currentUrl)
|
|
476
|
+
nextUrl.searchParams.set('device', targetDevice)
|
|
477
|
+
driver.setSimRouteHint(nextUrl.toString())
|
|
478
|
+
}
|
|
479
|
+
return Boolean(
|
|
480
|
+
await bridge.send({
|
|
481
|
+
type: 'call',
|
|
482
|
+
simId: driver.simId,
|
|
483
|
+
path: 'SootSim.bridges.settings.set',
|
|
484
|
+
args: ['deviceModel', targetDevice],
|
|
485
|
+
}),
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
driver = new SootSimBridgeFlowRunner(bridge, {
|
|
489
|
+
screenshotDir,
|
|
490
|
+
flowDir: path.dirname(path.resolve(flowPath)),
|
|
491
|
+
screenshotPathMode,
|
|
492
|
+
screenshotLayers,
|
|
493
|
+
simId: pinnedSimId,
|
|
494
|
+
recordingOutputDir: record ? outDir : undefined,
|
|
495
|
+
recordingFormat: preview ? 'mp4' : 'webm',
|
|
496
|
+
// preview mode uploads to previewOrigin; entitlement check must
|
|
497
|
+
// talk to the same origin, not whatever the shared desktop sim
|
|
498
|
+
// happens to have cached.
|
|
499
|
+
billingOriginOverride: preview ? previewOrigin : undefined,
|
|
500
|
+
// any recording reached via a github installation token is a ci /
|
|
501
|
+
// pr-preview run, so allow it for preview uploads. local flow recording
|
|
502
|
+
// writes a file and matches `sootsim record --mode video`.
|
|
503
|
+
allowGitHubRecording: preview,
|
|
504
|
+
requireRecordingEntitlement: preview,
|
|
505
|
+
// re-arm the event recorder after every launchApp-with-reload. the
|
|
506
|
+
// recorder lives in-page state and gets wiped by window.location.reload.
|
|
507
|
+
onAfterLaunch: async () => {
|
|
508
|
+
const deviceChanged = await applyFlowFrontmatter()
|
|
509
|
+
if (deviceChanged) await driver.waitForTree(120000)
|
|
510
|
+
if (!preview) return
|
|
511
|
+
await bridge.send({
|
|
512
|
+
type: 'evaluate',
|
|
513
|
+
simId: driver.simId,
|
|
514
|
+
code: `(() => {
|
|
515
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
516
|
+
return r?.start?.() ?? false
|
|
517
|
+
})()`,
|
|
518
|
+
})
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
if (slow) driver.stepDelay = slow
|
|
522
|
+
// live step rail + pause gate in the sim devtools "test" tab, named by the
|
|
523
|
+
// flow file so the rail identifies what is running.
|
|
524
|
+
driver.liveStatus = new FlowLiveStatusReporter(
|
|
525
|
+
bridge,
|
|
526
|
+
path.basename(flowPath),
|
|
527
|
+
() => driver.simId,
|
|
528
|
+
)
|
|
529
|
+
// maestro default vars + flow-level `env:` frontmatter, defined into the
|
|
530
|
+
// flow's JS context before the first step (upstream withDefaultEnvVars +
|
|
531
|
+
// DefineVariablesCommand).
|
|
532
|
+
driver.js.putEnv('MAESTRO_FILENAME', path.basename(flowPath, path.extname(flowPath)))
|
|
533
|
+
if (frontmatter.env && typeof frontmatter.env === 'object') {
|
|
534
|
+
for (const [key, value] of Object.entries(frontmatter.env)) {
|
|
535
|
+
driver.js.putEnv(key, value)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let profileResult: SootSimFlowProfileResult | null = null
|
|
540
|
+
let videoPath: string | null = null
|
|
541
|
+
let videoDurationMs: number | null = null
|
|
542
|
+
let capturedEvents: Array<Record<string, any>> | null = null
|
|
543
|
+
let previewScreenshotPath: string | null = null
|
|
544
|
+
let failureBundleDir: string | null = null
|
|
545
|
+
let exitCode = 0
|
|
546
|
+
const startsWithLaunchApp = !!steps[0]?.launchApp
|
|
547
|
+
try {
|
|
548
|
+
if (!startsWithLaunchApp) {
|
|
549
|
+
await driver.waitForTree(120000)
|
|
550
|
+
}
|
|
551
|
+
const deviceChanged = await applyFlowFrontmatter()
|
|
552
|
+
if (deviceChanged && !startsWithLaunchApp) await driver.waitForTree(120000)
|
|
553
|
+
// preview replay must reproduce the SAME initial conditions the agent
|
|
554
|
+
// explored from. the explore session runs on a fresh ephemeral profile
|
|
555
|
+
// (cold, logged-out), so its recorded steps assume that cold start —
|
|
556
|
+
// e.g. step 1 taps a dev/login button. but the replay tab reuses the
|
|
557
|
+
// explore tab's now-dirtied storage (persisted auth/session), so without
|
|
558
|
+
// a reset the app boots already-authenticated and the recorded login
|
|
559
|
+
// step targets an element that no longer exists — the flow hangs on
|
|
560
|
+
// step 1 ("made no progress … bridge or bundle probably hung"). flows
|
|
561
|
+
// that open with an explicit launchApp manage their own state; every
|
|
562
|
+
// other preview flow gets a clean cold launch here so the recording
|
|
563
|
+
// captures the same journey from the same starting point. app-agnostic,
|
|
564
|
+
// no per-repo configuration — the bot just figures it out.
|
|
565
|
+
// skip when the caller already established a clean cold state (e.g. the
|
|
566
|
+
// contrastbot run.sh pre-replay reset reopens an --ephemeral sim: fresh
|
|
567
|
+
// profile, logged-out, brought to readiness). a second clearState+reload
|
|
568
|
+
// here is redundant and triggers a sim-id rotation on the reload that
|
|
569
|
+
// routes the flow to a stale sim, hanging step 1 ("made no progress …
|
|
570
|
+
// bridge or bundle probably hung"). one reset is enough — the bot
|
|
571
|
+
// signals it did one via SOOTSIM_PREVIEW_RESET_DONE.
|
|
572
|
+
if (preview && !startsWithLaunchApp && !process.env.SOOTSIM_PREVIEW_RESET_DONE) {
|
|
573
|
+
await driver.launchApp({ clearState: true })
|
|
574
|
+
}
|
|
575
|
+
if (record) {
|
|
576
|
+
if (startsWithLaunchApp) {
|
|
577
|
+
driver.prepareRecording()
|
|
578
|
+
} else {
|
|
579
|
+
await driver.startRecording()
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (profile) {
|
|
583
|
+
await driver.startProfile()
|
|
584
|
+
}
|
|
585
|
+
if (preview) {
|
|
586
|
+
// start the preview event recorder on the tab so agent-driven taps
|
|
587
|
+
// (via sootsim:agentAction) land in the capture stream for replay.
|
|
588
|
+
// some shells expose the recorder via window.SootSim, others via
|
|
589
|
+
// the legacy alias only — try both.
|
|
590
|
+
const started = await bridge.send({
|
|
591
|
+
type: 'evaluate',
|
|
592
|
+
simId: driver.simId,
|
|
593
|
+
code: `(() => {
|
|
594
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
595
|
+
return r?.start?.() ?? false
|
|
596
|
+
})()`,
|
|
597
|
+
})
|
|
598
|
+
if (!started) {
|
|
599
|
+
console.warn(
|
|
600
|
+
' warn: eventRecorder.start() returned false — preview will upload bundle without replay events',
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
await driver.runFlow(steps)
|
|
605
|
+
if (profile) {
|
|
606
|
+
profileResult = await driver.stopProfile()
|
|
607
|
+
}
|
|
608
|
+
await driver.waitForRecordingTail({ maxMs: tailWaitMs, smart: preview })
|
|
609
|
+
if (record) {
|
|
610
|
+
videoPath = await driver.stopRecording()
|
|
611
|
+
videoDurationMs = driver.getLastRecordingDurationMs()
|
|
612
|
+
}
|
|
613
|
+
if (preview) {
|
|
614
|
+
capturedEvents = (await bridge.send({
|
|
615
|
+
type: 'evaluate',
|
|
616
|
+
simId: driver.simId,
|
|
617
|
+
code: `(() => {
|
|
618
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
619
|
+
return r?.stop?.() ?? []
|
|
620
|
+
})()`,
|
|
621
|
+
})) as Array<Record<string, any>>
|
|
622
|
+
console.log(` captured ${capturedEvents.length} replay events`)
|
|
623
|
+
previewScreenshotPath = path.join(screenshotDir, 'preview-final.png')
|
|
624
|
+
await driver.captureScreenshot(previewScreenshotPath)
|
|
625
|
+
console.log(` final screenshot: ${previewScreenshotPath}`)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const screenshotSteps = steps.filter(
|
|
629
|
+
(s: any) => s && typeof s === 'object' && 'takeScreenshot' in s,
|
|
630
|
+
).length
|
|
631
|
+
console.log(`\n + completed (${steps.length} steps)`)
|
|
632
|
+
if (screenshotSteps > 0) {
|
|
633
|
+
// surface where the assets actually landed so the user doesn't have to
|
|
634
|
+
// grep through verbose [flow] step output for paths
|
|
635
|
+
console.log(` screenshots: ${screenshotSteps} → ${screenshotDir}`)
|
|
636
|
+
}
|
|
637
|
+
if (profileResult) {
|
|
638
|
+
printFlowProfile(profileResult)
|
|
639
|
+
}
|
|
640
|
+
if (videoPath) {
|
|
641
|
+
console.log(` video: ${videoPath}`)
|
|
642
|
+
}
|
|
643
|
+
console.log()
|
|
644
|
+
} catch (err: any) {
|
|
645
|
+
if (profile && !profileResult) {
|
|
646
|
+
try {
|
|
647
|
+
profileResult = await driver.stopProfile()
|
|
648
|
+
} catch {
|
|
649
|
+
// ignore profiling teardown failures while already handling the main error
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (record && !videoPath) {
|
|
653
|
+
try {
|
|
654
|
+
videoPath = await driver.stopRecording()
|
|
655
|
+
videoDurationMs = driver.getLastRecordingDurationMs()
|
|
656
|
+
} catch (recordErr: any) {
|
|
657
|
+
console.warn(
|
|
658
|
+
` recording stop failed: ${recordErr?.message || String(recordErr)}`,
|
|
659
|
+
)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (preview && !capturedEvents) {
|
|
663
|
+
try {
|
|
664
|
+
capturedEvents = (await bridge.send({
|
|
665
|
+
type: 'evaluate',
|
|
666
|
+
simId: driver.simId,
|
|
667
|
+
code: `(() => {
|
|
668
|
+
const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
669
|
+
return r?.stop?.() ?? []
|
|
670
|
+
})()`,
|
|
671
|
+
})) as Array<Record<string, any>>
|
|
672
|
+
console.log(` captured ${capturedEvents.length} partial replay events`)
|
|
673
|
+
} catch (eventErr: any) {
|
|
674
|
+
console.warn(
|
|
675
|
+
` event recorder stop failed: ${eventErr?.message || String(eventErr)}`,
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
console.error(`\n x failed: ${err.message}\n`)
|
|
680
|
+
if (profileResult) {
|
|
681
|
+
console.log(' partial profile:')
|
|
682
|
+
printFlowProfile(profileResult)
|
|
683
|
+
}
|
|
684
|
+
if (videoPath) {
|
|
685
|
+
console.log(` partial video: ${videoPath}`)
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
// rich per-failure bundle: screenshot + describe.json + a11y +
|
|
689
|
+
// tree + console + failed-requests. dir is named by the failing
|
|
690
|
+
// step so consecutive failing runs don't overwrite each other.
|
|
691
|
+
const failedStep = driver.lastFailedStep
|
|
692
|
+
const bundleName = failedStep
|
|
693
|
+
? `step-${String(failedStep.index + 1).padStart(2, '0')}-${failedStep.kind}`
|
|
694
|
+
: 'unstaged'
|
|
695
|
+
const bundleDir = path.join(screenshotDir, bundleName)
|
|
696
|
+
await driver.captureFailureBundle(bundleDir, {
|
|
697
|
+
error: err,
|
|
698
|
+
stepIndex: failedStep?.index,
|
|
699
|
+
stepKind: failedStep?.kind,
|
|
700
|
+
stepTarget: failedStep?.target,
|
|
701
|
+
})
|
|
702
|
+
failureBundleDir = bundleDir
|
|
703
|
+
console.log(` failure bundle: ${bundleDir}`)
|
|
704
|
+
console.log(
|
|
705
|
+
` (contents: screenshot.png, describe.json, a11y.txt, tree.txt, console.json, error.json)`,
|
|
706
|
+
)
|
|
707
|
+
} catch (bundleErr) {
|
|
708
|
+
// fall back to the bare screenshot if the full bundle blew up —
|
|
709
|
+
// at least the viewer has one thing to look at.
|
|
710
|
+
try {
|
|
711
|
+
const errShot = path.join(screenshotDir, 'error.png')
|
|
712
|
+
await driver.captureScreenshot(errShot)
|
|
713
|
+
console.log(` error screenshot: ${errShot}`)
|
|
714
|
+
} catch {}
|
|
715
|
+
console.log(
|
|
716
|
+
` (failure bundle capture failed: ${
|
|
717
|
+
bundleErr instanceof Error ? bundleErr.message : String(bundleErr)
|
|
718
|
+
})`,
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
await printBridgeFailureDiagnostics(bridge, {
|
|
722
|
+
errorsCommand: `sootsim get errors 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
|
|
723
|
+
warningsCommand: `sootsim get warnings 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
|
|
724
|
+
requestsCommand: `sootsim get requests 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
|
|
725
|
+
})
|
|
726
|
+
exitCode = 1
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
await driver.liveStatus?.end(exitCode === 0 ? 'passed' : 'failed')
|
|
730
|
+
|
|
731
|
+
// under --preview, kick the captured events into the preview uploader so the viewer
|
|
732
|
+
// gets a real /preview/<id> with scrubbable replay. failed flows upload the
|
|
733
|
+
// partial recording when available so dashboard runs can link to the trace.
|
|
734
|
+
if (preview && (exitCode === 0 || videoPath || capturedEvents)) {
|
|
735
|
+
try {
|
|
736
|
+
const eventsJsonlGz = await prepareEventsFile(capturedEvents ?? [])
|
|
737
|
+
const uploadArgs = ['--origin', previewOrigin, '--events', eventsJsonlGz]
|
|
738
|
+
if (previewPublicOrigin) {
|
|
739
|
+
uploadArgs.push('--public-origin', previewPublicOrigin)
|
|
740
|
+
}
|
|
741
|
+
// chain the recorded mp4 so preview-page can layer it over the
|
|
742
|
+
// canvas during scrub for smooth motion while the real app
|
|
743
|
+
// catches up.
|
|
744
|
+
if (videoPath) uploadArgs.push('--video', videoPath)
|
|
745
|
+
if (previewScreenshotPath) {
|
|
746
|
+
uploadArgs.push('--screenshot', previewScreenshotPath)
|
|
747
|
+
}
|
|
748
|
+
if (videoPath && videoDurationMs && Number.isFinite(videoDurationMs)) {
|
|
749
|
+
uploadArgs.push('--video-duration-ms', String(Math.round(videoDurationMs)))
|
|
750
|
+
uploadArgs.push('--recorded-duration-ms', String(Math.round(videoDurationMs)))
|
|
751
|
+
}
|
|
752
|
+
const recordingStartedAtMs = driver.getLastRecordingStartedAtMs()
|
|
753
|
+
if (recordingStartedAtMs && Number.isFinite(recordingStartedAtMs)) {
|
|
754
|
+
uploadArgs.push(
|
|
755
|
+
'--recording-started-at-ms',
|
|
756
|
+
String(Math.round(recordingStartedAtMs)),
|
|
757
|
+
)
|
|
758
|
+
}
|
|
759
|
+
if (parsedBridgeArgs.wsPort) {
|
|
760
|
+
uploadArgs.push('--port', String(parsedBridgeArgs.wsPort))
|
|
761
|
+
}
|
|
762
|
+
if (driver.simId) {
|
|
763
|
+
uploadArgs.push('--sim', driver.simId)
|
|
764
|
+
}
|
|
765
|
+
if (previewOpen) uploadArgs.push('--open')
|
|
766
|
+
if (previewOwner) uploadArgs.push('--owner', previewOwner)
|
|
767
|
+
if (previewRepo) uploadArgs.push('--repo', previewRepo)
|
|
768
|
+
uploadArgs.push('--run-scope', previewRunScope)
|
|
769
|
+
if (previewBillingKind) {
|
|
770
|
+
uploadArgs.push('--billing-kind', previewBillingKind)
|
|
771
|
+
}
|
|
772
|
+
console.log(`\n preparing preview upload…`)
|
|
773
|
+
const flowTraceSteps = driver.getFlowTraceSteps()
|
|
774
|
+
if (flowTraceSteps.length > 0) {
|
|
775
|
+
uploadArgs.push(
|
|
776
|
+
'--timeline-events',
|
|
777
|
+
await prepareFlowStepTimelineFile(flowTraceSteps),
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
const snapshotManifest = await prepareFlowSnapshotManifest(
|
|
781
|
+
flowTraceSteps,
|
|
782
|
+
recordingStartedAtMs,
|
|
783
|
+
)
|
|
784
|
+
if (snapshotManifest) {
|
|
785
|
+
uploadArgs.push('--snapshot-manifest', snapshotManifest)
|
|
786
|
+
}
|
|
787
|
+
if (failureBundleDir) {
|
|
788
|
+
// failed runs ship the per-step debug bundle with the share so the
|
|
789
|
+
// dashboard's run row can show the failing screen + full context.
|
|
790
|
+
uploadArgs.push('--failure-bundle', failureBundleDir)
|
|
791
|
+
}
|
|
792
|
+
const frameStats = driver.getLastRecordingFrameStats()
|
|
793
|
+
if (frameStats) {
|
|
794
|
+
uploadArgs.push('--trace-frame-stats', await prepareFrameStatsFile(frameStats))
|
|
795
|
+
}
|
|
796
|
+
const { runUpload } = await import('./upload')
|
|
797
|
+
lastPreviewUploadResult = await runUpload(uploadArgs, {})
|
|
798
|
+
} catch (err: any) {
|
|
799
|
+
console.error(` preview upload failed: ${err?.message || err}`)
|
|
800
|
+
exitCode = 1
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
lastFlowTraceSteps = driver.getFlowTraceSteps()
|
|
805
|
+
|
|
806
|
+
bridge.close()
|
|
807
|
+
|
|
808
|
+
if (closeManagedDriverSimAfterRun) {
|
|
809
|
+
if (driver.simId) ownedSimIds.add(driver.simId)
|
|
810
|
+
await closeFlowOwnedSims(parsedBridgeArgs.wsPort, parsedBridgeArgs.commandTimeoutMs, [
|
|
811
|
+
...ownedSimIds,
|
|
812
|
+
])
|
|
813
|
+
} else if (driver.simId && driver.simId !== pinnedSimId) {
|
|
814
|
+
// `launchApp clearState` rotates the sim id mid-flow (the shell reconnects
|
|
815
|
+
// under a fresh id). the runner tracks that in-memory, but a directory run
|
|
816
|
+
// (`sootsim maestro test <dir>`) spawns the next flow fresh — it re-reads
|
|
817
|
+
// the saved sim id, which would still point at the pre-rotation sim and
|
|
818
|
+
// fail with "sim not responding / stale orphan". persist the rotated id so
|
|
819
|
+
// the next flow reuses the live sim. only when we're NOT closing it.
|
|
820
|
+
saveCurrentSimId(driver.simId)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// flush before returning to caller — caller typically calls
|
|
824
|
+
// process.exit(returnedCode), and node's `exit` hook can't await, so
|
|
825
|
+
// without this the flow telemetry (and any chained upload's events)
|
|
826
|
+
// get silently dropped.
|
|
827
|
+
await flushCliTelemetry()
|
|
828
|
+
return exitCode
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function closeFlowOwnedSims(
|
|
832
|
+
wsPort: number,
|
|
833
|
+
commandTimeoutMs: number,
|
|
834
|
+
simIds: string[],
|
|
835
|
+
): Promise<void> {
|
|
836
|
+
const ids = [...new Set(simIds.filter(Boolean))]
|
|
837
|
+
if (ids.length === 0) return
|
|
838
|
+
const bridge = createBridge(wsPort, { commandTimeoutMs })
|
|
839
|
+
try {
|
|
840
|
+
const sims = await bridge.listSims()
|
|
841
|
+
const openIds = ids.filter((id) =>
|
|
842
|
+
sims.some((sim) => sim.id === id && sim.readyState === 'open'),
|
|
843
|
+
)
|
|
844
|
+
if (openIds.length === 0) return
|
|
845
|
+
const result = await closeSimsBulk(bridge, wsPort, commandTimeoutMs, openIds)
|
|
846
|
+
await terminatePlaywrightHostsForSims(sims, openIds)
|
|
847
|
+
if (result.closed.length > 0) {
|
|
848
|
+
console.log(` closed flow sim(s): ${result.closed.join(', ')}`)
|
|
849
|
+
}
|
|
850
|
+
if (result.remaining.length > 0) {
|
|
851
|
+
console.warn(
|
|
852
|
+
` warn: flow sim(s) still connected after close: ${result.remaining.join(', ')}`,
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
} catch (err) {
|
|
856
|
+
console.warn(
|
|
857
|
+
` warn: failed to close flow sim(s): ${
|
|
858
|
+
err instanceof Error ? err.message : String(err)
|
|
859
|
+
}`,
|
|
860
|
+
)
|
|
861
|
+
} finally {
|
|
862
|
+
bridge.close()
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// write the captured event stream to a gzipped jsonl file in a temp dir.
|
|
867
|
+
// returns the absolute path for the upload command to pick up.
|
|
868
|
+
async function prepareEventsFile(events: Array<Record<string, any>>): Promise<string> {
|
|
869
|
+
const { gzipSync } = await import('zlib')
|
|
870
|
+
const lines =
|
|
871
|
+
events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')
|
|
872
|
+
const gz = gzipSync(Buffer.from(lines, 'utf8'))
|
|
873
|
+
const outPath = path.join(tmpdir(), `sootsim-events-${Date.now()}.jsonl.gz`)
|
|
874
|
+
fs.writeFileSync(outPath, gz)
|
|
875
|
+
console.log(` events: ${events.length} written to ${outPath} (${gz.length} bytes gz)`)
|
|
876
|
+
return outPath
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async function prepareFlowStepTimelineFile(
|
|
880
|
+
steps: SootSimFlowTraceStep[],
|
|
881
|
+
): Promise<string> {
|
|
882
|
+
const { gzipSync } = await import('zlib')
|
|
883
|
+
const events = steps.map((step, index) => ({
|
|
884
|
+
schemaVersion: 1,
|
|
885
|
+
t: step.endedAtMs,
|
|
886
|
+
seq: 1_000_000 + index,
|
|
887
|
+
context: 'host',
|
|
888
|
+
kind: 'flow-step',
|
|
889
|
+
id: `flow-step-${step.stepIndex}`,
|
|
890
|
+
data: {
|
|
891
|
+
stepIndex: step.stepIndex,
|
|
892
|
+
stepNumber: step.stepIndex + 1,
|
|
893
|
+
stepName: step.stepName,
|
|
894
|
+
...(step.targetLabel ? { targetLabel: step.targetLabel } : {}),
|
|
895
|
+
status: step.status,
|
|
896
|
+
durationMs: step.durationMs,
|
|
897
|
+
startedAtMs: step.startedAtMs,
|
|
898
|
+
endedAtMs: step.endedAtMs,
|
|
899
|
+
...(step.error ? { error: step.error } : {}),
|
|
900
|
+
},
|
|
901
|
+
}))
|
|
902
|
+
const lines = events.map((event) => JSON.stringify(event)).join('\n') + '\n'
|
|
903
|
+
const gz = gzipSync(Buffer.from(lines, 'utf8'))
|
|
904
|
+
const outPath = path.join(tmpdir(), `sootsim-flow-steps-${Date.now()}.jsonl.gz`)
|
|
905
|
+
fs.writeFileSync(outPath, gz)
|
|
906
|
+
console.log(
|
|
907
|
+
` flow trace: ${events.length} step events written to ${outPath} (${gz.length} bytes gz)`,
|
|
908
|
+
)
|
|
909
|
+
return outPath
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function prepareFlowSnapshotManifest(
|
|
913
|
+
steps: SootSimFlowTraceStep[],
|
|
914
|
+
recordingStartedAtMs: number | null,
|
|
915
|
+
): Promise<string | null> {
|
|
916
|
+
if (!recordingStartedAtMs) return null
|
|
917
|
+
const snapshots = steps.flatMap((step) =>
|
|
918
|
+
step.screenshotPath
|
|
919
|
+
? [
|
|
920
|
+
{
|
|
921
|
+
id: `flow-step-${step.stepIndex}`,
|
|
922
|
+
label: step.targetLabel ?? `Step ${step.stepIndex + 1}`,
|
|
923
|
+
kind: 'interaction',
|
|
924
|
+
t: Math.max(0, step.endedAtMs - recordingStartedAtMs),
|
|
925
|
+
path: step.screenshotPath,
|
|
926
|
+
},
|
|
927
|
+
]
|
|
928
|
+
: [],
|
|
929
|
+
)
|
|
930
|
+
if (snapshots.length === 0) return null
|
|
931
|
+
const outPath = path.join(tmpdir(), `sootsim-flow-snapshots-${Date.now()}.json`)
|
|
932
|
+
fs.writeFileSync(outPath, JSON.stringify(snapshots))
|
|
933
|
+
console.log(` flow snapshots: ${snapshots.length} written to ${outPath}`)
|
|
934
|
+
return outPath
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
async function prepareFrameStatsFile(frameStats: unknown): Promise<string> {
|
|
938
|
+
const outPath = path.join(tmpdir(), `sootsim-frame-stats-${Date.now()}.json`)
|
|
939
|
+
fs.writeFileSync(outPath, JSON.stringify(frameStats))
|
|
940
|
+
console.log(` frame stats: written to ${outPath}`)
|
|
941
|
+
return outPath
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function runFlowSession(args: string[]) {
|
|
945
|
+
const subcommand = args[0]
|
|
946
|
+
const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name)
|
|
947
|
+
|
|
948
|
+
switch (subcommand) {
|
|
949
|
+
case 'start': {
|
|
950
|
+
const { path, state } = startFlowSession()
|
|
951
|
+
console.log(` flow draft started`)
|
|
952
|
+
console.log(` session: ${path}`)
|
|
953
|
+
console.log(` steps: ${state.steps.length}`)
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
case 'keep':
|
|
958
|
+
case 'good': {
|
|
959
|
+
const result = keepFlowCandidate()
|
|
960
|
+
if (!result.active) {
|
|
961
|
+
console.error(' no active flow draft — run `sootsim maestro start` first')
|
|
962
|
+
process.exit(1)
|
|
963
|
+
}
|
|
964
|
+
if (!result.kept) {
|
|
965
|
+
console.log(' no pending action to keep')
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
console.log(` kept: ${result.candidate.summary}`)
|
|
969
|
+
console.log(` steps: ${result.stepCount}`)
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
case 'end': {
|
|
974
|
+
const outputPath =
|
|
975
|
+
getFlag('--output') || (args[1] && !args[1].startsWith('-') ? args[1] : undefined)
|
|
976
|
+
const validate = args.includes('--validate') || args.includes('--video')
|
|
977
|
+
const video = args.includes('--video')
|
|
978
|
+
// ending the draft commits the trailing pending action. the last
|
|
979
|
+
// successful `do` is presumably one the user wants; without this,
|
|
980
|
+
// `maestro start → do → maestro end` (no explicit keep) exported an empty
|
|
981
|
+
// [] and silently dropped the work (F13-4).
|
|
982
|
+
const autoKept = keepFlowCandidate()
|
|
983
|
+
if (autoKept.active && autoKept.kept) {
|
|
984
|
+
console.log(` auto-kept trailing action: ${autoKept.candidate.summary}`)
|
|
985
|
+
}
|
|
986
|
+
const result = exportFlowSession(
|
|
987
|
+
outputPath || (validate ? createDraftValidationPath() : undefined),
|
|
988
|
+
)
|
|
989
|
+
if (!result.active) {
|
|
990
|
+
console.error(' no active flow draft — run `sootsim maestro start` first')
|
|
991
|
+
process.exit(1)
|
|
992
|
+
}
|
|
993
|
+
if (!result.valid) {
|
|
994
|
+
console.error(' flow draft is not valid:')
|
|
995
|
+
for (const issue of result.issues) console.error(` - ${issue}`)
|
|
996
|
+
console.error(
|
|
997
|
+
' draft preserved — keep at least one real interaction or run `sootsim maestro start` to reset',
|
|
998
|
+
)
|
|
999
|
+
process.exit(1)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (validate) {
|
|
1003
|
+
const playbackArgs = buildFlowValidationArgs(args, result.outputPath)
|
|
1004
|
+
const exitCode = await runFlowPlayback(playbackArgs)
|
|
1005
|
+
if (exitCode !== 0) {
|
|
1006
|
+
console.error(
|
|
1007
|
+
'\n validation failed — draft preserved so you can keep iterating',
|
|
1008
|
+
)
|
|
1009
|
+
process.exitCode = exitCode
|
|
1010
|
+
return
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
finalizeFlowSession()
|
|
1014
|
+
console.log(
|
|
1015
|
+
` flow draft validated (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`,
|
|
1016
|
+
)
|
|
1017
|
+
if (result.outputPath) {
|
|
1018
|
+
console.log(` saved: ${result.outputPath}`)
|
|
1019
|
+
}
|
|
1020
|
+
if (video) {
|
|
1021
|
+
console.log(' video: recorded during validation run')
|
|
1022
|
+
}
|
|
1023
|
+
return
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
finalizeFlowSession()
|
|
1027
|
+
console.log(
|
|
1028
|
+
` flow draft ended (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`,
|
|
1029
|
+
)
|
|
1030
|
+
if (result.outputPath) {
|
|
1031
|
+
console.log(` saved: ${result.outputPath}`)
|
|
1032
|
+
console.log(` next: sootsim maestro test ${result.outputPath} --record`)
|
|
1033
|
+
return
|
|
1034
|
+
}
|
|
1035
|
+
console.log('')
|
|
1036
|
+
process.stdout.write(result.yaml)
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
case 'validate': {
|
|
1041
|
+
const flowPath = args[1]
|
|
1042
|
+
if (!flowPath || flowPath.startsWith('-')) {
|
|
1043
|
+
console.error(' usage: sootsim maestro validate <path>')
|
|
1044
|
+
process.exit(1)
|
|
1045
|
+
}
|
|
1046
|
+
const issues = validateFlowFile(flowPath)
|
|
1047
|
+
if (issues.length > 0) {
|
|
1048
|
+
console.error(` x ${flowPath} failed validation:`)
|
|
1049
|
+
for (const issue of issues) console.error(` - ${issue}`)
|
|
1050
|
+
process.exit(1)
|
|
1051
|
+
}
|
|
1052
|
+
console.log(` + ${flowPath} looks valid`)
|
|
1053
|
+
return
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// the subcommand / flow-file path is the first positional arg, but a
|
|
1059
|
+
// `--sim <id>` pair can sit in front of it: bin.ts prepends the global
|
|
1060
|
+
// `--sim` to every command's args, and `sootsim maestro --sim a2 start` is a
|
|
1061
|
+
// documented form. hoist a leading `--sim <value>` to the end so `args[0]`
|
|
1062
|
+
// is the real subcommand again (downstream getFlag / parseBridgeCliArgs
|
|
1063
|
+
// still find `--sim` wherever it lands). without this, `--sim 6a5 flow
|
|
1064
|
+
// start` parsed `--sim` as the flow path and failed `--sim not found`.
|
|
1065
|
+
export function hoistLeadingSimFlag(args: string[]): string[] {
|
|
1066
|
+
if (args[0] === '--sim' && args.length >= 2) {
|
|
1067
|
+
return [...args.slice(2), '--sim', args[1]]
|
|
1068
|
+
}
|
|
1069
|
+
return args
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export async function runMaestroAuthoring(rawArgs: string[]): Promise<number> {
|
|
1073
|
+
const args = hoistLeadingSimFlag(rawArgs)
|
|
1074
|
+
const subcommand = args[0]
|
|
1075
|
+
if (
|
|
1076
|
+
subcommand === 'start' ||
|
|
1077
|
+
subcommand === 'keep' ||
|
|
1078
|
+
subcommand === 'good' ||
|
|
1079
|
+
subcommand === 'end' ||
|
|
1080
|
+
subcommand === 'validate'
|
|
1081
|
+
) {
|
|
1082
|
+
await runFlowSession(args)
|
|
1083
|
+
return 0
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const exitCode = await runFlowPlayback(args)
|
|
1087
|
+
if (exitCode !== 0) {
|
|
1088
|
+
process.exitCode = exitCode
|
|
1089
|
+
}
|
|
1090
|
+
return exitCode
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function printFlowProfile(result: SootSimFlowProfileResult) {
|
|
1094
|
+
console.log('')
|
|
1095
|
+
printShellPerfReport(result)
|
|
1096
|
+
}
|
|
1097
|
+
function createDraftValidationPath() {
|
|
1098
|
+
return path.join(tmpdir(), `sootsim-flow-draft-${Date.now()}.yaml`)
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function buildFlowValidationArgs(args: string[], outputPath: string | null) {
|
|
1102
|
+
if (!outputPath) {
|
|
1103
|
+
throw new Error('validated flow draft requires an output path')
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const nextArgs = [outputPath]
|
|
1107
|
+
for (let i = 1; i < args.length; i++) {
|
|
1108
|
+
const arg = args[i]
|
|
1109
|
+
if (arg === '--validate' || arg === '--video') continue
|
|
1110
|
+
if (arg === '--output') {
|
|
1111
|
+
i += 1
|
|
1112
|
+
continue
|
|
1113
|
+
}
|
|
1114
|
+
if (i === 1 && !arg.startsWith('-')) {
|
|
1115
|
+
continue
|
|
1116
|
+
}
|
|
1117
|
+
nextArgs.push(arg)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (!nextArgs.includes('--record') && args.includes('--video')) {
|
|
1121
|
+
nextArgs.push('--record')
|
|
1122
|
+
}
|
|
1123
|
+
return nextArgs
|
|
1124
|
+
}
|