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,1225 @@
1
+ // sootsim record upload — capture the currently-loaded bundle + device spec from
2
+ // a running sootsim sim and POST it to the Contrast upload endpoint. returns a
3
+ // /preview/<id> url that anyone can open to load this bundle in a frame.
4
+ //
5
+ // uploads are unlisted-public (unguessable id) but require a signed-in
6
+ // desktop session so the share is associated with the user's account.
7
+
8
+ import { createHash } from 'crypto'
9
+ import { readFileSync } from 'fs'
10
+ import path from 'path'
11
+ import { gzipSync, gunzipSync } from 'zlib'
12
+ import { shouldCompress } from 'sootsim-engine/preview/compress'
13
+ import { brotliBundle } from 'sootsim-engine/preview/compress-node'
14
+ import {
15
+ runPresignedUpload,
16
+ type InitRequestBody,
17
+ } from 'sootsim-engine/preview/presigned-upload'
18
+ import {
19
+ TRACE_CAPTURE_POSTROLL_MS,
20
+ TRACE_CAPTURE_PREROLL_MS,
21
+ isTraceTimelineEvent,
22
+ parseTraceTimelineJsonl,
23
+ } from 'sootsim-engine/preview/trace'
24
+ import { readSharedDesktopAuthSession } from '../../src/auth/shared-session'
25
+ import { rnxPublicBrand } from '../../src/public-brand'
26
+ import { fetchAppFontFiles } from '../app-fonts'
27
+ import { fetchAppSplashImages } from '../app-splash'
28
+ import { authHeaderValue, githubUploadIdentity, resolveCliAuth } from '../auth'
29
+ import { openUrl } from '../open-url'
30
+ import { resolveRunProvenance } from '../run-registry'
31
+ import { flushCliTelemetry, trackCliEvent } from '../telemetry'
32
+ import { createBridgeFromParsed, evalInBridge, parseBridgeCliArgs } from '../ws-bridge'
33
+ import type { SootSimTimelineEvent } from '@sootsim/globals'
34
+
35
+ interface UploadOptions {
36
+ port?: number
37
+ verbose?: boolean
38
+ }
39
+
40
+ export type UploadResult = {
41
+ previewUrl: string
42
+ shareId: string
43
+ }
44
+
45
+ // pull the server's human-readable `message` out of a JSON error body so the
46
+ // CLI can print it directly instead of dumping the raw `{"error":...}` blob.
47
+ function parseServerMessage(responseText?: string): string | null {
48
+ if (!responseText) return null
49
+ try {
50
+ const body = JSON.parse(responseText) as { message?: unknown }
51
+ return typeof body.message === 'string' && body.message ? body.message : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ type BundleSnapshot = {
58
+ bundleUrl: string | null
59
+ entry: string | null
60
+ deviceSpec: InitRequestBody['deviceSpec']
61
+ runtimeVersion: string | null
62
+ pageHref: string
63
+ transformedBundle: {
64
+ url: string
65
+ byteLength: number
66
+ timestamp: number
67
+ } | null
68
+ }
69
+
70
+ type AttachedInteractionSnapshot = {
71
+ meta: NonNullable<InitRequestBody['snapshots']>[number] & { id: string }
72
+ bytes: Uint8Array
73
+ }
74
+
75
+ function readSnapshotManifest(manifestPath: string): AttachedInteractionSnapshot[] {
76
+ const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
77
+ if (!Array.isArray(parsed)) throw new Error('snapshot manifest must be an array')
78
+ const seen = new Set<string>()
79
+ return parsed.map((entry, index) => {
80
+ if (entry === null || typeof entry !== 'object') {
81
+ throw new Error(`snapshot manifest entry ${index} must be an object`)
82
+ }
83
+ const id = Reflect.get(entry, 'id')
84
+ const label = Reflect.get(entry, 'label')
85
+ const filePath = Reflect.get(entry, 'path')
86
+ const t = Reflect.get(entry, 't')
87
+ if (typeof id !== 'string' || !id || seen.has(id)) {
88
+ throw new Error(`snapshot manifest entry ${index} has an invalid or duplicate id`)
89
+ }
90
+ if (typeof filePath !== 'string' || !filePath) {
91
+ throw new Error(`snapshot manifest entry ${index} has no PNG path`)
92
+ }
93
+ if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) {
94
+ throw new Error(`snapshot manifest entry ${index} has an invalid capture time`)
95
+ }
96
+ seen.add(id)
97
+ const bytes = readFileSync(filePath)
98
+ return {
99
+ meta: {
100
+ id,
101
+ label: typeof label === 'string' && label ? label : id,
102
+ kind: 'interaction',
103
+ t: Math.round(t),
104
+ contentType: 'image/png',
105
+ sizeBytes: bytes.byteLength,
106
+ },
107
+ bytes,
108
+ }
109
+ })
110
+ }
111
+
112
+ const REMOTE_DEFAULT_ORIGIN = process.env.SOOTSIM_UPLOAD_ORIGIN || 'https://contrast.dev'
113
+ // the canonical public preview link. Uploads go to the contrast backend, but
114
+ // reviewers/users open SootSim preview pages on sootsim.com.
115
+ const REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN =
116
+ process.env.SOOTSIM_PREVIEW_ORIGIN || rnxPublicBrand.origin
117
+ const LOCAL_DEFAULT_ORIGIN = 'https://contrast.localhost:3000'
118
+ const LOCAL_UPLOAD_PROBE_TIMEOUT_MS = 2_000
119
+
120
+ export async function resolveDefaultUploadOrigin(
121
+ explicitOrigin?: string,
122
+ ): Promise<string> {
123
+ if (explicitOrigin) return explicitOrigin
124
+ if (process.env.SOOTSIM_UPLOAD_ORIGIN) return process.env.SOOTSIM_UPLOAD_ORIGIN
125
+
126
+ try {
127
+ const res = await fetch(`${LOCAL_DEFAULT_ORIGIN}/api/preview/upload/init`, {
128
+ method: 'OPTIONS',
129
+ signal: AbortSignal.timeout(LOCAL_UPLOAD_PROBE_TIMEOUT_MS),
130
+ })
131
+ if (res.ok || res.status === 204 || res.status === 405) {
132
+ return LOCAL_DEFAULT_ORIGIN
133
+ }
134
+ } catch {}
135
+
136
+ return REMOTE_DEFAULT_ORIGIN
137
+ }
138
+
139
+ function normalizeOrigin(origin: string): string {
140
+ return origin.replace(/\/$/, '')
141
+ }
142
+
143
+ function isLoopbackOrigin(origin: string): boolean {
144
+ try {
145
+ const parsed = new URL(origin)
146
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
147
+ return (
148
+ hostname === 'localhost' ||
149
+ hostname.endsWith('.localhost') ||
150
+ hostname === '0.0.0.0' ||
151
+ hostname === '::1' ||
152
+ /^127(?:\.\d{1,3}){3}$/.test(hostname)
153
+ )
154
+ } catch {
155
+ return false
156
+ }
157
+ }
158
+
159
+ function isContrastOrigin(origin: string): boolean {
160
+ try {
161
+ const hostname = new URL(origin).hostname.toLowerCase()
162
+ return hostname === 'contrast.dev' || hostname.endsWith('.contrast.dev')
163
+ } catch {
164
+ return false
165
+ }
166
+ }
167
+
168
+ export function resolvePublicPreviewOrigin(
169
+ uploadOrigin: string,
170
+ explicitOrigin?: string,
171
+ ): string {
172
+ if (explicitOrigin) return normalizeOrigin(explicitOrigin)
173
+ if (process.env.SOOTSIM_PREVIEW_ORIGIN) {
174
+ return normalizeOrigin(process.env.SOOTSIM_PREVIEW_ORIGIN)
175
+ }
176
+ if (isLoopbackOrigin(uploadOrigin)) return normalizeOrigin(uploadOrigin)
177
+ if (isContrastOrigin(uploadOrigin)) {
178
+ return normalizeOrigin(REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN)
179
+ }
180
+ return normalizeOrigin(uploadOrigin)
181
+ }
182
+
183
+ function printHelp() {
184
+ console.log(`
185
+ rnxsim record upload — publish the current recorded bundle as a /preview/<id> link
186
+
187
+ usage:
188
+ rnxsim record upload [--origin <url>] [--public-origin <url>] (--events <path> | --video <path>) [--screenshot <path>] [--owner <org> --repo <repo>] [--sim <sim>] [--open] [--assets-only]
189
+
190
+ options:
191
+ --origin <url> upload target (default: auto)
192
+ prefers ${LOCAL_DEFAULT_ORIGIN} when available, otherwise
193
+ falls back to ${REMOTE_DEFAULT_ORIGIN}
194
+ override with SOOTSIM_UPLOAD_ORIGIN env var
195
+ --public-origin <url>
196
+ public /preview link origin (default: ${REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN}
197
+ for prod uploads, upload origin for localhost/custom origins).
198
+ override with SOOTSIM_PREVIEW_ORIGIN env var
199
+ --events <path> path to a gzipped events .jsonl.gz file to attach
200
+ required unless --video is present; previews must
201
+ have replay or recording playback data
202
+ --screenshot <path>
203
+ path to a PNG thumbnail to attach to the share
204
+ --snapshot-manifest <path>
205
+ JSON manifest of existing interaction PNGs to attach
206
+ --video <path> path to a mp4/gif flow recording. embedded inline
207
+ in the pr sticky comment served at
208
+ /api/preview/flow-video?id=<share-id>
209
+ --video-duration-ms <ms>
210
+ duration hint for attached flow video
211
+ --timeline-events <path>
212
+ extra trace timeline rows, JSONL or JSONL gz
213
+ --trace-frame-stats <path>
214
+ frame stats JSON returned by the recorder bridge
215
+ --failure-bundle <dir>
216
+ per-failure debug bundle dir written by the flow runner
217
+ (error.json, describe.json, a11y.txt, tree.txt,
218
+ console.json, screenshot.png). uploaded with the share
219
+ so failed CI runs are debuggable from the dashboard
220
+ --owner <org> associate a session upload with a linked org repo
221
+ --repo <repo> repo name for --owner; the signed-in user must belong
222
+ to the local/org team that owns the link
223
+ --billing-kind test_run
224
+ meter this upload as a natural-language test run
225
+ --sim <sim> target a specific sim (see: rnxsim list)
226
+ --open open the resulting /preview/<id> url in the browser
227
+ --assets-only drop API/JSON/HTML records before upload; keep images,
228
+ fonts, css, js, and binary blobs. live-data demos hit
229
+ the real network at replay time instead of serving
230
+ recorded API snapshots
231
+ -h, --help
232
+
233
+ examples:
234
+ rnxsim record upload --events ./my-session.jsonl.gz
235
+ rnxsim record upload --origin https://contrast.localhost:3000 --events ./my-session.jsonl.gz --open
236
+ rnxsim record upload --video /tmp/sootsim-flow.mp4 --video-duration-ms 12000
237
+ `)
238
+ }
239
+
240
+ function extractStringFlag(args: string[], name: string): string | undefined {
241
+ const idx = args.findIndex((a) => a === name)
242
+ if (idx < 0) return undefined
243
+ const value = args[idx + 1]
244
+ args.splice(idx, 2)
245
+ return value
246
+ }
247
+
248
+ function extractBoolFlag(args: string[], name: string): boolean {
249
+ const idx = args.findIndex((a) => a === name)
250
+ if (idx < 0) return false
251
+ args.splice(idx, 1)
252
+ return true
253
+ }
254
+
255
+ function parsePositiveMs(raw: string | undefined, name: string): number | undefined {
256
+ if (!raw) return undefined
257
+ const value = Number(raw)
258
+ if (!Number.isFinite(value) || value <= 0) {
259
+ console.error(` invalid ${name}: ${raw}`)
260
+ process.exit(1)
261
+ }
262
+ return Math.round(value)
263
+ }
264
+
265
+ // mirrors fetch-recorder.ts#isAssetContentType — kept in sync manually.
266
+ // applied when --assets-only is set so the client-side filter matches what
267
+ // the recorder would have captured in assets-only mode.
268
+ function isAssetContentType(rawContentType: string): boolean {
269
+ const ct = (rawContentType || '').toLowerCase().split(';')[0].trim()
270
+ if (!ct) return false
271
+ if (
272
+ ct.startsWith('image/') ||
273
+ ct.startsWith('font/') ||
274
+ ct.startsWith('video/') ||
275
+ ct.startsWith('audio/') ||
276
+ ct.startsWith('model/')
277
+ )
278
+ return true
279
+ if (ct === 'text/css') return true
280
+ if (ct === 'application/javascript' || ct === 'text/javascript') return true
281
+ if (ct === 'application/wasm') return true
282
+ if (ct === 'application/font-woff' || ct === 'application/font-woff2') return true
283
+ if (ct === 'application/octet-stream') return true
284
+ return false
285
+ }
286
+
287
+ async function fetchBytes(url: string): Promise<Uint8Array> {
288
+ const res = await fetch(url)
289
+ if (!res.ok) {
290
+ throw new Error(`fetch ${url} -> ${res.status} ${res.statusText}`)
291
+ }
292
+ return new Uint8Array(await res.arrayBuffer())
293
+ }
294
+
295
+ function guessBundleContentType(url: string): string {
296
+ if (/\.bundle($|\?)/.test(url)) return 'application/javascript'
297
+ if (/\.js($|\?)/.test(url)) return 'application/javascript'
298
+ if (/\.zip($|\?)/.test(url)) return 'application/zip'
299
+ return 'application/javascript'
300
+ }
301
+
302
+ function appFontsWireFromPageHref(pageHref: string): string {
303
+ try {
304
+ return new URL(pageHref).searchParams.get('appFonts') || ''
305
+ } catch {
306
+ return ''
307
+ }
308
+ }
309
+
310
+ function appSplashWireFromPageHref(pageHref: string): string {
311
+ try {
312
+ return new URL(pageHref).searchParams.get('appSplash') || ''
313
+ } catch {
314
+ return ''
315
+ }
316
+ }
317
+
318
+ // metro serves a self-contained bundle when lazy=false. rewrite the
319
+ // bundle URL to force that so the captured JS has every chunk inlined.
320
+ function forceInlineBundle(bundleUrl: string): string {
321
+ try {
322
+ const u = new URL(bundleUrl)
323
+ if (u.searchParams.get('lazy') === 'true') {
324
+ u.searchParams.set('lazy', 'false')
325
+ return u.toString()
326
+ }
327
+ return bundleUrl
328
+ } catch {
329
+ return bundleUrl
330
+ }
331
+ }
332
+
333
+ // pull window.__sootsimLastTransformedBundle.text over the bridge in
334
+ // slices. a single eval returning tens of MB of base64 hits the ws
335
+ // message framing cap; stringly slicing lets us page it in safely.
336
+ async function pullTransformedBundle(
337
+ bridge: ReturnType<typeof createBridgeFromParsed>,
338
+ byteLength: number,
339
+ ): Promise<string | null> {
340
+ const CHUNK = 1_500_000 // ~1.5 MB of utf-16 source per call
341
+ const pieces: string[] = []
342
+ for (let offset = 0; offset < byteLength; offset += CHUNK) {
343
+ const end = Math.min(offset + CHUNK, byteLength)
344
+ const chunk = await evalInBridge<string | null>(
345
+ bridge,
346
+ `(window.__sootsimLastTransformedBundle?.text || "").slice(${offset}, ${end})`,
347
+ )
348
+ if (typeof chunk !== 'string') return null
349
+ pieces.push(chunk)
350
+ }
351
+ return pieces.join('')
352
+ }
353
+
354
+ type RecordedEntry = {
355
+ url: string
356
+ urlhash: string
357
+ method?: string
358
+ bodyHash?: string
359
+ requestVaryHash?: string
360
+ status: number
361
+ contentType: string
362
+ responseHeaders?: Array<{ name: string; value: string }>
363
+ size: number
364
+ }
365
+
366
+ type RecordedFetch = RecordedEntry & {
367
+ bodyBase64: string
368
+ }
369
+
370
+ function traceTimelineQuery(args: {
371
+ recordedDurationMs?: number
372
+ recordingStartedAtMs?: number
373
+ }): { since?: number; until?: number; limit: number } {
374
+ if (args.recordedDurationMs && args.recordingStartedAtMs) {
375
+ return {
376
+ since: args.recordingStartedAtMs - TRACE_CAPTURE_PREROLL_MS,
377
+ until:
378
+ args.recordingStartedAtMs + args.recordedDurationMs + TRACE_CAPTURE_POSTROLL_MS,
379
+ limit: 2000,
380
+ }
381
+ }
382
+ return { limit: 2000 }
383
+ }
384
+
385
+ function readTraceTimelineEventsFile(filePath: string): SootSimTimelineEvent[] {
386
+ const bytes = readFileSync(filePath)
387
+ const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b
388
+ const text = (isGzip ? gunzipSync(bytes) : bytes).toString('utf8')
389
+ return parseTraceTimelineJsonl(text)
390
+ }
391
+
392
+ function readTraceFrameStatsFile(filePath: string): unknown {
393
+ return JSON.parse(readFileSync(filePath, 'utf8'))
394
+ }
395
+
396
+ function frameRowsFromStats(frameStats: unknown): SootSimTimelineEvent[] {
397
+ if (frameStats === null || typeof frameStats !== 'object') return []
398
+ const record = frameStats as { startedAtEpochMs?: unknown; samples?: unknown }
399
+ const startedAt =
400
+ typeof record.startedAtEpochMs === 'number' && record.startedAtEpochMs > 0
401
+ ? record.startedAtEpochMs
402
+ : null
403
+ if (startedAt === null || !Array.isArray(record.samples)) return []
404
+ const rows: SootSimTimelineEvent[] = []
405
+ for (const sample of record.samples) {
406
+ if (!Array.isArray(sample) || typeof sample[0] !== 'number') continue
407
+ const num = (value: unknown) =>
408
+ typeof value === 'number' && Number.isFinite(value)
409
+ ? Math.round(value * 100) / 100
410
+ : 0
411
+ rows.push({
412
+ schemaVersion: 1,
413
+ t: startedAt + Math.round(sample[0]),
414
+ seq: rows.length + 1,
415
+ context: 'tenant',
416
+ kind: 'frame',
417
+ data: {
418
+ totalMs: num(sample[1]),
419
+ layoutMs: num(sample[2]),
420
+ renderMs: num(sample[3]),
421
+ copyMs: num(sample[4]),
422
+ },
423
+ })
424
+ }
425
+ return rows
426
+ }
427
+
428
+ function frameStatsMeta(frameStats: unknown): unknown {
429
+ if (frameStats === null || typeof frameStats !== 'object') return frameStats
430
+ const { samples: _samples, ...aggregates } = frameStats as {
431
+ samples?: unknown
432
+ [key: string]: unknown
433
+ }
434
+ return aggregates
435
+ }
436
+
437
+ function mergeRecordedEntries<T extends { url: string; urlhash?: string }>(
438
+ entries: T[],
439
+ ): T[] {
440
+ const merged = new Map<string, T>()
441
+ for (const entry of entries) {
442
+ merged.set(entry.urlhash || entry.url, entry)
443
+ }
444
+ return Array.from(merged.values())
445
+ }
446
+
447
+ export async function runUpload(
448
+ args: string[],
449
+ _opts: UploadOptions,
450
+ ): Promise<UploadResult> {
451
+ if (args.includes('--help') || args.includes('-h')) {
452
+ printHelp()
453
+ process.exit(0)
454
+ }
455
+
456
+ const mutableArgs = [...args]
457
+ const origin = await resolveDefaultUploadOrigin(
458
+ extractStringFlag(mutableArgs, '--origin'),
459
+ )
460
+ const publicOrigin = resolvePublicPreviewOrigin(
461
+ origin,
462
+ extractStringFlag(mutableArgs, '--public-origin'),
463
+ )
464
+ const eventsPath = extractStringFlag(mutableArgs, '--events')
465
+ const screenshotPath = extractStringFlag(mutableArgs, '--screenshot')
466
+ const snapshotManifestPath = extractStringFlag(mutableArgs, '--snapshot-manifest')
467
+ const videoPath = extractStringFlag(mutableArgs, '--video')
468
+ const timelineEventsPath = extractStringFlag(mutableArgs, '--timeline-events')
469
+ const traceFrameStatsPath = extractStringFlag(mutableArgs, '--trace-frame-stats')
470
+ const failureBundleDir = extractStringFlag(mutableArgs, '--failure-bundle')
471
+ const videoDurationMsRaw = extractStringFlag(mutableArgs, '--video-duration-ms')
472
+ const recordedDurationMs = parsePositiveMs(
473
+ extractStringFlag(mutableArgs, '--recorded-duration-ms'),
474
+ '--recorded-duration-ms',
475
+ )
476
+ const recordingStartedAtMs = parsePositiveMs(
477
+ extractStringFlag(mutableArgs, '--recording-started-at-ms'),
478
+ '--recording-started-at-ms',
479
+ )
480
+ const owner = extractStringFlag(mutableArgs, '--owner')
481
+ const repo = extractStringFlag(mutableArgs, '--repo')
482
+ const billingKind = extractStringFlag(mutableArgs, '--billing-kind')
483
+ const runScope = extractStringFlag(mutableArgs, '--run-scope')
484
+ const openResult = extractBoolFlag(mutableArgs, '--open')
485
+ if ((owner && !repo) || (!owner && repo)) {
486
+ console.error(' --owner and --repo must be provided together')
487
+ process.exit(1)
488
+ }
489
+ if (billingKind && billingKind !== 'test_run') {
490
+ console.error(` invalid --billing-kind: ${billingKind}`)
491
+ process.exit(1)
492
+ }
493
+ if (!eventsPath && !videoPath) {
494
+ console.error(
495
+ ' preview uploads require playback data: pass --events or --video.\n' +
496
+ ' for a normal local preview, use `rnxsim record --mode combined --open`\n' +
497
+ ' or `rnxsim maestro test --preview` instead of uploading a bundle-only snapshot.',
498
+ )
499
+ process.exit(1)
500
+ }
501
+ // assets-only filters captured cross-origin responses to just image/font/
502
+ // css/js/binary records before upload — API responses (JSON, HTML) hit the
503
+ // live network at replay time. used by the home demo-app capture so
504
+ // demos stay fresh (token prices, feeds) instead of serving snapshots.
505
+ const assetsOnly = extractBoolFlag(mutableArgs, '--assets-only')
506
+
507
+ const parsed = parseBridgeCliArgs(mutableArgs, {
508
+ stripBooleanFlags: [],
509
+ stripValueFlags: [],
510
+ })
511
+
512
+ const bridge = createBridgeFromParsed(parsed)
513
+ let snapshot: BundleSnapshot
514
+ // bundle-origin manifest (no bodies) — CLI re-fetches these over http so
515
+ // they can be large without blowing the ws frame cap. sibling bundle
516
+ // chunks and static assets from the dev server.
517
+ let manifest: RecordedEntry[] = []
518
+ // cross-origin fetches with bodies already captured in-memory (clerk,
519
+ // supabase, the app's api worker, etc.). the dev bundle's auth and
520
+ // cookies were on *its* session; re-fetching those urls from a fresh
521
+ // node process would fail or get different payloads. use the recorded
522
+ // response so replay is deterministic and prod-viable.
523
+ let recordedFetches: RecordedFetch[] = []
524
+ let traceTimelineEvents: SootSimTimelineEvent[] = timelineEventsPath
525
+ ? readTraceTimelineEventsFile(timelineEventsPath)
526
+ : []
527
+ const traceFrameStats = traceFrameStatsPath
528
+ ? readTraceFrameStatsFile(traceFrameStatsPath)
529
+ : null
530
+ traceTimelineEvents.push(...frameRowsFromStats(traceFrameStats))
531
+ const localTraceTimelineCount = traceTimelineEvents.length
532
+ let transformedBundleText: string | null = null
533
+ try {
534
+ snapshot = await evalInBridge<BundleSnapshot>(
535
+ bridge,
536
+ '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null',
537
+ )
538
+ if (snapshot?.bundleUrl) {
539
+ const bundleOrigin = new URL(snapshot.bundleUrl).origin
540
+ // manifest stays the body-less re-fetch path for bundle-origin static
541
+ // assets (chunks, fonts, wasm). bundle-origin api endpoints would also
542
+ // land here after the recorder's /__app-api unwrap, but re-fetching
543
+ // them from the dev server fails (no session cookies) — they get
544
+ // pulled with bodies via the recordedFetches dump below instead.
545
+ const [mainManifest, workerManifest] = await Promise.all([
546
+ evalInBridge<RecordedEntry[]>(
547
+ bridge,
548
+ `(window.__sootsimPreviewRecorder?.list?.(${JSON.stringify(bundleOrigin)}) || [])`,
549
+ ),
550
+ evalInBridge<RecordedEntry[]>(
551
+ bridge,
552
+ `(async () => {
553
+ const list = window.__sootsimListWorkerFetchRecorder
554
+ return typeof list === 'function'
555
+ ? await list(${JSON.stringify(bundleOrigin)})
556
+ : []
557
+ })()`,
558
+ ),
559
+ ])
560
+ manifest = mergeRecordedEntries([...mainManifest, ...workerManifest]).filter(
561
+ (entry) => {
562
+ try {
563
+ return !new URL(entry.url).pathname.startsWith('/api/')
564
+ } catch {
565
+ return true
566
+ }
567
+ },
568
+ )
569
+ // dump everything recorded, filter to cross-origin. these carry
570
+ // response bodies (base64), which can exceed the ws frame cap for
571
+ // large responses — gate on count + size before pulling.
572
+ const recorderLists = await evalInBridge<{
573
+ main: RecordedEntry[]
574
+ worker: RecordedEntry[]
575
+ } | null>(
576
+ bridge,
577
+ `(async () => {
578
+ const rec = window.__sootsimPreviewRecorder
579
+ const workerList = window.__sootsimListWorkerFetchRecorder
580
+ return {
581
+ main: rec?.list ? rec.list() : [],
582
+ worker: typeof workerList === 'function' ? await workerList() : [],
583
+ }
584
+ })()`,
585
+ )
586
+ const allRecordedEntries = recorderLists
587
+ ? mergeRecordedEntries([...recorderLists.main, ...recorderLists.worker])
588
+ : []
589
+ const crossOriginEntries = allRecordedEntries.filter((entry) => {
590
+ try {
591
+ return new URL(entry.url).origin !== bundleOrigin
592
+ } catch {
593
+ return false
594
+ }
595
+ })
596
+ const crossOriginStats =
597
+ crossOriginEntries.length > 0
598
+ ? {
599
+ count: crossOriginEntries.length,
600
+ totalBytes: crossOriginEntries.reduce((n, e) => n + (e.size || 0), 0),
601
+ }
602
+ : null
603
+ if (crossOriginStats && crossOriginStats.count > 0) {
604
+ console.log(
605
+ ` ${crossOriginStats.count} recorded cross-origin responses ` +
606
+ `(${(crossOriginStats.totalBytes / 1024).toFixed(1)} KiB)`,
607
+ )
608
+ // fetch the full records (with bodies). limit to ~20 MiB total —
609
+ // if we're over, warn and truncate. most dev sessions are well
610
+ // under this.
611
+ // also pull bundle-origin-keyed api responses with bodies so the
612
+ // upload can use them instead of re-fetching from the dev server
613
+ // (which fails with 401 because the cli has no auth context). main
614
+ // bundle + static assets stay on the manifest re-fetch path.
615
+ const dumped = await evalInBridge<{
616
+ main: RecordedFetch[]
617
+ worker: RecordedFetch[]
618
+ }>(
619
+ bridge,
620
+ `(async () => {
621
+ const rec = window.__sootsimPreviewRecorder
622
+ const workerDump = window.__sootsimDumpWorkerFetchRecorder
623
+ const bundleUrl = ${JSON.stringify(snapshot.bundleUrl)}
624
+ const keep = (r) => {
625
+ try {
626
+ const u = new URL(r.url)
627
+ if (u.origin !== ${JSON.stringify(bundleOrigin)}) return true
628
+ // bundle-origin api responses came from the rewritten
629
+ // /__app-api proxy at record time (re-keyed by the
630
+ // recorder's unwrap step). they need bodies attached the
631
+ // same way as cross-origin records — direct re-fetch from
632
+ // the dev server lacks the bundle's session cookies.
633
+ if (r.url !== bundleUrl && u.pathname.startsWith('/api/')) return true
634
+ return false
635
+ } catch { return false }
636
+ }
637
+ return {
638
+ main: rec?.dump ? rec.dump().filter(keep) : [],
639
+ worker: typeof workerDump === 'function'
640
+ ? (await workerDump()).filter(keep)
641
+ : [],
642
+ }
643
+ })()`,
644
+ )
645
+ recordedFetches = mergeRecordedEntries([...dumped.main, ...dumped.worker])
646
+ if (assetsOnly) {
647
+ const before = recordedFetches.length
648
+ recordedFetches = recordedFetches.filter((r) =>
649
+ isAssetContentType(r.contentType),
650
+ )
651
+ const droppedBytes =
652
+ crossOriginStats.totalBytes -
653
+ recordedFetches.reduce((n, r) => n + (r.size || 0), 0)
654
+ console.log(
655
+ ` --assets-only: kept ${recordedFetches.length}/${before} records ` +
656
+ `(dropped ${(droppedBytes / 1024).toFixed(1)} KiB of API responses)`,
657
+ )
658
+ }
659
+ }
660
+ }
661
+ if (snapshot?.transformedBundle) {
662
+ transformedBundleText = await pullTransformedBundle(
663
+ bridge,
664
+ snapshot.transformedBundle.byteLength,
665
+ )
666
+ }
667
+ const timelineResult = await evalInBridge<{ events?: unknown[] } | null>(
668
+ bridge,
669
+ `(() => {
670
+ const bridge = window.SootSim?.bridges?.timeline ?? window.__sootsimTimeline
671
+ if (!bridge || typeof bridge.recent !== 'function') return null
672
+ return bridge.recent(${JSON.stringify(
673
+ traceTimelineQuery({ recordedDurationMs, recordingStartedAtMs }),
674
+ )})
675
+ })()`,
676
+ ).catch((err: unknown) => {
677
+ console.warn(
678
+ ` warning: failed to capture trace timeline: ${
679
+ err instanceof Error ? err.message : String(err)
680
+ }`,
681
+ )
682
+ return null
683
+ })
684
+ const timelineEvents = Array.isArray(timelineResult?.events)
685
+ ? timelineResult.events
686
+ : []
687
+ traceTimelineEvents = [
688
+ ...traceTimelineEvents,
689
+ ...timelineEvents.filter(isTraceTimelineEvent),
690
+ ].sort((a, b) => a.t - b.t || a.seq - b.seq)
691
+ } finally {
692
+ bridge.close()
693
+ }
694
+
695
+ if (!snapshot) {
696
+ console.error(
697
+ ' could not read bundle snapshot — is rnx running and is the bundle loaded?',
698
+ )
699
+ process.exit(2)
700
+ }
701
+ if (!snapshot.bundleUrl) {
702
+ console.error(
703
+ ' no ?bundle= URL on the current rnx tab.\n' +
704
+ ' open the app you want to share first (e.g. rnxsim open 8082), then run upload.',
705
+ )
706
+ process.exit(2)
707
+ }
708
+
709
+ // prefer the post-transform bundle captured from the running tab. this
710
+ // is what actually evaluated — preview replay evaluates the same JS and
711
+ // does not have to re-run sootsim's transforms. fall back to re-fetching
712
+ // the raw metro URL + forcing lazy=false when the transform hasn't
713
+ // published yet (e.g., tab was opened but bundle not finished loading).
714
+ let bundleBytes: Uint8Array
715
+ let isTransformed = false
716
+ if (transformedBundleText !== null) {
717
+ bundleBytes = new TextEncoder().encode(transformedBundleText)
718
+ isTransformed = true
719
+ console.log(
720
+ ` using post-transform bundle: ${(bundleBytes.byteLength / 1024).toFixed(1)} KiB`,
721
+ )
722
+ } else {
723
+ const fetchBundleUrl = forceInlineBundle(snapshot.bundleUrl)
724
+ if (fetchBundleUrl !== snapshot.bundleUrl) {
725
+ console.log(` forcing lazy=false for self-contained bundle`)
726
+ }
727
+ console.log(` capturing: ${fetchBundleUrl}`)
728
+ bundleBytes = await fetchBytes(fetchBundleUrl)
729
+ console.log(` main bundle: ${(bundleBytes.byteLength / 1024).toFixed(1)} KiB`)
730
+ }
731
+
732
+ // everything else captured becomes a side file served from R2 via the
733
+ // preview page's service worker. skip the main bundle (already sent).
734
+ let extraEntries = manifest.filter((r) => r.url !== snapshot.bundleUrl)
735
+ if (assetsOnly) {
736
+ extraEntries = extraEntries.filter((r) => isAssetContentType(r.contentType))
737
+ }
738
+ console.log(` fetching ${extraEntries.length} extra files…`)
739
+
740
+ // fetch all extras in parallel (all localhost or proxy-passthrough).
741
+ // each is small-to-medium; keep it simple, no concurrency cap needed
742
+ // for typical v1 payloads.
743
+ const extras = await Promise.all(
744
+ extraEntries.map(async (entry) => {
745
+ try {
746
+ const bytes = await fetchBytes(entry.url)
747
+ return { ...entry, bytes }
748
+ } catch (err) {
749
+ console.error(
750
+ ` warning: failed to re-fetch ${entry.url}: ${err instanceof Error ? err.message : err}`,
751
+ )
752
+ return null
753
+ }
754
+ }),
755
+ )
756
+ const extrasOk = extras.filter((x): x is NonNullable<typeof x> => !!x)
757
+ const totalExtrasBytes = extrasOk.reduce((n, r) => n + r.bytes.byteLength, 0)
758
+ console.log(
759
+ ` ${extrasOk.length} extra files: ${(totalExtrasBytes / 1024).toFixed(1)} KiB`,
760
+ )
761
+
762
+ let eventsGz: Uint8Array | undefined
763
+ if (eventsPath) {
764
+ // the CLI accepts a pre-gzipped .jsonl.gz (that's what the docstring
765
+ // promises), so we pass the bytes straight through. if someone hands
766
+ // us a plain jsonl, the server will still accept it but the preview
767
+ // page's event replayer decodes via DecompressionStream — pre-gzip
768
+ // stays the contract.
769
+ eventsGz = readFileSync(eventsPath)
770
+ }
771
+ traceTimelineEvents.sort((a, b) => a.t - b.t || a.seq - b.seq)
772
+ const traceTimelineJsonl = traceTimelineEvents
773
+ .map((event) => JSON.stringify(event))
774
+ .join('\n')
775
+ const timelineGz = gzipSync(traceTimelineJsonl ? `${traceTimelineJsonl}\n` : '')
776
+ console.log(
777
+ ` trace timeline: ${traceTimelineEvents.length} events${
778
+ localTraceTimelineCount ? ` (${localTraceTimelineCount} from runner sidecars)` : ''
779
+ }`,
780
+ )
781
+
782
+ let screenshotBytes: Uint8Array | undefined
783
+ if (screenshotPath) {
784
+ screenshotBytes = readFileSync(screenshotPath)
785
+ console.log(
786
+ ` attaching screenshot: ${(screenshotBytes.byteLength / 1024).toFixed(1)} KiB`,
787
+ )
788
+ }
789
+ const interactionSnapshots = snapshotManifestPath
790
+ ? readSnapshotManifest(snapshotManifestPath)
791
+ : []
792
+ if (interactionSnapshots.length > 0) {
793
+ console.log(` attaching ${interactionSnapshots.length} interaction snapshots`)
794
+ }
795
+
796
+ // the flow runner's per-failure debug bundle: text artifacts fold into one
797
+ // gzipped JSON envelope stored at the share's failure slot; screenshot.png
798
+ // rides the snapshots channel as a 'failure' frame so the dashboard's run
799
+ // rows can show the failing screen without fetching the whole envelope.
800
+ let failureBundleGz: Uint8Array | undefined
801
+ let failureScreenshotBytes: Uint8Array | undefined
802
+ if (failureBundleDir) {
803
+ const readText = (name: string): string | null => {
804
+ try {
805
+ return readFileSync(path.join(failureBundleDir, name), 'utf8')
806
+ } catch {
807
+ return null
808
+ }
809
+ }
810
+ const readJson = (name: string): unknown => {
811
+ const text = readText(name)
812
+ if (text === null) return null
813
+ try {
814
+ return JSON.parse(text)
815
+ } catch {
816
+ return text
817
+ }
818
+ }
819
+ const envelope = {
820
+ error: readJson('error.json'),
821
+ describe: readJson('describe.json'),
822
+ console: readJson('console.json'),
823
+ a11y: readText('a11y.txt'),
824
+ tree: readText('tree.txt'),
825
+ }
826
+ if (Object.values(envelope).some((v) => v !== null)) {
827
+ failureBundleGz = gzipSync(JSON.stringify(envelope))
828
+ console.log(
829
+ ` attaching failure bundle: ${(failureBundleGz.byteLength / 1024).toFixed(1)} KiB gz`,
830
+ )
831
+ }
832
+ try {
833
+ failureScreenshotBytes = readFileSync(path.join(failureBundleDir, 'screenshot.png'))
834
+ } catch {
835
+ // screenshot capture itself failed at the time — envelope still helps
836
+ }
837
+ }
838
+
839
+ let videoBytes: Uint8Array | undefined
840
+ let videoContentType: string | undefined
841
+ let videoDurationMs: number | undefined
842
+ if (videoPath) {
843
+ videoBytes = readFileSync(videoPath)
844
+ videoContentType = videoPath.endsWith('.mp4')
845
+ ? 'video/mp4'
846
+ : videoPath.endsWith('.gif')
847
+ ? 'image/gif'
848
+ : undefined
849
+ if (!videoContentType) {
850
+ console.error(' preview flow videos must be mp4 or gif')
851
+ await flushCliTelemetry()
852
+ process.exit(1)
853
+ }
854
+ if (videoDurationMsRaw) {
855
+ const parsed = Number(videoDurationMsRaw)
856
+ if (!Number.isFinite(parsed) || parsed <= 0) {
857
+ console.error(` invalid --video-duration-ms: ${videoDurationMsRaw}`)
858
+ await flushCliTelemetry()
859
+ process.exit(1)
860
+ }
861
+ videoDurationMs = Math.round(parsed)
862
+ }
863
+ console.log(
864
+ ` attaching flow video: ${(videoBytes.byteLength / 1024).toFixed(1)} KiB (${videoContentType})`,
865
+ )
866
+ }
867
+
868
+ // priority: RNX_API_KEY env → github runner token → saved credential
869
+ // file → session token. github runner auth is what keeps installed PR
870
+ // previews from requiring a new Contrast secret.
871
+ const cliAuth = resolveCliAuth()
872
+ const gitHubIdentity = githubUploadIdentity(cliAuth)
873
+ const provenance = resolveRunProvenance()
874
+ const sharedSession = readSharedDesktopAuthSession()
875
+ trackCliEvent({
876
+ event: 'preview_upload_started',
877
+ identity: {
878
+ userId: cliAuth?.kind === 'session' ? (sharedSession?.user?.id ?? null) : null,
879
+ repoId: gitHubIdentity?.repoId ?? null,
880
+ installationId: gitHubIdentity?.installationId ?? null,
881
+ },
882
+ properties: {
883
+ origin,
884
+ hasAuth: Boolean(cliAuth),
885
+ authMode: cliAuth?.kind ?? 'none',
886
+ bundleBytes: bundleBytes.byteLength,
887
+ isTransformed,
888
+ extraFiles: extrasOk.length,
889
+ recordedFetches: recordedFetches.length,
890
+ hasEvents: Boolean(eventsGz),
891
+ traceTimelineEvents: traceTimelineEvents.length,
892
+ hasScreenshot: Boolean(screenshotBytes),
893
+ hasVideo: Boolean(videoBytes),
894
+ },
895
+ })
896
+ if (!cliAuth) {
897
+ console.error(' preview uploads need auth.')
898
+ console.error(
899
+ ' set RNX_API_KEY=sk_rnx_..., run `rnxsim login`, or use the Contrast github runner.',
900
+ )
901
+ await flushCliTelemetry()
902
+ process.exit(1)
903
+ }
904
+ const resolvedAuthHeader = authHeaderValue(cliAuth)
905
+
906
+ const bundleOrigin = (() => {
907
+ try {
908
+ return new URL(snapshot.bundleUrl).origin
909
+ } catch {
910
+ return null
911
+ }
912
+ })()
913
+
914
+ // compress the bundle (brotli, below) + compressible files (gzip).
915
+ // bundle-origin extras + cross-origin recorded responses share one list —
916
+ // the preview SW decides how to match each at replay time.
917
+ type PreparedFile = {
918
+ url: string
919
+ urlhash: string
920
+ method: string
921
+ bodyHash: string
922
+ requestVaryHash?: string
923
+ contentType: string
924
+ responseHeaders?: Array<{ name: string; value: string }>
925
+ bytes: Uint8Array
926
+ encoding: 'gzip' | undefined
927
+ }
928
+ const prepareFile = (
929
+ raw: Uint8Array,
930
+ url: string,
931
+ urlhash: string,
932
+ method: string,
933
+ bodyHash: string,
934
+ contentType: string,
935
+ responseHeaders?: Array<{ name: string; value: string }>,
936
+ requestVaryHash?: string,
937
+ ): PreparedFile => {
938
+ const compress = shouldCompress(contentType)
939
+ const bytes = compress ? gzipSync(raw) : raw
940
+ return {
941
+ url,
942
+ urlhash,
943
+ method,
944
+ bodyHash,
945
+ requestVaryHash,
946
+ contentType,
947
+ responseHeaders,
948
+ bytes,
949
+ encoding: compress ? 'gzip' : undefined,
950
+ }
951
+ }
952
+ // config-plugin app fonts (expo-font plugin etc.) are NOT in the metro
953
+ // asset-registry and are fetched once at engine boot — before the recorder
954
+ // starts — so neither the manifest re-fetch nor recordedFetches captures
955
+ // them and the /preview replay 404s. backfill them here via the shared
956
+ // fetcher (same code path as the build's `sootsim app-fonts stage`) so both
957
+ // pipelines store identical bytes keyed by sha256(url).
958
+ const appFontFiles: PreparedFile[] = []
959
+ const appFontsWire = appFontsWireFromPageHref(snapshot.pageHref) || ''
960
+ if (appFontsWire) {
961
+ const fonts = await fetchAppFontFiles(appFontsWire, {
962
+ onStaged: ({ url, byteLength }) =>
963
+ console.log(` staged app font: ${url} (${(byteLength / 1024).toFixed(1)} KiB)`),
964
+ onError: (url, err) =>
965
+ console.error(
966
+ ` warning: failed to fetch app font ${url}: ${err instanceof Error ? err.message : err}`,
967
+ ),
968
+ })
969
+ for (const font of fonts) {
970
+ appFontFiles.push(
971
+ prepareFile(
972
+ Buffer.from(font.bytes),
973
+ font.url,
974
+ font.urlhash,
975
+ 'GET',
976
+ '-',
977
+ font.contentType,
978
+ ),
979
+ )
980
+ }
981
+ }
982
+
983
+ // stage the app's configured splash image(s) the same way: the metro asset
984
+ // they reference is gone in a preview share, so re-fetch the bytes off the
985
+ // still-live metro and persist them keyed by sha256(url). the engine's
986
+ // <img src> resolves them through the share fetch proxy in preview mode.
987
+ const appSplashFiles: PreparedFile[] = []
988
+ const appSplashWire = appSplashWireFromPageHref(snapshot.pageHref) || ''
989
+ if (appSplashWire) {
990
+ const images = await fetchAppSplashImages(appSplashWire, {
991
+ onStaged: ({ url, byteLength }) =>
992
+ console.log(
993
+ ` staged splash image: ${url} (${(byteLength / 1024).toFixed(1)} KiB)`,
994
+ ),
995
+ onError: (url, err) =>
996
+ console.error(
997
+ ` warning: failed to fetch splash image ${url}: ${err instanceof Error ? err.message : err}`,
998
+ ),
999
+ })
1000
+ for (const image of images) {
1001
+ appSplashFiles.push(
1002
+ prepareFile(
1003
+ Buffer.from(image.bytes),
1004
+ image.url,
1005
+ image.urlhash,
1006
+ 'GET',
1007
+ '-',
1008
+ image.contentType,
1009
+ ),
1010
+ )
1011
+ }
1012
+ }
1013
+
1014
+ const prepared: PreparedFile[] = [
1015
+ ...extrasOk.map((r) =>
1016
+ prepareFile(
1017
+ r.bytes,
1018
+ r.url,
1019
+ r.urlhash,
1020
+ 'GET',
1021
+ '-',
1022
+ r.contentType,
1023
+ r.responseHeaders,
1024
+ r.requestVaryHash,
1025
+ ),
1026
+ ),
1027
+ ...recordedFetches.map((r) =>
1028
+ prepareFile(
1029
+ Buffer.from(r.bodyBase64, 'base64'),
1030
+ r.url,
1031
+ r.urlhash,
1032
+ r.method || 'GET',
1033
+ r.bodyHash || '-',
1034
+ r.contentType,
1035
+ r.responseHeaders,
1036
+ r.requestVaryHash,
1037
+ ),
1038
+ ),
1039
+ ...appFontFiles,
1040
+ ...appSplashFiles,
1041
+ ]
1042
+
1043
+ // brotli source bytes for the upload. finalize verifies the source hash,
1044
+ // minifies in the node build container, and stores one canonical brotli
1045
+ // bundle. assets stay gzip below.
1046
+ const bundleBr = brotliBundle(bundleBytes)
1047
+ const bundleSha256 = createHash('sha256').update(bundleBytes).digest('hex')
1048
+ console.log(
1049
+ ` bundle raw=${(bundleBytes.byteLength / 1024).toFixed(1)} KiB br=${(bundleBr.byteLength / 1024).toFixed(1)} KiB`,
1050
+ )
1051
+
1052
+ const initBody: InitRequestBody = {
1053
+ contentHash: bundleSha256,
1054
+ bundleSizeBytes: bundleBytes.byteLength,
1055
+ bundleContentType: guessBundleContentType(snapshot.bundleUrl),
1056
+ bundleEncoding: 'br',
1057
+ bundleMinified: false,
1058
+ bundleOrigin,
1059
+ runtimeVersion: snapshot.runtimeVersion ?? undefined,
1060
+ // config-only fonts resolved by `sootsim open` and carried on the engine
1061
+ // URL. persist them onto the share meta so /preview/<id> re-applies
1062
+ // `?appFonts=` and registers the same typefaces.
1063
+ appFonts: appFontsWire || undefined,
1064
+ // configured splash (image + bg) resolved by `sootsim open`. persist it so
1065
+ // /preview/<id> re-applies `?appSplash=` and paints the same splash; the
1066
+ // image bytes are staged above so the engine's <img> resolves in preview.
1067
+ appSplash: appSplashWire || undefined,
1068
+ entry: snapshot.entry,
1069
+ isTransformed,
1070
+ deviceSpec: snapshot.deviceSpec,
1071
+ installationId: gitHubIdentity?.installationId ?? undefined,
1072
+ repoId: gitHubIdentity?.repoId,
1073
+ owner: owner ?? gitHubIdentity?.owner ?? provenance.owner ?? undefined,
1074
+ repo: repo ?? gitHubIdentity?.repo ?? provenance.repo ?? undefined,
1075
+ branch: provenance.branch ?? undefined,
1076
+ commitSha: provenance.commitSha ?? undefined,
1077
+ githubUsername: provenance.githubUsername ?? undefined,
1078
+ pullRequestNumber: provenance.pullRequestNumber ?? undefined,
1079
+ pullRequestTitle: provenance.pullRequestTitle ?? undefined,
1080
+ billingKind: billingKind === 'test_run' ? 'test_run' : undefined,
1081
+ runScope: runScope || undefined,
1082
+ files: prepared.map((f) => ({
1083
+ url: f.url,
1084
+ urlhash: f.urlhash,
1085
+ method: f.method,
1086
+ bodyHash: f.bodyHash,
1087
+ contentType: f.contentType,
1088
+ responseHeaders: f.responseHeaders,
1089
+ requestVaryHash: f.requestVaryHash,
1090
+ encoding: f.encoding,
1091
+ sizeBytes: f.bytes.byteLength,
1092
+ })),
1093
+ events: eventsGz ? { sizeBytes: eventsGz.byteLength } : undefined,
1094
+ timeline: { sizeBytes: timelineGz.byteLength, encoding: 'gzip' },
1095
+ traceFrameStats: frameStatsMeta(traceFrameStats),
1096
+ recordedDurationMs,
1097
+ recordingStartedAtMs,
1098
+ failureBundle: failureBundleGz
1099
+ ? { sizeBytes: failureBundleGz.byteLength, encoding: 'gzip' }
1100
+ : undefined,
1101
+ snapshots: (() => {
1102
+ const entries: NonNullable<InitRequestBody['snapshots']> = []
1103
+ if (screenshotBytes) {
1104
+ entries.push({
1105
+ id: 'landing',
1106
+ label: 'Landing',
1107
+ kind: 'landing',
1108
+ t: 0,
1109
+ contentType: 'image/png',
1110
+ sizeBytes: screenshotBytes.byteLength,
1111
+ })
1112
+ }
1113
+ if (failureScreenshotBytes) {
1114
+ entries.push({
1115
+ id: 'failure',
1116
+ label: 'Failure',
1117
+ kind: 'failure',
1118
+ contentType: 'image/png',
1119
+ sizeBytes: failureScreenshotBytes.byteLength,
1120
+ })
1121
+ }
1122
+ for (const snapshot of interactionSnapshots) entries.push(snapshot.meta)
1123
+ return entries.length > 0 ? entries : undefined
1124
+ })(),
1125
+ flowVideo:
1126
+ videoBytes && videoContentType
1127
+ ? {
1128
+ sizeBytes: videoBytes.byteLength,
1129
+ contentType: videoContentType,
1130
+ durationMs: videoDurationMs,
1131
+ }
1132
+ : undefined,
1133
+ }
1134
+
1135
+ const filesByHash = new Map<string, Uint8Array>()
1136
+ for (const f of prepared) filesByHash.set(f.urlhash, f.bytes)
1137
+ const snapshotBytes = new Map<string, Uint8Array>()
1138
+ if (screenshotBytes) snapshotBytes.set('landing', screenshotBytes)
1139
+ if (failureScreenshotBytes) snapshotBytes.set('failure', failureScreenshotBytes)
1140
+ for (const snapshot of interactionSnapshots) {
1141
+ snapshotBytes.set(snapshot.meta.id, snapshot.bytes)
1142
+ }
1143
+
1144
+ const putT0 = Date.now()
1145
+ let putBytes = bundleBr.byteLength
1146
+ for (const f of prepared) putBytes += f.bytes.byteLength
1147
+ if (eventsGz) putBytes += eventsGz.byteLength
1148
+ putBytes += timelineGz.byteLength
1149
+ if (screenshotBytes) putBytes += screenshotBytes.byteLength
1150
+ if (failureScreenshotBytes) putBytes += failureScreenshotBytes.byteLength
1151
+ for (const snapshot of interactionSnapshots) putBytes += snapshot.bytes.byteLength
1152
+ if (failureBundleGz) putBytes += failureBundleGz.byteLength
1153
+ if (videoBytes) putBytes += videoBytes.byteLength
1154
+
1155
+ const initUrl = `${normalizeOrigin(origin)}/api/preview/upload/init`
1156
+ console.log(` init: ${initUrl}`)
1157
+
1158
+ let finalize
1159
+ try {
1160
+ const result = await runPresignedUpload({
1161
+ originBase: origin,
1162
+ initBody,
1163
+ bundleBytes: bundleBr,
1164
+ filesByHash,
1165
+ extras: {
1166
+ eventsBytes: eventsGz,
1167
+ timelineBytes: timelineGz,
1168
+ videoBytes,
1169
+ failureBundleBytes: failureBundleGz,
1170
+ snapshotBytes,
1171
+ },
1172
+ authHeader: resolvedAuthHeader,
1173
+ concurrency: 16,
1174
+ })
1175
+ finalize = result.finalize
1176
+ const totalObjects =
1177
+ 1 +
1178
+ result.init.files.length +
1179
+ (result.init.events ? 1 : 0) +
1180
+ (result.init.timeline ? 1 : 0) +
1181
+ result.init.snapshots.length +
1182
+ (result.init.flowVideo ? 1 : 0) +
1183
+ (result.init.failureBundle ? 1 : 0)
1184
+ console.log(
1185
+ ` PUT ${totalObjects} objects in ${Date.now() - putT0}ms (${(putBytes / 1024).toFixed(1)} KiB)`,
1186
+ )
1187
+ } catch (err: unknown) {
1188
+ const e = err as {
1189
+ phase?: 'init' | 'finalize'
1190
+ status?: number
1191
+ message?: string
1192
+ responseText?: string
1193
+ }
1194
+ if (e.phase === 'init' && e.status === 401) {
1195
+ console.error(' preview upload requires a valid login.')
1196
+ console.error(' run `rnxsim login` and retry.')
1197
+ await flushCliTelemetry()
1198
+ process.exit(1)
1199
+ }
1200
+ if (e.status === 402) {
1201
+ // billing gate — surface the server's own `message`, not raw JSON.
1202
+ console.error(
1203
+ ` ${parseServerMessage(e.responseText) ?? 'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'}`,
1204
+ )
1205
+ await flushCliTelemetry()
1206
+ process.exit(1)
1207
+ }
1208
+ // PresignedUploadError.message already carries the "<phase> failed:"
1209
+ // prefix — don't re-prefix it (that produced "init failed: init failed:").
1210
+ console.error(` ${e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`}`)
1211
+ await flushCliTelemetry()
1212
+ process.exit(1)
1213
+ }
1214
+
1215
+ const previewUrl = `${publicOrigin}${finalize.url}`
1216
+ console.log(`\n stored ${finalize.filesStored ?? 0} extra files`)
1217
+ console.log(` preview: ${previewUrl}`)
1218
+
1219
+ if (openResult) {
1220
+ await openUrl(previewUrl)
1221
+ }
1222
+
1223
+ await flushCliTelemetry()
1224
+ return { previewUrl, shareId: finalize.id }
1225
+ }