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,2086 @@
1
+ // transport-agnostic rnx inspect-verb kernels.
2
+ //
3
+ // each function here sends the smallest bridge command its verb needs and
4
+ // returns *structured data* — no arg parsing or stdout. the
5
+ // CLI subcommand handlers wrap these with their WS bridge + text formatting;
6
+ // the in-browser agent wraps them with an in-process bridge over its preview
7
+ // window. one implementation, two transports — the DRY core of the inspect
8
+ // surface.
9
+ //
10
+ // MUST stay browser-safe: no node builtins, no `process`, no `ws`. the only
11
+ // dependency is the `InspectBridge.send` seam below, which both transports
12
+ // satisfy (the CLI's `WsBridge` already has a matching `send`).
13
+
14
+ import {
15
+ HEADLESS_TENANT_FETCH_HUNG_MARKER,
16
+ PUBLIC_NO_NETWORK_MARKER,
17
+ type SemanticWaitResult,
18
+ } from '../../../src/bridge-contract'
19
+ export {
20
+ readyProbeHasContent,
21
+ readyProbeHasTargetContent,
22
+ } from '../../../src/bridge-contract'
23
+ import type {
24
+ SimSemanticNode,
25
+ SimSemanticQueryResult,
26
+ SimSemanticSelector,
27
+ } from '../../../src/bridge-contract'
28
+ import type {
29
+ SootSimRouteInfo,
30
+ SootSimRequestEntry,
31
+ SootSimTimelineEvent,
32
+ SootSimTimelineKind,
33
+ SootSimTimelineQuery,
34
+ SootSimTimelineQueryResult,
35
+ SootSimTimelineSummary,
36
+ } from '@rnx/globals'
37
+ import type { KeyboardLayoutSnapshot } from 'sootsim-engine/render-worker/native-ui-protocol'
38
+
39
+ // resolve a `--max-ms <n>` flag, tolerating the spellings agents and humans
40
+ // actually type (`--maxMs`, `--maxms`, `--max_ms`). a silently-ignored
41
+ // timeout flag is a sharp edge: the call doesn't error, it just uses the
42
+ // default, so the operator thinks their bound applied when it didn't.
43
+ export function resolveMaxMsFlag(args: string[], fallbackMs: number): number {
44
+ const aliases = ['--max-ms', '--maxMs', '--maxms', '--max_ms']
45
+ for (const flag of aliases) {
46
+ const i = args.indexOf(flag)
47
+ if (i >= 0 && args[i + 1]) {
48
+ const n = Number(args[i + 1])
49
+ if (Number.isFinite(n)) return Math.max(100, n)
50
+ }
51
+ }
52
+ return fallbackMs
53
+ }
54
+
55
+ // the minimal bridge an inspect kernel needs: a JSON command channel into a
56
+ // rnx instance.
57
+ export interface InspectBridge {
58
+ readonly plane?: 'local' | 'cloud'
59
+ send(
60
+ cmd: { type: string; [k: string]: any },
61
+ opts?: { timeoutMs?: number },
62
+ ): Promise<any>
63
+ }
64
+
65
+ const INSPECT_PICK_COMMANDS = new Set([
66
+ 'tap',
67
+ 'double-tap',
68
+ 'tap-text',
69
+ 'tap-id',
70
+ 'long-press',
71
+ 'touch',
72
+ ])
73
+
74
+ export function isInspectPickCommand(subcommand: string | null | undefined): boolean {
75
+ return typeof subcommand === 'string' && INSPECT_PICK_COMMANDS.has(subcommand)
76
+ }
77
+
78
+ export async function isInspectModeActive(bridge: InspectBridge): Promise<boolean> {
79
+ const active = await bridge.send({
80
+ type: 'evaluate',
81
+ code: 'window.__sootsimEngineState?.inspectActive === true',
82
+ })
83
+ return active === true
84
+ }
85
+
86
+ export async function shouldSkipAutoSettleForInspectPick(
87
+ bridge: InspectBridge,
88
+ subcommand: string | null | undefined,
89
+ ): Promise<boolean> {
90
+ if (!isInspectPickCommand(subcommand)) return false
91
+ try {
92
+ return await isInspectModeActive(bridge)
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ // `get count` — total SootSimNode count in the live tree.
99
+ export async function inspectNodeCount(
100
+ bridge: InspectBridge,
101
+ ): Promise<{ nodes: number }> {
102
+ const result: unknown = await bridge.send({
103
+ type: 'query',
104
+ query: { kind: 'count' },
105
+ })
106
+ return { nodes: isSemanticQueryResult(result) ? result.total : 0 }
107
+ }
108
+
109
+ // `get tree` — pruned visible semantic tree. formatting stays client-side.
110
+ export async function inspectTree(
111
+ bridge: InspectBridge,
112
+ depth = 5,
113
+ ): Promise<{ depth: number; tree: unknown }> {
114
+ const result: unknown = await bridge.send({
115
+ type: 'query',
116
+ query: { kind: 'tree', depth },
117
+ })
118
+ const tree = isSemanticQueryResult(result) ? result.nodes : []
119
+ return { depth, tree }
120
+ }
121
+
122
+ // `get url` — the current page URL the bridge is attached to.
123
+ export async function inspectUrl(bridge: InspectBridge): Promise<{ url: string }> {
124
+ const url = await bridge.send({ type: 'evaluate', code: 'window.location.href' })
125
+ return { url: typeof url === 'string' ? url : '' }
126
+ }
127
+
128
+ // ─── describe ───
129
+
130
+ export interface DescribeDumpOptions {
131
+ describe: boolean
132
+ verbose: boolean
133
+ filter: string
134
+ testIdLike?: string
135
+ onlyGlob?: string
136
+ subtreeRoot?: string
137
+ compact: boolean
138
+ hideXy: boolean
139
+ // machine consumers keep the whole string; the terminal dump clips it
140
+ fullText?: boolean
141
+ // depth for the recursive dump. omitted means the default 12. setting it
142
+ // forces the recursive path, because the compact inspectable listing has no
143
+ // depth to honor and would silently ignore the request.
144
+ maxDepth?: number
145
+ }
146
+
147
+ export interface DescribeResult {
148
+ tree?: string
149
+ nodeCount?: number
150
+ }
151
+
152
+ function isSemanticGeometry(value: unknown): boolean {
153
+ return (
154
+ value !== null &&
155
+ typeof value === 'object' &&
156
+ [
157
+ Reflect.get(value, 'x'),
158
+ Reflect.get(value, 'y'),
159
+ Reflect.get(value, 'width'),
160
+ Reflect.get(value, 'height'),
161
+ ].every((coordinate) =>
162
+ typeof coordinate === 'number' ? Number.isFinite(coordinate) : false,
163
+ )
164
+ )
165
+ }
166
+
167
+ export function isSimSemanticTree(value: unknown): value is SimSemanticNode[] {
168
+ if (!Array.isArray(value)) return false
169
+ return value.every((node) => {
170
+ if (
171
+ node === null ||
172
+ typeof node !== 'object' ||
173
+ !Number.isInteger(Reflect.get(node, 'nodeId')) ||
174
+ typeof Reflect.get(node, 'type') !== 'string' ||
175
+ !isSemanticGeometry(Reflect.get(node, 'geometry'))
176
+ ) {
177
+ return false
178
+ }
179
+ for (const key of ['testID', 'text', 'role', 'label']) {
180
+ const value = Reflect.get(node, key)
181
+ if (value !== undefined && typeof value !== 'string') return false
182
+ }
183
+ const pressable = Reflect.get(node, 'pressable')
184
+ if (pressable !== undefined && pressable !== true) return false
185
+ const children = Reflect.get(node, 'children')
186
+ return children === undefined || isSimSemanticTree(children)
187
+ })
188
+ }
189
+
190
+ function isSemanticQueryResult(value: unknown): value is SimSemanticQueryResult {
191
+ const total =
192
+ value !== null && typeof value === 'object' ? Reflect.get(value, 'total') : undefined
193
+ return (
194
+ value !== null &&
195
+ typeof value === 'object' &&
196
+ typeof total === 'number' &&
197
+ Number.isInteger(total) &&
198
+ total >= 0 &&
199
+ isSimSemanticTree(Reflect.get(value, 'nodes'))
200
+ )
201
+ }
202
+
203
+ function globRegex(glob: string): RegExp {
204
+ const escaped = glob.replace(/[.+^$(){}|[\]\\]/g, '\\$&')
205
+ return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i')
206
+ }
207
+
208
+ export function formatSemanticTree(
209
+ nodes: readonly SimSemanticNode[],
210
+ opts: Partial<DescribeDumpOptions> = {},
211
+ ): string {
212
+ const filter = opts.filter?.toLowerCase() ?? ''
213
+ const testIDRegex = opts.testIdLike ? globRegex(opts.testIdLike) : null
214
+ const onlyRegex = opts.onlyGlob ? globRegex(opts.onlyGlob) : null
215
+ const lines: string[] = []
216
+ const visit = (node: SimSemanticNode, indent: number): void => {
217
+ const joined = [node.role, node.label, node.text, node.testID]
218
+ .filter((value): value is string => typeof value === 'string')
219
+ .join(' ')
220
+ const matches =
221
+ (!filter || joined.toLowerCase().includes(filter)) &&
222
+ (!testIDRegex || testIDRegex.test(node.testID ?? '')) &&
223
+ (!onlyRegex || onlyRegex.test(joined))
224
+ if (matches) {
225
+ const parts = [
226
+ node.role ? `[${node.role}]` : node.pressable ? '[tap]' : `<${node.type}>`,
227
+ ]
228
+ if (node.text) {
229
+ const text =
230
+ opts.fullText || node.text.length <= 50
231
+ ? node.text
232
+ : `${node.text.slice(0, 49).trimEnd()}…`
233
+ parts.push(JSON.stringify(text))
234
+ }
235
+ if (node.testID) parts.push(`#${node.testID}`)
236
+ if (node.label && node.label !== node.text) {
237
+ parts.push(`label=${JSON.stringify(node.label)}`)
238
+ }
239
+ if (!opts.hideXy) {
240
+ const geometry = node.geometry
241
+ parts.push(
242
+ `@(${Math.round(geometry.x)},${Math.round(geometry.y)}) ${Math.round(geometry.width)}x${Math.round(geometry.height)}`,
243
+ )
244
+ }
245
+ if (node.pressable) parts.push('(tap)')
246
+ lines.push(`${' '.repeat(indent + 1)}${parts.join(' ')}`)
247
+ }
248
+ for (const child of node.children ?? []) visit(child, indent + 1)
249
+ }
250
+ let roots = nodes
251
+ if (opts.subtreeRoot) {
252
+ const pending = [...nodes]
253
+ let found: SimSemanticNode | undefined
254
+ while (pending.length > 0 && !found) {
255
+ const candidate = pending.shift()
256
+ if (!candidate) continue
257
+ if (candidate.testID === opts.subtreeRoot) found = candidate
258
+ else pending.push(...(candidate.children ?? []))
259
+ }
260
+ if (!found) return `__SUBTREE_NOT_FOUND__:${opts.subtreeRoot}`
261
+ roots = [found]
262
+ }
263
+ for (const root of roots) visit(root, 0)
264
+ return lines.join('\n')
265
+ }
266
+
267
+ // `describe` — format the visible structured tree for humans. callers own
268
+ // arg-parsing, preflight, hints, and the watch loop.
269
+ export async function inspectDescribe(
270
+ bridge: InspectBridge,
271
+ dumpOpts: DescribeDumpOptions,
272
+ ): Promise<DescribeResult> {
273
+ const dumpDepth = dumpOpts.maxDepth ?? 12
274
+ const result: unknown = await bridge.send({
275
+ type: 'query',
276
+ query: { kind: 'tree', depth: dumpDepth },
277
+ })
278
+ if (!isSemanticQueryResult(result)) return {}
279
+ return {
280
+ tree: formatSemanticTree(result.nodes, dumpOpts),
281
+ nodeCount: result.total,
282
+ }
283
+ }
284
+
285
+ // ─── get-layout ───
286
+ //
287
+ // per-element bounding-box measurement for meaningful elements on the current
288
+ // screen. `describe` gives the element tree; this gives the numbers a designer
289
+ // needs to check spacing/sizing parity: the on-screen box (x/y/w/h). callers opt
290
+ // into computed borderRadius, per-side padding, fontSize, and visual style
291
+ // fields with `styling:true` / `--styling`.
292
+ //
293
+ // the SAME LayoutElement shape is produced by both preview surfaces:
294
+ // - native/rnx — `LAYOUT_GET_NATIVE_EVAL` reads the engine node tree via
295
+ // __sootsimTest.listInspectable() (the source `describe` uses), where each
296
+ // SootSimInspectInfo already carries absolute layout and optional style /
297
+ // box-model data.
298
+ // - web — the DOM extractor in webLayout.ts measures candidate elements with
299
+ // getBoundingClientRect(), and calls getComputedStyle() only for styling.
300
+ // both feed the same `formatLayoutElements` renderer, so the agent tool and the
301
+ // CLI print identical output regardless of surface.
302
+
303
+ export interface LayoutBox {
304
+ x: number
305
+ y: number
306
+ w: number
307
+ h: number
308
+ }
309
+
310
+ export interface LayoutPadding {
311
+ t: number
312
+ r: number
313
+ b: number
314
+ l: number
315
+ }
316
+
317
+ export interface LayoutExtractionOptions {
318
+ styling?: boolean
319
+ // case-insensitive substring matched against text, testID, role, and
320
+ // selector. narrows a full-screen read down to the part being checked so
321
+ // styling detail stays affordable.
322
+ filter?: string
323
+ }
324
+
325
+ export interface LayoutTextContrast {
326
+ ratio: number | null
327
+ required: 3 | 4.5
328
+ foreground?: string
329
+ background?: string
330
+ reason?: string
331
+ }
332
+
333
+ export function scoreLayoutTextContrast(input: {
334
+ normal: { width: number; height: number; data: ArrayLike<number> }
335
+ background: { width: number; height: number; data: ArrayLike<number> }
336
+ box: LayoutBox
337
+ fontSize: number
338
+ fontWeight?: number
339
+ }): LayoutTextContrast {
340
+ const required: 3 | 4.5 =
341
+ input.fontSize >= 18 || (input.fontSize >= 14 && (input.fontWeight ?? 400) >= 700)
342
+ ? 3
343
+ : 4.5
344
+ const { normal, background } = input
345
+ if (
346
+ normal.width <= 0 ||
347
+ normal.height <= 0 ||
348
+ normal.width !== background.width ||
349
+ normal.height !== background.height
350
+ ) {
351
+ return { ratio: null, required, reason: 'capture dimensions do not match' }
352
+ }
353
+ const left = Math.max(0, Math.floor(input.box.x))
354
+ const top = Math.max(0, Math.floor(input.box.y))
355
+ const right = Math.min(normal.width, Math.ceil(input.box.x + input.box.w))
356
+ const bottom = Math.min(normal.height, Math.ceil(input.box.y + input.box.h))
357
+ if (right <= left || bottom <= top) {
358
+ return { ratio: null, required, reason: 'text is outside the captured frame' }
359
+ }
360
+
361
+ const linear = (value: number) => {
362
+ const channel = value / 255
363
+ return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
364
+ }
365
+ const ratioOf = (foreground: readonly number[], behind: readonly number[]) => {
366
+ const foregroundLuminance =
367
+ 0.2126 * linear(foreground[0]) +
368
+ 0.7152 * linear(foreground[1]) +
369
+ 0.0722 * linear(foreground[2])
370
+ const backgroundLuminance =
371
+ 0.2126 * linear(behind[0]) + 0.7152 * linear(behind[1]) + 0.0722 * linear(behind[2])
372
+ return (
373
+ (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
374
+ (Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
375
+ )
376
+ }
377
+ const hex = (color: readonly number[]) =>
378
+ `#${color
379
+ .slice(0, 3)
380
+ .map((value) => Math.round(value).toString(16).padStart(2, '0'))
381
+ .join('')}`
382
+ const area = (right - left) * (bottom - top)
383
+ const step = Math.max(1, Math.floor(Math.sqrt(area / 12_000)))
384
+ const offsetAt = (x: number, y: number) => (y * normal.width + x) * 4
385
+ const deltaAt = (offset: number) =>
386
+ Math.max(
387
+ Math.abs(normal.data[offset] - background.data[offset]),
388
+ Math.abs(normal.data[offset + 1] - background.data[offset + 1]),
389
+ Math.abs(normal.data[offset + 2] - background.data[offset + 2]),
390
+ )
391
+
392
+ let strongestDelta = 0
393
+ let strongestForeground: number[] | null = null
394
+ let opaqueBackground: number[] | null = null
395
+ for (let y = top; y < bottom; y += step) {
396
+ for (let x = left; x < right; x += step) {
397
+ const offset = offsetAt(x, y)
398
+ if (background.data[offset + 3] < 250) continue
399
+ const behind = [
400
+ background.data[offset],
401
+ background.data[offset + 1],
402
+ background.data[offset + 2],
403
+ ]
404
+ opaqueBackground ??= behind
405
+ const delta = deltaAt(offset)
406
+ if (delta <= strongestDelta) continue
407
+ strongestDelta = delta
408
+ strongestForeground = [
409
+ normal.data[offset],
410
+ normal.data[offset + 1],
411
+ normal.data[offset + 2],
412
+ ]
413
+ }
414
+ }
415
+ let bestRatio = Infinity
416
+ let bestForeground: number[] | null = null
417
+ let bestBackground: number[] | null = null
418
+ const consider = (foreground: number[], behind: number[]) => {
419
+ const ratio = ratioOf(foreground, behind)
420
+ if (ratio >= bestRatio) return
421
+ bestRatio = ratio
422
+ bestForeground = foreground
423
+ bestBackground = behind
424
+ }
425
+
426
+ if (strongestDelta >= 2 && strongestForeground) {
427
+ // The largest normal/background delta is the closest rendered sample to
428
+ // the solid glyph color. Compare that color against every background pixel
429
+ // where the suppressed pass proves text actually drew. This preserves the
430
+ // weak side of text over gradients instead of selecting its best contrast.
431
+ for (let y = top; y < bottom; y += step) {
432
+ for (let x = left; x < right; x += step) {
433
+ const offset = offsetAt(x, y)
434
+ if (background.data[offset + 3] < 250 || deltaAt(offset) < 2) {
435
+ continue
436
+ }
437
+ consider(strongestForeground, [
438
+ background.data[offset],
439
+ background.data[offset + 1],
440
+ background.data[offset + 2],
441
+ ])
442
+ }
443
+ }
444
+ } else if (opaqueBackground) {
445
+ // No final pixel changed when the renderer omitted this visible text. Its
446
+ // effective foreground is therefore identical to the pixels behind it:
447
+ // exact 1:1 contrast, not an "unmeasurable" escape hatch.
448
+ consider(opaqueBackground, opaqueBackground)
449
+ }
450
+
451
+ if (!bestForeground || !bestBackground || !Number.isFinite(bestRatio)) {
452
+ return {
453
+ ratio: null,
454
+ required,
455
+ reason: 'could not isolate opaque rendered text pixels',
456
+ }
457
+ }
458
+ return {
459
+ ratio: Math.round(bestRatio * 100) / 100,
460
+ required,
461
+ foreground: hex(bestForeground),
462
+ background: hex(bestBackground),
463
+ }
464
+ }
465
+
466
+ export interface LayoutElement {
467
+ // extractor-local identity and closest-first ancestry. app_get_layout uses
468
+ // these internally to distinguish real overlaps from ordinary nesting; the
469
+ // formatter intentionally omits them from the agent-facing rows.
470
+ nodeId?: string | number
471
+ ancestorIds?: Array<string | number>
472
+ // a stable way to refer to this element. testID/role when present; otherwise
473
+ // a CSS selector (web) or the engine node type (native).
474
+ selector?: string
475
+ testID?: string
476
+ role?: string
477
+ type: string
478
+ text?: string
479
+ // on-screen bounding box in device/viewport px (x/y are absolute).
480
+ box: LayoutBox
481
+ // box-model fields are available only when the caller opts into styling.
482
+ // measured zero remains distinct from an unavailable field.
483
+ borderRadius?: number
484
+ padding?: LayoutPadding
485
+ fontSize?: number
486
+ // styling — so a text-only agent (no vision) can still reason about visual
487
+ // quality: theme correctness (is this screen actually emerald?), contrast
488
+ // (color vs bg), washed-out opacity, garbled/struck text, clipping
489
+ // (overflow:hidden + tiny box), flat-vs-styled (no bg where a card is
490
+ // expected). colors are compacted css strings; each field is omitted when it
491
+ // holds its boring default so rows stay scannable.
492
+ color?: string
493
+ bg?: string
494
+ opacity?: number
495
+ fontWeight?: number
496
+ textDecoration?: string
497
+ overflow?: string
498
+ borderWidth?: number
499
+ // only when the element actually draws a border. every element has a
500
+ // resolved border-color, so reporting it unconditionally would be pure noise.
501
+ borderColor?: string
502
+ contrast?: LayoutTextContrast
503
+ // text-flow metrics (web extractor) — wrap quality a no-vision agent cannot
504
+ // otherwise see: how many lines the text broke into, whether the last line
505
+ // is a lone hanging word, and whether ellipsis truncation is active.
506
+ textLines?: number
507
+ lastLineWords?: number
508
+ // last line width relative to the widest line (0-1).
509
+ lastLineWidthRatio?: number
510
+ ellipsisActive?: boolean
511
+ }
512
+
513
+ export interface LayoutResult {
514
+ preview: 'web' | 'native'
515
+ count: number
516
+ elements: LayoutElement[]
517
+ }
518
+
519
+ // in-page extractor for the native preview. mirrors the visibility + device
520
+ // sizing the a11y / wait-ready kernels use so "what's on screen" agrees across
521
+ // the inspect surface. returns LayoutElement[] (or [] when the bridge is cold).
522
+ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string {
523
+ const includeStyle = opts.styling === true
524
+ const filter = opts.filter?.trim().toLowerCase() ?? ''
525
+ return `(async () => {
526
+ const t = window.__sootsimTest
527
+ if (!t || typeof t.listInspectable !== 'function') return []
528
+ const list = await t.listInspectable({})
529
+ if (!Array.isArray(list)) return []
530
+ const includeStyle = ${JSON.stringify(includeStyle)}
531
+ const scoreContrast = ${scoreLayoutTextContrast.toString()}
532
+ // narrows by ELEMENT, not by style field: fewer rows, full detail on each.
533
+ const filter = ${JSON.stringify(filter)}
534
+ const matchesFilter = (n, text) => {
535
+ if (!filter) return true
536
+ const fields = [text, n.testID || n.testId, n.accessibilityRole || n.role, n.type]
537
+ return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(filter))
538
+ }
539
+
540
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
541
+ const round = (v) => Math.round(num(v))
542
+
543
+ // borderRadius can be authored as a string ("12px") or split per-corner;
544
+ // prefer the uniform borderRadius, else the largest corner radius present.
545
+ const radiusOf = (style) => {
546
+ if (!style) return 0
547
+ const keys = ['borderRadius','borderTopLeftRadius','borderTopRightRadius','borderBottomLeftRadius','borderBottomRightRadius']
548
+ let max = 0
549
+ for (const k of keys) {
550
+ const raw = style[k]
551
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
552
+ if (Number.isFinite(n)) max = Math.max(max, n)
553
+ }
554
+ return Math.round(max)
555
+ }
556
+ const fontSizeOf = (style) => {
557
+ if (!style) return 0
558
+ const raw = style.fontSize
559
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
560
+ return Number.isFinite(n) ? Math.round(n) : 0
561
+ }
562
+ // styling extractors — omit boring defaults so the agent only sees notable
563
+ // styling (low opacity, struck text, clipping, a real border/color).
564
+ const colorStr = (v) => {
565
+ if (v == null) return undefined
566
+ const s = String(v).split(' ').join('')
567
+ return s && s !== 'transparent' && s !== 'rgba(0,0,0,0)' ? s : undefined
568
+ }
569
+ const opacityOf = (style) => {
570
+ const o = style && style.opacity
571
+ const n = typeof o === 'number' ? o : (typeof o === 'string' ? parseFloat(o) : NaN)
572
+ return Number.isFinite(n) && n < 1 ? Math.round(n * 100) / 100 : undefined
573
+ }
574
+ const weightOf = (style) => {
575
+ const w = style && style.fontWeight
576
+ const n = w === 'bold' ? 700 : (typeof w === 'number' ? w : (typeof w === 'string' ? parseInt(w, 10) : NaN))
577
+ return Number.isFinite(n) && n >= 600 ? n : undefined
578
+ }
579
+ const decoOf = (style) => {
580
+ const d = style && (style.textDecorationLine || style.textDecoration)
581
+ return d && d !== 'none' && d !== 'normal' ? String(d).split(' ')[0] : undefined
582
+ }
583
+ const overflowOf = (style) => (style && style.overflow === 'hidden' ? 'hidden' : undefined)
584
+ const borderWOf = (style) => {
585
+ if (!style) return undefined
586
+ const keys = ['borderWidth','borderTopWidth','borderBottomWidth','borderLeftWidth','borderRightWidth']
587
+ let max = 0
588
+ for (const k of keys) {
589
+ const r = style[k]
590
+ const n = typeof r === 'number' ? r : (typeof r === 'string' ? parseFloat(r) : NaN)
591
+ if (Number.isFinite(n)) max = Math.max(max, n)
592
+ }
593
+ return max > 0 ? Math.round(max) : undefined
594
+ }
595
+ // only meaningful alongside a real border width, so it is resolved by the
596
+ // caller below rather than on its own.
597
+ const borderColorOf = (style) => {
598
+ if (!style) return undefined
599
+ const keys = ['borderColor','borderTopColor','borderBottomColor','borderLeftColor','borderRightColor']
600
+ for (const k of keys) {
601
+ const c = colorStr(style[k])
602
+ if (c) return c
603
+ }
604
+ return undefined
605
+ }
606
+
607
+ const isVisible = (n) => {
608
+ const layout = n && n.layout
609
+ if (!layout || layout.width <= 0 || layout.height <= 0) return false
610
+ const abs = (n && (n.absolute || n.absolutePosition)) || null
611
+ if (!abs) return true
612
+ const device = (n && n.device) || {}
613
+ const screenW = Number(device.width) || window.innerWidth || 0
614
+ const screenH = Number(device.height) || window.innerHeight || 0
615
+ if (!screenW || !screenH) return true
616
+ return abs.x + layout.width > 0 && abs.y + layout.height > 0 && abs.x < screenW && abs.y < screenH
617
+ }
618
+
619
+ const elements = list.filter(isVisible).filter((n) => {
620
+ const t = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
621
+ return matchesFilter(n, t)
622
+ }).map((n) => {
623
+ const layout = n.layout || { x: 0, y: 0, width: 0, height: 0 }
624
+ const abs = n.absolute || n.absolutePosition || { x: layout.x, y: layout.y }
625
+ const text = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
626
+ const base = {
627
+ ...(typeof n.nodeId === 'number'
628
+ ? {
629
+ nodeId: n.nodeId,
630
+ ancestorIds: Array.isArray(n.ancestors)
631
+ ? n.ancestors.map((ancestor) => ancestor && ancestor.nodeId).filter((id) => typeof id === 'number')
632
+ : [],
633
+ }
634
+ : {}),
635
+ testID: n.testID || n.testId || undefined,
636
+ role: n.accessibilityRole || n.role || undefined,
637
+ type: typeof n.type === 'string' ? n.type : 'node',
638
+ text,
639
+ box: { x: round(abs.x), y: round(abs.y), w: round(layout.width), h: round(layout.height) },
640
+ }
641
+ if (!includeStyle) return base
642
+ // computedStyle has the resolved values; style is the curated subset —
643
+ // fall back across both so radius/fontSize survive either shape.
644
+ const style = Object.assign({}, n.style || {}, n.computedStyle || {})
645
+ const pad = (n.boxModel && n.boxModel.padding) || {}
646
+ const borderWidth = borderWOf(style)
647
+ return {
648
+ ...base,
649
+ borderRadius: radiusOf(style),
650
+ padding: { t: round(pad.top), r: round(pad.right), b: round(pad.bottom), l: round(pad.left) },
651
+ fontSize: text ? (fontSizeOf(style) || 14) : 0,
652
+ color: text ? colorStr(style.color) : undefined,
653
+ bg: colorStr(style.backgroundColor),
654
+ opacity: opacityOf(style),
655
+ fontWeight: weightOf(style),
656
+ textDecoration: decoOf(style),
657
+ overflow: overflowOf(style),
658
+ borderWidth,
659
+ borderColor: borderWidth ? borderColorOf(style) : undefined,
660
+ }
661
+ })
662
+ const textElements = elements.filter((element) => element.text && element.fontSize > 0)
663
+ if (!includeStyle || textElements.length === 0) return elements
664
+
665
+ const screenshot = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.screenshot
666
+ if (typeof screenshot !== 'function') {
667
+ throw new Error('styled native layout requires the rnx capture bridge')
668
+ }
669
+ const device = list.find((node) => node && node.device)?.device || {}
670
+ const width = Math.round(Number(device.width) || window.innerWidth || 0)
671
+ const height = Math.round(Number(device.height) || window.innerHeight || 0)
672
+ if (!width || !height) throw new Error('native layout has no capture dimensions')
673
+ const capture = async (suppressText) => {
674
+ const dataUrl = await screenshot({
675
+ format: 'png',
676
+ outputWidth: width,
677
+ outputHeight: height,
678
+ ...(suppressText ? { suppressText: true } : {}),
679
+ })
680
+ const image = await new Promise((resolve, reject) => {
681
+ const next = new Image()
682
+ next.onload = () => resolve(next)
683
+ next.onerror = () => reject(new Error('could not decode native layout pixels'))
684
+ next.src = dataUrl
685
+ })
686
+ const canvas = document.createElement('canvas')
687
+ canvas.width = image.naturalWidth
688
+ canvas.height = image.naturalHeight
689
+ const context = canvas.getContext('2d', { willReadFrequently: true })
690
+ if (!context) throw new Error('could not read native layout pixels')
691
+ context.drawImage(image, 0, 0)
692
+ return context.getImageData(0, 0, canvas.width, canvas.height)
693
+ }
694
+ const normal = await capture(false)
695
+ const background = await capture(true)
696
+ for (const element of textElements) {
697
+ element.contrast = scoreContrast({
698
+ normal,
699
+ background,
700
+ box: element.box,
701
+ fontSize: element.fontSize,
702
+ fontWeight: element.fontWeight,
703
+ })
704
+ }
705
+ return elements
706
+ })()`
707
+ }
708
+
709
+ export const LAYOUT_GET_NATIVE_EVAL = layoutGetNativeEval()
710
+
711
+ // `get-layout` (native) — bounding boxes for visible meaningful elements on the
712
+ // rnx/native preview. transport-agnostic: the CLI runs it over its WS
713
+ // bridge, the agent runs it over its in-process preview bridge.
714
+ export async function inspectGetLayout(
715
+ bridge: InspectBridge,
716
+ opts: LayoutExtractionOptions = {},
717
+ ): Promise<LayoutElement[]> {
718
+ if (!opts.styling) {
719
+ const result: unknown = await bridge.send({
720
+ type: 'query',
721
+ query: { kind: 'find', selector: { visible: true } },
722
+ })
723
+ if (!isSemanticQueryResult(result)) return []
724
+ const filter = opts.filter?.trim().toLowerCase() ?? ''
725
+ return result.nodes
726
+ .filter((node) => {
727
+ if (!filter) return true
728
+ return [node.text, node.testID, node.role, node.type].some(
729
+ (value) => typeof value === 'string' && value.toLowerCase().includes(filter),
730
+ )
731
+ })
732
+ .map((node) => ({
733
+ nodeId: node.nodeId,
734
+ ...(node.testID ? { testID: node.testID } : {}),
735
+ ...(node.role ? { role: node.role } : {}),
736
+ type: node.type,
737
+ ...(node.text ? { text: node.text } : {}),
738
+ box: {
739
+ x: Math.round(node.geometry.x),
740
+ y: Math.round(node.geometry.y),
741
+ w: Math.round(node.geometry.width),
742
+ h: Math.round(node.geometry.height),
743
+ },
744
+ }))
745
+ }
746
+ const raw = await bridge.send({
747
+ type: 'evaluate',
748
+ code: layoutGetNativeEval(opts),
749
+ })
750
+ return Array.isArray(raw) ? (raw as LayoutElement[]) : []
751
+ }
752
+
753
+ // one-line-per-element renderer shared by the CLI and the agent tool so web +
754
+ // native + every transport print the same thing. padding collapses to a single
755
+ // number when uniform, and zero-valued fields are omitted to keep rows scannable.
756
+ export function formatLayoutElements(
757
+ elements: readonly LayoutElement[],
758
+ opts?: { styling?: boolean },
759
+ ): string {
760
+ if (elements.length === 0) return ' no visible elements found'
761
+ const lines = elements.map((el) => {
762
+ const ident = el.testID
763
+ ? `#${el.testID}`
764
+ : el.selector
765
+ ? el.selector
766
+ : el.role
767
+ ? `[${el.role}]`
768
+ : `<${el.type}>`
769
+ const parts = [ident, `@(${el.box.x},${el.box.y})`, `${el.box.w}x${el.box.h}`]
770
+ if (el.borderRadius !== undefined && el.borderRadius > 0) {
771
+ parts.push(`radius:${el.borderRadius}`)
772
+ }
773
+ const p = el.padding
774
+ if (p && (p.t || p.r || p.b || p.l)) {
775
+ const uniform = p.t === p.r && p.r === p.b && p.b === p.l
776
+ parts.push(uniform ? `pad:${p.t}` : `pad:${p.t},${p.r},${p.b},${p.l}`)
777
+ }
778
+ if (el.fontSize !== undefined && el.fontSize > 0) {
779
+ parts.push(`font:${el.fontSize}`)
780
+ }
781
+ // styling is opt-in (the `styling` flag): the default rows stay scannable
782
+ // for UI inspection, and a no-vision agent asks for styling when judging
783
+ // visual quality (contrast, washed-out, garbled/struck, clipping, flat).
784
+ if (opts?.styling) {
785
+ if (el.fontWeight) parts.push(`weight:${el.fontWeight}`)
786
+ if (el.color) parts.push(`color:${el.color}`)
787
+ if (el.bg) parts.push(`bg:${el.bg}`)
788
+ if (el.contrast) {
789
+ const score =
790
+ el.contrast.ratio == null
791
+ ? `?/${el.contrast.required}`
792
+ : `${el.contrast.ratio}/${el.contrast.required}${el.contrast.ratio < el.contrast.required ? ' FAIL' : ''}`
793
+ parts.push(`contrast:${score}`)
794
+ if (el.contrast.background) parts.push(`on:${el.contrast.background}`)
795
+ }
796
+ if (el.opacity != null) parts.push(`opacity:${el.opacity}`)
797
+ if (el.textDecoration) parts.push(`deco:${el.textDecoration}`)
798
+ if (el.overflow) parts.push(`overflow:${el.overflow}`)
799
+ if (el.borderWidth) {
800
+ parts.push(
801
+ el.borderColor
802
+ ? `border:${el.borderWidth}/${el.borderColor}`
803
+ : `border:${el.borderWidth}`,
804
+ )
805
+ }
806
+ }
807
+ if (el.text) {
808
+ const text = el.text.length > 40 ? `${el.text.slice(0, 39)}…` : el.text
809
+ parts.push(`"${text}"`)
810
+ }
811
+ return ` ${parts.join(' ')}`
812
+ })
813
+ return lines.join('\n')
814
+ }
815
+
816
+ // ─── accessibility ───
817
+
818
+ export interface AccessibilityTreeNode {
819
+ role: string
820
+ label: string | null
821
+ hint: string | null
822
+ state: Record<string, unknown> | null
823
+ testID: string | null
824
+ position: { x: number; y: number } | null
825
+ size: { w: number; h: number } | null
826
+ }
827
+
828
+ // `get a11y` — flat accessibility summary projected from the visible semantic
829
+ // tree, which already excludes hidden and covered content.
830
+ export async function inspectAccessibilityTree(
831
+ bridge: InspectBridge,
832
+ ): Promise<AccessibilityTreeNode[]> {
833
+ const result: unknown = await bridge.send({
834
+ type: 'query',
835
+ query: { kind: 'find', selector: { visible: true } },
836
+ })
837
+ if (!isSemanticQueryResult(result)) return []
838
+ return result.nodes.flatMap((node) => {
839
+ if (!node.role && !node.label && !node.text && !node.pressable) return []
840
+ return [
841
+ {
842
+ role:
843
+ node.role ??
844
+ (node.pressable ? 'button' : node.type === 'text' ? 'statictext' : 'none'),
845
+ label: node.label ?? node.text ?? null,
846
+ hint: null,
847
+ state: null,
848
+ testID: node.testID ?? null,
849
+ position: {
850
+ x: Math.round(node.geometry.x),
851
+ y: Math.round(node.geometry.y),
852
+ },
853
+ size: {
854
+ w: Math.round(node.geometry.width),
855
+ h: Math.round(node.geometry.height),
856
+ },
857
+ },
858
+ ]
859
+ })
860
+ }
861
+
862
+ // ─── find ───
863
+
864
+ export type FindMode =
865
+ | 'testid'
866
+ | 'role'
867
+ | 'type'
868
+ | 'pressable'
869
+ | 'interactive-targets'
870
+ | 'visible'
871
+ | 'text'
872
+
873
+ export interface FindQuery {
874
+ testId?: string | null
875
+ role?: string | null
876
+ type?: string | null
877
+ text?: string | null
878
+ pressable?: boolean
879
+ visible?: boolean
880
+ interactive?: boolean
881
+ }
882
+
883
+ // resolve a find query to its mode + structured selector. precedence matches
884
+ // the CLI: testid → role → type → pressable → interactive → visible → text.
885
+ // returns null when nothing was asked for (caller prints usage).
886
+ export function resolveFindMode(
887
+ q: FindQuery,
888
+ ): { mode: FindMode; selector: SimSemanticSelector } | null {
889
+ if (q.testId) {
890
+ return { mode: 'testid', selector: { testID: q.testId } }
891
+ }
892
+ if (q.role) {
893
+ return { mode: 'role', selector: { role: q.role } }
894
+ }
895
+ if (q.type) {
896
+ return { mode: 'type', selector: { type: q.type } }
897
+ }
898
+ if (q.pressable) {
899
+ return { mode: 'pressable', selector: { pressable: true } }
900
+ }
901
+ if (q.interactive) {
902
+ return { mode: 'interactive-targets', selector: { pressable: true } }
903
+ }
904
+ if (q.visible) {
905
+ return { mode: 'visible', selector: { visible: true } }
906
+ }
907
+ if (q.text) {
908
+ return { mode: 'text', selector: { text: q.text } }
909
+ }
910
+ return null
911
+ }
912
+
913
+ // `find` — locate nodes by text / testID / role / type / predicate. returns
914
+ // the raw bridge result (a node, a node array, or null) plus the resolved
915
+ // mode; null when the query was empty.
916
+ export async function inspectFind(
917
+ bridge: InspectBridge,
918
+ q: FindQuery,
919
+ ): Promise<{ mode: FindMode; result: unknown } | null> {
920
+ const resolved = resolveFindMode(q)
921
+ if (!resolved) return null
922
+ const result: unknown = await bridge.send({
923
+ type: 'query',
924
+ query: { kind: 'find', selector: resolved.selector },
925
+ })
926
+ if (!isSemanticQueryResult(result)) return { mode: resolved.mode, result: [] }
927
+ const nodes = result.nodes.map(
928
+ (node): InteractiveNode => ({
929
+ type: node.type,
930
+ nodeId: node.nodeId,
931
+ ...(node.testID ? { testID: node.testID } : {}),
932
+ ...(node.text ? { text: node.text } : {}),
933
+ ...(node.pressable ? { pressable: true } : {}),
934
+ ...(node.role ? { accessibilityRole: node.role } : {}),
935
+ ...(node.label ? { accessibilityLabel: node.label } : {}),
936
+ absolutePosition: { x: node.geometry.x, y: node.geometry.y },
937
+ layout: { width: node.geometry.width, height: node.geometry.height },
938
+ }),
939
+ )
940
+ const resultValue =
941
+ resolved.mode === 'testid' || resolved.mode === 'text' ? (nodes[0] ?? null) : nodes
942
+ return { mode: resolved.mode, result: resultValue }
943
+ }
944
+
945
+ export interface InteractiveNode {
946
+ type?: string
947
+ text?: string
948
+ testID?: string
949
+ pressable?: boolean
950
+ accessibilityRole?: string
951
+ accessibilityLabel?: string
952
+ absolutePosition?: { x: number; y: number }
953
+ layout?: { width: number; height: number }
954
+ [key: string]: unknown
955
+ }
956
+
957
+ // `find --interactive-targets` ranking. visible+pressable nodes scored by
958
+ // identifying signal (testID > visible text > a11y label > role) and area,
959
+ // with chrome / off-screen nodes de-prioritised. higher score = better
960
+ // "next tap" candidate. shared so the CLI and the agent rank identically.
961
+ export function rankInteractive<T extends InteractiveNode>(nodes: T[]): T[] {
962
+ return [...nodes].sort((a, b) => scoreInteractive(b) - scoreInteractive(a))
963
+ }
964
+
965
+ export function scoreInteractive(n: InteractiveNode): number {
966
+ let score = 0
967
+ if (n.testID) score += 100
968
+ if (typeof n.text === 'string' && n.text.trim().length > 0) score += 60
969
+ if (
970
+ typeof n.accessibilityLabel === 'string' &&
971
+ n.accessibilityLabel.trim().length > 0
972
+ ) {
973
+ score += 30
974
+ }
975
+ if (n.accessibilityRole) score += 15
976
+ const w = n.layout?.width ?? 0
977
+ const h = n.layout?.height ?? 0
978
+ const area = w * h
979
+ if (area >= 400 && area <= 60_000) score += 25
980
+ else if (area > 60_000) score -= 20
981
+ const y = n.absolutePosition?.y ?? 0
982
+ if (y < 0) score -= 30
983
+ return score
984
+ }
985
+
986
+ // the copy-paste `rnx do` tap command for an interactive node.
987
+ export function tapCommandForNode(n: InteractiveNode): string {
988
+ if (n.testID) return `rnx do tap-id ${shellEscape(n.testID)}`
989
+ const text = typeof n.text === 'string' ? n.text.trim() : ''
990
+ if (text.length > 0 && text.length <= 80) return `rnx do tap-text ${shellEscape(text)}`
991
+ const x =
992
+ Math.round(((n.absolutePosition?.x ?? 0) + (n.layout?.width ?? 0) / 2) * 10) / 10
993
+ const y =
994
+ Math.round(((n.absolutePosition?.y ?? 0) + (n.layout?.height ?? 0) / 2) * 10) / 10
995
+ return `rnx do tap ${x} ${y}`
996
+ }
997
+
998
+ function shellEscape(value: string): string {
999
+ if (/^[A-Za-z0-9_./@:-]+$/.test(value)) return value
1000
+ return `'${value.replace(/'/g, `'\\''`)}'`
1001
+ }
1002
+
1003
+ // ─── wait ───
1004
+
1005
+ export interface WaitReadyStatus {
1006
+ ready: boolean
1007
+ elapsedMs: number
1008
+ nodes: number
1009
+ targets: number
1010
+ liveFrameActive: boolean
1011
+ liveFrameChannels: number
1012
+ liveFramePublishes: number
1013
+ flag: unknown
1014
+ loadingText: string
1015
+ externalReady: boolean | null
1016
+ externalStatus: string
1017
+ externalError: string
1018
+ suppressedEntryError: string
1019
+ errors: number
1020
+ // non-empty when the probe itself stopped reaching the sim (tab closed,
1021
+ // sim never connected) — the wait failed on the bridge, not on the app.
1022
+ bridgeError: string
1023
+ }
1024
+
1025
+ export interface WaitReadyOptions {
1026
+ progressIntervalMs?: number
1027
+ onProgress?: (status: WaitReadyStatus) => void
1028
+ simId?: string
1029
+ }
1030
+
1031
+ export function waitReadyReason(
1032
+ status: Pick<
1033
+ WaitReadyStatus,
1034
+ | 'externalError'
1035
+ | 'loadingText'
1036
+ | 'externalReady'
1037
+ | 'externalStatus'
1038
+ | 'flag'
1039
+ | 'targets'
1040
+ | 'suppressedEntryError'
1041
+ > & { bridgeError?: string },
1042
+ ): string {
1043
+ if (status.bridgeError) {
1044
+ if (
1045
+ status.bridgeError.includes(HEADLESS_TENANT_FETCH_HUNG_MARKER) ||
1046
+ status.bridgeError.includes(PUBLIC_NO_NETWORK_MARKER)
1047
+ ) {
1048
+ return status.bridgeError
1049
+ }
1050
+ return `sim unreachable: ${status.bridgeError} — the target tab is closed or no sim is connected (check \`rnx list\`, reopen with \`rnx open\`)`
1051
+ }
1052
+ const waitingOnGuestFetch =
1053
+ status.externalReady === false && /https?:\/\//.test(status.externalStatus)
1054
+ const base = status.externalError
1055
+ ? `guest app errored: ${status.externalError}`
1056
+ : waitingOnGuestFetch
1057
+ ? `waiting on a guest fetch: ${status.externalStatus}`
1058
+ : status.loadingText
1059
+ ? `still showing "${status.loadingText}"`
1060
+ : status.externalReady === false
1061
+ ? 'guest app is still loading'
1062
+ : status.flag !== true && status.targets > 0
1063
+ ? 'native content is rendered but the ready signal has not settled'
1064
+ : status.flag !== true
1065
+ ? 'guest app has not emitted sootsim:externalAppReady'
1066
+ : status.targets <= 0
1067
+ ? 'ready flag emitted but no visible app content is inspectable yet'
1068
+ : 'node tree is still changing'
1069
+ // suppressed entry errors never gate readiness, but on a stall they are
1070
+ // often the actual diagnosis — surface them as a hint.
1071
+ return !status.externalError && status.suppressedEntryError
1072
+ ? `${base} (suppressed entry error: ${status.suppressedEntryError})`
1073
+ : base
1074
+ }
1075
+
1076
+ // `wait ready` — block until the guest app bundle has mounted and painted.
1077
+ // the persistent `__sootsimExternalAppReady` flag is necessary but not enough:
1078
+ // under the shell renderer the flag can fire before the shell has materialized
1079
+ // the tenant tree, and the node count can then jump through placeholder
1080
+ // counts before the app has finished publishing its first real surface. require
1081
+ // visible inspectable content (or a larger non-placeholder tree) and a brief
1082
+ // stable count. the engine owns the full polling loop, so every caller pays
1083
+ // exactly one bridge request even for a cold guest bundle.
1084
+ export async function inspectWaitReady(
1085
+ bridge: InspectBridge,
1086
+ timeoutMs = 20_000,
1087
+ options: WaitReadyOptions = {},
1088
+ ): Promise<WaitReadyStatus> {
1089
+ const start = Date.now()
1090
+ let result: SemanticWaitResult
1091
+ try {
1092
+ result = await bridge.send(
1093
+ {
1094
+ type: 'waitFor',
1095
+ ...(options.simId ? { simId: options.simId } : {}),
1096
+ waitForOptions: { condition: { type: 'ready' }, timeoutMs },
1097
+ },
1098
+ { timeoutMs: timeoutMs + 1_000 },
1099
+ )
1100
+ } catch (error) {
1101
+ const bridgeError = error instanceof Error ? error.message : String(error)
1102
+ if (
1103
+ bridgeError.startsWith('multiple sims are connected:') ||
1104
+ bridgeError.startsWith('saved sim ')
1105
+ ) {
1106
+ throw error
1107
+ }
1108
+ result = {
1109
+ matched: false,
1110
+ elapsedMs: Date.now() - start,
1111
+ polls: 0,
1112
+ error: bridgeError,
1113
+ }
1114
+ }
1115
+ const probe = result.probe
1116
+ // a guest app that redboxes still paints: its ErrorBoundary is a real tree
1117
+ // with real nodes, so the engine's own readiness condition matches and every
1118
+ // caller reads exit 0 as "the app is up". an app that has errored is not
1119
+ // ready, so a guest error is disqualifying here rather than merely printed
1120
+ // on the failure path. `errors` (a console.error count) is deliberately not
1121
+ // used because apps can log errors benignly. `suppressedEntryError` remains
1122
+ // excluded because it never gates readiness by contract.
1123
+ const externalError = probe?.externalError ?? ''
1124
+ const status: WaitReadyStatus = {
1125
+ ready: result.matched && !externalError,
1126
+ elapsedMs: result.elapsedMs,
1127
+ nodes: probe?.nodes ?? 0,
1128
+ targets: probe?.targets ?? 0,
1129
+ liveFrameActive: probe?.liveFrameActive ?? false,
1130
+ liveFrameChannels: probe?.liveFrameChannels ?? 0,
1131
+ liveFramePublishes: probe?.liveFramePublishes ?? 0,
1132
+ flag: probe?.flag,
1133
+ loadingText: probe?.loadingText ?? '',
1134
+ externalReady: probe?.externalReady ?? null,
1135
+ externalStatus: probe?.externalStatus ?? '',
1136
+ externalError,
1137
+ suppressedEntryError: probe?.suppressedEntryError ?? '',
1138
+ errors: probe?.errors ?? 0,
1139
+ bridgeError: result.error ?? '',
1140
+ }
1141
+ if (!status.ready && options.onProgress) options.onProgress(status)
1142
+ return status
1143
+ }
1144
+
1145
+ // `wait selector` — block until a node with the given testID is present and
1146
+ // laid out. the polling loop runs inside the evaluated code (one round-trip).
1147
+ export async function inspectWaitSelector(
1148
+ bridge: InspectBridge,
1149
+ testId: string,
1150
+ timeoutMs = 5000,
1151
+ opts: { gone?: boolean; structural?: boolean } = {},
1152
+ ): Promise<{ found: boolean; node?: SimSemanticNode; elapsed: number }> {
1153
+ const gone = opts.gone === true
1154
+ const result: SemanticWaitResult = await bridge.send(
1155
+ {
1156
+ type: 'waitFor',
1157
+ waitForOptions: {
1158
+ condition: {
1159
+ type: gone ? 'absent' : opts.structural === true ? 'mounted' : 'present',
1160
+ selector: { testID: testId },
1161
+ },
1162
+ timeoutMs,
1163
+ },
1164
+ },
1165
+ { timeoutMs: timeoutMs + 1_000 },
1166
+ )
1167
+ if (result?.error) throw new Error(result.error)
1168
+ if (result?.node && !isSimSemanticTree([result.node])) {
1169
+ throw new Error('waitFor returned an invalid semantic node')
1170
+ }
1171
+ return {
1172
+ found: result?.matched === true,
1173
+ ...(result?.node ? { node: result.node } : {}),
1174
+ elapsed: result?.elapsedMs ?? timeoutMs,
1175
+ }
1176
+ }
1177
+
1178
+ // ─── console errors / warnings ───
1179
+
1180
+ // a captured console entry. `__sootsimConsole` records each console.error /
1181
+ // console.warn with its args and (for errors) a stack. `source` is set when
1182
+ // the entry came from the engine observability store (e.g. 'render-worker')
1183
+ // so the reader can tell page-realm errors from forwarded tenant-worker ones.
1184
+ export interface ConsoleEntry {
1185
+ timestamp: number
1186
+ args: unknown[]
1187
+ stack?: string
1188
+ source?: string
1189
+ [key: string]: unknown
1190
+ }
1191
+
1192
+ // in-page merge of the two error/warning sources, evaluated in the preview
1193
+ // realm. `level` is 'error' | 'warn'.
1194
+ //
1195
+ // why two sources: `__sootsimConsole` (the ws-bridge ring buffer) only sees a
1196
+ // tenant render-worker error if the worker→host `{type:'log'}` re-emit makes it
1197
+ // through the host realm's monkey-patched `console.error` — and only when
1198
+ // ws-bridge's `setupConsoleCapture()` ran (gated on localhost/electron). The
1199
+ // engine observability store (`__sootsimObservability.logs`) captures the SAME
1200
+ // worker errors via two unconditional paths — the worker's own BroadcastChannel
1201
+ // capture and the host's `recordForwardedWorkerLog('render-worker', …)` — and
1202
+ // carries an explicit `source`/`level`. Reading only `__sootsimConsole` is how
1203
+ // a native render-worker crash (e.g. a tamagui "Missing theme" thrown by the
1204
+ // One root error boundary in the tenant worker) can be live and screaming in
1205
+ // the driver's console while the agent's `sootsim_errors` reports nothing.
1206
+ // Merge both, dedup, so worker + root-error-boundary failures always surface.
1207
+ function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
1208
+ const getter = level === 'error' ? 'getErrors' : 'getWarnings'
1209
+ return `(() => {
1210
+ const out = []
1211
+ const seen = new Set()
1212
+ const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
1213
+ const key = (ts, args) => Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
1214
+ const push = (e) => {
1215
+ if (!e) return
1216
+ const ts = typeof e.timestamp === 'number' ? e.timestamp : (typeof e.ts === 'number' ? e.ts : 0)
1217
+ const k = key(ts, e.args)
1218
+ if (seen.has(k)) return
1219
+ seen.add(k)
1220
+ out.push({ timestamp: ts, args: Array.isArray(e.args) ? e.args : [e.args], stack: e.stack || undefined, source: e.source || undefined })
1221
+ }
1222
+ // page-realm ws-bridge buffer first (richest stacks for page-origin errors)
1223
+ try { for (const e of (window.__sootsimConsole?.${getter}(${limit}) || [])) push(e) } catch {}
1224
+ // engine observability store — the always-on render-worker / forwarded path
1225
+ try {
1226
+ const obs = window.__sootsimObservability
1227
+ const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
1228
+ for (const e of snap) if (e && e.level === ${JSON.stringify(level)}) push(e)
1229
+ } catch {}
1230
+ return out.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)).slice(-${limit})
1231
+ })()`
1232
+ }
1233
+
1234
+ // `get errors` — the most recent captured `console.error` entries, merged from
1235
+ // the ws-bridge buffer and the engine observability store so forwarded
1236
+ // tenant-worker errors (render-worker / root-error-boundary) are never missed.
1237
+ export async function inspectErrors(
1238
+ bridge: InspectBridge,
1239
+ limit = 20,
1240
+ ): Promise<ConsoleEntry[]> {
1241
+ const result = await bridge.send({
1242
+ type: 'evaluate',
1243
+ code: consoleEntriesEval('error', limit),
1244
+ })
1245
+ return Array.isArray(result) ? (result as ConsoleEntry[]) : []
1246
+ }
1247
+
1248
+ // `get warnings` — the most recent captured `console.warn` entries, merged from
1249
+ // both capture sources (see inspectErrors).
1250
+ export async function inspectWarnings(
1251
+ bridge: InspectBridge,
1252
+ limit = 20,
1253
+ ): Promise<ConsoleEntry[]> {
1254
+ const result = await bridge.send({
1255
+ type: 'evaluate',
1256
+ code: consoleEntriesEval('warn', limit),
1257
+ })
1258
+ return Array.isArray(result) ? (result as ConsoleEntry[]) : []
1259
+ }
1260
+
1261
+ // merged error/warning COUNT across both capture sources, evaluated in the
1262
+ // preview realm. used by the CLI's proactive "console: N errors" notice and
1263
+ // the `get state` diagnostics so the count agrees with what inspectErrors /
1264
+ // inspectWarnings actually return — otherwise a render-worker-only error would
1265
+ // read as `0 errors` and the agent would never be nudged to inspect it.
1266
+ export const MERGED_CONSOLE_COUNT_EVAL = `(() => {
1267
+ const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
1268
+ const key = (lvl, ts, args) => lvl + '|' + Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
1269
+ const seen = new Set()
1270
+ let errors = 0
1271
+ let warnings = 0
1272
+ const add = (lvl, ts, args) => {
1273
+ if (lvl !== 'error' && lvl !== 'warn') return
1274
+ const k = key(lvl, ts, args)
1275
+ if (seen.has(k)) return
1276
+ seen.add(k)
1277
+ if (lvl === 'error') errors++
1278
+ else warnings++
1279
+ }
1280
+ try {
1281
+ const c = window.__sootsimConsole
1282
+ for (const e of (c?.getErrors?.(200) || [])) add('error', e?.timestamp ?? e?.ts, e?.args)
1283
+ for (const e of (c?.getWarnings?.(200) || [])) add('warn', e?.timestamp ?? e?.ts, e?.args)
1284
+ } catch {}
1285
+ try {
1286
+ const obs = window.__sootsimObservability
1287
+ const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
1288
+ for (const e of snap) if (e) add(e.level, e.ts, e.args)
1289
+ } catch {}
1290
+ return { errors, warnings, total: errors + warnings }
1291
+ })()`
1292
+
1293
+ // `get errors clear` — drop BOTH captured buffers so a follow-up read starts
1294
+ // clean (the ws-bridge ring buffer and the engine observability log store).
1295
+ export async function clearConsole(bridge: InspectBridge): Promise<void> {
1296
+ await bridge.send({
1297
+ type: 'evaluate',
1298
+ code: 'window.__sootsimConsole?.clear(); window.__sootsimObservability?.logs?.clear?.(); "cleared"',
1299
+ })
1300
+ }
1301
+
1302
+ // ─── network requests ───
1303
+
1304
+ // `get requests` — the captured fetch / XHR entries. `failed` restricts to
1305
+ // unsuccessful / errored requests (the CLI's default); pass `failed: false` for
1306
+ // every request. backed by the test bridge's request observability store.
1307
+ export async function inspectRequests(
1308
+ bridge: InspectBridge,
1309
+ opts: { failed?: boolean; limit?: number } = {},
1310
+ ): Promise<SootSimRequestEntry[]> {
1311
+ const limit = opts.limit ?? 20
1312
+ const method = opts.failed === false ? 'getRequests' : 'getFailedRequests'
1313
+ const result = await bridge.send({
1314
+ type: 'call',
1315
+ path: `__sootsimTest.${method}`,
1316
+ args: [limit],
1317
+ })
1318
+ return Array.isArray(result) ? (result as SootSimRequestEntry[]) : []
1319
+ }
1320
+
1321
+ // `get requests clear` — drop the captured request buffer.
1322
+ export async function clearRequests(bridge: InspectBridge): Promise<void> {
1323
+ await bridge.send({ type: 'call', path: '__sootsimTest.clearRequests', args: [] })
1324
+ }
1325
+
1326
+ // ─── scroll state ───
1327
+
1328
+ export interface ScrollState {
1329
+ scrollOffsetX?: number
1330
+ scrollOffsetY?: number
1331
+ offsetX?: number
1332
+ offsetY?: number
1333
+ [k: string]: unknown
1334
+ }
1335
+
1336
+ // the scroll state of whichever scrollable sits under a screen coordinate.
1337
+ // used to verify a swipe / scroll actually moved content — read it before
1338
+ // and after the gesture and compare the offsets.
1339
+ export async function inspectScrollStateAt(
1340
+ bridge: InspectBridge,
1341
+ x: number,
1342
+ y: number,
1343
+ ): Promise<ScrollState | null> {
1344
+ const result = await bridge.send({
1345
+ type: 'call',
1346
+ path: '__sootsimTest.getScrollStateAt',
1347
+ args: [x, y],
1348
+ })
1349
+ if (!result || typeof result !== 'object') return null
1350
+ const state = result as ScrollState
1351
+ return {
1352
+ ...state,
1353
+ scrollOffsetX:
1354
+ typeof state.scrollOffsetX === 'number' ? state.scrollOffsetX : state.offsetX,
1355
+ scrollOffsetY:
1356
+ typeof state.scrollOffsetY === 'number' ? state.scrollOffsetY : state.offsetY,
1357
+ }
1358
+ }
1359
+
1360
+ // ─── console logs ───
1361
+
1362
+ export type LogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
1363
+
1364
+ export interface LogEntry {
1365
+ id: string
1366
+ source: string
1367
+ level: LogLevel
1368
+ ts: number
1369
+ args: string[]
1370
+ stack: string | null
1371
+ }
1372
+
1373
+ export interface LogFilterOptions {
1374
+ level?: ReadonlySet<LogLevel> | null
1375
+ filter?: string | null
1376
+ // engine-internal `[sootsim]` bootstrap logs are hidden unless this is set.
1377
+ showInternal?: boolean
1378
+ }
1379
+
1380
+ // `logs` — the full captured console snapshot (log / info / warn / error /
1381
+ // debug) from the engine observability store. when the observability bridge has
1382
+ // not installed yet, return an empty snapshot so validation tools don't turn a
1383
+ // missing diagnostics buffer into the app failure being diagnosed.
1384
+ export async function inspectLogs(bridge: InspectBridge): Promise<LogEntry[]> {
1385
+ if (bridge.plane === 'cloud') {
1386
+ const state: unknown = await bridge.send({
1387
+ type: 'state',
1388
+ stateOptions: {
1389
+ screenshot: false,
1390
+ tree: false,
1391
+ route: false,
1392
+ errors: false,
1393
+ logs: true,
1394
+ logLimit: 100,
1395
+ },
1396
+ })
1397
+ const recentLogs =
1398
+ state &&
1399
+ typeof state === 'object' &&
1400
+ Array.isArray(Reflect.get(state, 'recentLogs'))
1401
+ ? Reflect.get(state, 'recentLogs')
1402
+ : []
1403
+ return recentLogs.flatMap((entry: unknown, index: number) => {
1404
+ if (!entry || typeof entry !== 'object') return []
1405
+ const text = Reflect.get(entry, 'text')
1406
+ const rawLevel = Reflect.get(entry, 'level')
1407
+ if (typeof text !== 'string') return []
1408
+ const level: LogLevel =
1409
+ rawLevel === 'log' ||
1410
+ rawLevel === 'info' ||
1411
+ rawLevel === 'warn' ||
1412
+ rawLevel === 'error' ||
1413
+ rawLevel === 'debug'
1414
+ ? rawLevel
1415
+ : 'error'
1416
+ const at = Reflect.get(entry, 'at')
1417
+ const ts = typeof at === 'number' && Number.isFinite(at) ? at : 0
1418
+ return [
1419
+ {
1420
+ id: `cloud-${ts}-${index}`,
1421
+ source: 'rnx-cloud',
1422
+ level,
1423
+ ts,
1424
+ args: [text],
1425
+ stack: null,
1426
+ },
1427
+ ]
1428
+ })
1429
+ }
1430
+ const res = (await bridge.send({
1431
+ type: 'evaluate',
1432
+ code: `(() => {
1433
+ const obs = window.__sootsimObservability;
1434
+ if (!obs) return { ok: false };
1435
+ return { ok: true, entries: obs.logs.getSnapshot() };
1436
+ })()`,
1437
+ })) as { ok: boolean; entries?: LogEntry[] } | null
1438
+ if (!res || !res.ok) return []
1439
+ return res.entries ?? []
1440
+ }
1441
+
1442
+ // `logs clear` — drop the engine's captured log buffer.
1443
+ export async function clearLogs(bridge: InspectBridge): Promise<void> {
1444
+ await bridge.send({
1445
+ type: 'evaluate',
1446
+ code: 'window.__sootsimObservability?.logs.clear(); "cleared"',
1447
+ })
1448
+ }
1449
+
1450
+ function isForwardedWorkerLogTwin(a: LogEntry, b: LogEntry): boolean {
1451
+ if (a.source === b.source) return false
1452
+ if (a.level !== b.level) return false
1453
+ if (Math.abs(a.ts - b.ts) > 1000) return false
1454
+ if (a.args.length !== b.args.length) return false
1455
+ const sources = new Set([a.source, b.source])
1456
+ if (!sources.has('sootsim-worker')) return false
1457
+ if (!sources.has('render-worker') && !sources.has('forwarded-render-worker')) {
1458
+ return false
1459
+ }
1460
+ return a.args.every((arg, index) => arg === b.args[index])
1461
+ }
1462
+
1463
+ // shared log filtering — engine-internal `[sootsim]` noise hidden by default,
1464
+ // optional level set + message substring. used by the CLI `logs` verb and the
1465
+ // agent so both see the same filtered view.
1466
+ export function filterLogEntries(
1467
+ entries: LogEntry[],
1468
+ opts: LogFilterOptions = {},
1469
+ ): LogEntry[] {
1470
+ let out: LogEntry[] = []
1471
+ for (const entry of entries) {
1472
+ if (out.some((candidate) => isForwardedWorkerLogTwin(candidate, entry))) {
1473
+ continue
1474
+ }
1475
+ out.push(entry)
1476
+ }
1477
+ if (!opts.showInternal) {
1478
+ out = out.filter((e) => {
1479
+ const first = e.args[0]
1480
+ return !(
1481
+ typeof first === 'string' && /^\[(?:rnx|sootsim)(?:\s[^\]]*)?\]/.test(first)
1482
+ )
1483
+ })
1484
+ }
1485
+ if (opts.level) out = out.filter((e) => opts.level!.has(e.level))
1486
+ if (opts.filter) {
1487
+ const lf = opts.filter.toLowerCase()
1488
+ out = out.filter((e) => e.args.join(' ').toLowerCase().includes(lf))
1489
+ }
1490
+ return out
1491
+ }
1492
+
1493
+ // ─── timeline / what-happened ───
1494
+
1495
+ // `what-happened --summary` — counts by kind since the query window.
1496
+ export async function inspectTimelineSummary(
1497
+ bridge: InspectBridge,
1498
+ query: SootSimTimelineQuery,
1499
+ ): Promise<SootSimTimelineSummary> {
1500
+ return (await bridge.send({
1501
+ type: 'call',
1502
+ path: 'SootSim.bridges.timeline.summary',
1503
+ args: [query],
1504
+ })) as SootSimTimelineSummary
1505
+ }
1506
+
1507
+ // `what-happened` — the recent timeline events for the query window.
1508
+ export async function inspectTimelineRecent(
1509
+ bridge: InspectBridge,
1510
+ query: SootSimTimelineQuery,
1511
+ ): Promise<SootSimTimelineQueryResult> {
1512
+ return (await bridge.send({
1513
+ type: 'call',
1514
+ path: 'SootSim.bridges.timeline.recent',
1515
+ args: [query],
1516
+ })) as SootSimTimelineQueryResult
1517
+ }
1518
+
1519
+ // a blocking native-UI surface (iOS Alert / ActionSheet) currently presented
1520
+ // by the shell worker. `label` is the human word for the CLI note; `title` is
1521
+ // the surface's title text when it has one.
1522
+ export interface OpenNativeUISurface {
1523
+ label: string
1524
+ title: string | null
1525
+ }
1526
+
1527
+ // detect blocking native-UI surfaces that are currently open, computed from the
1528
+ // timeline. the shell native-UI provider records a `show` then a `resolve`
1529
+ // event per surface (both DEFAULT_ON kinds, so no `timeline start` is needed),
1530
+ // so the latest event of each kind tells whether it's still up. the CLI surfaces
1531
+ // this because these surfaces live in the shell worker — invisible to `describe`
1532
+ // (which reads the tenant tree) — yet an open modal silently swallows every
1533
+ // tap/drag/type meant for the app underneath, which reads as "the action did
1534
+ // nothing." returns [] on any read error; this is an advisory note, never fatal.
1535
+ export async function detectOpenNativeUI(
1536
+ bridge: InspectBridge,
1537
+ ): Promise<OpenNativeUISurface[]> {
1538
+ let events: SootSimTimelineEvent[]
1539
+ try {
1540
+ const res = await inspectTimelineRecent(bridge, {
1541
+ kinds: ['alert', 'actionsheet'],
1542
+ limit: 80,
1543
+ })
1544
+ events = res.events
1545
+ } catch {
1546
+ return []
1547
+ }
1548
+ // events arrive time-ordered; keep the latest event per kind.
1549
+ const latest = new Map<string, SootSimTimelineEvent>()
1550
+ for (const ev of events) latest.set(ev.kind, ev)
1551
+ const out: OpenNativeUISurface[] = []
1552
+ const check = (kind: string, label: string) => {
1553
+ const ev = latest.get(kind)
1554
+ if (!ev || !ev.data || typeof ev.data !== 'object') return
1555
+ // timeline payloads are plain JSON records (see formatTimelinePayload)
1556
+ const d = ev.data as Record<string, unknown>
1557
+ if (d.phase !== 'show') return
1558
+ out.push({ label, title: typeof d.title === 'string' ? d.title : null })
1559
+ }
1560
+ check('alert', 'Alert')
1561
+ check('actionsheet', 'ActionSheet')
1562
+ return out
1563
+ }
1564
+
1565
+ // advance a caller's cursor past the events it has consumed, so the next
1566
+ // `what-happened` for the same cursor key shows only newer events.
1567
+ export async function inspectTimelineAdvanceCursor(
1568
+ bridge: InspectBridge,
1569
+ cursorKey: string,
1570
+ watermark: number,
1571
+ ): Promise<void> {
1572
+ await bridge.send({
1573
+ type: 'call',
1574
+ path: 'SootSim.bridges.timeline.cursorAdvance',
1575
+ args: [cursorKey, watermark],
1576
+ })
1577
+ }
1578
+
1579
+ // ─── keyboard ───
1580
+
1581
+ // `__sootsimKeyboard.getLayout()` returns the shell's own snapshot, so the
1582
+ // engine's contract is the type. a copy here drifts from it silently.
1583
+ export type KeyboardState = KeyboardLayoutSnapshot
1584
+
1585
+ // `get keyboard` — the live iOS keyboard state from `__sootsimKeyboard`,
1586
+ // populated by shell-worker `keyboard.layout` broadcasts. returns the raw
1587
+ // payload or `{ error }` when the keyboard bridge has not installed yet.
1588
+ export async function inspectKeyboard(
1589
+ bridge: InspectBridge,
1590
+ ): Promise<KeyboardState | { error: string }> {
1591
+ const result = (await bridge.send({
1592
+ type: 'evaluate',
1593
+ code: `(() => {
1594
+ const kb = window.__sootsimKeyboard
1595
+ if (!kb || typeof kb.getLayout !== 'function') {
1596
+ return { error: 'keyboard bridge getLayout() not available' }
1597
+ }
1598
+ return kb.getLayout()
1599
+ })()`,
1600
+ })) as KeyboardState | { error: string }
1601
+ return result ?? { error: 'keyboard bridge returned no result' }
1602
+ }
1603
+
1604
+ // ─── shell state ───
1605
+
1606
+ // true when a `SootSim.bridges.mainShell.*` call failed because the shell
1607
+ // bridge has not installed yet (boot race) — distinct from a real error.
1608
+ export function isShellCommandUnavailable(error: unknown): boolean {
1609
+ const message = error instanceof Error ? error.message : String(error)
1610
+ return (
1611
+ message.includes('call target not found: SootSim.bridges.mainShell') ||
1612
+ message.includes('test bridge unavailable before app-in-worker boot')
1613
+ )
1614
+ }
1615
+
1616
+ // read `mainShell.getState()`, retrying past the boot race for up to
1617
+ // `readyTimeoutMs`. shared by the CLI's inspect verbs and the agent.
1618
+ export async function getShellState(
1619
+ bridge: InspectBridge,
1620
+ readyTimeoutMs = 0,
1621
+ ): Promise<Record<string, any> | null> {
1622
+ const deadline = Date.now() + Math.max(0, readyTimeoutMs)
1623
+ while (true) {
1624
+ try {
1625
+ return (await bridge.send({
1626
+ type: 'call',
1627
+ path: 'SootSim.bridges.mainShell.getState',
1628
+ args: [],
1629
+ })) as Record<string, any> | null
1630
+ } catch (error) {
1631
+ if (!isShellCommandUnavailable(error) || Date.now() >= deadline) throw error
1632
+ await new Promise((r) => setTimeout(r, 50))
1633
+ }
1634
+ }
1635
+ }
1636
+
1637
+ // ─── screens ───
1638
+
1639
+ export interface ScreenEntry {
1640
+ id: string
1641
+ routeName?: string
1642
+ isActive: boolean
1643
+ headerHeight: number
1644
+ largeTitleState?: string
1645
+ }
1646
+
1647
+ export interface NavSnapshot {
1648
+ screens: ScreenEntry[]
1649
+ activeScreenId: string | null
1650
+ transitionPhase: string
1651
+ activeTransitionCount: number
1652
+ activeHeaderHeight: number
1653
+ activeLargeTitleState?: string
1654
+ }
1655
+
1656
+ export interface ScreensReport {
1657
+ shell: Record<string, any> | null
1658
+ nav: NavSnapshot | null
1659
+ route: SootSimRouteInfo | null
1660
+ keyboard: {
1661
+ visible: boolean
1662
+ mode?: string
1663
+ spec?: { keyboardType?: string; returnKeyType?: string } | null
1664
+ } | null
1665
+ }
1666
+
1667
+ // `get screens` — the navigation stack + shell state + keyboard, a "what
1668
+ // screen am I on?" dashboard. nav + keyboard are read in one evaluate so the
1669
+ // snapshot is consistent; shell state is a best-effort separate call.
1670
+ export async function inspectScreens(bridge: InspectBridge): Promise<ScreensReport> {
1671
+ const payload = (await bridge.send({
1672
+ type: 'evaluate',
1673
+ code: `(async () => {
1674
+ const test = window.__sootsimTest
1675
+ const kb = window.__sootsimKeyboard
1676
+ const navSnap =
1677
+ test && typeof test.getNavigationSnapshot === 'function'
1678
+ ? await test.getNavigationSnapshot()
1679
+ : null
1680
+ const route =
1681
+ test && typeof test.getRouteInfo === 'function'
1682
+ ? await test.getRouteInfo()
1683
+ : null
1684
+ const keyboard =
1685
+ kb && typeof kb.getLayout === 'function'
1686
+ ? (() => {
1687
+ const layout = kb.getLayout()
1688
+ return layout ? {
1689
+ visible: layout.visible,
1690
+ mode: layout.mode,
1691
+ spec: layout.spec
1692
+ ? {
1693
+ keyboardType: layout.spec.keyboardType,
1694
+ returnKeyType: layout.spec.returnKeyType,
1695
+ }
1696
+ : null,
1697
+ } : null
1698
+ })()
1699
+ : null
1700
+ return { nav: navSnap, route, keyboard }
1701
+ })()`,
1702
+ })) as {
1703
+ nav: NavSnapshot | null
1704
+ route: SootSimRouteInfo | null
1705
+ keyboard: ScreensReport['keyboard']
1706
+ } | null
1707
+ const shell = await getShellState(bridge, 500).catch(() => null)
1708
+ return {
1709
+ shell,
1710
+ nav: payload?.nav ?? null,
1711
+ route: payload?.route ?? null,
1712
+ keyboard: payload?.keyboard ?? null,
1713
+ }
1714
+ }
1715
+
1716
+ // ─── debug channels ───
1717
+
1718
+ // the `window.__sootsimDebug` channels the engine instruments. `all` is a
1719
+ // meta-target accepted by enable/disable.
1720
+ export const DEBUG_CHANNELS = [
1721
+ 'portals',
1722
+ 'sheets',
1723
+ 'layout',
1724
+ 'onlayout',
1725
+ 'animated',
1726
+ 'render',
1727
+ 'touch',
1728
+ 'yoga',
1729
+ ] as const
1730
+
1731
+ export type DebugChannel = (typeof DEBUG_CHANNELS)[number]
1732
+
1733
+ // `debug status` — which debug channels are currently enabled + their counts.
1734
+ export async function inspectDebugStatus(bridge: InspectBridge): Promise<unknown> {
1735
+ return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.status()' })
1736
+ }
1737
+
1738
+ // `debug flags` — the engine's debug feature flags.
1739
+ export async function inspectDebugFlags(bridge: InspectBridge): Promise<unknown> {
1740
+ return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.flags()' })
1741
+ }
1742
+
1743
+ // `debug find sheets|portals` — locate all live sheet / portal nodes.
1744
+ // routed through the test bridge (not the page __sootsimDebug global) so the
1745
+ // walk runs against the worker's live tree — the page-local tree is empty
1746
+ // under the worker renderer.
1747
+ export async function inspectDebugFind(
1748
+ bridge: InspectBridge,
1749
+ target: 'sheets' | 'portals' | 'boundaries',
1750
+ ): Promise<unknown> {
1751
+ return bridge.send({
1752
+ type: 'evaluate',
1753
+ code: `window.__sootsimTest.debugFind(${JSON.stringify(target)})`,
1754
+ })
1755
+ }
1756
+
1757
+ // debug buffers belong to their execution context. tenant calls use the
1758
+ // existing test bridge; host calls keep shell/compositor flag forwarding intact.
1759
+ export async function callDebugBridge(
1760
+ bridge: InspectBridge,
1761
+ expression: string,
1762
+ host = false,
1763
+ ): Promise<unknown> {
1764
+ const code = `globalThis.__sootsimDebug.${expression}`
1765
+ if (host) return bridge.send({ type: 'evaluate', code })
1766
+ return bridge.send({
1767
+ type: 'evaluate',
1768
+ code: `(async () => {
1769
+ const result = await window.__sootsimTest.evalInTenant(${JSON.stringify(code)})
1770
+ if (!result.ok) throw new Error(result.error)
1771
+ return result.value
1772
+ })()`,
1773
+ })
1774
+ }
1775
+
1776
+ // `debug recent` reads tenant events; --host explicitly selects the page buffer.
1777
+ export async function inspectDebugRecent(
1778
+ bridge: InspectBridge,
1779
+ channel?: string,
1780
+ limit = 50,
1781
+ host = false,
1782
+ ): Promise<unknown> {
1783
+ return callDebugBridge(
1784
+ bridge,
1785
+ `recent(${channel && channel !== 'all' ? JSON.stringify(channel) : 'undefined'}, ${limit})`,
1786
+ host,
1787
+ )
1788
+ }
1789
+
1790
+ // channel controls reach the host (and its shell/compositor subscribers) plus
1791
+ // the active tenant. await the tenant reply before reporting success.
1792
+ export async function setDebugChannels(
1793
+ bridge: InspectBridge,
1794
+ action: 'enable' | 'disable',
1795
+ channels: string[],
1796
+ host = false,
1797
+ ): Promise<unknown> {
1798
+ const args =
1799
+ channels.length > 0
1800
+ ? channels.map((c) => JSON.stringify(c)).join(', ')
1801
+ : action === 'disable'
1802
+ ? "'all'"
1803
+ : ''
1804
+ const expression = `${action}(${args})`
1805
+ const active = await callDebugBridge(bridge, expression, true)
1806
+ if (host) return active
1807
+ return callDebugBridge(bridge, expression)
1808
+ }
1809
+
1810
+ // ─── memory ───
1811
+
1812
+ export interface ImageLoaderStats {
1813
+ cacheEntries: number
1814
+ cachePixelBytes: number
1815
+ cachePixelBudget: number
1816
+ cacheMaxEntries: number
1817
+ pendingFetches: number
1818
+ pendingBytes: number
1819
+ failedUris: number
1820
+ snapshots: number
1821
+ liveFrames: number
1822
+ }
1823
+
1824
+ export interface WorkerHeapStats {
1825
+ usedJSHeapSize: number
1826
+ totalJSHeapSize: number
1827
+ jsHeapSizeLimit: number
1828
+ }
1829
+
1830
+ export interface EngineObjectCounts {
1831
+ pictures: number
1832
+ rasterImages: number
1833
+ paragraphs: number
1834
+ yogaNodes: number
1835
+ }
1836
+
1837
+ export interface WorkerMemorySample {
1838
+ // total nodes ever registered in the worker's handleToNode map — a
1839
+ // monotonically climbing value while the on-screen tree is stable means
1840
+ // discarded subtrees are being retained.
1841
+ nodesRegistered: number | null
1842
+ // registered nodes not reachable from a root container, by count and type
1843
+ nodesDetached: number | null
1844
+ detachedTypes: Record<string, number> | null
1845
+ objects: EngineObjectCounts | null
1846
+ activeNativeAnimations: number | null
1847
+ imageLoader: ImageLoaderStats | null
1848
+ workerHeap: WorkerHeapStats | null
1849
+ // canvaskit's wasm linear memory in this worker. every worker that loads
1850
+ // canvaskit allocates its own, no js-heap counter includes it, and on webkit
1851
+ // (where performance.memory does not exist) it is the only number available.
1852
+ wasmHeapBytes: number | null
1853
+ }
1854
+
1855
+ export interface MemoryReport {
1856
+ tenant: WorkerMemorySample | null
1857
+ shell: WorkerMemorySample | null
1858
+ compositor: WorkerMemorySample | null
1859
+ hostHeap: WorkerHeapStats | null
1860
+ }
1861
+
1862
+ // `get memory` / `debug memory` — per-worker object counts + image-loader
1863
+ // cache + JS heaps. tenant numbers ride the queryStats memory block; the
1864
+ // shell worker answers via __sootsimShellMemory, and the compositor — which
1865
+ // paints home + app:one + app:two, and therefore holds every app-surface
1866
+ // picture and raster image — answers through its stats channel.
1867
+ // performance.memory is chrome-only, null on webkit.
1868
+ export async function inspectMemory(bridge: InspectBridge): Promise<MemoryReport> {
1869
+ if (bridge.plane === 'cloud') {
1870
+ const value: unknown = await bridge.send({ type: 'memory' })
1871
+ const rawWasmHeapBytes =
1872
+ typeof value === 'object' && value !== null
1873
+ ? Reflect.get(value, 'wasmHeapBytes')
1874
+ : null
1875
+ const wasmHeapBytes = typeof rawWasmHeapBytes === 'number' ? rawWasmHeapBytes : null
1876
+ return {
1877
+ tenant: {
1878
+ nodesRegistered: null,
1879
+ nodesDetached: null,
1880
+ detachedTypes: null,
1881
+ objects: null,
1882
+ activeNativeAnimations: null,
1883
+ imageLoader: null,
1884
+ workerHeap: null,
1885
+ wasmHeapBytes,
1886
+ },
1887
+ shell: null,
1888
+ compositor: null,
1889
+ hostHeap: null,
1890
+ }
1891
+ }
1892
+ const result = (await bridge.send({
1893
+ type: 'evaluate',
1894
+ code: `(async () => {
1895
+ const host = window.__sootsimRenderHost
1896
+ const stats = host?.queryStats ? await host.queryStats() : null
1897
+ const shellRaw = window.__sootsimShellMemory
1898
+ ? await window.__sootsimShellMemory()
1899
+ : null
1900
+ const shell = shellRaw && !shellRaw.error ? shellRaw : null
1901
+ // the compositor paints home + app:one + app:two, so every app-surface
1902
+ // picture and raster image is counted here and nowhere else.
1903
+ let compositor = null
1904
+ try {
1905
+ const cs = await window.__sootsimCompositor?.getStats?.(false, true)
1906
+ compositor = cs?.memory ?? null
1907
+ } catch {}
1908
+ const normalize = (m) =>
1909
+ m
1910
+ ? {
1911
+ nodesRegistered: m.nodesRegistered ?? null,
1912
+ nodesDetached: m.nodesDetached ?? null,
1913
+ detachedTypes: m.detachedTypes ?? null,
1914
+ objects: m.objects ?? null,
1915
+ activeNativeAnimations: m.activeNativeAnimations ?? null,
1916
+ imageLoader: m.imageLoader ?? null,
1917
+ workerHeap: m.workerHeap ?? null,
1918
+ wasmHeapBytes: m.wasmHeapBytes ?? null,
1919
+ }
1920
+ : null
1921
+ const hostMem = performance.memory
1922
+ ? {
1923
+ usedJSHeapSize: performance.memory.usedJSHeapSize,
1924
+ totalJSHeapSize: performance.memory.totalJSHeapSize,
1925
+ jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
1926
+ }
1927
+ : null
1928
+ return {
1929
+ tenant: normalize(stats?.memory),
1930
+ shell: normalize(shell),
1931
+ compositor: normalize(compositor),
1932
+ hostHeap: hostMem,
1933
+ }
1934
+ })()`,
1935
+ })) as MemoryReport | null
1936
+ return result ?? { tenant: null, shell: null, compositor: null, hostHeap: null }
1937
+ }
1938
+
1939
+ // one-line counts, ordered by descriptive priority. shared by the CLI's
1940
+ // `what-happened --summary` and the agent's sootsim_what_happened.
1941
+ export function formatTimelineSummary(summary: SootSimTimelineSummary): string {
1942
+ if (summary.total === 0) return 'nothing recorded'
1943
+ const parts: string[] = []
1944
+ const ORDER = [
1945
+ 'error',
1946
+ 'warning',
1947
+ 'console',
1948
+ 'fetch',
1949
+ 'toast',
1950
+ 'alert',
1951
+ 'actionsheet',
1952
+ 'picker',
1953
+ 'notification',
1954
+ 'screen',
1955
+ 'route',
1956
+ 'keyboard',
1957
+ 'app-launch',
1958
+ 'shell',
1959
+ 'scroll',
1960
+ 'gesture',
1961
+ 'text-input',
1962
+ 'react-commit',
1963
+ 'animation',
1964
+ 'reanimated',
1965
+ ]
1966
+ const seen = new Set<string>()
1967
+ for (const k of ORDER) {
1968
+ const n = summary.byKind[k]
1969
+ if (n) {
1970
+ parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
1971
+ seen.add(k)
1972
+ }
1973
+ }
1974
+ for (const [k, n] of Object.entries(summary.byKind)) {
1975
+ if (!seen.has(k) && n) parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
1976
+ }
1977
+ return parts.join(' · ')
1978
+ }
1979
+
1980
+ // high-frequency render/layout/scroll kinds that drown the meaningful
1981
+ // events. both the `what-happened` CLI and the sootsim_what_happened agent
1982
+ // tool hide these by default so a single tap doesn't get buried under 1000+
1983
+ // react-commit rows. callers opt back in explicitly.
1984
+ export const NOISY_TIMELINE_KINDS: ReadonlySet<string> = new Set([
1985
+ 'react-commit',
1986
+ 'layout',
1987
+ 'scroll',
1988
+ ])
1989
+
1990
+ function formatTimelineRelativeTime(t: number, anchor: number | null): string {
1991
+ if (anchor === null) return new Date(t).toLocaleTimeString()
1992
+ const dt = (t - anchor) / 1000
1993
+ const sign = dt >= 0 ? '+' : ''
1994
+ return `${sign}${dt.toFixed(2)}s`
1995
+ }
1996
+
1997
+ // payload rendering per timeline-event kind. one exhaustive switch over
1998
+ // SootSimTimelineKind so adding a kind forces a formatter decision rather
1999
+ // than silently leaving it JSON-dumped. shared by the `what-happened` CLI and
2000
+ // the sootsim_what_happened agent tool — one renderer, two transports.
2001
+ export function formatTimelinePayload(
2002
+ kind: SootSimTimelineKind,
2003
+ d: Record<string, unknown>,
2004
+ ): string {
2005
+ switch (kind) {
2006
+ case 'app-launch':
2007
+ return d.phase === 'launch'
2008
+ ? `launch ${d.appName ?? d.toAppId ?? ''}`
2009
+ : `dismiss ${d.appName ?? d.fromAppId ?? ''} → ${d.toAppId ?? ''}`
2010
+ case 'toast':
2011
+ return `"${d.text ?? ''}"${d.durationMs ? ` (${d.durationMs}ms)` : ''}`
2012
+ case 'keyboard':
2013
+ return `${d.phase ?? '?'}${d.heightPx ? ` h=${d.heightPx}` : ''}${d.mode ? ` ${d.mode}` : ''}`
2014
+ case 'screen':
2015
+ return `${d.phase ?? '?'} ${d.name ?? d.activeName ?? ''}`
2016
+ case 'flow-step':
2017
+ return `${d.status ?? '?'} ${d.stepName ?? 'step'}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
2018
+ case 'route':
2019
+ return `${d.phase ?? '?'} ${d.path ?? d.pathname ?? ''}`
2020
+ case 'alert':
2021
+ case 'actionsheet':
2022
+ case 'picker':
2023
+ return `${d.phase ?? '?'} ${d.title ?? d.message ?? ''}`
2024
+ case 'notification':
2025
+ return `${d.title ?? ''}${d.body ? ` — ${d.body}` : ''}`
2026
+ case 'fetch':
2027
+ return `${d.method ?? 'GET'} ${d.url ?? ''}${d.status ? ` -> ${d.status}` : ''}`
2028
+ case 'console':
2029
+ case 'console-log':
2030
+ return `${d.level ?? 'log'}: ${(d.message ?? '').toString().slice(0, 120)}`
2031
+ case 'shell':
2032
+ return `${d.event ?? d.type ?? d.phase ?? ''}`
2033
+ case 'scroll':
2034
+ return `${d.phase ?? '?'} ${d.target ?? ''}`
2035
+ case 'gesture':
2036
+ return `${d.phase ?? '?'} ${d.type ?? ''}`
2037
+ case 'text-input':
2038
+ return `${d.phase ?? '?'}${d.value !== undefined ? ` "${String(d.value).slice(0, 40)}"` : ''}`
2039
+ case 'layout':
2040
+ return `${d.kind ?? '?'} ${d.testID ?? d.type ?? ''}${
2041
+ d.skipped ? ` skipped:${d.reason ?? 'unknown'}` : ''
2042
+ }`
2043
+ case 'react-commit': {
2044
+ const slowest = d.slowest as
2045
+ | { displayName?: unknown; durationMs?: unknown }
2046
+ | null
2047
+ | undefined
2048
+ return `${d.fiberCount ?? '?'} fibers ${d.durationMs ?? '?'}ms${
2049
+ slowest?.displayName
2050
+ ? ` · ${slowest.displayName} ${slowest.durationMs ?? '?'}ms`
2051
+ : ''
2052
+ }`
2053
+ }
2054
+ case 'frame':
2055
+ return `${d.totalMs ?? '?'}ms${d.renderMs ? ` · render ${d.renderMs}ms` : ''}${
2056
+ d.layoutMs ? ` · layout ${d.layoutMs}ms` : ''
2057
+ }${d.copyMs ? ` · copy ${d.copyMs}ms` : ''}`
2058
+ case 'reanimated':
2059
+ case 'animation':
2060
+ return `${d.kind ?? ''} ${d.target ?? ''}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
2061
+ }
2062
+ return ''
2063
+ }
2064
+
2065
+ // one compact line per event: ` +0.42s shell [fetch] GET /api -> 200`.
2066
+ // pass anchor = first event's `t` for relative times, or null for wall-clock.
2067
+ export function formatTimelineEvent(
2068
+ event: SootSimTimelineEvent,
2069
+ anchor: number | null,
2070
+ ): string {
2071
+ const ts = formatTimelineRelativeTime(event.t, anchor).padStart(8)
2072
+ const ctx = event.context.padEnd(6)
2073
+ const kind = `[${event.kind}]`.padEnd(15)
2074
+ const d = event.data as Record<string, unknown> | null
2075
+ const payload = d && typeof d === 'object' ? formatTimelinePayload(event.kind, d) : ''
2076
+ return ` ${ts} ${ctx} ${kind} ${payload}`
2077
+ }
2078
+
2079
+ // render an event list as compact lines, anchored to the first event so times
2080
+ // read +0.00s, +0.13s, … . the agent tool and CLI both call this rather than
2081
+ // JSON-dumping the events array.
2082
+ export function formatTimelineEvents(events: readonly SootSimTimelineEvent[]): string {
2083
+ if (!events.length) return ''
2084
+ const anchor = events[0]?.t ?? null
2085
+ return events.map((e) => formatTimelineEvent(e, anchor)).join('\n')
2086
+ }