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,1434 @@
|
|
|
1
|
+
// sootsim record — drives the engine's built-in canvas recorder over
|
|
2
|
+
// the WS bridge. no playwright, no ffmpeg. encoding happens in the
|
|
3
|
+
// running sootsim page (webm via MediaRecorder, mp4 via WebCodecs, gif
|
|
4
|
+
// via gifenc, raw png frames via a shared bitmap sampler).
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'fs'
|
|
14
|
+
import { homedir, tmpdir } from 'os'
|
|
15
|
+
import { dirname, extname, join, resolve } from 'path'
|
|
16
|
+
import { githubUploadIdentity, resolveCliAuth } from '../auth'
|
|
17
|
+
import { getCliIdentityKey } from '../current-sim'
|
|
18
|
+
import { openUrl } from '../open-url'
|
|
19
|
+
import { ensureCliRecordingEntitlement } from '../recording-access'
|
|
20
|
+
import { resolveRunProvenance } from '../run-registry'
|
|
21
|
+
import {
|
|
22
|
+
checkSimHealth,
|
|
23
|
+
createBridgeFromParsed,
|
|
24
|
+
parseBridgeCliArgs,
|
|
25
|
+
type WsBridge,
|
|
26
|
+
} from '../ws-bridge'
|
|
27
|
+
|
|
28
|
+
interface RecordOptions {
|
|
29
|
+
port?: number
|
|
30
|
+
verbose?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type OutputFormat = 'webm' | 'mp4' | 'gif' | 'png'
|
|
34
|
+
|
|
35
|
+
// recording mode — 'video' is a local webm/mp4 download via the headless
|
|
36
|
+
// recorder bridge (current CLI default). 'live' and 'combined' route
|
|
37
|
+
// through the recording store so they capture pointer events (and, for
|
|
38
|
+
// combined, video) and upload to /preview/<id>. mirrors RecordingMode in
|
|
39
|
+
// packages/sootsim-engine/src/recording/recordingStore.ts.
|
|
40
|
+
type RecordMode = 'video' | 'live' | 'combined'
|
|
41
|
+
|
|
42
|
+
// how long to wait for the live/combined upload to settle after stop. dev
|
|
43
|
+
// bundles routinely include tens of MB of transformed JS plus captured
|
|
44
|
+
// request bodies; 60s was too tight and left valid PR-preview recordings
|
|
45
|
+
// uploading in the page after the CLI had already reported failure.
|
|
46
|
+
const UPLOAD_TIMEOUT_MS = 240_000
|
|
47
|
+
|
|
48
|
+
// how long `record start --mode live/combined` waits for each setup bridge
|
|
49
|
+
// eval (auth inject, store start) to round-trip. unlike the light `video`
|
|
50
|
+
// start (a single __sootsimRecorder.start), the store-backed start must reach
|
|
51
|
+
// an engine that — on a contended CI runner, right after the PR-preview agent
|
|
52
|
+
// drove a populated app — is saturated rendering at a few fps and cannot answer
|
|
53
|
+
// a bridge eval within the 15s the video path uses. that 15s cap made
|
|
54
|
+
// `record start` time out, the agent retry it many times, and the leading idle
|
|
55
|
+
// from the failed attempts blow the 15s first-interaction quality gate
|
|
56
|
+
// (preview-quality.ts) so the take was rejected even once it finally recorded.
|
|
57
|
+
// give the upload-bearing start the slack the atomic combined path
|
|
58
|
+
// (runRecord, commandTimeoutMs 60s) already uses, plus headroom for a starved
|
|
59
|
+
// engine. video start stays light at 15s.
|
|
60
|
+
const STORE_RECORD_START_TIMEOUT_MS = 90_000
|
|
61
|
+
|
|
62
|
+
// per-step bridge-eval budget for local-file capture (gif / video). the
|
|
63
|
+
// blanket 60s command timeout means a contended or wedged render host
|
|
64
|
+
// produces a 60s generic "command timed out" hang with no hint which step
|
|
65
|
+
// stalled (QA F19-5). scope each capture eval to its own recording duration
|
|
66
|
+
// plus a generous encode/teardown allowance so a stalled host fails fast
|
|
67
|
+
// and actionably. the bitmap-capture lock now self-releases on abandon
|
|
68
|
+
// (bitmap-capture.ts), so recovery is simply to retry.
|
|
69
|
+
const CAPTURE_STEP_ENCODE_BUDGET_MS = 30_000
|
|
70
|
+
|
|
71
|
+
async function evalCaptureStep<T>(
|
|
72
|
+
bridge: WsBridge,
|
|
73
|
+
code: string,
|
|
74
|
+
step: string,
|
|
75
|
+
durationMs: number,
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
const timeoutMs = Math.max(20_000, durationMs + CAPTURE_STEP_ENCODE_BUDGET_MS)
|
|
78
|
+
try {
|
|
79
|
+
return (await bridge.send({ type: 'evaluate', code }, { timeoutMs })) as T
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
82
|
+
if (/^command timed out after \d+s$/.test(msg)) {
|
|
83
|
+
console.error(
|
|
84
|
+
` ${step} stalled — the render host did not finish within ` +
|
|
85
|
+
`${Math.round(timeoutMs / 1000)}s; it is likely contended or wedged.\n` +
|
|
86
|
+
` the capture lock auto-releases, so simply retry the recording.\n` +
|
|
87
|
+
` if it persists, recover the sim with \`rnxsim close --sim <id>\`.`,
|
|
88
|
+
)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
// a leaked/contended render-host streaming session surfaces as an
|
|
92
|
+
// engine-side error delivered over the WS bridge. on the gif/video
|
|
93
|
+
// start path this rejection previously had no upstream catch, so it
|
|
94
|
+
// escaped as a raw unhandled stack trace AND the process self-exited
|
|
95
|
+
// 0 (false success, no file written) — QA F20-1. handle it here:
|
|
96
|
+
// actionable message, non-zero exit, no stack.
|
|
97
|
+
if (
|
|
98
|
+
/render host not available|already streaming|already recording|lock already held/i.test(
|
|
99
|
+
msg,
|
|
100
|
+
)
|
|
101
|
+
) {
|
|
102
|
+
console.error(
|
|
103
|
+
` ${step} failed: ${msg}\n` +
|
|
104
|
+
` a prior recording was abandoned and its capture is still\n` +
|
|
105
|
+
` releasing. the lock self-heals in ~${Math.round(
|
|
106
|
+
(durationMs + 20_000) / 1000,
|
|
107
|
+
)}s — retry then, or recover now with\n` +
|
|
108
|
+
` \`rnxsim close --sim <id>\` and reopen the sim.`,
|
|
109
|
+
)
|
|
110
|
+
process.exit(1)
|
|
111
|
+
}
|
|
112
|
+
// never let any other capture-eval rejection escape as an unhandled
|
|
113
|
+
// promise (raw stack + false exit 0). surface it and exit non-zero.
|
|
114
|
+
console.error(` ${step} failed: ${msg}`)
|
|
115
|
+
process.exit(1)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// value-taking flags that may legitimately appear before the subcommand —
|
|
120
|
+
// notably `--sim`, which bin.ts prepends to the command args. their values
|
|
121
|
+
// must not be mistaken for the subcommand.
|
|
122
|
+
const RECORD_VALUE_FLAGS = new Set([
|
|
123
|
+
'--sim',
|
|
124
|
+
'--port',
|
|
125
|
+
'-p',
|
|
126
|
+
'--mode',
|
|
127
|
+
'--duration',
|
|
128
|
+
'--fps',
|
|
129
|
+
'--format',
|
|
130
|
+
'--output',
|
|
131
|
+
'-o',
|
|
132
|
+
'--frames',
|
|
133
|
+
'--max-width',
|
|
134
|
+
'--origin',
|
|
135
|
+
'--owner',
|
|
136
|
+
'--repo',
|
|
137
|
+
])
|
|
138
|
+
const RECORD_SUBCOMMANDS = new Set([
|
|
139
|
+
'start',
|
|
140
|
+
'stop',
|
|
141
|
+
'cancel',
|
|
142
|
+
'status',
|
|
143
|
+
'prelude-start',
|
|
144
|
+
'upload',
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
// the record subcommand (start/stop/cancel/status) is the first non-flag
|
|
148
|
+
// positional — NOT necessarily args[0]. bin.ts prepends `--sim <id>` when a
|
|
149
|
+
// global --sim target is set, so a naive args[0] check silently fell through
|
|
150
|
+
// to the atomic-record path and started a spurious recording (F41).
|
|
151
|
+
function findRecordSubcommand(args: string[]): { name: string; index: number } | null {
|
|
152
|
+
for (let i = 0; i < args.length; i++) {
|
|
153
|
+
const a = args[i]
|
|
154
|
+
if (a.startsWith('-')) {
|
|
155
|
+
if (RECORD_VALUE_FLAGS.has(a)) i++ // skip the flag's value
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
return RECORD_SUBCOMMANDS.has(a) ? { name: a, index: i } : null
|
|
159
|
+
}
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type RepoScope = { owner: string; repo: string } | null
|
|
164
|
+
|
|
165
|
+
// git provenance for PR-preview shares, read from the github-actions env
|
|
166
|
+
// (run.sh exports these). repo-agnostic: every PR-preview run carries the
|
|
167
|
+
// standard GITHUB_* vars, with CONTRAST_* overrides matching resolveCliAuth.
|
|
168
|
+
// merged into the upload identity so the org/recent feed shows the same
|
|
169
|
+
// branch / PR title + "sha · author" for previews as for branch-builds.
|
|
170
|
+
function readRepoScope(args: string[]): RepoScope {
|
|
171
|
+
const owner = valueOf(args, '--owner')?.trim()
|
|
172
|
+
const repo = valueOf(args, '--repo')?.trim()
|
|
173
|
+
if ((owner && !repo) || (!owner && repo)) {
|
|
174
|
+
console.error(' --owner and --repo must be provided together')
|
|
175
|
+
process.exit(1)
|
|
176
|
+
}
|
|
177
|
+
return owner && repo ? { owner, repo } : null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function runRecord(args: string[], opts: RecordOptions) {
|
|
181
|
+
// note: `--help` / `-h` never reach here — bin.ts intercepts them and
|
|
182
|
+
// renders the registry-driven help page (packages/sootsim-skills meta.ts).
|
|
183
|
+
|
|
184
|
+
const sub = findRecordSubcommand(args)
|
|
185
|
+
if (sub) {
|
|
186
|
+
// drop the subcommand token; the rest (incl. any --sim/--port) flows on
|
|
187
|
+
// to the subcommand handlers, which parse bridge flags themselves.
|
|
188
|
+
const rest = [...args.slice(0, sub.index), ...args.slice(sub.index + 1)]
|
|
189
|
+
if (sub.name === 'start') {
|
|
190
|
+
await recordStart(rest, opts)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
if (sub.name === 'prelude-start') {
|
|
194
|
+
await recordPreludeStart(rest, opts)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (sub.name === 'upload') {
|
|
198
|
+
const { runUpload } = await import('./upload')
|
|
199
|
+
await runUpload(rest, opts)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (sub.name === 'stop') {
|
|
203
|
+
await recordStop(rest, opts)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (sub.name === 'cancel') {
|
|
207
|
+
await recordCancel(rest, opts)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
await recordStatus(rest, opts)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
215
|
+
port: opts.port,
|
|
216
|
+
stripBooleanFlags: ['--no-shell', '--shell-only', '--open', '--lockstep'],
|
|
217
|
+
stripValueFlags: [
|
|
218
|
+
'--mode',
|
|
219
|
+
'--duration',
|
|
220
|
+
'--fps',
|
|
221
|
+
'--format',
|
|
222
|
+
'--output',
|
|
223
|
+
'--frames',
|
|
224
|
+
'--max-width',
|
|
225
|
+
'--origin',
|
|
226
|
+
'--owner',
|
|
227
|
+
'--repo',
|
|
228
|
+
],
|
|
229
|
+
})
|
|
230
|
+
const mode = parseMode(valueOf(args, '--mode'))
|
|
231
|
+
const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
|
|
232
|
+
? 'shell'
|
|
233
|
+
: args.includes('--no-shell')
|
|
234
|
+
? 'tenant'
|
|
235
|
+
: undefined
|
|
236
|
+
const formatArg = valueOf(args, '--format') as OutputFormat | undefined
|
|
237
|
+
if (formatArg && !['webm', 'mp4', 'gif', 'png'].includes(formatArg)) {
|
|
238
|
+
console.error(` invalid --format "${formatArg}" — expected webm | mp4 | gif | png`)
|
|
239
|
+
process.exit(1)
|
|
240
|
+
}
|
|
241
|
+
const outputArg = valueOf(args, '--output')
|
|
242
|
+
const durationSec = Number(valueOf(args, '--duration') ?? '10')
|
|
243
|
+
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
|
244
|
+
console.error(
|
|
245
|
+
` invalid --duration "${valueOf(args, '--duration')}" — expected a positive number of seconds`,
|
|
246
|
+
)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
}
|
|
249
|
+
// upper bound: a fat-fingered `--duration 99999` (≈27.7h) otherwise
|
|
250
|
+
// started a runaway recording with no warning (QA F23-3). 600s mirrors
|
|
251
|
+
// the server's MAX_FLOW_VIDEO_DURATION_MS — live/combined uploads past
|
|
252
|
+
// it are rejected anyway, and no legitimate atomic capture runs longer
|
|
253
|
+
// (the bitmap watchdog tears down well before that). float seconds stay
|
|
254
|
+
// allowed (rounded to ms below) — unlike --frames, a duration is a time,
|
|
255
|
+
// not a count, so sub-second precision is meaningful.
|
|
256
|
+
const MAX_DURATION_SEC = 600
|
|
257
|
+
if (durationSec > MAX_DURATION_SEC) {
|
|
258
|
+
console.error(
|
|
259
|
+
` invalid --duration "${valueOf(args, '--duration')}" — exceeds the ${MAX_DURATION_SEC}s (10m) maximum`,
|
|
260
|
+
)
|
|
261
|
+
process.exit(1)
|
|
262
|
+
}
|
|
263
|
+
const fps = Number(valueOf(args, '--fps') ?? '30')
|
|
264
|
+
const framesArg = valueOf(args, '--frames')
|
|
265
|
+
// --frames is a count, not a duration — reject 0 / negatives / fractions
|
|
266
|
+
// up front instead of silently "saving 0 frames" (F11-3), mirroring the
|
|
267
|
+
// --duration guard above.
|
|
268
|
+
if (framesArg !== undefined) {
|
|
269
|
+
const n = Number(framesArg)
|
|
270
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
271
|
+
console.error(
|
|
272
|
+
` invalid --frames "${framesArg}" — expected a positive integer count`,
|
|
273
|
+
)
|
|
274
|
+
process.exit(1)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const openAfter = args.includes('--open')
|
|
278
|
+
const maxWidth = valueOf(args, '--max-width')
|
|
279
|
+
? Number(valueOf(args, '--max-width'))
|
|
280
|
+
: undefined
|
|
281
|
+
if (args.includes('--lockstep')) {
|
|
282
|
+
// atomic mode times the recording in wall seconds, but lockstep virtual
|
|
283
|
+
// time runs slower than the wall clock, so `--duration` would lie about
|
|
284
|
+
// the captured span. drive lockstep through start/stop instead.
|
|
285
|
+
console.error(
|
|
286
|
+
' --lockstep works with `record start` / `record stop` (stateful mode) — atomic --duration is wall-clock and cannot bound a virtual-time capture',
|
|
287
|
+
)
|
|
288
|
+
process.exit(1)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const durationMs = Math.max(100, Math.round(durationSec * 1000))
|
|
292
|
+
// --origin explicitly pins the billing/upload origin. it wins over the
|
|
293
|
+
// saved desktop-session origin so a key minted against a local :3000
|
|
294
|
+
// stack verifies there instead of prod (the e2e blocker — F12).
|
|
295
|
+
const originOverride = valueOf(args, '--origin')
|
|
296
|
+
const repoScope = readRepoScope(args)
|
|
297
|
+
|
|
298
|
+
// live / combined route through the recording store (events + optional
|
|
299
|
+
// video, upload to /preview/<id>) instead of the headless recorder.
|
|
300
|
+
if (mode === 'live' || mode === 'combined') {
|
|
301
|
+
// --output only applies to local-file capture (video/gif/png). live and
|
|
302
|
+
// combined upload to /preview/<id> and write no local file — say so
|
|
303
|
+
// rather than silently swallowing the flag.
|
|
304
|
+
if (outputArg) {
|
|
305
|
+
console.log(
|
|
306
|
+
` note: --output is ignored for --mode ${mode} — the recording uploads to /preview/<id>`,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
// --no-shell / --shell-only select which layers the captured video
|
|
310
|
+
// shows. live mode records events only (no video), so the flag has
|
|
311
|
+
// nothing to act on there — say so rather than dropping it silently
|
|
312
|
+
// (F12-1).
|
|
313
|
+
if (layers && mode === 'live') {
|
|
314
|
+
console.log(
|
|
315
|
+
` note: --${layers === 'shell' ? 'shell-only' : 'no-shell'} is ignored for --mode live — live recordings capture events only, no video`,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
// only the upload-bearing modes touch the cloud, so gate the recording
|
|
319
|
+
// entitlement here, not on local-file video/gif/png capture (F19).
|
|
320
|
+
// allowGitHubAuth mirrors preview flow recording: the github pr-preview
|
|
321
|
+
// runner authenticates with an installation token, not RNX_API_KEY
|
|
322
|
+
// (zero-secrets design). the bypass only fires when auth.kind ===
|
|
323
|
+
// 'github' (ci/preview); local paid users have session/
|
|
324
|
+
// api-key auth and still go through the billing entitlement check.
|
|
325
|
+
await ensureCliRecordingEntitlement('record', {
|
|
326
|
+
originOverride,
|
|
327
|
+
allowGitHubAuth: true,
|
|
328
|
+
})
|
|
329
|
+
const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
|
|
330
|
+
try {
|
|
331
|
+
await assertStoreBridgesAvailable(bridge)
|
|
332
|
+
await assertGuestBundleLoaded(bridge, mode)
|
|
333
|
+
if (mode === 'combined') await assertTabVisibleForCapture(bridge)
|
|
334
|
+
await injectPreviewSessionAuth(bridge, originOverride, repoScope)
|
|
335
|
+
const started = await evalStoreStart(
|
|
336
|
+
bridge,
|
|
337
|
+
mode,
|
|
338
|
+
mode === 'combined' ? layers : undefined,
|
|
339
|
+
)
|
|
340
|
+
if (!started.ok) {
|
|
341
|
+
console.error(
|
|
342
|
+
` start failed: recording store refused to start (${mode})` +
|
|
343
|
+
(started.error ? `: ${started.error}` : ''),
|
|
344
|
+
)
|
|
345
|
+
process.exit(1)
|
|
346
|
+
}
|
|
347
|
+
console.log(` recording ${mode} for ${durationSec}s`)
|
|
348
|
+
await new Promise((r) => setTimeout(r, durationMs))
|
|
349
|
+
await evalStoreStop(bridge)
|
|
350
|
+
const result = await pollStoreUpload(bridge)
|
|
351
|
+
handleStoreUploadResult(result, openAfter, mode)
|
|
352
|
+
} finally {
|
|
353
|
+
bridge.close()
|
|
354
|
+
}
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const framesCount = framesArg ? Number(framesArg) : null
|
|
359
|
+
const format: OutputFormat = resolveFormat(formatArg, outputArg, framesCount)
|
|
360
|
+
|
|
361
|
+
// --open launches the uploaded /preview/<id> page — local-file capture
|
|
362
|
+
// (video/gif/png) produces no URL, so say it's ignored rather than
|
|
363
|
+
// swallowing the flag silently (F12-4).
|
|
364
|
+
if (openAfter) {
|
|
365
|
+
console.log(
|
|
366
|
+
` note: --open is ignored for local-file capture — it applies to --mode live/combined`,
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 })
|
|
371
|
+
try {
|
|
372
|
+
await assertRecorderAvailable(bridge)
|
|
373
|
+
await assertTabVisibleForCapture(bridge)
|
|
374
|
+
|
|
375
|
+
if (format === 'png') {
|
|
376
|
+
const count = framesCount ?? 10
|
|
377
|
+
const outDir = resolveRecordingOutput(outputArg, `sootsim-frames-${stamp()}`)
|
|
378
|
+
// --frames writes one png PER frame, so --output names a directory, not
|
|
379
|
+
// a file. a caller passing `--output shot.png` gets a directory literally
|
|
380
|
+
// named `shot.png` — flag the likely mistake rather than silently doing it.
|
|
381
|
+
if (outputArg && extname(outputArg)) {
|
|
382
|
+
console.log(
|
|
383
|
+
` note: --frames writes multiple pngs, so --output "${outputArg}" is a directory — frame-NNN.png files land inside it`,
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
mkdirSync(outDir, { recursive: true })
|
|
387
|
+
// clear stale frame-NNN.png from a prior run into the same dir — a
|
|
388
|
+
// shorter `--frames` count would otherwise leave higher-numbered frames
|
|
389
|
+
// behind, so the dir mixes two captures (F43). only frame-*.png is
|
|
390
|
+
// touched; any other files the caller put there are left alone.
|
|
391
|
+
for (const name of readdirSync(outDir)) {
|
|
392
|
+
if (/^frame-\d+\.png$/.test(name)) rmSync(join(outDir, name), { force: true })
|
|
393
|
+
}
|
|
394
|
+
console.log(` sampling ${count} frames over ${durationSec}s → ${outDir}`)
|
|
395
|
+
const start: { ok: boolean; requestId?: number; error?: string } =
|
|
396
|
+
await bridge.send({
|
|
397
|
+
type: 'evaluate',
|
|
398
|
+
code: `window.__sootsimRecorder.startFrameCapture({ count: ${count}, durationMs: ${durationMs}${
|
|
399
|
+
layers ? `, layers: ${JSON.stringify(layers)}` : ''
|
|
400
|
+
} })`,
|
|
401
|
+
})
|
|
402
|
+
if (!start.ok || !start.requestId) {
|
|
403
|
+
console.error(` frame capture start failed: ${start.error ?? 'unknown error'}`)
|
|
404
|
+
process.exit(1)
|
|
405
|
+
}
|
|
406
|
+
await new Promise((r) => setTimeout(r, durationMs))
|
|
407
|
+
const deadline = Date.now() + Math.max(5_000, durationMs)
|
|
408
|
+
let frames: Array<{ data: string; size: number }> | null = null
|
|
409
|
+
for (;;) {
|
|
410
|
+
const result: {
|
|
411
|
+
ok: boolean
|
|
412
|
+
done: boolean
|
|
413
|
+
frames?: Array<{ data: string; size: number }>
|
|
414
|
+
error?: string
|
|
415
|
+
} | null = await bridge.send({
|
|
416
|
+
type: 'evaluate',
|
|
417
|
+
code: `window.__sootsimRecorder.getFrameCaptureResult(${start.requestId})`,
|
|
418
|
+
})
|
|
419
|
+
if (!result) {
|
|
420
|
+
console.error(' frame capture result missing')
|
|
421
|
+
process.exit(1)
|
|
422
|
+
}
|
|
423
|
+
if (result.done) {
|
|
424
|
+
if (!result.ok) {
|
|
425
|
+
console.error(` frame capture failed: ${result.error ?? 'unknown error'}`)
|
|
426
|
+
process.exit(1)
|
|
427
|
+
}
|
|
428
|
+
frames = result.frames ?? []
|
|
429
|
+
break
|
|
430
|
+
}
|
|
431
|
+
if (Date.now() >= deadline) {
|
|
432
|
+
console.error(' frame capture timed out')
|
|
433
|
+
process.exit(1)
|
|
434
|
+
}
|
|
435
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
436
|
+
}
|
|
437
|
+
frames.forEach((f, i) => {
|
|
438
|
+
const p = `${outDir}/frame-${String(i + 1).padStart(3, '0')}.png`
|
|
439
|
+
writeFileSync(p, Buffer.from(f.data, 'base64'))
|
|
440
|
+
})
|
|
441
|
+
console.log(` saved ${frames.length} frames`)
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (format === 'gif') {
|
|
446
|
+
const count = framesCount ?? Math.max(10, Math.round((durationSec * fps) / 3))
|
|
447
|
+
const out = resolveRecordingOutput(outputArg, `sootsim-${stamp()}.gif`)
|
|
448
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
449
|
+
console.log(` encoding gif: ${count} frames over ${durationSec}s → ${out}`)
|
|
450
|
+
const result: { data: string; size: number } | null = await evalCaptureStep(
|
|
451
|
+
bridge,
|
|
452
|
+
`window.__sootsimRecorder.captureGif({ frames: ${count}, durationMs: ${durationMs}${
|
|
453
|
+
maxWidth ? `, maxWidth: ${maxWidth}` : ''
|
|
454
|
+
}${layers ? `, layers: ${JSON.stringify(layers)}` : ''} })`,
|
|
455
|
+
'gif encode',
|
|
456
|
+
durationMs,
|
|
457
|
+
)
|
|
458
|
+
if (!result) {
|
|
459
|
+
console.error(' gif capture returned no frames')
|
|
460
|
+
process.exit(1)
|
|
461
|
+
}
|
|
462
|
+
writeFileSync(out, Buffer.from(result.data, 'base64'))
|
|
463
|
+
console.log(` saved: ${out} (${formatBytes(result.size)})`)
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// video path: webm or mp4
|
|
468
|
+
const out = resolveRecordingOutput(outputArg, `sootsim-${stamp()}.${format}`)
|
|
469
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
470
|
+
|
|
471
|
+
const startOpts: {
|
|
472
|
+
format: string
|
|
473
|
+
fps: number
|
|
474
|
+
layers?: string
|
|
475
|
+
durationMs: number
|
|
476
|
+
} = {
|
|
477
|
+
format,
|
|
478
|
+
fps,
|
|
479
|
+
// thread the known duration so an abandoned recording's bitmap lock
|
|
480
|
+
// self-heals in ~duration+20s instead of the 8-min ceiling (F20-1).
|
|
481
|
+
durationMs,
|
|
482
|
+
}
|
|
483
|
+
if (layers) startOpts.layers = layers
|
|
484
|
+
const startResult: { ok: boolean; error?: string; format?: string } =
|
|
485
|
+
await evalCaptureStep(
|
|
486
|
+
bridge,
|
|
487
|
+
`window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
|
|
488
|
+
`${format} start`,
|
|
489
|
+
durationMs,
|
|
490
|
+
)
|
|
491
|
+
if (!startResult.ok) {
|
|
492
|
+
console.error(` start failed: ${startResult.error ?? 'unknown error'}`)
|
|
493
|
+
// a stale render-host streaming session is the usual cause — say how
|
|
494
|
+
// to recover instead of leaving the user staring at a bare error.
|
|
495
|
+
if (
|
|
496
|
+
/render host not available|already streaming|already recording|lock already held/i.test(
|
|
497
|
+
startResult.error ?? '',
|
|
498
|
+
)
|
|
499
|
+
) {
|
|
500
|
+
console.error(
|
|
501
|
+
' a prior recording was abandoned and its capture is still\n' +
|
|
502
|
+
' releasing. retry in ~20s, or recover now with\n' +
|
|
503
|
+
' `rnxsim close --sim <id>` then reopen the sim.',
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
process.exit(1)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
console.log(` recording ${format} for ${durationSec}s → ${out}`)
|
|
510
|
+
await new Promise((r) => setTimeout(r, durationMs))
|
|
511
|
+
|
|
512
|
+
const stopResult: {
|
|
513
|
+
ok: boolean
|
|
514
|
+
error?: string
|
|
515
|
+
size?: number
|
|
516
|
+
mime?: string
|
|
517
|
+
durationMs?: number
|
|
518
|
+
frameCount?: number
|
|
519
|
+
} = await evalCaptureStep(
|
|
520
|
+
bridge,
|
|
521
|
+
`window.__sootsimRecorder.stop()`,
|
|
522
|
+
`${format} encode/flush`,
|
|
523
|
+
durationMs,
|
|
524
|
+
)
|
|
525
|
+
if (!stopResult.ok) {
|
|
526
|
+
console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
|
|
527
|
+
process.exit(1)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (!stopResult.size) {
|
|
531
|
+
console.error(' recorder returned an empty blob — nothing written')
|
|
532
|
+
process.exit(1)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
await downloadBlob(bridge, out)
|
|
536
|
+
console.log(` saved: ${out} (${formatBytes(stopResult.size)})`)
|
|
537
|
+
if (stopResult.durationMs && stopResult.frameCount !== undefined) {
|
|
538
|
+
console.log(
|
|
539
|
+
` source frames: ${stopResult.frameCount} over ${(stopResult.durationMs / 1000).toFixed(2)}s ` +
|
|
540
|
+
`(${(stopResult.frameCount / (stopResult.durationMs / 1000)).toFixed(1)}fps)`,
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
} finally {
|
|
544
|
+
bridge.close()
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// readiness probe with a short timeout. a sim can be registered with the
|
|
549
|
+
// bridge yet never answer an `evaluate` — it loaded no app, or its page/worker
|
|
550
|
+
// died. without the short cap the probe stalls the full recording timeout
|
|
551
|
+
// (60s) before failing; cap it at 6s and fail fast with an actionable message.
|
|
552
|
+
const SIM_PROBE_TIMEOUT_MS = 6_000
|
|
553
|
+
async function probeSim(bridge: WsBridge, code: string): Promise<boolean> {
|
|
554
|
+
try {
|
|
555
|
+
return await bridge.send(
|
|
556
|
+
{ type: 'evaluate', code },
|
|
557
|
+
{ timeoutMs: SIM_PROBE_TIMEOUT_MS },
|
|
558
|
+
)
|
|
559
|
+
} catch (err) {
|
|
560
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
561
|
+
if (/^command timed out after \d+s$/.test(msg)) {
|
|
562
|
+
console.error(
|
|
563
|
+
' sim did not respond — it is connected to the bridge but has not\n' +
|
|
564
|
+
' loaded an app (or its page is unresponsive). run `rnxsim list`\n' +
|
|
565
|
+
' and target a sim with a loaded app via --sim <id>.',
|
|
566
|
+
)
|
|
567
|
+
process.exit(1)
|
|
568
|
+
}
|
|
569
|
+
throw err
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function assertRecorderAvailable(bridge: WsBridge) {
|
|
574
|
+
const ok = await probeSim(bridge, 'typeof window.__sootsimRecorder !== "undefined"')
|
|
575
|
+
if (!ok) {
|
|
576
|
+
console.error(
|
|
577
|
+
' window.__sootsimRecorder missing — is rnx engine running in this sim?',
|
|
578
|
+
)
|
|
579
|
+
process.exit(1)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// frame-capturing recordings (video / gif / png / combined) drive their
|
|
584
|
+
// encoder off requestAnimationFrame and throttled timers, which browsers
|
|
585
|
+
// clamp to ~1fps when the sootsim tab is hidden (backgrounded, or its chrome
|
|
586
|
+
// window minimized). that silently produced a frozen-looking file and is
|
|
587
|
+
// exactly why agents started pre-checking `document.hidden` by hand. refuse
|
|
588
|
+
// up front with an actionable message instead. checkSimHealth already emits
|
|
589
|
+
// the shared hidden-tab warning over the existing bridge probe (DRY — one
|
|
590
|
+
// source for hidden detection); this only adds the hard stop. event-only
|
|
591
|
+
// `live` recordings don't capture frames, so callers skip this guard there.
|
|
592
|
+
async function assertTabVisibleForCapture(bridge: WsBridge) {
|
|
593
|
+
const { hidden } = await checkSimHealth(bridge)
|
|
594
|
+
if (hidden) {
|
|
595
|
+
console.error(
|
|
596
|
+
' refusing to record: the rnx tab is hidden, so frame capture\n' +
|
|
597
|
+
' would be throttled to ~1fps and produce a frozen-looking file.\n' +
|
|
598
|
+
' bring the chrome window to the foreground (un-minimize / switch to\n' +
|
|
599
|
+
' its tab) or run the recording in headless playwright, then retry.',
|
|
600
|
+
)
|
|
601
|
+
process.exit(1)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function downloadBlob(bridge: WsBridge, outPath: string) {
|
|
606
|
+
// stream the blob back in chunks to avoid a single huge base64 payload
|
|
607
|
+
const chunks: Buffer[] = []
|
|
608
|
+
let offset = 0
|
|
609
|
+
while (true) {
|
|
610
|
+
const result: {
|
|
611
|
+
data: string
|
|
612
|
+
size: number
|
|
613
|
+
offset: number
|
|
614
|
+
done: boolean
|
|
615
|
+
mime: string
|
|
616
|
+
} | null = await bridge.send({
|
|
617
|
+
type: 'evaluate',
|
|
618
|
+
code: `window.__sootsimRecorder.getBlobBase64({ offset: ${offset}, chunk: ${
|
|
619
|
+
2 * 1024 * 1024
|
|
620
|
+
} })`,
|
|
621
|
+
})
|
|
622
|
+
if (!result) throw new Error('no blob available on recorder')
|
|
623
|
+
chunks.push(Buffer.from(result.data, 'base64'))
|
|
624
|
+
offset = result.offset
|
|
625
|
+
if (result.done) break
|
|
626
|
+
}
|
|
627
|
+
writeFileSync(outPath, Buffer.concat(chunks))
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export function valueOf(args: string[], flag: string): string | undefined {
|
|
631
|
+
const idx = args.indexOf(flag)
|
|
632
|
+
if (idx < 0 || idx === args.length - 1) return undefined
|
|
633
|
+
return args[idx + 1]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function extToFormat(path?: string): OutputFormat | undefined {
|
|
637
|
+
if (!path) return undefined
|
|
638
|
+
const ext = extname(path).toLowerCase().replace(/^\./, '')
|
|
639
|
+
if (ext === 'webm' || ext === 'mp4' || ext === 'gif') return ext as OutputFormat
|
|
640
|
+
if (ext === 'png') return 'png'
|
|
641
|
+
return undefined
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function resolveFormat(
|
|
645
|
+
formatArg: string | undefined,
|
|
646
|
+
outputArg: string | undefined,
|
|
647
|
+
framesCount: number | null,
|
|
648
|
+
): OutputFormat {
|
|
649
|
+
if (formatArg) return formatArg as OutputFormat
|
|
650
|
+
if (framesCount != null) return 'png'
|
|
651
|
+
return extToFormat(outputArg) ?? 'webm'
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function stamp(): string {
|
|
655
|
+
return new Date().toISOString().replace(/[:T]/g, '-').replace(/\..+/, '')
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// default home for recordings when --output is omitted. writing into the
|
|
659
|
+
// process cwd litters whatever directory the CLI was invoked from (the repo
|
|
660
|
+
// root, most often) with sootsim-*.mp4 / sootsim-frames-*/ — F11-2. an
|
|
661
|
+
// explicit --output still resolves relative to cwd as the caller expects.
|
|
662
|
+
function recordingsDir(): string {
|
|
663
|
+
const dir = join(homedir(), '.sootsim', 'recordings')
|
|
664
|
+
mkdirSync(dir, { recursive: true })
|
|
665
|
+
return dir
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function resolveRecordingOutput(
|
|
669
|
+
outputArg: string | undefined,
|
|
670
|
+
defaultName: string,
|
|
671
|
+
): string {
|
|
672
|
+
return outputArg
|
|
673
|
+
? resolve(process.cwd(), outputArg)
|
|
674
|
+
: join(recordingsDir(), defaultName)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function formatBytes(n: number): string {
|
|
678
|
+
if (n < 1024) return `${n}B`
|
|
679
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
|
|
680
|
+
return `${(n / (1024 * 1024)).toFixed(2)}MB`
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
interface RecordingState {
|
|
684
|
+
simId: string | null
|
|
685
|
+
mode: RecordMode
|
|
686
|
+
format: 'webm' | 'mp4'
|
|
687
|
+
fps: number
|
|
688
|
+
layers?: 'tenant' | 'shell'
|
|
689
|
+
startedAt: string
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function parseMode(raw: string | undefined): RecordMode {
|
|
693
|
+
if (!raw) return 'video'
|
|
694
|
+
if (raw === 'video' || raw === 'live' || raw === 'combined') return raw
|
|
695
|
+
console.error(` invalid --mode "${raw}" — expected video | live | combined`)
|
|
696
|
+
process.exit(1)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function recordingStatePath(): string {
|
|
700
|
+
return join(tmpdir(), `sootsim-recording-${getCliIdentityKey()}.json`)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function readRecordingState(): RecordingState | null {
|
|
704
|
+
const p = recordingStatePath()
|
|
705
|
+
if (!existsSync(p)) return null
|
|
706
|
+
try {
|
|
707
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8')) as Partial<RecordingState>
|
|
708
|
+
// default mode for state files written before --mode existed
|
|
709
|
+
return { mode: 'video', ...parsed } as RecordingState
|
|
710
|
+
} catch {
|
|
711
|
+
rmSync(p, { force: true })
|
|
712
|
+
return null
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function writeRecordingState(state: RecordingState) {
|
|
717
|
+
writeFileSync(recordingStatePath(), JSON.stringify(state, null, 2))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function clearRecordingState() {
|
|
721
|
+
rmSync(recordingStatePath(), { force: true })
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// the record state file is global — keyed by CLI identity, not per-sim — so
|
|
725
|
+
// `status`/`stop`/`cancel` act on whichever `record start` session exists
|
|
726
|
+
// regardless of --sim. when the caller targets a different sim than the one
|
|
727
|
+
// the session was started on, say so instead of silently reporting/stopping
|
|
728
|
+
// another sim's recording (F11-1).
|
|
729
|
+
function noteSimMismatch(
|
|
730
|
+
requestedSimId: string | null | undefined,
|
|
731
|
+
state: RecordingState,
|
|
732
|
+
) {
|
|
733
|
+
if (requestedSimId && state.simId && requestedSimId !== state.simId) {
|
|
734
|
+
console.log(
|
|
735
|
+
` note: the tracked recording session is on sim ${state.simId}, not the\n` +
|
|
736
|
+
` requested sim ${requestedSimId}. record state is global (not per-sim);\n` +
|
|
737
|
+
` this command acts on that session regardless of --sim.`,
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async function recordStart(args: string[], opts: RecordOptions) {
|
|
743
|
+
const existing = readRecordingState()
|
|
744
|
+
if (existing) {
|
|
745
|
+
console.error(
|
|
746
|
+
` recording already in progress (started ${existing.startedAt}, sim ${existing.simId ?? '?'}). run \`rnxsim record stop\` first, or \`rnxsim record cancel\` to discard.`,
|
|
747
|
+
)
|
|
748
|
+
process.exit(1)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
752
|
+
port: opts.port,
|
|
753
|
+
stripBooleanFlags: ['--no-shell', '--shell-only', '--lockstep'],
|
|
754
|
+
stripValueFlags: [
|
|
755
|
+
'--mode',
|
|
756
|
+
'--fps',
|
|
757
|
+
'--format',
|
|
758
|
+
'--max-width',
|
|
759
|
+
'--origin',
|
|
760
|
+
'--owner',
|
|
761
|
+
'--repo',
|
|
762
|
+
],
|
|
763
|
+
})
|
|
764
|
+
const mode = parseMode(valueOf(args, '--mode'))
|
|
765
|
+
const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only')
|
|
766
|
+
? 'shell'
|
|
767
|
+
: args.includes('--no-shell')
|
|
768
|
+
? 'tenant'
|
|
769
|
+
: undefined
|
|
770
|
+
const formatArg = valueOf(args, '--format') as OutputFormat | undefined
|
|
771
|
+
const format = formatArg === 'mp4' ? 'mp4' : 'webm'
|
|
772
|
+
if (formatArg && format !== formatArg) {
|
|
773
|
+
console.error(
|
|
774
|
+
` record start only supports webm or mp4 (got: ${formatArg}). for gif/png use atomic mode: rnxsim record --format ${formatArg} --duration <s>`,
|
|
775
|
+
)
|
|
776
|
+
process.exit(1)
|
|
777
|
+
}
|
|
778
|
+
const fps = Number(valueOf(args, '--fps') ?? '30')
|
|
779
|
+
const repoScope = readRepoScope(args)
|
|
780
|
+
|
|
781
|
+
const bridge = createBridgeFromParsed({
|
|
782
|
+
...parsed,
|
|
783
|
+
commandTimeoutMs:
|
|
784
|
+
mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000,
|
|
785
|
+
})
|
|
786
|
+
try {
|
|
787
|
+
if (mode === 'live' || mode === 'combined') {
|
|
788
|
+
// live mode captures events only — --no-shell / --shell-only have no
|
|
789
|
+
// video to act on there (F12-1).
|
|
790
|
+
if (layers && mode === 'live') {
|
|
791
|
+
console.log(
|
|
792
|
+
` note: --${layers === 'shell' ? 'shell-only' : 'no-shell'} is ignored for --mode live — live recordings capture events only, no video`,
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
// only upload-bearing modes hit the cloud — gate the recording
|
|
796
|
+
// entitlement here, not on local-file webm/mp4 capture (F19).
|
|
797
|
+
// allowGitHubAuth: the PR-preview runner (`record start --mode
|
|
798
|
+
// combined`) authenticates with a GitHub installation token, not
|
|
799
|
+
// RNX_API_KEY — the whole pipeline is zero-secrets by design.
|
|
800
|
+
// mirrors `flow --record`. the bypass only triggers for github-kind
|
|
801
|
+
// auth (CI); local paid users still hit the billing check.
|
|
802
|
+
await ensureCliRecordingEntitlement('record', {
|
|
803
|
+
originOverride: valueOf(args, '--origin'),
|
|
804
|
+
allowGitHubAuth: true,
|
|
805
|
+
})
|
|
806
|
+
await assertStoreBridgesAvailable(bridge)
|
|
807
|
+
await assertGuestBundleLoaded(bridge, mode)
|
|
808
|
+
if (mode === 'combined') await assertTabVisibleForCapture(bridge)
|
|
809
|
+
await injectPreviewSessionAuth(bridge, valueOf(args, '--origin'), repoScope)
|
|
810
|
+
const started = await evalStoreStart(
|
|
811
|
+
bridge,
|
|
812
|
+
mode,
|
|
813
|
+
mode === 'combined' ? layers : undefined,
|
|
814
|
+
)
|
|
815
|
+
if (!started.ok) {
|
|
816
|
+
console.error(
|
|
817
|
+
` start failed: recording store refused to start (${mode})` +
|
|
818
|
+
(started.error ? `: ${started.error}` : ''),
|
|
819
|
+
)
|
|
820
|
+
process.exit(1)
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
await assertRecorderAvailable(bridge)
|
|
824
|
+
await assertTabVisibleForCapture(bridge)
|
|
825
|
+
const lockstep = args.includes('--lockstep')
|
|
826
|
+
if (lockstep && format !== 'mp4') {
|
|
827
|
+
console.error(' --lockstep requires --format mp4 (webm is realtime-clocked)')
|
|
828
|
+
process.exit(1)
|
|
829
|
+
}
|
|
830
|
+
const startOpts: {
|
|
831
|
+
format: string
|
|
832
|
+
fps: number
|
|
833
|
+
layers?: string
|
|
834
|
+
lockstep?: boolean
|
|
835
|
+
} = { format, fps }
|
|
836
|
+
if (layers) startOpts.layers = layers
|
|
837
|
+
if (lockstep) startOpts.lockstep = true
|
|
838
|
+
const result: { ok: boolean; error?: string } = await bridge.send({
|
|
839
|
+
type: 'evaluate',
|
|
840
|
+
code: `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`,
|
|
841
|
+
})
|
|
842
|
+
if (!result.ok) {
|
|
843
|
+
console.error(` start failed: ${result.error ?? 'unknown error'}`)
|
|
844
|
+
process.exit(1)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
writeRecordingState({
|
|
848
|
+
simId: parsed.simId ?? null,
|
|
849
|
+
mode,
|
|
850
|
+
format,
|
|
851
|
+
fps,
|
|
852
|
+
layers,
|
|
853
|
+
startedAt: new Date().toISOString(),
|
|
854
|
+
})
|
|
855
|
+
if (mode === 'video') {
|
|
856
|
+
console.log(
|
|
857
|
+
` recording ${format} @ ${fps}fps${layers ? ` (${layers})` : ''} — run \`rnxsim record stop --output <path>\` when done`,
|
|
858
|
+
)
|
|
859
|
+
} else {
|
|
860
|
+
console.log(
|
|
861
|
+
` recording ${mode} — run \`rnxsim record stop\` when done (add --open to launch the preview URL)`,
|
|
862
|
+
)
|
|
863
|
+
}
|
|
864
|
+
} finally {
|
|
865
|
+
bridge.close()
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function recordPreludeStart(args: string[], opts: RecordOptions) {
|
|
870
|
+
const existing = readRecordingState()
|
|
871
|
+
if (existing) {
|
|
872
|
+
console.error(
|
|
873
|
+
` recording already in progress (started ${existing.startedAt}, sim ${existing.simId ?? '?'}). run \`rnxsim record stop\` first, or \`rnxsim record cancel\` to discard.`,
|
|
874
|
+
)
|
|
875
|
+
process.exit(1)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
879
|
+
port: opts.port,
|
|
880
|
+
stripBooleanFlags: [],
|
|
881
|
+
stripValueFlags: ['--origin'],
|
|
882
|
+
})
|
|
883
|
+
const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 15_000 })
|
|
884
|
+
try {
|
|
885
|
+
await assertStoreBridgesAvailable(bridge)
|
|
886
|
+
await assertTabVisibleForCapture(bridge)
|
|
887
|
+
const ok: boolean = await bridge.send({
|
|
888
|
+
type: 'evaluate',
|
|
889
|
+
code: `(() => {
|
|
890
|
+
const start = window.SootSim?.bridges?.startRecordingPrelude
|
|
891
|
+
if (typeof start !== 'function') return false
|
|
892
|
+
return Promise.resolve(start()).then((value) => value === true)
|
|
893
|
+
})()`,
|
|
894
|
+
})
|
|
895
|
+
if (!ok) {
|
|
896
|
+
const reason = await bridge
|
|
897
|
+
.send({
|
|
898
|
+
type: 'evaluate',
|
|
899
|
+
code: `(() => {
|
|
900
|
+
const getError = window.SootSim?.bridges?.getRecordingStartError
|
|
901
|
+
return typeof getError === 'function' ? getError() : null
|
|
902
|
+
})()`,
|
|
903
|
+
})
|
|
904
|
+
.catch(() => null)
|
|
905
|
+
console.error(
|
|
906
|
+
` prelude start failed: recording store refused to start${
|
|
907
|
+
typeof reason === 'string' && reason ? ` (${reason})` : ''
|
|
908
|
+
}`,
|
|
909
|
+
)
|
|
910
|
+
process.exit(1)
|
|
911
|
+
}
|
|
912
|
+
console.log(
|
|
913
|
+
' recording preview prelude — run `rnxsim record start --mode combined` when the visible proof segment is ready',
|
|
914
|
+
)
|
|
915
|
+
} finally {
|
|
916
|
+
bridge.close()
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// query the engine for the *actual* recording state. the local state file
|
|
921
|
+
// only tracks `record start` sessions — an atomic `record --duration` run
|
|
922
|
+
// or the in-browser record button records without writing it. probing the
|
|
923
|
+
// engine lets `status`/`cancel` report the truth instead of lying "no
|
|
924
|
+
// recording in progress" while one is plainly running (F52).
|
|
925
|
+
//
|
|
926
|
+
// two independent recorder surfaces exist and must both be checked:
|
|
927
|
+
// - the recording store (`SootSim.bridges.getRecordingState`) drives
|
|
928
|
+
// live/combined preview-share recordings
|
|
929
|
+
// - the headless recorder (`__sootsimRecorder`) drives atomic video/gif/
|
|
930
|
+
// png capture — its state lives on a separate `active` flag
|
|
931
|
+
// - the event recorder alone drives hidden PR-preview preludes before a
|
|
932
|
+
// visible live/combined segment exists
|
|
933
|
+
// returns null when the engine can't be reached.
|
|
934
|
+
type EngineRecordingState = 'idle' | 'recording' | 'processing' | 'prelude'
|
|
935
|
+
|
|
936
|
+
async function probeEngineRecordingState(
|
|
937
|
+
bridge: WsBridge,
|
|
938
|
+
): Promise<EngineRecordingState | null> {
|
|
939
|
+
try {
|
|
940
|
+
const snap: { store?: string; headless?: string; eventRecording?: boolean } | null =
|
|
941
|
+
await bridge.send({
|
|
942
|
+
type: 'evaluate',
|
|
943
|
+
code: `(() => {
|
|
944
|
+
const store = window.SootSim?.bridges?.getRecordingState?.();
|
|
945
|
+
const headless = window.__sootsimRecorder?.state?.();
|
|
946
|
+
const eventRecorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder;
|
|
947
|
+
const eventRecording =
|
|
948
|
+
typeof eventRecorder?.isRecording === 'function'
|
|
949
|
+
? eventRecorder.isRecording() === true
|
|
950
|
+
: false;
|
|
951
|
+
return { store: store ? store.state : undefined, headless, eventRecording };
|
|
952
|
+
})()`,
|
|
953
|
+
})
|
|
954
|
+
if (!snap) return null
|
|
955
|
+
if (snap.store === 'recording' || snap.headless === 'recording') return 'recording'
|
|
956
|
+
if (snap.store === 'processing') return 'processing'
|
|
957
|
+
if (snap.eventRecording) return 'prelude'
|
|
958
|
+
if (snap.store === 'idle' || snap.headless === 'idle') return 'idle'
|
|
959
|
+
return null
|
|
960
|
+
} catch {
|
|
961
|
+
return null
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async function cancelEngineRecording(bridge: WsBridge): Promise<{
|
|
966
|
+
store: boolean
|
|
967
|
+
headless: boolean
|
|
968
|
+
}> {
|
|
969
|
+
const storeCanceled: boolean = await bridge
|
|
970
|
+
.send({
|
|
971
|
+
type: 'evaluate',
|
|
972
|
+
code: `(() => {
|
|
973
|
+
const store = window.SootSim?.bridges?.getRecordingState?.();
|
|
974
|
+
const eventRecorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder;
|
|
975
|
+
const hadStoreRecording = store && store.state !== 'idle';
|
|
976
|
+
const hadEventRecording =
|
|
977
|
+
typeof eventRecorder?.isRecording === 'function'
|
|
978
|
+
? eventRecorder.isRecording() === true
|
|
979
|
+
: false;
|
|
980
|
+
const cancel = window.SootSim?.bridges?.cancelRecording;
|
|
981
|
+
if (typeof cancel === 'function') cancel();
|
|
982
|
+
return Boolean(hadStoreRecording || hadEventRecording);
|
|
983
|
+
})()`,
|
|
984
|
+
})
|
|
985
|
+
.catch(() => false)
|
|
986
|
+
const headlessCanceled: boolean = await bridge
|
|
987
|
+
.send({
|
|
988
|
+
type: 'evaluate',
|
|
989
|
+
code: `(async () => {
|
|
990
|
+
const r = window.__sootsimRecorder;
|
|
991
|
+
if (r && typeof r.forceRelease === 'function') return await r.forceRelease();
|
|
992
|
+
return false;
|
|
993
|
+
})()`,
|
|
994
|
+
})
|
|
995
|
+
.catch(() => false)
|
|
996
|
+
return { store: storeCanceled, headless: headlessCanceled }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function recordStatus(args: string[], opts: RecordOptions) {
|
|
1000
|
+
const state = readRecordingState()
|
|
1001
|
+
if (state) {
|
|
1002
|
+
noteSimMismatch(parseBridgeCliArgs(args, { port: opts.port }).simId, state)
|
|
1003
|
+
if (state.mode === 'video') {
|
|
1004
|
+
console.log(
|
|
1005
|
+
` recording ${state.mode} (${state.format} @ ${state.fps}fps) on sim ${state.simId ?? '?'} since ${state.startedAt}`,
|
|
1006
|
+
)
|
|
1007
|
+
} else {
|
|
1008
|
+
console.log(
|
|
1009
|
+
` recording ${state.mode} on sim ${state.simId ?? '?'} since ${state.startedAt}`,
|
|
1010
|
+
)
|
|
1011
|
+
}
|
|
1012
|
+
return
|
|
1013
|
+
}
|
|
1014
|
+
// no `record start` session — but an atomic run or the rail button may
|
|
1015
|
+
// still be recording. ask the engine before claiming nothing is happening.
|
|
1016
|
+
const parsed = parseBridgeCliArgs(args, { port: opts.port })
|
|
1017
|
+
const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 8_000 })
|
|
1018
|
+
try {
|
|
1019
|
+
const engineState = await probeEngineRecordingState(bridge)
|
|
1020
|
+
if (
|
|
1021
|
+
engineState === 'recording' ||
|
|
1022
|
+
engineState === 'processing' ||
|
|
1023
|
+
engineState === 'prelude'
|
|
1024
|
+
) {
|
|
1025
|
+
if (engineState === 'prelude') {
|
|
1026
|
+
console.log(
|
|
1027
|
+
' a recording preview prelude is active. hidden setup is being\n' +
|
|
1028
|
+
' captured for the next live/combined preview recording. finish\n' +
|
|
1029
|
+
' it with `rnxsim record start --mode combined`, or discard it\n' +
|
|
1030
|
+
' with `rnxsim record cancel`.',
|
|
1031
|
+
)
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
console.log(
|
|
1035
|
+
` a recording is in progress (engine state: ${engineState}), but not via\n` +
|
|
1036
|
+
' `record start` — it was started by an atomic `record --duration`\n' +
|
|
1037
|
+
' run or the in-browser record button, which own their own lifecycle.\n' +
|
|
1038
|
+
' if that controlling run is still alive it will finish on its own\n' +
|
|
1039
|
+
' (atomic `--mode video/gif/png` writes a local file from that\n' +
|
|
1040
|
+
' process; live/combined upload to /preview/<id>). if the run was\n' +
|
|
1041
|
+
' killed, the recording is abandoned and will NOT finish — reclaim\n' +
|
|
1042
|
+
' it now with `rnxsim record cancel`, or it auto-reclaims when the\n' +
|
|
1043
|
+
" abandoned capture's deadline lapses.",
|
|
1044
|
+
)
|
|
1045
|
+
} else {
|
|
1046
|
+
console.log(' no recording in progress')
|
|
1047
|
+
}
|
|
1048
|
+
} finally {
|
|
1049
|
+
bridge.close()
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function recordCancel(args: string[], opts: RecordOptions) {
|
|
1054
|
+
const state = readRecordingState()
|
|
1055
|
+
if (!state) {
|
|
1056
|
+
// no `record start` session to cancel — but check the engine so we
|
|
1057
|
+
// don't falsely claim nothing is happening while an atomic run records.
|
|
1058
|
+
const probeParsed = parseBridgeCliArgs(args, { port: opts.port })
|
|
1059
|
+
const probeBridge = createBridgeFromParsed({
|
|
1060
|
+
...probeParsed,
|
|
1061
|
+
commandTimeoutMs: 8_000,
|
|
1062
|
+
})
|
|
1063
|
+
try {
|
|
1064
|
+
const engineState = await probeEngineRecordingState(probeBridge)
|
|
1065
|
+
if (
|
|
1066
|
+
engineState === 'recording' ||
|
|
1067
|
+
engineState === 'processing' ||
|
|
1068
|
+
engineState === 'prelude'
|
|
1069
|
+
) {
|
|
1070
|
+
// `record cancel` is explicit intent: the caller wants whatever is
|
|
1071
|
+
// recording gone. force-reclaim the headless/store recorder rather
|
|
1072
|
+
// than refusing and telling them to wait out the deadline (QA
|
|
1073
|
+
// F22-1). this also un-wedges live/combined, which the killed
|
|
1074
|
+
// atomic `--mode video` bitmap lock was blocking.
|
|
1075
|
+
const reclaimed = await cancelEngineRecording(probeBridge)
|
|
1076
|
+
if (reclaimed.store && engineState === 'prelude') {
|
|
1077
|
+
console.log(' recording preview prelude cancelled')
|
|
1078
|
+
} else if (reclaimed.store || reclaimed.headless) {
|
|
1079
|
+
console.log(
|
|
1080
|
+
' reclaimed an abandoned recording (started by an atomic\n' +
|
|
1081
|
+
' `record --duration` run or the record button whose owner is\n' +
|
|
1082
|
+
' gone). the recorder is idle again — live/combined can start.',
|
|
1083
|
+
)
|
|
1084
|
+
} else {
|
|
1085
|
+
console.log(
|
|
1086
|
+
' a recording is in progress and still owned by a live atomic\n' +
|
|
1087
|
+
' `record --duration` run or the in-browser record button — it\n' +
|
|
1088
|
+
' will finish on its own and nothing was reclaimed.',
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
} else {
|
|
1092
|
+
console.log(' no recording in progress')
|
|
1093
|
+
}
|
|
1094
|
+
} finally {
|
|
1095
|
+
probeBridge.close()
|
|
1096
|
+
}
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
const parsed = parseBridgeCliArgs(args, { port: opts.port })
|
|
1100
|
+
noteSimMismatch(parsed.simId, state)
|
|
1101
|
+
// the recorder (and its buffered blob) lives in the page of the sim the
|
|
1102
|
+
// recording was *started* on — `state.simId` wins over the requested
|
|
1103
|
+
// --sim, otherwise cancel would stop an idle recorder on the wrong sim
|
|
1104
|
+
// and leave the real recording running (F11-1).
|
|
1105
|
+
const simId = state.simId ?? parsed.simId ?? undefined
|
|
1106
|
+
const bridge = createBridgeFromParsed({
|
|
1107
|
+
...parsed,
|
|
1108
|
+
simId,
|
|
1109
|
+
commandTimeoutMs: 15_000,
|
|
1110
|
+
})
|
|
1111
|
+
try {
|
|
1112
|
+
if (state.mode === 'live' || state.mode === 'combined') {
|
|
1113
|
+
await bridge.send({
|
|
1114
|
+
type: 'evaluate',
|
|
1115
|
+
code: `void window.SootSim?.bridges?.cancelRecording?.()`,
|
|
1116
|
+
})
|
|
1117
|
+
} else {
|
|
1118
|
+
await bridge.send({
|
|
1119
|
+
type: 'evaluate',
|
|
1120
|
+
code: `window.__sootsimRecorder.stop()`,
|
|
1121
|
+
})
|
|
1122
|
+
}
|
|
1123
|
+
} catch {
|
|
1124
|
+
// best-effort — always clear local state
|
|
1125
|
+
} finally {
|
|
1126
|
+
clearRecordingState()
|
|
1127
|
+
bridge.close()
|
|
1128
|
+
}
|
|
1129
|
+
console.log(' recording cancelled')
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function recordStop(args: string[], opts: RecordOptions) {
|
|
1133
|
+
const state = readRecordingState()
|
|
1134
|
+
if (!state) {
|
|
1135
|
+
console.error(' no recording in progress. start one with `rnxsim record start`.')
|
|
1136
|
+
process.exit(1)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const parsed = parseBridgeCliArgs(args, {
|
|
1140
|
+
port: opts.port,
|
|
1141
|
+
stripBooleanFlags: ['--open'],
|
|
1142
|
+
stripValueFlags: ['--output'],
|
|
1143
|
+
})
|
|
1144
|
+
noteSimMismatch(parsed.simId, state)
|
|
1145
|
+
// the recorder + buffered blob live in the sim the recording was started
|
|
1146
|
+
// on — route there, not to the requested --sim, or stop would drain an
|
|
1147
|
+
// idle recorder on the wrong sim and lose the capture (F11-1).
|
|
1148
|
+
const simId = state.simId ?? parsed.simId ?? undefined
|
|
1149
|
+
const openAfter = args.includes('--open')
|
|
1150
|
+
|
|
1151
|
+
const bridge = createBridgeFromParsed({
|
|
1152
|
+
...parsed,
|
|
1153
|
+
simId,
|
|
1154
|
+
commandTimeoutMs:
|
|
1155
|
+
state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000,
|
|
1156
|
+
})
|
|
1157
|
+
try {
|
|
1158
|
+
if (state.mode === 'live' || state.mode === 'combined') {
|
|
1159
|
+
await evalStoreStop(bridge)
|
|
1160
|
+
const result = await pollStoreUpload(bridge)
|
|
1161
|
+
clearRecordingState()
|
|
1162
|
+
handleStoreUploadResult(result, openAfter, state.mode)
|
|
1163
|
+
return
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const outputArg = valueOf(args, '--output')
|
|
1167
|
+
const out = resolveRecordingOutput(outputArg, `sootsim-${stamp()}.${state.format}`)
|
|
1168
|
+
mkdirSync(dirname(out), { recursive: true })
|
|
1169
|
+
const stopResult: {
|
|
1170
|
+
ok: boolean
|
|
1171
|
+
error?: string
|
|
1172
|
+
size?: number
|
|
1173
|
+
mime?: string
|
|
1174
|
+
durationMs?: number
|
|
1175
|
+
frameCount?: number
|
|
1176
|
+
} = await bridge.send({
|
|
1177
|
+
type: 'evaluate',
|
|
1178
|
+
code: `window.__sootsimRecorder.stop()`,
|
|
1179
|
+
})
|
|
1180
|
+
if (!stopResult.ok) {
|
|
1181
|
+
console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`)
|
|
1182
|
+
clearRecordingState()
|
|
1183
|
+
process.exit(1)
|
|
1184
|
+
}
|
|
1185
|
+
if (!stopResult.size) {
|
|
1186
|
+
console.error(' recorder returned an empty blob — nothing written')
|
|
1187
|
+
clearRecordingState()
|
|
1188
|
+
process.exit(1)
|
|
1189
|
+
}
|
|
1190
|
+
await downloadBlob(bridge, out)
|
|
1191
|
+
clearRecordingState()
|
|
1192
|
+
console.log(` saved: ${out} (${formatBytes(stopResult.size)})`)
|
|
1193
|
+
if (stopResult.durationMs && stopResult.frameCount !== undefined) {
|
|
1194
|
+
console.log(
|
|
1195
|
+
` source frames: ${stopResult.frameCount} over ${(stopResult.durationMs / 1000).toFixed(2)}s ` +
|
|
1196
|
+
`(${(stopResult.frameCount / (stopResult.durationMs / 1000)).toFixed(1)}fps)`,
|
|
1197
|
+
)
|
|
1198
|
+
}
|
|
1199
|
+
} finally {
|
|
1200
|
+
bridge.close()
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// ─── store-backed recording helpers (live / combined) ─────────────────────
|
|
1205
|
+
|
|
1206
|
+
async function assertStoreBridgesAvailable(bridge: WsBridge) {
|
|
1207
|
+
const ok = await probeSim(
|
|
1208
|
+
bridge,
|
|
1209
|
+
'typeof window.SootSim?.bridges?.startRecording === "function" && typeof window.SootSim?.bridges?.stopRecording === "function"',
|
|
1210
|
+
)
|
|
1211
|
+
if (!ok) {
|
|
1212
|
+
console.error(
|
|
1213
|
+
' SootSim.bridges.startRecording missing — is rnx engine running in this sim?',
|
|
1214
|
+
)
|
|
1215
|
+
process.exit(1)
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// CLI-driven live/combined recording runs in a headless browser that was
|
|
1220
|
+
// never logged in, so browser-side upload has no bearer. push the CLI's own
|
|
1221
|
+
// auth — the same RNX_API_KEY / `sootsim login` / GitHub token
|
|
1222
|
+
// ensureCliRecordingEntitlement just verified — into the page's shared-session
|
|
1223
|
+
// store before starting. GitHub PR-preview auth also needs its repo identity
|
|
1224
|
+
// in the upload init body so the server treats the bearer as a GitHub upload
|
|
1225
|
+
// rather than a contrast user session.
|
|
1226
|
+
// combined/live recordings upload the guest bundle to /preview/<id> so the
|
|
1227
|
+
// page can replay the app. a built-in shell app (photos, settings, the home
|
|
1228
|
+
// grid) has no guest bundle — `uploadLivePreview` only discovers this at the
|
|
1229
|
+
// END and throws "no bundle loaded" *after* the full timed recording already
|
|
1230
|
+
// ran (QA F19-3). probe the same `__sootsimCaptureBundle` snapshot the upload
|
|
1231
|
+
// uses, up front, and fail fast with an actionable message instead.
|
|
1232
|
+
async function assertGuestBundleLoaded(bridge: WsBridge, mode: RecordMode) {
|
|
1233
|
+
const hasBundle = await probeSim(
|
|
1234
|
+
bridge,
|
|
1235
|
+
`(() => {
|
|
1236
|
+
const fn = window.__sootsimCaptureBundle
|
|
1237
|
+
if (typeof fn !== 'function') return false
|
|
1238
|
+
const snap = fn()
|
|
1239
|
+
return !!(snap && snap.bundleUrl)
|
|
1240
|
+
})()`,
|
|
1241
|
+
)
|
|
1242
|
+
if (!hasBundle) {
|
|
1243
|
+
console.error(
|
|
1244
|
+
` --mode ${mode} needs a guest app bundle to upload to /preview/<id>,\n` +
|
|
1245
|
+
` but this sim has no bundle loaded (a built-in shell screen like\n` +
|
|
1246
|
+
` photos/settings/home has none). open a metro/guest app first, or\n` +
|
|
1247
|
+
` use \`--mode video\` for a local screen recording with no upload.`,
|
|
1248
|
+
)
|
|
1249
|
+
process.exit(1)
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function injectPreviewSessionAuth(
|
|
1254
|
+
bridge: WsBridge,
|
|
1255
|
+
originOverride?: string,
|
|
1256
|
+
repoScope?: RepoScope,
|
|
1257
|
+
): Promise<void> {
|
|
1258
|
+
const auth = resolveCliAuth()
|
|
1259
|
+
// ensureCliRecordingEntitlement already exits the process when auth is
|
|
1260
|
+
// missing — this guard is purely defensive.
|
|
1261
|
+
if (!auth) return
|
|
1262
|
+
const token = auth.kind === 'api-key' ? auth.secret : auth.token
|
|
1263
|
+
const provenance = resolveRunProvenance()
|
|
1264
|
+
const gitContext = {
|
|
1265
|
+
branch: provenance.branch ?? undefined,
|
|
1266
|
+
commitSha: provenance.commitSha ?? undefined,
|
|
1267
|
+
githubUsername: provenance.githubUsername ?? undefined,
|
|
1268
|
+
pullRequestNumber: provenance.pullRequestNumber ?? undefined,
|
|
1269
|
+
pullRequestTitle: provenance.pullRequestTitle ?? undefined,
|
|
1270
|
+
}
|
|
1271
|
+
const uploadIdentity =
|
|
1272
|
+
auth.kind === 'github'
|
|
1273
|
+
? {
|
|
1274
|
+
...githubUploadIdentity(auth),
|
|
1275
|
+
owner: repoScope?.owner,
|
|
1276
|
+
repo: repoScope?.repo,
|
|
1277
|
+
...gitContext,
|
|
1278
|
+
}
|
|
1279
|
+
: repoScope
|
|
1280
|
+
? {
|
|
1281
|
+
owner: repoScope.owner,
|
|
1282
|
+
repo: repoScope.repo,
|
|
1283
|
+
...gitContext,
|
|
1284
|
+
}
|
|
1285
|
+
: null
|
|
1286
|
+
const uploadOrigin = originOverride ? originOverride.replace(/\/$/, '') : null
|
|
1287
|
+
const ok: boolean = await bridge.send({
|
|
1288
|
+
type: 'evaluate',
|
|
1289
|
+
code: `(() => {
|
|
1290
|
+
const set = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.setSession
|
|
1291
|
+
if (typeof set !== 'function') return false
|
|
1292
|
+
set({ token: ${JSON.stringify(token)}, user: null })
|
|
1293
|
+
window.__sootsimPreviewUploadIdentity = ${JSON.stringify(uploadIdentity)}
|
|
1294
|
+
window.__sootsimPreviewUseInjectedBearer = true
|
|
1295
|
+
${
|
|
1296
|
+
uploadOrigin
|
|
1297
|
+
? `window.__sootsimUploadOrigin = ${JSON.stringify(uploadOrigin)}`
|
|
1298
|
+
: 'delete window.__sootsimUploadOrigin'
|
|
1299
|
+
}
|
|
1300
|
+
return true
|
|
1301
|
+
})()`,
|
|
1302
|
+
})
|
|
1303
|
+
if (!ok) {
|
|
1304
|
+
// the usual cause is a stale tab — a sim that connected before the
|
|
1305
|
+
// engine was rebuilt keeps the old bundle until it reloads. an actually
|
|
1306
|
+
// outdated install is the rarer case, so lead with the cheap fix.
|
|
1307
|
+
console.error(
|
|
1308
|
+
' SootSim.bridges.setSession missing — this sim is running an engine\n' +
|
|
1309
|
+
' build without CLI-injected preview auth. reload the sim or open a\n' +
|
|
1310
|
+
' fresh one (`rnxsim open --new`); if it persists, update rnx.',
|
|
1311
|
+
)
|
|
1312
|
+
process.exit(1)
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function evalStoreStart(
|
|
1317
|
+
bridge: WsBridge,
|
|
1318
|
+
mode: RecordMode,
|
|
1319
|
+
layers?: 'tenant' | 'shell',
|
|
1320
|
+
): Promise<{ ok: boolean; error: string | null }> {
|
|
1321
|
+
// the CLI already made the entitlement/auth decision before entering the
|
|
1322
|
+
// engine. pass that explicit fact through so GitHub PR-preview auth is not
|
|
1323
|
+
// rejected by the browser-session billing gate.
|
|
1324
|
+
const layersArg = layers ? JSON.stringify(layers) : 'undefined'
|
|
1325
|
+
const ok: boolean = await bridge.send({
|
|
1326
|
+
type: 'evaluate',
|
|
1327
|
+
code: `window.SootSim.bridges.startRecording(${JSON.stringify(mode)}, ${layersArg}, { skipEntitlement: true })`,
|
|
1328
|
+
})
|
|
1329
|
+
if (ok === true) return { ok: true, error: null }
|
|
1330
|
+
// the engine knows exactly why it refused — read the structured reason
|
|
1331
|
+
// back so the operator (and the PR-preview agent) sees it instead of an
|
|
1332
|
+
// opaque "recording store refused to start".
|
|
1333
|
+
const error: string | null = await bridge
|
|
1334
|
+
.send({
|
|
1335
|
+
type: 'evaluate',
|
|
1336
|
+
code: `(window.SootSim?.bridges?.getRecordingStartError?.() ?? null)`,
|
|
1337
|
+
})
|
|
1338
|
+
.catch(() => null)
|
|
1339
|
+
return { ok: false, error: error ?? null }
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function evalStoreStop(bridge: WsBridge): Promise<void> {
|
|
1343
|
+
await bridge.send({
|
|
1344
|
+
type: 'evaluate',
|
|
1345
|
+
code: `void window.SootSim.bridges.stopRecording()`,
|
|
1346
|
+
})
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
interface StoreUploadResult {
|
|
1350
|
+
previewUrl?: string
|
|
1351
|
+
uploadError?: string
|
|
1352
|
+
eventCount?: number
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async function pollStoreUpload(bridge: WsBridge): Promise<StoreUploadResult> {
|
|
1356
|
+
const deadline = Date.now() + UPLOAD_TIMEOUT_MS
|
|
1357
|
+
while (Date.now() < deadline) {
|
|
1358
|
+
const snap: {
|
|
1359
|
+
state: 'idle' | 'recording' | 'processing'
|
|
1360
|
+
lastUpload: { previewUrl?: string; eventCount?: number } | null
|
|
1361
|
+
uploadError: string | null
|
|
1362
|
+
} | null = await bridge.send({
|
|
1363
|
+
type: 'evaluate',
|
|
1364
|
+
code: `(() => { const s = window.SootSim?.bridges?.getRecordingState?.(); return s ? { state: s.state, lastUpload: s.lastUpload, uploadError: s.uploadError } : null })()`,
|
|
1365
|
+
})
|
|
1366
|
+
if (snap && snap.state === 'idle') {
|
|
1367
|
+
if (snap.uploadError) return { uploadError: snap.uploadError }
|
|
1368
|
+
if (snap.lastUpload?.previewUrl) {
|
|
1369
|
+
return {
|
|
1370
|
+
previewUrl: snap.lastUpload.previewUrl,
|
|
1371
|
+
eventCount: snap.lastUpload.eventCount,
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
1376
|
+
}
|
|
1377
|
+
return { uploadError: `upload did not settle within ${UPLOAD_TIMEOUT_MS / 1000}s` }
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function handleStoreUploadResult(
|
|
1381
|
+
result: StoreUploadResult,
|
|
1382
|
+
openAfter: boolean,
|
|
1383
|
+
mode: RecordMode,
|
|
1384
|
+
) {
|
|
1385
|
+
if (result.uploadError) {
|
|
1386
|
+
console.error(` upload failed: ${result.uploadError}`)
|
|
1387
|
+
process.exit(1)
|
|
1388
|
+
}
|
|
1389
|
+
if (!result.previewUrl) {
|
|
1390
|
+
console.error(' upload returned no preview URL')
|
|
1391
|
+
process.exit(1)
|
|
1392
|
+
}
|
|
1393
|
+
console.log(` preview: ${result.previewUrl}`)
|
|
1394
|
+
// the preview id is content-addressed (shareId = hash of the bundle), so
|
|
1395
|
+
// re-recording the *same* app bundle deliberately reuses this URL and
|
|
1396
|
+
// replaces the prior capture there — this is how stable demo shares work.
|
|
1397
|
+
// it's intentional, but it was silent: a quick throwaway re-record could
|
|
1398
|
+
// clobber a good earlier capture at a URL someone already shared, with no
|
|
1399
|
+
// warning (QA F19-4). say so once so the reuse is a known choice.
|
|
1400
|
+
console.log(
|
|
1401
|
+
' note: this URL is content-addressed — re-recording the same bundle\n' +
|
|
1402
|
+
' replaces this capture at the same /preview/<id>.',
|
|
1403
|
+
)
|
|
1404
|
+
// surface how many interaction events were captured (F14-7). without this
|
|
1405
|
+
// the CLI gave no signal that an event-capture regression shipped a stale
|
|
1406
|
+
// or empty stream — the only tell was opening the preview and watching
|
|
1407
|
+
// replay come up short.
|
|
1408
|
+
if ((mode === 'live' || mode === 'combined') && typeof result.eventCount === 'number') {
|
|
1409
|
+
console.log(
|
|
1410
|
+
` captured ${result.eventCount} event${result.eventCount === 1 ? '' : 's'}`,
|
|
1411
|
+
)
|
|
1412
|
+
}
|
|
1413
|
+
// a recording with no captured events uploads a valid share, but the
|
|
1414
|
+
// preview page's Live tab has nothing to replay — it just boots a fresh
|
|
1415
|
+
// interactive session, which reads as a broken link. say so rather than
|
|
1416
|
+
// letting the user discover it after sharing (F50). combined recordings
|
|
1417
|
+
// still have a playable Video tab, but Live has no action stream.
|
|
1418
|
+
if ((mode === 'live' || mode === 'combined') && result.eventCount === 0) {
|
|
1419
|
+
if (mode === 'combined') {
|
|
1420
|
+
console.log(
|
|
1421
|
+
' note: this combined recording captured no events — the Live tab has\n' +
|
|
1422
|
+
' nothing to replay. Video will play the captured screen recording.',
|
|
1423
|
+
)
|
|
1424
|
+
} else {
|
|
1425
|
+
console.log(
|
|
1426
|
+
' note: this live recording captured no events — the preview has nothing\n' +
|
|
1427
|
+
' to replay and will boot a fresh interactive session instead.',
|
|
1428
|
+
)
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
if (openAfter) {
|
|
1432
|
+
void openUrl(result.previewUrl)
|
|
1433
|
+
}
|
|
1434
|
+
}
|