peachsim 0.1.527

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