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.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- 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
|
+
}
|