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,941 @@
1
+ // rnx perf cpu — capture sampled CPU traces from the page and every worker.
2
+ //
3
+ // the JS Self-Profiler API (`new Profiler()`) cannot be constructed in a
4
+ // dedicated-worker scope, and rnx runs the guest app in the tenant worker,
5
+ // so the old in-worker approach was unsupported (F28). this implementation
6
+ // attaches Chromium's `Profiler` CDP domain to the page and worker targets —
7
+ // which returns the .cpuprofile shape directly for every execution context.
8
+ //
9
+ // dedicated workers are not top-level CDP targets, so we connect to the page
10
+ // target, `Target.setAutoAttach` to discover the worker child sessions, and
11
+ // route `Profiler.*` to each worker via its sessionId (flatten mode).
12
+ //
13
+ // the user's normal rnx tab runs in plain Chrome without a debug port.
14
+ // the easiest way to get a profilable sim is to open one with a CDP port —
15
+ // it stays driveable over the WS bridge, so you can scroll/navigate while
16
+ // profiling (the playwright driver passes --remote-debugging-port through):
17
+ //
18
+ // rnx open <metro-port> --new --driver playwright --cdp-port 9222
19
+ // rnx perf cpu --cdp-port 9222 --match <metro-port> --duration 5
20
+ //
21
+ // (a standalone CDP Chrome also works for profiling a static page, but it
22
+ // does NOT register on the bridge, so it can't be driven — see printNoCdp.)
23
+ //
24
+ // interact with the app during the capture window. the reference probe that
25
+ // also drives the interaction itself lives at
26
+ // scripts/debug/cdp-worker-cpu-profile.ts.
27
+
28
+ import { createHash } from 'node:crypto'
29
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
30
+ import { dirname, resolve } from 'node:path'
31
+ import { WebSocket } from 'ws'
32
+ import { getCliVersion } from '../../src/cli-version'
33
+ import { IS_STANDALONE } from '../standalone'
34
+
35
+ interface ProfileOptions {
36
+ port?: number
37
+ verbose?: boolean
38
+ }
39
+
40
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
41
+
42
+ // compact CDP client with flatten-mode sessionId routing — one ws to the page
43
+ // target carries the page session plus every auto-attached worker session.
44
+ class Cdp {
45
+ private ws: WebSocket
46
+ private nextId = 1
47
+ private pending = new Map<
48
+ number,
49
+ {
50
+ res: (v: any) => void
51
+ rej: (e: any) => void
52
+ timeout: ReturnType<typeof setTimeout>
53
+ }
54
+ >()
55
+ private listeners = new Set<(method: string, params: any, sessionId?: string) => void>()
56
+ private ready: Promise<void>
57
+
58
+ constructor(wsUrl: string) {
59
+ this.ws = new WebSocket(wsUrl, { maxPayload: 1 << 28 })
60
+ this.ready = new Promise((res, rej) => {
61
+ this.ws.once('open', () => res())
62
+ this.ws.once('error', rej)
63
+ })
64
+ this.ws.on('message', (data) => {
65
+ const msg = JSON.parse(data.toString())
66
+ if (msg.id && this.pending.has(msg.id)) {
67
+ const { res, rej, timeout } = this.pending.get(msg.id)!
68
+ this.pending.delete(msg.id)
69
+ clearTimeout(timeout)
70
+ msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result)
71
+ } else if (msg.method) {
72
+ for (const l of this.listeners) l(msg.method, msg.params, msg.sessionId)
73
+ }
74
+ })
75
+ this.ws.on('close', () => {
76
+ for (const [id, request] of this.pending) {
77
+ clearTimeout(request.timeout)
78
+ request.rej(new Error(`CDP connection closed with request ${id} pending`))
79
+ }
80
+ this.pending.clear()
81
+ })
82
+ }
83
+
84
+ waitOpen() {
85
+ return this.ready
86
+ }
87
+
88
+ on(l: (method: string, params: any, sessionId?: string) => void) {
89
+ this.listeners.add(l)
90
+ }
91
+
92
+ send(
93
+ method: string,
94
+ params: Record<string, unknown> = {},
95
+ sessionId?: string,
96
+ ): Promise<any> {
97
+ const id = this.nextId++
98
+ return new Promise((res, rej) => {
99
+ const timeout = setTimeout(() => {
100
+ this.pending.delete(id)
101
+ rej(new Error(`CDP ${method} timed out after 5s`))
102
+ }, 5_000)
103
+ this.pending.set(id, { res, rej, timeout })
104
+ this.ws.send(
105
+ JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }),
106
+ )
107
+ })
108
+ }
109
+
110
+ close() {
111
+ try {
112
+ this.ws.close()
113
+ } catch {}
114
+ }
115
+ }
116
+
117
+ interface CdpTarget {
118
+ type: string
119
+ title?: string
120
+ url: string
121
+ webSocketDebuggerUrl: string
122
+ }
123
+
124
+ interface AttachedTarget {
125
+ attachedAtMs: number
126
+ detached: boolean
127
+ detachedAtMs?: number
128
+ error?: string
129
+ profile?: any
130
+ profileStartedAtMs?: number
131
+ profileStoppedAtMs?: number
132
+ role: string
133
+ sessionId?: string
134
+ started: boolean
135
+ title: string
136
+ type: string
137
+ url: string
138
+ }
139
+
140
+ export interface CpuProfileInteractionBarrier {
141
+ marker: string
142
+ reachedAtEpochMs: number
143
+ releasedBy: string
144
+ releasedAtEpochMs: number
145
+ }
146
+
147
+ export interface CpuProfileCoverage {
148
+ coverageMs: number
149
+ coverageRatio: number
150
+ startedOffsetMs: number | null
151
+ stoppedOffsetMs: number | null
152
+ }
153
+
154
+ export interface CpuProfileBrowserExecutableIdentity {
155
+ path: string
156
+ sha256: string
157
+ }
158
+
159
+ export interface CpuProfileBrowserIdentity {
160
+ name: string
161
+ version: string
162
+ executable: CpuProfileBrowserExecutableIdentity
163
+ }
164
+
165
+ export interface CpuProfileCaptureEvidence {
166
+ label: string
167
+ actionEpochMs: number
168
+ }
169
+
170
+ export interface CpuProfileWorkloadIdentity {
171
+ scenario: string
172
+ sourceSha256: string
173
+ }
174
+
175
+ export interface CpuProfileWorkloadEvidence {
176
+ browserIdentity: CpuProfileBrowserIdentity
177
+ captures: CpuProfileCaptureEvidence[]
178
+ completionMarker: string
179
+ expectedCaptureLabels: string[]
180
+ runtimeIdentity: Record<string, unknown>
181
+ stage: string
182
+ workloadIdentity: CpuProfileWorkloadIdentity
183
+ }
184
+
185
+ export function cpuProfileCoverage(
186
+ profileStartedAtMs: number | undefined,
187
+ profileStoppedAtMs: number | undefined,
188
+ windowStartedAtMs: number,
189
+ windowEndedAtMs: number,
190
+ ): CpuProfileCoverage {
191
+ const requestedMs = Math.max(0, windowEndedAtMs - windowStartedAtMs)
192
+ if (
193
+ profileStartedAtMs === undefined ||
194
+ profileStoppedAtMs === undefined ||
195
+ requestedMs === 0
196
+ ) {
197
+ return {
198
+ coverageMs: 0,
199
+ coverageRatio: 0,
200
+ startedOffsetMs:
201
+ profileStartedAtMs === undefined ? null : profileStartedAtMs - windowStartedAtMs,
202
+ stoppedOffsetMs:
203
+ profileStoppedAtMs === undefined ? null : profileStoppedAtMs - windowStartedAtMs,
204
+ }
205
+ }
206
+ const coverageMs = Math.max(
207
+ 0,
208
+ Math.min(profileStoppedAtMs, windowEndedAtMs) -
209
+ Math.max(profileStartedAtMs, windowStartedAtMs),
210
+ )
211
+ return {
212
+ coverageMs,
213
+ coverageRatio: coverageMs / requestedMs,
214
+ startedOffsetMs: profileStartedAtMs - windowStartedAtMs,
215
+ stoppedOffsetMs: profileStoppedAtMs - windowStartedAtMs,
216
+ }
217
+ }
218
+
219
+ export function cpuProfileRoleLabels(
220
+ targets: readonly { captured: boolean; role: string }[],
221
+ ): string[] {
222
+ const labels = new Array<string>(targets.length)
223
+ const roleCounts = new Map<string, number>()
224
+ const assign = (index: number) => {
225
+ const role = targets[index].role
226
+ const count = (roleCounts.get(role) ?? 0) + 1
227
+ roleCounts.set(role, count)
228
+ labels[index] = count === 1 ? role : `${role}-${count}`
229
+ }
230
+ for (let index = 0; index < targets.length; index++) {
231
+ if (targets[index].captured) assign(index)
232
+ }
233
+ for (let index = 0; index < targets.length; index++) {
234
+ if (!targets[index].captured) assign(index)
235
+ }
236
+ return labels
237
+ }
238
+
239
+ export function cpuProfileTargetOverlapsWindow(
240
+ detachedAtMs: number | undefined,
241
+ windowStartedAtMs: number,
242
+ ): boolean {
243
+ return detachedAtMs === undefined || detachedAtMs > windowStartedAtMs
244
+ }
245
+
246
+ export function cpuProfileActionsWithinWindow(
247
+ actionEpochMs: readonly number[],
248
+ windowStartedAtEpochMs: number,
249
+ windowEndedAtEpochMs: number,
250
+ ): boolean {
251
+ return (
252
+ actionEpochMs.length > 0 &&
253
+ actionEpochMs.every(
254
+ (epochMs) =>
255
+ Number.isFinite(epochMs) &&
256
+ epochMs >= windowStartedAtEpochMs &&
257
+ epochMs <= windowEndedAtEpochMs,
258
+ )
259
+ )
260
+ }
261
+
262
+ function cpuProfileRecord(value: unknown): Record<string, unknown> | null {
263
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
264
+ return Object.fromEntries(Object.entries(value))
265
+ }
266
+
267
+ function cpuProfileNonemptyString(value: unknown): value is string {
268
+ return typeof value === 'string' && value.length > 0
269
+ }
270
+
271
+ function cpuProfileSha256(value: unknown): value is string {
272
+ return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
273
+ }
274
+
275
+ function cpuProfileViewportIdentityValid(value: unknown): boolean {
276
+ const viewport = cpuProfileRecord(value)
277
+ return (
278
+ typeof viewport?.width === 'number' &&
279
+ Number.isFinite(viewport.width) &&
280
+ viewport.width > 0 &&
281
+ typeof viewport.height === 'number' &&
282
+ Number.isFinite(viewport.height) &&
283
+ viewport.height > 0 &&
284
+ typeof viewport.devicePixelRatio === 'number' &&
285
+ Number.isFinite(viewport.devicePixelRatio) &&
286
+ viewport.devicePixelRatio > 0
287
+ )
288
+ }
289
+
290
+ export function readCpuProfileWorkloadEvidence(
291
+ value: unknown,
292
+ ): CpuProfileWorkloadEvidence | null {
293
+ const partial = cpuProfileRecord(value)
294
+ const browserIdentity = cpuProfileRecord(partial?.browserIdentity)
295
+ const browserExecutable = cpuProfileRecord(browserIdentity?.executable)
296
+ const runtimeIdentity = cpuProfileRecord(partial?.runtimeIdentity)
297
+ const engineManifest = cpuProfileRecord(runtimeIdentity?.engineManifest)
298
+ const hostPresentation = cpuProfileRecord(runtimeIdentity?.hostPresentation)
299
+ const workloadIdentity = cpuProfileRecord(partial?.workloadIdentity)
300
+ const stage = partial?.stage
301
+ const completionMarker = partial?.completionMarker
302
+ const expectedCaptureLabels = partial?.expectedCaptureLabels
303
+ const captures = partial?.captures
304
+ if (
305
+ !cpuProfileNonemptyString(stage) ||
306
+ !cpuProfileNonemptyString(completionMarker) ||
307
+ stage !== completionMarker ||
308
+ !Array.isArray(expectedCaptureLabels) ||
309
+ expectedCaptureLabels.length === 0 ||
310
+ expectedCaptureLabels.some(
311
+ (label, index, labels) =>
312
+ !cpuProfileNonemptyString(label) || labels.indexOf(label) !== index,
313
+ ) ||
314
+ !Array.isArray(captures) ||
315
+ !cpuProfileNonemptyString(browserIdentity?.name) ||
316
+ !cpuProfileNonemptyString(browserIdentity.version) ||
317
+ !cpuProfileNonemptyString(browserExecutable?.path) ||
318
+ !cpuProfileSha256(browserExecutable.sha256) ||
319
+ runtimeIdentity === null ||
320
+ !cpuProfileNonemptyString(engineManifest?.buildIdentity) ||
321
+ !cpuProfileNonemptyString(engineManifest.generation) ||
322
+ hostPresentation === null ||
323
+ !cpuProfileViewportIdentityValid(hostPresentation.rootViewport) ||
324
+ !cpuProfileViewportIdentityValid(hostPresentation.targetViewport) ||
325
+ !cpuProfileNonemptyString(workloadIdentity?.scenario) ||
326
+ workloadIdentity.scenario !== partial?.scenario ||
327
+ !cpuProfileSha256(workloadIdentity.sourceSha256)
328
+ ) {
329
+ return null
330
+ }
331
+ const captureEvidence: CpuProfileCaptureEvidence[] = []
332
+ for (const captureValue of captures) {
333
+ const capture = cpuProfileRecord(captureValue)
334
+ const note = cpuProfileRecord(capture?.note)
335
+ if (
336
+ !cpuProfileNonemptyString(capture?.label) ||
337
+ typeof note?.actionEpochMs !== 'number' ||
338
+ !Number.isFinite(note.actionEpochMs)
339
+ ) {
340
+ return null
341
+ }
342
+ captureEvidence.push({ label: capture.label, actionEpochMs: note.actionEpochMs })
343
+ }
344
+ if (
345
+ JSON.stringify(captureEvidence.map((capture) => capture.label)) !==
346
+ JSON.stringify(expectedCaptureLabels)
347
+ ) {
348
+ return null
349
+ }
350
+ return {
351
+ browserIdentity: {
352
+ name: browserIdentity.name,
353
+ version: browserIdentity.version,
354
+ executable: {
355
+ path: browserExecutable.path,
356
+ sha256: browserExecutable.sha256,
357
+ },
358
+ },
359
+ captures: captureEvidence,
360
+ completionMarker,
361
+ expectedCaptureLabels: [...expectedCaptureLabels],
362
+ runtimeIdentity,
363
+ stage,
364
+ workloadIdentity: {
365
+ scenario: workloadIdentity.scenario,
366
+ sourceSha256: workloadIdentity.sourceSha256,
367
+ },
368
+ }
369
+ }
370
+
371
+ export function cpuProfileInteractionBarrierValid(
372
+ barrier: CpuProfileInteractionBarrier | null,
373
+ windowStartedAtEpochMs: number,
374
+ windowEndedAtEpochMs: number,
375
+ ): boolean {
376
+ return (
377
+ barrier !== null &&
378
+ barrier.marker.length > 0 &&
379
+ barrier.releasedBy === 'profiler' &&
380
+ Number.isFinite(barrier.reachedAtEpochMs) &&
381
+ Number.isFinite(barrier.releasedAtEpochMs) &&
382
+ barrier.reachedAtEpochMs <= barrier.releasedAtEpochMs &&
383
+ barrier.releasedAtEpochMs >= windowStartedAtEpochMs &&
384
+ barrier.releasedAtEpochMs <= windowEndedAtEpochMs
385
+ )
386
+ }
387
+
388
+ function readInteractionBarrierState(
389
+ value: unknown,
390
+ ): CpuProfileInteractionBarrier | null {
391
+ if (!value || typeof value !== 'object') return null
392
+ const marker = Reflect.get(value, 'marker')
393
+ const reachedAtEpochMs = Reflect.get(value, 'reachedAtEpochMs')
394
+ const releasedBy = Reflect.get(value, 'releasedBy')
395
+ const releasedAtEpochMs = Reflect.get(value, 'releasedAtEpochMs')
396
+ if (
397
+ typeof marker !== 'string' ||
398
+ typeof reachedAtEpochMs !== 'number' ||
399
+ typeof releasedBy !== 'string' ||
400
+ typeof releasedAtEpochMs !== 'number'
401
+ ) {
402
+ return null
403
+ }
404
+ return { marker, reachedAtEpochMs, releasedBy, releasedAtEpochMs }
405
+ }
406
+
407
+ export async function runCpuProfile(
408
+ args: string[],
409
+ opts: ProfileOptions,
410
+ ): Promise<number> {
411
+ const duration = Number(valueOf(args, '--duration') ?? '5')
412
+ if (!Number.isFinite(duration) || duration <= 0) {
413
+ console.error(' --duration must be a positive number (seconds)')
414
+ return 1
415
+ }
416
+ const sampleInterval = Number(valueOf(args, '--sample-interval') ?? '0.1') // ms
417
+ const interactionBarrierEnabled = args.includes('--interaction-barrier')
418
+ const interactionBarrierTimeoutSeconds = Number(
419
+ valueOf(args, '--interaction-barrier-timeout') ?? '90',
420
+ )
421
+ if (
422
+ !Number.isFinite(interactionBarrierTimeoutSeconds) ||
423
+ interactionBarrierTimeoutSeconds <= 0
424
+ ) {
425
+ console.error(' --interaction-barrier-timeout must be positive (seconds)')
426
+ return 1
427
+ }
428
+ const interactionBarrierLeaseMs = Math.min(
429
+ 2_147_000_000,
430
+ Math.ceil((interactionBarrierTimeoutSeconds + duration + 5) * 1000),
431
+ )
432
+ const cdpPort = Number(
433
+ valueOf(args, '--cdp-port') ?? process.env.RNX_CDP_PORT ?? '9222',
434
+ )
435
+ // optional substring filter to pick the right page when several sims share
436
+ // the CDP browser (e.g. `--match 8089` or `--match /rn/8089`).
437
+ const match = valueOf(args, '--match') ?? '/rn/'
438
+ const outputArg = valueOf(args, '--output') ?? valueOf(args, '-o')
439
+ const outputPath = resolve(process.cwd(), outputArg ?? '/tmp/rnx.cpuprofile')
440
+
441
+ // locate the page target on the CDP endpoint. NOTE: Chrome's DevTools HTTP
442
+ // `/json` endpoints reject any Host header that isn't `localhost` (a
443
+ // DNS-rebinding guard) — `http://127.0.0.1:<port>/json/list` returns
444
+ // "Not found" / 404, so we must use the `localhost` hostname here. (this is
445
+ // what made the command silently fall into the no-CDP path even with a live
446
+ // CDP browser.)
447
+ let targets: CdpTarget[] = []
448
+ try {
449
+ targets = (await (
450
+ await fetch(`http://localhost:${cdpPort}/json/list`)
451
+ ).json()) as CdpTarget[]
452
+ } catch {
453
+ printNoCdp(cdpPort)
454
+ return 1
455
+ }
456
+ const page = targets.find((t) => t.type === 'page' && t.url.includes(match))
457
+ if (!page) {
458
+ console.error(` no page target matching "${match}" on CDP :${cdpPort}`)
459
+ console.error(` open targets: ${targets.map((t) => t.url).join(', ') || '(none)'}`)
460
+ return 1
461
+ }
462
+
463
+ const cdp = new Cdp(page.webSocketDebuggerUrl)
464
+ await cdp.waitOpen()
465
+ let interactionBarrierInstalled = false
466
+ const evaluatePage = async (expression: string): Promise<unknown> => {
467
+ const response = await cdp.send('Runtime.evaluate', {
468
+ expression,
469
+ returnByValue: true,
470
+ })
471
+ if (response?.exceptionDetails) {
472
+ throw new Error(
473
+ `page evaluation failed: ${response.exceptionDetails.text ?? 'unknown error'}`,
474
+ )
475
+ }
476
+ return response?.result?.value
477
+ }
478
+ const releaseInteractionBarrier = (releasedBy: 'cleanup' | 'profiler') =>
479
+ evaluatePage(`(() => {
480
+ const release = Reflect.get(globalThis, '__sootsimReleaseCpuProfileBarrier')
481
+ return typeof release === 'function'
482
+ ? Reflect.apply(release, globalThis, [${JSON.stringify(releasedBy)}])
483
+ : null
484
+ })()`)
485
+ try {
486
+ if (interactionBarrierEnabled) {
487
+ const installed = await evaluatePage(`(() => {
488
+ const state = {
489
+ marker: null,
490
+ reachedAtEpochMs: null,
491
+ releasedBy: null,
492
+ releasedAtEpochMs: null,
493
+ }
494
+ let releaseGate = () => {}
495
+ const gate = new Promise((resolve) => {
496
+ releaseGate = resolve
497
+ })
498
+ Reflect.set(globalThis, '__sootsimCpuProfileBarrierState', state)
499
+ Reflect.set(globalThis, '__sootsimCpuProfileBarrier', async (marker) => {
500
+ if (state.marker === null) {
501
+ state.marker = String(marker)
502
+ state.reachedAtEpochMs = Date.now()
503
+ }
504
+ await gate
505
+ })
506
+ let lease = null
507
+ const releaseBarrier = (releasedBy) => {
508
+ if (state.releasedAtEpochMs === null) {
509
+ state.releasedAtEpochMs = Date.now()
510
+ state.releasedBy = String(releasedBy)
511
+ if (lease !== null) clearTimeout(lease)
512
+ releaseGate()
513
+ }
514
+ return { ...state }
515
+ }
516
+ lease = setTimeout(
517
+ () => releaseBarrier('lease'),
518
+ ${interactionBarrierLeaseMs},
519
+ )
520
+ Reflect.set(globalThis, '__sootsimReleaseCpuProfileBarrier', releaseBarrier)
521
+ return true
522
+ })()`)
523
+ if (installed !== true) {
524
+ console.error(' failed to install the interaction profile barrier')
525
+ return 1
526
+ }
527
+ interactionBarrierInstalled = true
528
+ }
529
+
530
+ // discover every child worker. keep detached entries in the manifest: a
531
+ // worker can exit during the capture, and deleting it used to make the
532
+ // command either mislabel the busiest survivor as "tenant" or lose the
533
+ // entire run when Profiler.stop hit the detached session first.
534
+ const workers = new Map<string, AttachedTarget>()
535
+ let captureStarted = false
536
+ let captureWindowClosed = false
537
+ const startPromises: Array<Promise<void>> = []
538
+ const discoveryPromises: Array<Promise<void>> = []
539
+ let attachmentGeneration = 0
540
+ let awaitedDiscoveryCount = 0
541
+ let awaitedStartCount = 0
542
+ const enableRecursiveAutoAttach = (sessionId?: string) =>
543
+ cdp.send(
544
+ 'Target.setAutoAttach',
545
+ {
546
+ autoAttach: true,
547
+ waitForDebuggerOnStart: false,
548
+ flatten: true,
549
+ },
550
+ sessionId,
551
+ )
552
+ const startTarget = async (target: AttachedTarget) => {
553
+ try {
554
+ await cdp.send('Profiler.enable', {}, target.sessionId)
555
+ await cdp.send(
556
+ 'Profiler.setSamplingInterval',
557
+ { interval: sampleInterval * 1000 },
558
+ target.sessionId,
559
+ )
560
+ await cdp.send('Profiler.start', {}, target.sessionId)
561
+ target.started = true
562
+ target.profileStartedAtMs = performance.now()
563
+ } catch (error) {
564
+ target.error = error instanceof Error ? error.message : String(error)
565
+ }
566
+ }
567
+ const waitForStableTargetSetup = async () => {
568
+ while (true) {
569
+ const observedGeneration = attachmentGeneration
570
+ if (awaitedDiscoveryCount < discoveryPromises.length) {
571
+ const pendingDiscoveries = discoveryPromises.slice(awaitedDiscoveryCount)
572
+ awaitedDiscoveryCount = discoveryPromises.length
573
+ await Promise.all(pendingDiscoveries)
574
+ }
575
+ if (awaitedStartCount < startPromises.length) {
576
+ const pendingStarts = startPromises.slice(awaitedStartCount)
577
+ awaitedStartCount = startPromises.length
578
+ await Promise.all(pendingStarts)
579
+ }
580
+ // recursive auto-attach notifications can arrive just after the CDP
581
+ // command resolves. require a quiet generation before releasing work.
582
+ await sleep(50)
583
+ if (
584
+ observedGeneration === attachmentGeneration &&
585
+ awaitedDiscoveryCount === discoveryPromises.length &&
586
+ awaitedStartCount === startPromises.length
587
+ ) {
588
+ return
589
+ }
590
+ }
591
+ }
592
+ cdp.on((method, params) => {
593
+ if (method === 'Target.attachedToTarget' && params.targetInfo?.type === 'worker') {
594
+ if (captureWindowClosed) return
595
+ const sessionId = params.sessionId
596
+ const targetInfo = params.targetInfo
597
+ const target: AttachedTarget = {
598
+ attachedAtMs: performance.now(),
599
+ detached: false,
600
+ role: cpuProfileTargetRole(targetInfo.url, targetInfo.title),
601
+ sessionId,
602
+ started: false,
603
+ title: targetInfo.title ?? '',
604
+ type: targetInfo.type,
605
+ url: targetInfo.url,
606
+ }
607
+ workers.set(sessionId, target)
608
+ attachmentGeneration += 1
609
+ discoveryPromises.push(
610
+ enableRecursiveAutoAttach(sessionId).catch((error: unknown) => {
611
+ target.error = `recursive auto-attach failed: ${error instanceof Error ? error.message : String(error)}`
612
+ }),
613
+ )
614
+ if (captureStarted) startPromises.push(startTarget(target))
615
+ }
616
+ if (method === 'Target.detachedFromTarget') {
617
+ const target = workers.get(params.sessionId)
618
+ if (target) {
619
+ target.detached = true
620
+ target.detachedAtMs = performance.now()
621
+ }
622
+ }
623
+ })
624
+ await enableRecursiveAutoAttach()
625
+ // auto-attach events arrive asynchronously after the call resolves.
626
+ await sleep(500)
627
+ await waitForStableTargetSetup()
628
+ const pageTarget: AttachedTarget = {
629
+ attachedAtMs: performance.now(),
630
+ detached: false,
631
+ role: 'page',
632
+ started: false,
633
+ title: page.title ?? '',
634
+ type: 'page',
635
+ url: page.url,
636
+ }
637
+ if (interactionBarrierEnabled) {
638
+ console.log(' waiting for the workload interaction barrier…')
639
+ const deadline = performance.now() + interactionBarrierTimeoutSeconds * 1000
640
+ let reached = false
641
+ while (performance.now() < deadline) {
642
+ const value = await evaluatePage(`(() => {
643
+ const state = Reflect.get(globalThis, '__sootsimCpuProfileBarrierState')
644
+ if (!state || typeof state !== 'object') return null
645
+ return {
646
+ marker: Reflect.get(state, 'marker'),
647
+ reachedAtEpochMs: Reflect.get(state, 'reachedAtEpochMs'),
648
+ }
649
+ })()`)
650
+ if (
651
+ value &&
652
+ typeof value === 'object' &&
653
+ typeof Reflect.get(value, 'marker') === 'string' &&
654
+ typeof Reflect.get(value, 'reachedAtEpochMs') === 'number'
655
+ ) {
656
+ reached = true
657
+ break
658
+ }
659
+ await sleep(25)
660
+ }
661
+ if (!reached) {
662
+ console.error(
663
+ ` workload interaction barrier was not reached within ${interactionBarrierTimeoutSeconds}s`,
664
+ )
665
+ return 1
666
+ }
667
+ }
668
+ captureStarted = true
669
+ startPromises.push(startTarget(pageTarget))
670
+ for (const target of workers.values()) startPromises.push(startTarget(target))
671
+ await waitForStableTargetSetup()
672
+ const startedCount = [pageTarget, ...workers.values()].filter(
673
+ (target) => target.started,
674
+ ).length
675
+ if (startedCount === 0) {
676
+ console.error(' no CDP targets accepted Profiler.start')
677
+ return 1
678
+ }
679
+ const captureWindowStartedAtMs = performance.now()
680
+ const captureWindowStartedAtEpochMs = Date.now()
681
+ const captureWindowEndedAtMs = captureWindowStartedAtMs + duration * 1000
682
+ const captureWindowEndedAtEpochMs = captureWindowStartedAtEpochMs + duration * 1000
683
+ const interactionBarrier = interactionBarrierEnabled
684
+ ? readInteractionBarrierState(await releaseInteractionBarrier('profiler'))
685
+ : null
686
+ if (opts.verbose) {
687
+ for (const target of [pageTarget, ...workers.values()]) {
688
+ console.log(
689
+ ` target ${target.role} ${target.type} ${target.title || '(untitled)'} ${target.url}`,
690
+ )
691
+ }
692
+ }
693
+ console.log(
694
+ ` recording ${duration}s across ${startedCount} target(s) — interact now…`,
695
+ )
696
+ await sleep(Math.max(0, captureWindowEndedAtMs - performance.now()))
697
+ captureStarted = false
698
+ captureWindowClosed = true
699
+ await Promise.allSettled(startPromises)
700
+ const results = [pageTarget, ...workers.values()]
701
+ await Promise.all(
702
+ results.map(async (target) => {
703
+ if (!target.started) return
704
+ try {
705
+ const { profile } = await cdp.send('Profiler.stop', {}, target.sessionId)
706
+ target.profile = profile
707
+ target.profileStoppedAtMs = performance.now()
708
+ } catch (error) {
709
+ target.error = error instanceof Error ? error.message : String(error)
710
+ target.profileStoppedAtMs = target.detachedAtMs
711
+ }
712
+ await cdp.send('Profiler.disable', {}, target.sessionId).catch(() => {})
713
+ }),
714
+ )
715
+ // querying the page while five high-frequency profilers are still active
716
+ // can itself time out and lengthen the requested sampling interval. stop
717
+ // first, then read the already-recorded workload completion evidence.
718
+ const workloadEvidence = interactionBarrierEnabled
719
+ ? readCpuProfileWorkloadEvidence(
720
+ await evaluatePage(
721
+ `Reflect.get(globalThis, '__sootsimInteractionPartialResult') ?? null`,
722
+ ),
723
+ )
724
+ : null
725
+ const workloadActionEpochMs =
726
+ workloadEvidence?.captures.map((capture) => capture.actionEpochMs) ?? []
727
+
728
+ mkdirSync(dirname(outputPath), { recursive: true })
729
+ const roleLabels = cpuProfileRoleLabels(
730
+ results.map((target) => ({
731
+ captured: target.profile !== undefined,
732
+ role: target.role,
733
+ })),
734
+ )
735
+ const manifest = results.map((target, index) => {
736
+ const coverage = cpuProfileCoverage(
737
+ target.profileStartedAtMs,
738
+ target.profileStoppedAtMs,
739
+ captureWindowStartedAtMs,
740
+ captureWindowEndedAtMs,
741
+ )
742
+ const role = roleLabels[index]
743
+ const profilePath = target.profile
744
+ ? outputPath.replace(/(\.[^.]+)?$/, `.${role}$1`)
745
+ : null
746
+ if (profilePath) {
747
+ writeFileSync(profilePath, JSON.stringify(target.profile))
748
+ console.log(
749
+ ` ${role} ${shortUrl(target.url)}: ${target.profile.samples.length} samples → ${profilePath}`,
750
+ )
751
+ } else {
752
+ console.error(
753
+ ` ${role} ${shortUrl(target.url)}: no profile${target.error ? ` — ${target.error}` : ''}`,
754
+ )
755
+ }
756
+ if (opts.verbose && target.profile?.samples.length > 0) {
757
+ for (const fn of topSelfTime(target.profile, 12)) {
758
+ console.log(` ${fn.pct.toFixed(1).padStart(5)}% ${fn.name} ${fn.url}`)
759
+ }
760
+ }
761
+ return {
762
+ role,
763
+ baseRole: target.role,
764
+ type: target.type,
765
+ title: target.title,
766
+ url: target.url,
767
+ detached: target.detached,
768
+ attachedOffsetMs: target.attachedAtMs - captureWindowStartedAtMs,
769
+ detachedOffsetMs:
770
+ target.detachedAtMs === undefined
771
+ ? null
772
+ : target.detachedAtMs - captureWindowStartedAtMs,
773
+ requiredForComplete: cpuProfileTargetOverlapsWindow(
774
+ target.detachedAtMs,
775
+ captureWindowStartedAtMs,
776
+ ),
777
+ ...coverage,
778
+ samples: target.profile?.samples.length ?? 0,
779
+ profilePath,
780
+ error: target.error ?? null,
781
+ }
782
+ })
783
+ const requiredRoles = ['page', 'shell', 'compositor', 'tenant']
784
+ const capturedRoles = new Set(
785
+ manifest
786
+ .filter((target) => target.requiredForComplete && target.profilePath !== null)
787
+ .map((target) => target.baseRole),
788
+ )
789
+ const missingRoles = requiredRoles.filter((role) => !capturedRoles.has(role))
790
+ const interactionBarrierComplete =
791
+ !interactionBarrierEnabled ||
792
+ (cpuProfileInteractionBarrierValid(
793
+ interactionBarrier,
794
+ captureWindowStartedAtEpochMs,
795
+ captureWindowEndedAtEpochMs,
796
+ ) &&
797
+ workloadEvidence !== null &&
798
+ cpuProfileActionsWithinWindow(
799
+ workloadActionEpochMs,
800
+ captureWindowStartedAtEpochMs,
801
+ captureWindowEndedAtEpochMs,
802
+ ))
803
+ const complete =
804
+ missingRoles.length === 0 &&
805
+ interactionBarrierComplete &&
806
+ manifest.length > 0 &&
807
+ manifest
808
+ .filter((target) => target.requiredForComplete)
809
+ .every(
810
+ (target) =>
811
+ target.profilePath !== null &&
812
+ target.error === null &&
813
+ target.coverageRatio >= 0.99,
814
+ )
815
+ const manifestPath = outputPath.replace(/(\.[^.]+)?$/, '.manifest.json')
816
+ const executableSha256 = IS_STANDALONE
817
+ ? createHash('sha256').update(readFileSync(process.execPath)).digest('hex')
818
+ : null
819
+ writeFileSync(
820
+ manifestPath,
821
+ JSON.stringify(
822
+ {
823
+ cli: {
824
+ version: getCliVersion(),
825
+ standalone: IS_STANDALONE,
826
+ executablePath: process.execPath,
827
+ executableSha256,
828
+ argv: process.argv.slice(1),
829
+ },
830
+ page: page.url,
831
+ complete,
832
+ missingRoles,
833
+ requestedDurationMs: duration * 1000,
834
+ captureWindowStartedAt: new Date(captureWindowStartedAtEpochMs).toISOString(),
835
+ captureWindowEndedAt: new Date(captureWindowEndedAtEpochMs).toISOString(),
836
+ interactionBarrier,
837
+ workloadEvidence,
838
+ workloadActionEpochMs,
839
+ manifest,
840
+ },
841
+ null,
842
+ 2,
843
+ ),
844
+ )
845
+ console.log(` manifest → ${manifestPath}`)
846
+ if (!complete) {
847
+ console.error(
848
+ ` incomplete attribution capture${missingRoles.length ? ` — missing ${missingRoles.join(', ')}` : ''}`,
849
+ )
850
+ if (!interactionBarrierComplete) {
851
+ console.error(
852
+ ' workload did not complete every expected action inside the interaction profile window',
853
+ )
854
+ }
855
+ }
856
+ console.log(' open in chrome devtools → Performance → Load profile to inspect.')
857
+ return complete ? 0 : 1
858
+ } finally {
859
+ if (interactionBarrierInstalled) {
860
+ await releaseInteractionBarrier('cleanup').catch(() => {})
861
+ }
862
+ cdp.close()
863
+ }
864
+ }
865
+
866
+ function printNoCdp(cdpPort: number) {
867
+ console.error(` perf cpu needs a CDP-enabled browser on :${cdpPort}.`)
868
+ console.error(
869
+ " the tenant worker can't be profiled via the JS Self-Profiler API (F28),",
870
+ )
871
+ console.error(' so the sim must expose Chrome remote debugging. easiest path —')
872
+ console.error(' open the sim with a CDP port (it stays driveable over the bridge,')
873
+ console.error(' so you can scroll/navigate while profiling):')
874
+ console.error('')
875
+ console.error(
876
+ ` rnx open <metro-port> --new --driver playwright --cdp-port ${cdpPort}`,
877
+ )
878
+ console.error(` rnx perf cpu --cdp-port ${cdpPort} --match <metro-port>`)
879
+ console.error('')
880
+ console.error(' or launch a standalone CDP Chrome (not bridge-driveable):')
881
+ console.error('')
882
+ console.error(' "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \\')
883
+ console.error(
884
+ ` --remote-debugging-port=${cdpPort} --user-data-dir=/tmp/rnx-cdp \\`,
885
+ )
886
+ console.error(' --no-sandbox --disable-gpu-sandbox \\')
887
+ // never suggest swiftshader — software rendering pegs every core for the
888
+ // browser's lifetime (documented machine-freeze cause). metal is the real
889
+ // rendering path anyway, so the profile is representative.
890
+ console.error(' --use-gl=angle --use-angle=metal \\')
891
+ console.error(' "http://localhost:5173/rn/<metro-port>" &')
892
+ console.error('')
893
+ console.error(' override the port with --cdp-port or $RNX_CDP_PORT.')
894
+ }
895
+
896
+ function shortUrl(url: string): string {
897
+ if (!url) return '(native)'
898
+ return url
899
+ .replace(/^https?:\/\/[^/]+/, '')
900
+ .replace(/\?.*$/, '')
901
+ .slice(-50)
902
+ }
903
+
904
+ export function cpuProfileTargetRole(url: string, title = ''): string {
905
+ const identity = `${title} ${url}`.toLowerCase()
906
+ if (identity.includes('compositor-worker') || identity.includes('sootsim-compositor')) {
907
+ return 'compositor'
908
+ }
909
+ if (identity.includes('shell-worker')) return 'shell'
910
+ if (identity.includes('named-worklet')) return 'worklet'
911
+ if (identity.includes('render-worker') || identity.includes('tenant-worker')) {
912
+ return 'tenant'
913
+ }
914
+ return 'worker'
915
+ }
916
+
917
+ function topSelfTime(profile: any, topN: number) {
918
+ const total = profile.samples.length || 1
919
+ const byFn = new Map<string, { self: number; name: string; url: string }>()
920
+ for (const node of profile.nodes) {
921
+ const f = node.callFrame
922
+ const key = `${f.functionName || '(anonymous)'}@${shortUrl(f.url)}:${f.lineNumber}`
923
+ const cur = byFn.get(key) ?? {
924
+ self: 0,
925
+ name: f.functionName || '(anonymous)',
926
+ url: `${shortUrl(f.url)}:${f.lineNumber}`,
927
+ }
928
+ cur.self += node.hitCount ?? 0
929
+ byFn.set(key, cur)
930
+ }
931
+ return [...byFn.values()]
932
+ .map((v) => ({ ...v, pct: (v.self / total) * 100 }))
933
+ .sort((a, b) => b.self - a.self)
934
+ .slice(0, topN)
935
+ }
936
+
937
+ function valueOf(args: string[], flag: string): string | undefined {
938
+ const i = args.indexOf(flag)
939
+ if (i < 0 || i === args.length - 1) return undefined
940
+ return args[i + 1]
941
+ }