rnxsim 0.1.313 → 0.1.314
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -10
- package/README.md +5 -0
- package/cli/app-config.ts +65 -0
- package/cli/app-fonts.ts +408 -0
- package/cli/app-project.ts +231 -0
- package/cli/app-splash.ts +185 -0
- package/cli/app-state-reset.ts +24 -0
- package/cli/auth.ts +155 -0
- package/cli/bin.ts +594 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2830 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/commands/agent-wrapper.ts +986 -0
- package/cli/commands/agent.ts +423 -0
- package/cli/commands/app-fonts.ts +98 -0
- package/cli/commands/assert.ts +541 -0
- package/cli/commands/auth.ts +59 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +87 -0
- package/cli/commands/config.ts +32 -0
- package/cli/commands/control.ts +2142 -0
- package/cli/commands/cpu-profile.ts +269 -0
- package/cli/commands/daemon-mac-app.ts +169 -0
- package/cli/commands/daemon.ts +874 -0
- package/cli/commands/debug.ts +719 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +197 -0
- package/cli/commands/detox.ts +385 -0
- package/cli/commands/device.ts +133 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/electron.ts +95 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1124 -0
- package/cli/commands/inspect/actions.ts +622 -0
- package/cli/commands/inspect/core.ts +2405 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +192 -0
- package/cli/commands/inspect/env.ts +23 -0
- package/cli/commands/inspect/find.ts +171 -0
- package/cli/commands/inspect/get-layout.ts +39 -0
- package/cli/commands/inspect/keyboard.ts +52 -0
- package/cli/commands/inspect/list.ts +58 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +82 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +22 -0
- package/cli/commands/inspect/settling.ts +158 -0
- package/cli/commands/inspect/shared.ts +353 -0
- package/cli/commands/inspect/sleep.ts +14 -0
- package/cli/commands/inspect/tree.ts +32 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +210 -0
- package/cli/commands/inspect/wait-idle.ts +24 -0
- package/cli/commands/inspect/wait-ready.ts +74 -0
- package/cli/commands/inspect/wait-selector.ts +54 -0
- package/cli/commands/inspect/wait.ts +31 -0
- package/cli/commands/inspect.ts +4519 -0
- package/cli/commands/install-desktop.ts +351 -0
- package/cli/commands/login.ts +331 -0
- package/cli/commands/logout.ts +31 -0
- package/cli/commands/maestro-generate.ts +361 -0
- package/cli/commands/maestro.ts +453 -0
- package/cli/commands/mode.ts +57 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +66 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/profile.ts +108 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1434 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +85 -0
- package/cli/commands/runtime.ts +351 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +143 -0
- package/cli/commands/screenshot-mode.ts +37 -0
- package/cli/commands/screenshot.ts +488 -0
- package/cli/commands/screenshots-capture.ts +607 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +168 -0
- package/cli/commands/setup.ts +545 -0
- package/cli/commands/shell-boolean-mode.ts +81 -0
- package/cli/commands/skills.ts +467 -0
- package/cli/commands/slides.ts +361 -0
- package/cli/commands/state.ts +87 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +54 -0
- package/cli/commands/three-mode.ts +763 -0
- package/cli/commands/timeline.ts +122 -0
- package/cli/commands/upgrade.ts +208 -0
- package/cli/commands/upload.ts +1225 -0
- package/cli/commands/version.ts +54 -0
- package/cli/commands/what-happened.ts +327 -0
- package/cli/current-sim.ts +204 -0
- package/cli/desktop-companion.ts +300 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +180 -0
- package/cli/drivers/playwright.ts +698 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +120 -0
- package/cli/flow-session.ts +187 -0
- package/cli/help.ts +80 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +216 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/maestro-js.ts +334 -0
- package/cli/open-url.ts +8 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +211 -0
- package/cli/prompt.ts +51 -0
- package/cli/recording-access.ts +107 -0
- package/cli/registry.ts +1 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/run-registry.ts +226 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/setup-repository.ts +187 -0
- package/cli/telemetry.ts +187 -0
- package/cli/ws-bridge.ts +798 -0
- package/dist-cli/bin.js +5 -5
- package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
- package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
- package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
- package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
- package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
- package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
- package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
- package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
- package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
- package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
- package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
- package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
- package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
- package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
- package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
- package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
- package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
- package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
- package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
- package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
- package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
- package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
- package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
- package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
- package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
- package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
- package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
- package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
- package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
- package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
- package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
- package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
- package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
- package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
- package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
- package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
- package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
- package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
- package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
- package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
- package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
- package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
- package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
- package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
- package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
- package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
- package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
- package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
- package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
- package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
- package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-WGGRJDRE.js} +1 -1
- package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
- package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
- package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
- package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
- package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
- package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
- package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
- package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
- package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
- package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
- package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
- package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
- package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
- package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
- package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
- package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
- package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
- package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
- package/dist-cli/chunks/{film-BJGTBYZB.js → film-GDMR33VO.js} +3 -3
- package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
- package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
- package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
- package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
- package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
- package/dist-cli/chunks/{login-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
- package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
- package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
- package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
- package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
- package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
- package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
- package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-TAI6DYZO.js} +2 -2
- package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
- package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
- package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
- package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
- package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
- package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
- package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
- package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
- package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
- package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-OYC57C4X.js} +6 -6
- package/dist-cli/chunks/store-32HSPZHI.js +4 -0
- package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
- package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
- package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-OEFNZRIP.js} +2 -2
- package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
- package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
- package/dist-cli/chunks/{web-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
- package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-YNHRFUDX.js} +3 -3
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract.cjs +20 -0
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +67 -28
- package/dist-lib/host/bridge-host.cjs +140 -12
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +2815 -40
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +2549 -2061
- package/dist-lib/sdk.mjs +2543 -2061
- package/dist-lib/skills.cjs +480 -280
- package/dist-lib/vite.cjs +1 -1
- package/package.json +8 -2
- package/src/bridge-constants.ts +3 -4
- package/src/bridge-contract.ts +251 -0
- package/src/connect.ts +83 -0
- package/src/disk-cleanup.ts +30 -0
- package/src/home-paths.ts +81 -38
- package/src/host/bridge-host.ts +134 -6
- package/src/index.ts +27 -1
- package/src/sdk.ts +8 -0
- package/src/sim-client.ts +660 -0
- package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
- package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
- package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
- package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
- package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
- package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
- package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
- package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
- package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
- package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
- package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
- package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
- package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
- package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
- package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
- package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
- package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
- package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
- package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
- package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
- package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
- package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
|
@@ -0,0 +1,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
|
+
}
|