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.
Files changed (297) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
  128. package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
  129. package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
  130. package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
  131. package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
  132. package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
  133. package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
  134. package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
  135. package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
  136. package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
  137. package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
  138. package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
  139. package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
  140. package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
  141. package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
  142. package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
  143. package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
  144. package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
  145. package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
  146. package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
  147. package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
  148. package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
  149. package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
  150. package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
  151. package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
  152. package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
  153. package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
  154. package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
  155. package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
  156. package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
  157. package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
  158. package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
  159. package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
  160. package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
  161. package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
  162. package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
  163. package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
  164. package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
  165. package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
  166. package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
  167. package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
  168. package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
  169. package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
  170. package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
  171. package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
  172. package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
  173. package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
  174. package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
  175. package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
  176. package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
  177. package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-WGGRJDRE.js} +1 -1
  178. package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
  179. package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
  180. package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
  181. package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
  182. package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
  183. package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
  184. package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
  185. package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
  186. package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
  187. package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
  188. package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
  189. package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
  190. package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
  191. package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
  192. package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
  193. package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
  196. package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
  197. package/dist-cli/chunks/{film-BJGTBYZB.js → film-GDMR33VO.js} +3 -3
  198. package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
  199. package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
  201. package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
  202. package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
  203. package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
  204. package/dist-cli/chunks/{login-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
  205. package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
  206. package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
  208. package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
  210. package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
  211. package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
  212. package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-TAI6DYZO.js} +2 -2
  214. package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
  215. package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
  219. package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
  220. package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
  221. package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
  222. package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
  223. package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-OYC57C4X.js} +6 -6
  224. package/dist-cli/chunks/store-32HSPZHI.js +4 -0
  225. package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
  226. package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-OEFNZRIP.js} +2 -2
  228. package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
  229. package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
  230. package/dist-cli/chunks/{web-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-YNHRFUDX.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +480 -280
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
  277. package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
  278. package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
  279. package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
  280. package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
  281. package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
  282. package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
  283. package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
  284. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
  285. package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
  286. package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
  287. package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
  288. package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
  289. package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
  290. package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
  291. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
  292. package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
  293. package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
  294. package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
  295. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
  296. package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
  297. 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
+ }