rnxsim 0.1.313 → 0.1.314

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
  128. package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
  129. package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
  130. package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
  131. package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
  132. package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
  133. package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
  134. package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
  135. package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
  136. package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
  137. package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
  138. package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
  139. package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
  140. package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
  141. package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
  142. package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
  143. package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
  144. package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
  145. package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
  146. package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
  147. package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
  148. package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
  149. package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
  150. package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
  151. package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
  152. package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
  153. package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
  154. package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
  155. package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
  156. package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
  157. package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
  158. package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
  159. package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
  160. package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
  161. package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
  162. package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
  163. package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
  164. package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
  165. package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
  166. package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
  167. package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
  168. package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
  169. package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
  170. package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
  171. package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
  172. package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
  173. package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
  174. package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
  175. package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
  176. package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
  177. package/dist-cli/chunks/{chunk-5TPRP5QT.js → chunk-WGGRJDRE.js} +1 -1
  178. package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
  179. package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
  180. package/dist-cli/chunks/{chunk-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
  181. package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
  182. package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
  183. package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
  184. package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
  185. package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
  186. package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
  187. package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
  188. package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
  189. package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
  190. package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
  191. package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
  192. package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
  193. package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
  196. package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
  197. package/dist-cli/chunks/{film-BJGTBYZB.js → film-GDMR33VO.js} +3 -3
  198. package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
  199. package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-ANOYADHM.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
  201. package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
  202. package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
  203. package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
  204. package/dist-cli/chunks/{login-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
  205. package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
  206. package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
  208. package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
  210. package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
  211. package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
  212. package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-7NMFP4HK.js → report-issue-TAI6DYZO.js} +2 -2
  214. package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
  215. package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
  219. package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
  220. package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
  221. package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
  222. package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
  223. package/dist-cli/chunks/{storage-XUIMJWAJ.js → storage-OYC57C4X.js} +6 -6
  224. package/dist-cli/chunks/store-32HSPZHI.js +4 -0
  225. package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
  226. package/dist-cli/chunks/{timeline-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-7HDSIM7K.js → upgrade-OEFNZRIP.js} +2 -2
  228. package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
  229. package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
  230. package/dist-cli/chunks/{web-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-XFVUTZR7.js → what-happened-YNHRFUDX.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +480 -280
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-VL73YIKP.js +0 -22
  277. package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
  278. package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
  279. package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
  280. package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
  281. package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
  282. package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
  283. package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
  284. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
  285. package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
  286. package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
  287. package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
  288. package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
  289. package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
  290. package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
  291. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
  292. package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
  293. package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
  294. package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
  295. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
  296. package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
  297. package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
@@ -0,0 +1,2405 @@
1
+ // transport-agnostic sootsim inspect-verb kernels.
2
+ //
3
+ // each function here builds the JS a verb runs against a sootsim instance and
4
+ // returns *structured data* — no arg parsing, no stdout, no formatting. 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 type {
15
+ SootSimRouteInfo,
16
+ SootSimRequestEntry,
17
+ SootSimTimelineEvent,
18
+ SootSimTimelineKind,
19
+ SootSimTimelineQuery,
20
+ SootSimTimelineQueryResult,
21
+ SootSimTimelineSummary,
22
+ } from '@sootsim/globals'
23
+
24
+ // resolve a `--max-ms <n>` flag, tolerating the spellings agents and humans
25
+ // actually type (`--maxMs`, `--maxms`, `--max_ms`). a silently-ignored
26
+ // timeout flag is a sharp edge: the call doesn't error, it just uses the
27
+ // default, so the operator thinks their bound applied when it didn't.
28
+ export function resolveMaxMsFlag(args: string[], fallbackMs: number): number {
29
+ const aliases = ['--max-ms', '--maxMs', '--maxms', '--max_ms']
30
+ for (const flag of aliases) {
31
+ const i = args.indexOf(flag)
32
+ if (i >= 0 && args[i + 1]) {
33
+ const n = Number(args[i + 1])
34
+ if (Number.isFinite(n)) return Math.max(100, n)
35
+ }
36
+ }
37
+ return fallbackMs
38
+ }
39
+
40
+ // the minimal bridge an inspect kernel needs: a JSON command channel into a
41
+ // sootsim instance. `{ type: 'evaluate', code }` evaluates JS in the page;
42
+ // `{ type: 'call', path, args }` invokes a method by dotted path.
43
+ export interface InspectBridge {
44
+ send(
45
+ cmd: { type: string; [k: string]: any },
46
+ opts?: { timeoutMs?: number },
47
+ ): Promise<any>
48
+ }
49
+
50
+ const INSPECT_PICK_COMMANDS = new Set([
51
+ 'tap',
52
+ 'double-tap',
53
+ 'tap-text',
54
+ 'tap-id',
55
+ 'long-press',
56
+ 'touch',
57
+ ])
58
+
59
+ export function isInspectPickCommand(subcommand: string | null | undefined): boolean {
60
+ return typeof subcommand === 'string' && INSPECT_PICK_COMMANDS.has(subcommand)
61
+ }
62
+
63
+ export async function isInspectModeActive(bridge: InspectBridge): Promise<boolean> {
64
+ const active = await bridge.send({
65
+ type: 'evaluate',
66
+ code: 'window.__sootsimEngineState?.inspectActive === true',
67
+ })
68
+ return active === true
69
+ }
70
+
71
+ export async function shouldSkipAutoSettleForInspectPick(
72
+ bridge: InspectBridge,
73
+ subcommand: string | null | undefined,
74
+ ): Promise<boolean> {
75
+ if (!isInspectPickCommand(subcommand)) return false
76
+ try {
77
+ return await isInspectModeActive(bridge)
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
83
+ // `get count` — total SootSimNode count in the live tree.
84
+ export async function inspectNodeCount(
85
+ bridge: InspectBridge,
86
+ ): Promise<{ nodes: number }> {
87
+ const count = await bridge.send({
88
+ type: 'evaluate',
89
+ code: '(async () => await window.__sootsimTest.getNodeCount())()',
90
+ })
91
+ return { nodes: typeof count === 'number' ? count : 0 }
92
+ }
93
+
94
+ // `get tree` — raw node tree dump. goes through the merged test bridge
95
+ // (shell + tenant). `tree` is returned raw so each frontend formats it.
96
+ // `format: 'boxes'` asks the engine for the structured rect tree (a JSON
97
+ // string on the wire) and parses it here.
98
+ export async function inspectTree(
99
+ bridge: InspectBridge,
100
+ depth = 5,
101
+ opts?: { format?: 'boxes' },
102
+ ): Promise<{ depth: number; tree: unknown }> {
103
+ const optsArg = opts?.format ? `, ${JSON.stringify(opts)}` : ''
104
+ const tree = await bridge.send({
105
+ type: 'evaluate',
106
+ code: `(async () => await window.__sootsimTest.dumpTree(${depth}${optsArg}))()`,
107
+ })
108
+ if (opts?.format === 'boxes' && typeof tree === 'string') {
109
+ try {
110
+ return { depth, tree: JSON.parse(tree) }
111
+ } catch {
112
+ return { depth, tree }
113
+ }
114
+ }
115
+ return { depth, tree }
116
+ }
117
+
118
+ // `get url` — the current page URL the bridge is attached to.
119
+ export async function inspectUrl(bridge: InspectBridge): Promise<{ url: string }> {
120
+ const url = await bridge.send({ type: 'evaluate', code: 'window.location.href' })
121
+ return { url: typeof url === 'string' ? url : '' }
122
+ }
123
+
124
+ // ─── describe ───
125
+
126
+ export interface DescribeDumpOptions {
127
+ describe: boolean
128
+ verbose: boolean
129
+ filter: string
130
+ testIdLike?: string
131
+ onlyGlob?: string
132
+ subtreeRoot?: string
133
+ compact: boolean
134
+ hideXy: boolean
135
+ includeOccluded?: boolean
136
+ // machine consumers keep the whole string; the terminal dump clips it
137
+ fullText?: boolean
138
+ // depth for the recursive dump. omitted means the default 12. setting it
139
+ // forces the recursive path, because the compact inspectable listing has no
140
+ // depth to honor and would silently ignore the request.
141
+ maxDepth?: number
142
+ }
143
+
144
+ export interface DescribeKeyboardLayout {
145
+ visible?: boolean
146
+ spec?: {
147
+ autoCapitalize?: string
148
+ keyboardType?: string
149
+ returnKeyType?: string
150
+ }
151
+ mode?: string
152
+ shifted?: boolean
153
+ capsLock?: boolean
154
+ accessoryBarId?: string | null
155
+ }
156
+
157
+ export interface DescribeResult {
158
+ tree?: string
159
+ shell?: {
160
+ state?: string
161
+ activeApp?: string
162
+ showSwitcher?: boolean
163
+ switcherPhase?: string
164
+ } | null
165
+ nodeCount?: number
166
+ keyboard?: DescribeKeyboardLayout | null
167
+ }
168
+
169
+ // `describe` — the visible-UI tree with layout + style annotations, plus shell
170
+ // + keyboard state. the dump itself lives in the engine's test-bridge
171
+ // `dumpTree`; this kernel just bundles the one evaluate call. callers own
172
+ // arg-parsing, preflight, hints, watch loop, and formatting.
173
+ export async function inspectDescribe(
174
+ bridge: InspectBridge,
175
+ dumpOpts: DescribeDumpOptions,
176
+ ): Promise<DescribeResult> {
177
+ // resolve the depth here rather than inside the eval string: it is a typed
178
+ // decision, and the generated code stays a literal call the caller can read.
179
+ const dumpDepth = dumpOpts.maxDepth ?? 12
180
+ const code = `(async () => {
181
+ const t = window.__sootsimTest
182
+ const mainShell = window.SootSim?.bridges?.mainShell
183
+ const kb = window.__sootsimKeyboard
184
+ if (!t) return { error: 'no test bridge' }
185
+
186
+ let shell = null
187
+ try {
188
+ shell = typeof mainShell?.getState === 'function' ? await mainShell.getState() : null
189
+ } catch {}
190
+
191
+ const opts = ${JSON.stringify(dumpOpts)}
192
+ const needsRecursiveDump =
193
+ opts.verbose || opts.subtreeRoot || opts.includeOccluded || opts.maxDepth != null
194
+ const globToRegex = (glob) => {
195
+ const escaped = String(glob).replace(/[.+^$(){}|[\\]\\\\]/g, '\\\\$&')
196
+ const body = escaped.replace(/\\*/g, '.*').replace(/\\?/g, '.')
197
+ return new RegExp('^' + body + '$', 'i')
198
+ }
199
+ // clipping a describe line is a DISPLAY decision. opts.fullText (set by
200
+ // \`describe --json\`) keeps the real string, so a bundle-eval stack or a
201
+ // red-box message reaches the caller whole instead of ending in an
202
+ // ellipsis nothing downstream can undo.
203
+ const clipForDisplay = (value, max) => {
204
+ const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''
205
+ if (opts.fullText || text.length <= max) return text
206
+ return text.slice(0, max - 1).trimEnd() + '…'
207
+ }
208
+ const formatInspectableTree = async () => {
209
+ if (typeof t.listInspectable !== 'function') return null
210
+ const list = await t.listInspectable({})
211
+ if (!Array.isArray(list)) return null
212
+ const filter = typeof opts.filter === 'string' ? opts.filter.toLowerCase() : ''
213
+ const testIdRegex = opts.testIdLike ? globToRegex(opts.testIdLike) : null
214
+ const onlyRegex = opts.onlyGlob ? globToRegex(opts.onlyGlob) : null
215
+ const lines = []
216
+ let matched = 0
217
+ for (const node of list) {
218
+ const testID = node?.testID || node?.testId || ''
219
+ const role = node?.role || ''
220
+ const label = node?.accessibilityLabel || node?.label || ''
221
+ const text = node?.text || ''
222
+ if (testIdRegex && !testIdRegex.test(testID)) continue
223
+ const joined = [role, label, text, testID].filter(Boolean).join(' ')
224
+ if (onlyRegex && !onlyRegex.test(joined)) continue
225
+ if (filter && !joined.toLowerCase().includes(filter)) continue
226
+ matched++
227
+
228
+ const type = String(node?.type || 'node').toLowerCase()
229
+ const handlers = Array.isArray(node?.handlers) ? node.handlers : []
230
+ const parts = []
231
+ if (role) parts.push('[' + role + ']')
232
+ else if (handlers.length > 0) parts.push('[tap]')
233
+ else parts.push('<' + type + '>')
234
+ const visibleText = clipForDisplay(text, 50)
235
+ if (visibleText) parts.push(JSON.stringify(visibleText))
236
+ if (testID) parts.push('#' + testID)
237
+ if (label && label !== text) {
238
+ parts.push('label=' + JSON.stringify(clipForDisplay(label, 50)))
239
+ }
240
+ if (!opts.hideXy) {
241
+ const abs = node?.absolute || {}
242
+ const layout = node?.layout || {}
243
+ const x = Math.round(Number(abs.x) || 0)
244
+ const y = Math.round(Number(abs.y) || 0)
245
+ const w = Math.round(Number(layout.width) || 0)
246
+ const h = Math.round(Number(layout.height) || 0)
247
+ parts.push('@(' + x + ',' + y + ') ' + w + 'x' + h)
248
+ }
249
+ if (handlers.length > 0) parts.push('(tap)')
250
+ lines.push(' ' + parts.join(' '))
251
+ }
252
+ return { tree: lines.join('\\n'), matched }
253
+ }
254
+
255
+ let tree = ''
256
+ const compactResult = needsRecursiveDump ? null : await formatInspectableTree()
257
+ if (compactResult) {
258
+ tree = compactResult.tree
259
+ } else {
260
+ tree = await t.dumpTree(${dumpDepth}, opts)
261
+ }
262
+ const nodeCount = (await t.getNodeCount?.()) || 0
263
+ const keyboard = kb && typeof kb.getLayout === 'function' ? kb.getLayout() : null
264
+ return { tree, shell, nodeCount, keyboard }
265
+ })()`
266
+ const result = (await bridge.send({ type: 'evaluate', code })) as DescribeResult | null
267
+ return result ?? {}
268
+ }
269
+
270
+ // ─── get-layout ───
271
+ //
272
+ // per-element bounding-box measurement for meaningful elements on the current
273
+ // screen. `describe` gives the element tree; this gives the numbers a designer
274
+ // needs to check spacing/sizing parity: the on-screen box (x/y/w/h). callers opt
275
+ // into computed borderRadius, per-side padding, fontSize, and visual style
276
+ // fields with `styling:true` / `--styling`.
277
+ //
278
+ // the SAME LayoutElement shape is produced by both preview surfaces:
279
+ // - native/sootsim — `LAYOUT_GET_NATIVE_EVAL` reads the engine node tree via
280
+ // __sootsimTest.listInspectable() (the source `describe` uses), where each
281
+ // SootSimInspectInfo already carries absolute layout and optional style /
282
+ // box-model data.
283
+ // - web — the DOM extractor in webLayout.ts measures candidate elements with
284
+ // getBoundingClientRect(), and calls getComputedStyle() only for styling.
285
+ // both feed the same `formatLayoutElements` renderer, so the agent tool and the
286
+ // CLI print identical output regardless of surface.
287
+
288
+ export interface LayoutBox {
289
+ x: number
290
+ y: number
291
+ w: number
292
+ h: number
293
+ }
294
+
295
+ export interface LayoutPadding {
296
+ t: number
297
+ r: number
298
+ b: number
299
+ l: number
300
+ }
301
+
302
+ export interface LayoutExtractionOptions {
303
+ styling?: boolean
304
+ // case-insensitive substring matched against text, testID, role, and
305
+ // selector. narrows a full-screen read down to the part being checked so
306
+ // styling detail stays affordable.
307
+ filter?: string
308
+ }
309
+
310
+ export interface LayoutTextContrast {
311
+ ratio: number | null
312
+ required: 3 | 4.5
313
+ foreground?: string
314
+ background?: string
315
+ reason?: string
316
+ }
317
+
318
+ export function scoreLayoutTextContrast(input: {
319
+ normal: { width: number; height: number; data: ArrayLike<number> }
320
+ background: { width: number; height: number; data: ArrayLike<number> }
321
+ box: LayoutBox
322
+ fontSize: number
323
+ fontWeight?: number
324
+ }): LayoutTextContrast {
325
+ const required: 3 | 4.5 =
326
+ input.fontSize >= 18 || (input.fontSize >= 14 && (input.fontWeight ?? 400) >= 700)
327
+ ? 3
328
+ : 4.5
329
+ const { normal, background } = input
330
+ if (
331
+ normal.width <= 0 ||
332
+ normal.height <= 0 ||
333
+ normal.width !== background.width ||
334
+ normal.height !== background.height
335
+ ) {
336
+ return { ratio: null, required, reason: 'capture dimensions do not match' }
337
+ }
338
+ const left = Math.max(0, Math.floor(input.box.x))
339
+ const top = Math.max(0, Math.floor(input.box.y))
340
+ const right = Math.min(normal.width, Math.ceil(input.box.x + input.box.w))
341
+ const bottom = Math.min(normal.height, Math.ceil(input.box.y + input.box.h))
342
+ if (right <= left || bottom <= top) {
343
+ return { ratio: null, required, reason: 'text is outside the captured frame' }
344
+ }
345
+
346
+ const linear = (value: number) => {
347
+ const channel = value / 255
348
+ return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
349
+ }
350
+ const ratioOf = (foreground: readonly number[], behind: readonly number[]) => {
351
+ const foregroundLuminance =
352
+ 0.2126 * linear(foreground[0]) +
353
+ 0.7152 * linear(foreground[1]) +
354
+ 0.0722 * linear(foreground[2])
355
+ const backgroundLuminance =
356
+ 0.2126 * linear(behind[0]) + 0.7152 * linear(behind[1]) + 0.0722 * linear(behind[2])
357
+ return (
358
+ (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
359
+ (Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
360
+ )
361
+ }
362
+ const hex = (color: readonly number[]) =>
363
+ `#${color
364
+ .slice(0, 3)
365
+ .map((value) => Math.round(value).toString(16).padStart(2, '0'))
366
+ .join('')}`
367
+ const area = (right - left) * (bottom - top)
368
+ const step = Math.max(1, Math.floor(Math.sqrt(area / 12_000)))
369
+ const offsetAt = (x: number, y: number) => (y * normal.width + x) * 4
370
+ const deltaAt = (offset: number) =>
371
+ Math.max(
372
+ Math.abs(normal.data[offset] - background.data[offset]),
373
+ Math.abs(normal.data[offset + 1] - background.data[offset + 1]),
374
+ Math.abs(normal.data[offset + 2] - background.data[offset + 2]),
375
+ )
376
+
377
+ let strongestDelta = 0
378
+ let strongestForeground: number[] | null = null
379
+ let opaqueBackground: number[] | null = null
380
+ for (let y = top; y < bottom; y += step) {
381
+ for (let x = left; x < right; x += step) {
382
+ const offset = offsetAt(x, y)
383
+ if (background.data[offset + 3] < 250) continue
384
+ const behind = [
385
+ background.data[offset],
386
+ background.data[offset + 1],
387
+ background.data[offset + 2],
388
+ ]
389
+ opaqueBackground ??= behind
390
+ const delta = deltaAt(offset)
391
+ if (delta <= strongestDelta) continue
392
+ strongestDelta = delta
393
+ strongestForeground = [
394
+ normal.data[offset],
395
+ normal.data[offset + 1],
396
+ normal.data[offset + 2],
397
+ ]
398
+ }
399
+ }
400
+ let bestRatio = Infinity
401
+ let bestForeground: number[] | null = null
402
+ let bestBackground: number[] | null = null
403
+ const consider = (foreground: number[], behind: number[]) => {
404
+ const ratio = ratioOf(foreground, behind)
405
+ if (ratio >= bestRatio) return
406
+ bestRatio = ratio
407
+ bestForeground = foreground
408
+ bestBackground = behind
409
+ }
410
+
411
+ if (strongestDelta >= 2 && strongestForeground) {
412
+ // The largest normal/background delta is the closest rendered sample to
413
+ // the solid glyph color. Compare that color against every background pixel
414
+ // where the suppressed pass proves text actually drew. This preserves the
415
+ // weak side of text over gradients instead of selecting its best contrast.
416
+ for (let y = top; y < bottom; y += step) {
417
+ for (let x = left; x < right; x += step) {
418
+ const offset = offsetAt(x, y)
419
+ if (background.data[offset + 3] < 250 || deltaAt(offset) < 2) {
420
+ continue
421
+ }
422
+ consider(strongestForeground, [
423
+ background.data[offset],
424
+ background.data[offset + 1],
425
+ background.data[offset + 2],
426
+ ])
427
+ }
428
+ }
429
+ } else if (opaqueBackground) {
430
+ // No final pixel changed when the renderer omitted this visible text. Its
431
+ // effective foreground is therefore identical to the pixels behind it:
432
+ // exact 1:1 contrast, not an "unmeasurable" escape hatch.
433
+ consider(opaqueBackground, opaqueBackground)
434
+ }
435
+
436
+ if (!bestForeground || !bestBackground || !Number.isFinite(bestRatio)) {
437
+ return {
438
+ ratio: null,
439
+ required,
440
+ reason: 'could not isolate opaque rendered text pixels',
441
+ }
442
+ }
443
+ return {
444
+ ratio: Math.round(bestRatio * 100) / 100,
445
+ required,
446
+ foreground: hex(bestForeground),
447
+ background: hex(bestBackground),
448
+ }
449
+ }
450
+
451
+ export interface LayoutElement {
452
+ // extractor-local identity and closest-first ancestry. app_get_layout uses
453
+ // these internally to distinguish real overlaps from ordinary nesting; the
454
+ // formatter intentionally omits them from the agent-facing rows.
455
+ nodeId?: string | number
456
+ ancestorIds?: Array<string | number>
457
+ // a stable way to refer to this element. testID/role when present; otherwise
458
+ // a CSS selector (web) or the engine node type (native).
459
+ selector?: string
460
+ testID?: string
461
+ role?: string
462
+ type: string
463
+ text?: string
464
+ // on-screen bounding box in device/viewport px (x/y are absolute).
465
+ box: LayoutBox
466
+ // box-model fields are zero-filled unless the caller opts into styling.
467
+ borderRadius: number
468
+ padding: LayoutPadding
469
+ fontSize: number
470
+ // styling — so a text-only agent (no vision) can still reason about visual
471
+ // quality: theme correctness (is this screen actually emerald?), contrast
472
+ // (color vs bg), washed-out opacity, garbled/struck text, clipping
473
+ // (overflow:hidden + tiny box), flat-vs-styled (no bg where a card is
474
+ // expected). colors are compacted css strings; each field is omitted when it
475
+ // holds its boring default so rows stay scannable.
476
+ color?: string
477
+ bg?: string
478
+ opacity?: number
479
+ fontWeight?: number
480
+ textDecoration?: string
481
+ overflow?: string
482
+ borderWidth?: number
483
+ // only when the element actually draws a border. every element has a
484
+ // resolved border-color, so reporting it unconditionally would be pure noise.
485
+ borderColor?: string
486
+ contrast?: LayoutTextContrast
487
+ // text-flow metrics (web extractor) — wrap quality a no-vision agent cannot
488
+ // otherwise see: how many lines the text broke into, whether the last line
489
+ // is a lone hanging word, and whether ellipsis truncation is active.
490
+ textLines?: number
491
+ lastLineWords?: number
492
+ // last line width relative to the widest line (0-1).
493
+ lastLineWidthRatio?: number
494
+ ellipsisActive?: boolean
495
+ }
496
+
497
+ export interface LayoutResult {
498
+ preview: 'web' | 'native'
499
+ count: number
500
+ elements: LayoutElement[]
501
+ }
502
+
503
+ // in-page extractor for the native preview. mirrors the visibility + device
504
+ // sizing the a11y / wait-ready kernels use so "what's on screen" agrees across
505
+ // the inspect surface. returns LayoutElement[] (or [] when the bridge is cold).
506
+ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string {
507
+ const includeStyle = opts.styling === true
508
+ const filter = opts.filter?.trim().toLowerCase() ?? ''
509
+ return `(async () => {
510
+ const t = window.__sootsimTest
511
+ if (!t || typeof t.listInspectable !== 'function') return []
512
+ const list = await t.listInspectable({})
513
+ if (!Array.isArray(list)) return []
514
+ const includeStyle = ${JSON.stringify(includeStyle)}
515
+ const scoreContrast = ${scoreLayoutTextContrast.toString()}
516
+ // narrows by ELEMENT, not by style field: fewer rows, full detail on each.
517
+ const filter = ${JSON.stringify(filter)}
518
+ const matchesFilter = (n, text) => {
519
+ if (!filter) return true
520
+ const fields = [text, n.testID || n.testId, n.accessibilityRole || n.role, n.type]
521
+ return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(filter))
522
+ }
523
+
524
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
525
+ const round = (v) => Math.round(num(v))
526
+
527
+ // borderRadius can be authored as a string ("12px") or split per-corner;
528
+ // prefer the uniform borderRadius, else the largest corner radius present.
529
+ const radiusOf = (style) => {
530
+ if (!style) return 0
531
+ const keys = ['borderRadius','borderTopLeftRadius','borderTopRightRadius','borderBottomLeftRadius','borderBottomRightRadius']
532
+ let max = 0
533
+ for (const k of keys) {
534
+ const raw = style[k]
535
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
536
+ if (Number.isFinite(n)) max = Math.max(max, n)
537
+ }
538
+ return Math.round(max)
539
+ }
540
+ const fontSizeOf = (style) => {
541
+ if (!style) return 0
542
+ const raw = style.fontSize
543
+ const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
544
+ return Number.isFinite(n) ? Math.round(n) : 0
545
+ }
546
+ // styling extractors — omit boring defaults so the agent only sees notable
547
+ // styling (low opacity, struck text, clipping, a real border/color).
548
+ const colorStr = (v) => {
549
+ if (v == null) return undefined
550
+ const s = String(v).split(' ').join('')
551
+ return s && s !== 'transparent' && s !== 'rgba(0,0,0,0)' ? s : undefined
552
+ }
553
+ const opacityOf = (style) => {
554
+ const o = style && style.opacity
555
+ const n = typeof o === 'number' ? o : (typeof o === 'string' ? parseFloat(o) : NaN)
556
+ return Number.isFinite(n) && n < 1 ? Math.round(n * 100) / 100 : undefined
557
+ }
558
+ const weightOf = (style) => {
559
+ const w = style && style.fontWeight
560
+ const n = w === 'bold' ? 700 : (typeof w === 'number' ? w : (typeof w === 'string' ? parseInt(w, 10) : NaN))
561
+ return Number.isFinite(n) && n >= 600 ? n : undefined
562
+ }
563
+ const decoOf = (style) => {
564
+ const d = style && (style.textDecorationLine || style.textDecoration)
565
+ return d && d !== 'none' && d !== 'normal' ? String(d).split(' ')[0] : undefined
566
+ }
567
+ const overflowOf = (style) => (style && style.overflow === 'hidden' ? 'hidden' : undefined)
568
+ const borderWOf = (style) => {
569
+ if (!style) return undefined
570
+ const keys = ['borderWidth','borderTopWidth','borderBottomWidth','borderLeftWidth','borderRightWidth']
571
+ let max = 0
572
+ for (const k of keys) {
573
+ const r = style[k]
574
+ const n = typeof r === 'number' ? r : (typeof r === 'string' ? parseFloat(r) : NaN)
575
+ if (Number.isFinite(n)) max = Math.max(max, n)
576
+ }
577
+ return max > 0 ? Math.round(max) : undefined
578
+ }
579
+ // only meaningful alongside a real border width, so it is resolved by the
580
+ // caller below rather than on its own.
581
+ const borderColorOf = (style) => {
582
+ if (!style) return undefined
583
+ const keys = ['borderColor','borderTopColor','borderBottomColor','borderLeftColor','borderRightColor']
584
+ for (const k of keys) {
585
+ const c = colorStr(style[k])
586
+ if (c) return c
587
+ }
588
+ return undefined
589
+ }
590
+
591
+ const isVisible = (n) => {
592
+ const layout = n && n.layout
593
+ if (!layout || layout.width <= 0 || layout.height <= 0) return false
594
+ const abs = (n && (n.absolute || n.absolutePosition)) || null
595
+ if (!abs) return true
596
+ const device = (n && n.device) || {}
597
+ const screenW = Number(device.width) || window.innerWidth || 0
598
+ const screenH = Number(device.height) || window.innerHeight || 0
599
+ if (!screenW || !screenH) return true
600
+ return abs.x + layout.width > 0 && abs.y + layout.height > 0 && abs.x < screenW && abs.y < screenH
601
+ }
602
+
603
+ const elements = list.filter(isVisible).filter((n) => {
604
+ const t = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
605
+ return matchesFilter(n, t)
606
+ }).map((n) => {
607
+ const layout = n.layout || { x: 0, y: 0, width: 0, height: 0 }
608
+ const abs = n.absolute || n.absolutePosition || { x: layout.x, y: layout.y }
609
+ const text = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
610
+ const base = {
611
+ ...(typeof n.nodeId === 'number'
612
+ ? {
613
+ nodeId: n.nodeId,
614
+ ancestorIds: Array.isArray(n.ancestors)
615
+ ? n.ancestors.map((ancestor) => ancestor && ancestor.nodeId).filter((id) => typeof id === 'number')
616
+ : [],
617
+ }
618
+ : {}),
619
+ testID: n.testID || n.testId || undefined,
620
+ role: n.accessibilityRole || n.role || undefined,
621
+ type: typeof n.type === 'string' ? n.type : 'node',
622
+ text,
623
+ box: { x: round(abs.x), y: round(abs.y), w: round(layout.width), h: round(layout.height) },
624
+ borderRadius: 0,
625
+ padding: { t: 0, r: 0, b: 0, l: 0 },
626
+ fontSize: 0,
627
+ }
628
+ if (!includeStyle) return base
629
+ // computedStyle has the resolved values; style is the curated subset —
630
+ // fall back across both so radius/fontSize survive either shape.
631
+ const style = Object.assign({}, n.style || {}, n.computedStyle || {})
632
+ const pad = (n.boxModel && n.boxModel.padding) || {}
633
+ const borderWidth = borderWOf(style)
634
+ return {
635
+ ...base,
636
+ borderRadius: radiusOf(style),
637
+ padding: { t: round(pad.top), r: round(pad.right), b: round(pad.bottom), l: round(pad.left) },
638
+ fontSize: text ? (fontSizeOf(style) || 14) : 0,
639
+ color: text ? colorStr(style.color) : undefined,
640
+ bg: colorStr(style.backgroundColor),
641
+ opacity: opacityOf(style),
642
+ fontWeight: weightOf(style),
643
+ textDecoration: decoOf(style),
644
+ overflow: overflowOf(style),
645
+ borderWidth,
646
+ borderColor: borderWidth ? borderColorOf(style) : undefined,
647
+ }
648
+ })
649
+ const textElements = elements.filter((element) => element.text && element.fontSize > 0)
650
+ if (!includeStyle || textElements.length === 0) return elements
651
+
652
+ const screenshot = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.screenshot
653
+ if (typeof screenshot !== 'function') {
654
+ throw new Error('styled native layout requires the rnx capture bridge')
655
+ }
656
+ const device = list.find((node) => node && node.device)?.device || {}
657
+ const width = Math.round(Number(device.width) || window.innerWidth || 0)
658
+ const height = Math.round(Number(device.height) || window.innerHeight || 0)
659
+ if (!width || !height) throw new Error('native layout has no capture dimensions')
660
+ const capture = async (suppressText) => {
661
+ const dataUrl = await screenshot({
662
+ format: 'png',
663
+ outputWidth: width,
664
+ outputHeight: height,
665
+ ...(suppressText ? { suppressText: true } : {}),
666
+ })
667
+ const image = await new Promise((resolve, reject) => {
668
+ const next = new Image()
669
+ next.onload = () => resolve(next)
670
+ next.onerror = () => reject(new Error('could not decode native layout pixels'))
671
+ next.src = dataUrl
672
+ })
673
+ const canvas = document.createElement('canvas')
674
+ canvas.width = image.naturalWidth
675
+ canvas.height = image.naturalHeight
676
+ const context = canvas.getContext('2d', { willReadFrequently: true })
677
+ if (!context) throw new Error('could not read native layout pixels')
678
+ context.drawImage(image, 0, 0)
679
+ return context.getImageData(0, 0, canvas.width, canvas.height)
680
+ }
681
+ const normal = await capture(false)
682
+ const background = await capture(true)
683
+ for (const element of textElements) {
684
+ element.contrast = scoreContrast({
685
+ normal,
686
+ background,
687
+ box: element.box,
688
+ fontSize: element.fontSize,
689
+ fontWeight: element.fontWeight,
690
+ })
691
+ }
692
+ return elements
693
+ })()`
694
+ }
695
+
696
+ export const LAYOUT_GET_NATIVE_EVAL = layoutGetNativeEval()
697
+
698
+ // `get-layout` (native) — bounding boxes for visible meaningful elements on the
699
+ // sootsim/native preview. transport-agnostic: the CLI runs it over its WS
700
+ // bridge, the agent runs it over its in-process preview bridge.
701
+ export async function inspectGetLayout(
702
+ bridge: InspectBridge,
703
+ opts: LayoutExtractionOptions = {},
704
+ ): Promise<LayoutElement[]> {
705
+ const raw = await bridge.send({
706
+ type: 'evaluate',
707
+ code: layoutGetNativeEval(opts),
708
+ })
709
+ return Array.isArray(raw) ? (raw as LayoutElement[]) : []
710
+ }
711
+
712
+ // one-line-per-element renderer shared by the CLI and the agent tool so web +
713
+ // native + every transport print the same thing. padding collapses to a single
714
+ // number when uniform, and zero-valued fields are omitted to keep rows scannable.
715
+ export function formatLayoutElements(
716
+ elements: readonly LayoutElement[],
717
+ opts?: { styling?: boolean },
718
+ ): string {
719
+ if (elements.length === 0) return ' no visible elements found'
720
+ const lines = elements.map((el) => {
721
+ const ident = el.testID
722
+ ? `#${el.testID}`
723
+ : el.selector
724
+ ? el.selector
725
+ : el.role
726
+ ? `[${el.role}]`
727
+ : `<${el.type}>`
728
+ const parts = [ident, `@(${el.box.x},${el.box.y})`, `${el.box.w}x${el.box.h}`]
729
+ if (el.borderRadius > 0) parts.push(`radius:${el.borderRadius}`)
730
+ const p = el.padding
731
+ if (p.t || p.r || p.b || p.l) {
732
+ const uniform = p.t === p.r && p.r === p.b && p.b === p.l
733
+ parts.push(uniform ? `pad:${p.t}` : `pad:${p.t},${p.r},${p.b},${p.l}`)
734
+ }
735
+ if (el.fontSize > 0) parts.push(`font:${el.fontSize}`)
736
+ // styling is opt-in (the `styling` flag): the default rows stay scannable
737
+ // for UI inspection, and a no-vision agent asks for styling when judging
738
+ // visual quality (contrast, washed-out, garbled/struck, clipping, flat).
739
+ if (opts?.styling) {
740
+ if (el.fontWeight) parts.push(`weight:${el.fontWeight}`)
741
+ if (el.color) parts.push(`color:${el.color}`)
742
+ if (el.bg) parts.push(`bg:${el.bg}`)
743
+ if (el.contrast) {
744
+ const score =
745
+ el.contrast.ratio == null
746
+ ? `?/${el.contrast.required}`
747
+ : `${el.contrast.ratio}/${el.contrast.required}${el.contrast.ratio < el.contrast.required ? ' FAIL' : ''}`
748
+ parts.push(`contrast:${score}`)
749
+ if (el.contrast.background) parts.push(`on:${el.contrast.background}`)
750
+ }
751
+ if (el.opacity != null) parts.push(`opacity:${el.opacity}`)
752
+ if (el.textDecoration) parts.push(`deco:${el.textDecoration}`)
753
+ if (el.overflow) parts.push(`overflow:${el.overflow}`)
754
+ if (el.borderWidth) {
755
+ parts.push(
756
+ el.borderColor
757
+ ? `border:${el.borderWidth}/${el.borderColor}`
758
+ : `border:${el.borderWidth}`,
759
+ )
760
+ }
761
+ }
762
+ if (el.text) {
763
+ const text = el.text.length > 40 ? `${el.text.slice(0, 39)}…` : el.text
764
+ parts.push(`"${text}"`)
765
+ }
766
+ return ` ${parts.join(' ')}`
767
+ })
768
+ return lines.join('\n')
769
+ }
770
+
771
+ // ─── accessibility ───
772
+
773
+ export interface AccessibilityTreeNode {
774
+ role: string
775
+ label: string | null
776
+ hint: string | null
777
+ state: Record<string, unknown> | null
778
+ testID: string | null
779
+ position: { x: number; y: number } | null
780
+ size: { w: number; h: number } | null
781
+ }
782
+
783
+ const INSPECT_A11Y_CODE = `(async () => {
784
+ const t = window.__sootsimTest
785
+ if (!t) return []
786
+
787
+ const readLayout = (n) => n?.layout || null
788
+ const readAbs = (n) => n?.absolutePosition || n?.absolute || null
789
+ const readRole = (n) => n?.accessibilityRole || n?.role || null
790
+ const readLabel = (n) => n?.accessibilityLabel || n?.label || n?.text || null
791
+ const readHint = (n) => n?.accessibilityHint || n?.hint || null
792
+ const readState = (n) => n?.accessibilityState || n?.state || null
793
+ const readTestId = (n) => n?.testID || n?.testId || null
794
+ const readType = (n) => typeof n?.type === 'string' ? n.type.toLowerCase() : n?.type
795
+ const readPressable = (n) => {
796
+ if (n?.pressable) return true
797
+ const handlers = Array.isArray(n?.handlers) ? n.handlers : []
798
+ const role = readRole(n)
799
+ return handlers.length > 0 || role === 'button' || role === 'link' || role === 'tab'
800
+ }
801
+ const isTextInput = (n) => n?.isTextInput === true
802
+ const isTextNode = (n) => readType(n) === 'text'
803
+ const isVisibleTarget = (n) => {
804
+ const layout = readLayout(n)
805
+ if (!layout || layout.width <= 0 || layout.height <= 0) return false
806
+ const abs = readAbs(n)
807
+ if (!abs) return true
808
+ const device = n?.device || {}
809
+ const screenW = Number(device.width) || window.innerWidth || 0
810
+ const screenH = Number(device.height) || window.innerHeight || 0
811
+ return (
812
+ abs.x + layout.width > 0 &&
813
+ abs.y + layout.height > 0 &&
814
+ abs.x < screenW &&
815
+ abs.y < screenH
816
+ )
817
+ }
818
+ const hasAccessibleSignal = (n) => {
819
+ const role = readRole(n)
820
+ const label = readLabel(n)
821
+ const hint = readHint(n)
822
+ if (role) return true
823
+ if (hint) return true
824
+ if (isTextInput(n)) return true
825
+ if (readPressable(n)) return true
826
+ if (isTextNode(n) && n?.text) return true
827
+ if (typeof label === 'string' && label.length <= 30 && label !== n?.text) return true
828
+ return false
829
+ }
830
+ const normalize = (n) => {
831
+ const layout = readLayout(n)
832
+ const abs = readAbs(n)
833
+ const role = readRole(n) || (readPressable(n) ? 'button' : isTextInput(n) ? 'textfield' : isTextNode(n) ? 'statictext' : 'none')
834
+ return {
835
+ role,
836
+ label: readLabel(n),
837
+ hint: readHint(n),
838
+ state: readState(n),
839
+ testID: readTestId(n),
840
+ position: abs ? { x: Math.round(abs.x), y: Math.round(abs.y) } : null,
841
+ size: layout ? { w: Math.round(layout.width), h: Math.round(layout.height) } : null,
842
+ }
843
+ }
844
+
845
+ if (typeof t.listInspectable === 'function') {
846
+ const list = await t.listInspectable({})
847
+ if (Array.isArray(list)) {
848
+ return list.filter(n => isVisibleTarget(n) && hasAccessibleSignal(n)).map(normalize)
849
+ }
850
+ }
851
+
852
+ const all = await t.queryAll({ pruneHidden: true })
853
+ return all
854
+ .filter(n => isVisibleTarget(n) && hasAccessibleSignal(n))
855
+ .map(normalize)
856
+ })()`
857
+
858
+ // `get a11y` — flat accessibility summary for the currently visible SootSim
859
+ // surface. listInspectable is preferred because it already resolves the active
860
+ // shell/tenant surface and excludes covered launcher/app-switcher content.
861
+ export async function inspectAccessibilityTree(
862
+ bridge: InspectBridge,
863
+ ): Promise<AccessibilityTreeNode[]> {
864
+ const nodes = await bridge.send({ type: 'evaluate', code: INSPECT_A11Y_CODE })
865
+ return Array.isArray(nodes) ? (nodes as AccessibilityTreeNode[]) : []
866
+ }
867
+
868
+ // ─── find ───
869
+
870
+ export type FindMode =
871
+ | 'testid'
872
+ | 'role'
873
+ | 'type'
874
+ | 'pressable'
875
+ | 'interactive-targets'
876
+ | 'visible'
877
+ | 'text'
878
+
879
+ export interface FindQuery {
880
+ testId?: string | null
881
+ role?: string | null
882
+ type?: string | null
883
+ text?: string | null
884
+ pressable?: boolean
885
+ visible?: boolean
886
+ interactive?: boolean
887
+ }
888
+
889
+ const FIND_INSPECTABLE_TARGETS = `
890
+ const fromInspectable = async () => {
891
+ if (typeof t.listInspectable !== 'function') return null
892
+ const list = await t.listInspectable({})
893
+ if (!Array.isArray(list)) return null
894
+ return list.map((n) => {
895
+ const role = n.accessibilityRole ?? n.role
896
+ const label = n.accessibilityLabel ?? n.label
897
+ const layout = n.layout
898
+ const absolutePosition = n.absolutePosition ?? n.absolute
899
+ const screenW = Number(n.device?.width) || window.innerWidth || 0
900
+ const screenH = Number(n.device?.height) || window.innerHeight || 0
901
+ const visibleFrame = layout && absolutePosition
902
+ ? {
903
+ x: Math.max(0, absolutePosition.x),
904
+ y: Math.max(0, absolutePosition.y),
905
+ width: Math.max(
906
+ 0,
907
+ Math.min(screenW, absolutePosition.x + layout.width) -
908
+ Math.max(0, absolutePosition.x),
909
+ ),
910
+ height: Math.max(
911
+ 0,
912
+ Math.min(screenH, absolutePosition.y + layout.height) -
913
+ Math.max(0, absolutePosition.y),
914
+ ),
915
+ }
916
+ : null
917
+ const handlers = Array.isArray(n.handlers) ? n.handlers : []
918
+ const pressable =
919
+ handlers.length > 0 ||
920
+ role === 'button' ||
921
+ role === 'link' ||
922
+ role === 'tab'
923
+ return {
924
+ type: typeof n.type === 'string' ? n.type.toLowerCase() : n.type,
925
+ id: n.id ?? n.nodeId,
926
+ nodeId: n.nodeId,
927
+ testID: n.testID ?? n.testId,
928
+ text: n.text,
929
+ layout,
930
+ absolutePosition,
931
+ visibleFrame,
932
+ style: n.computedStyle ?? n.style ?? {},
933
+ childCount: n.childCount ?? 0,
934
+ pressed: n.pressed ?? false,
935
+ pressable,
936
+ isTextInput: n.isTextInput ?? false,
937
+ accessible: n.accessible ?? true,
938
+ accessibilityLabel: label,
939
+ accessibilityRole: role,
940
+ accessibilityHint: n.accessibilityHint,
941
+ accessibilityState: n.accessibilityState,
942
+ accessibilityValue: n.accessibilityValue,
943
+ }
944
+ })
945
+ }
946
+ const isVisibleTarget = (n) => {
947
+ if (!n?.layout) return false
948
+ const frame = n.visibleFrame
949
+ if (frame) return frame.width > 0 && frame.height > 0
950
+ const abs = n.absolutePosition
951
+ if (!abs) return n.layout.width > 0 && n.layout.height > 0
952
+ const screenW = window.innerWidth || 0
953
+ const screenH = window.innerHeight || 0
954
+ return (
955
+ abs.x + n.layout.width > 0 &&
956
+ abs.y + n.layout.height > 0 &&
957
+ abs.x < screenW &&
958
+ abs.y < screenH
959
+ )
960
+ }
961
+ `
962
+
963
+ // resolve a find query to its mode + the JS to evaluate. precedence matches
964
+ // the CLI: testid → role → type → pressable → interactive → visible → text.
965
+ // returns null when nothing was asked for (caller prints usage).
966
+ export function resolveFindMode(q: FindQuery): { mode: FindMode; code: string } | null {
967
+ if (q.testId) {
968
+ return {
969
+ mode: 'testid',
970
+ code: `(async () => {
971
+ const t = window.__sootsimTest
972
+ if (!t) return null
973
+ return (await t.findByTestId(${JSON.stringify(q.testId)})) || (await t.findById(${JSON.stringify(q.testId)}))
974
+ })()`,
975
+ }
976
+ }
977
+ if (q.role) {
978
+ return {
979
+ mode: 'role',
980
+ code: `(async () => {
981
+ const t = window.__sootsimTest
982
+ if (!t) return []
983
+ return await t.queryAll({ hasRole: ${JSON.stringify(q.role)}, pruneHidden: true })
984
+ })()`,
985
+ }
986
+ }
987
+ if (q.type) {
988
+ return {
989
+ mode: 'type',
990
+ code: `(async () => {
991
+ const t = window.__sootsimTest
992
+ if (!t) return []
993
+ return await t.queryAll({ type: ${JSON.stringify(q.type)}, pruneHidden: true })
994
+ })()`,
995
+ }
996
+ }
997
+ if (q.pressable) {
998
+ return {
999
+ mode: 'pressable',
1000
+ code: `(async () => {
1001
+ const t = window.__sootsimTest
1002
+ if (!t) return []
1003
+ ${FIND_INSPECTABLE_TARGETS}
1004
+ const inspectable = await fromInspectable()
1005
+ if (inspectable) return inspectable.filter(n => n.pressable && isVisibleTarget(n))
1006
+ const all = await t.queryAll({ pruneHidden: true })
1007
+ return all.filter(n => n.pressable && isVisibleTarget(n))
1008
+ })()`,
1009
+ }
1010
+ }
1011
+ if (q.interactive) {
1012
+ return {
1013
+ mode: 'interactive-targets',
1014
+ code: `(async () => {
1015
+ const t = window.__sootsimTest
1016
+ if (!t) return []
1017
+ ${FIND_INSPECTABLE_TARGETS}
1018
+ const inspectable = await fromInspectable()
1019
+ if (inspectable) {
1020
+ return inspectable.filter(n => n.pressable && isVisibleTarget(n))
1021
+ }
1022
+ const all = await t.queryAll({ pruneHidden: true })
1023
+ return all.filter(n => n.pressable && isVisibleTarget(n))
1024
+ })()`,
1025
+ }
1026
+ }
1027
+ if (q.visible) {
1028
+ return {
1029
+ mode: 'visible',
1030
+ code: `(async () => {
1031
+ const t = window.__sootsimTest
1032
+ if (!t) return []
1033
+ const all = await t.queryAll({ pruneHidden: true })
1034
+ return all.filter(n => n.layout && n.layout.width > 0 && n.layout.height > 0)
1035
+ })()`,
1036
+ }
1037
+ }
1038
+ if (q.text) {
1039
+ return {
1040
+ mode: 'text',
1041
+ code: `(async () => {
1042
+ const t = window.__sootsimTest
1043
+ if (!t) return null
1044
+ return await t.findByText(${JSON.stringify(q.text)})
1045
+ })()`,
1046
+ }
1047
+ }
1048
+ return null
1049
+ }
1050
+
1051
+ // `find` — locate nodes by text / testID / role / type / predicate. returns
1052
+ // the raw bridge result (a node, a node array, or null) plus the resolved
1053
+ // mode; null when the query was empty.
1054
+ export async function inspectFind(
1055
+ bridge: InspectBridge,
1056
+ q: FindQuery,
1057
+ ): Promise<{ mode: FindMode; result: unknown } | null> {
1058
+ const resolved = resolveFindMode(q)
1059
+ if (!resolved) return null
1060
+ const result = await bridge.send({ type: 'evaluate', code: resolved.code })
1061
+ return { mode: resolved.mode, result }
1062
+ }
1063
+
1064
+ export interface InteractiveNode {
1065
+ type?: string
1066
+ text?: string
1067
+ testID?: string
1068
+ pressable?: boolean
1069
+ accessibilityRole?: string
1070
+ accessibilityLabel?: string
1071
+ absolutePosition?: { x: number; y: number }
1072
+ layout?: { width: number; height: number }
1073
+ [key: string]: unknown
1074
+ }
1075
+
1076
+ // `find --interactive-targets` ranking. visible+pressable nodes scored by
1077
+ // identifying signal (testID > visible text > a11y label > role) and area,
1078
+ // with chrome / off-screen nodes de-prioritised. higher score = better
1079
+ // "next tap" candidate. shared so the CLI and the agent rank identically.
1080
+ export function rankInteractive<T extends InteractiveNode>(nodes: T[]): T[] {
1081
+ return [...nodes].sort((a, b) => scoreInteractive(b) - scoreInteractive(a))
1082
+ }
1083
+
1084
+ export function scoreInteractive(n: InteractiveNode): number {
1085
+ let score = 0
1086
+ if (n.testID) score += 100
1087
+ if (typeof n.text === 'string' && n.text.trim().length > 0) score += 60
1088
+ if (
1089
+ typeof n.accessibilityLabel === 'string' &&
1090
+ n.accessibilityLabel.trim().length > 0
1091
+ ) {
1092
+ score += 30
1093
+ }
1094
+ if (n.accessibilityRole) score += 15
1095
+ const w = n.layout?.width ?? 0
1096
+ const h = n.layout?.height ?? 0
1097
+ const area = w * h
1098
+ if (area >= 400 && area <= 60_000) score += 25
1099
+ else if (area > 60_000) score -= 20
1100
+ const y = n.absolutePosition?.y ?? 0
1101
+ if (y < 0) score -= 30
1102
+ return score
1103
+ }
1104
+
1105
+ // the copy-paste `sootsim do` tap command for an interactive node.
1106
+ export function tapCommandForNode(n: InteractiveNode): string {
1107
+ if (n.testID) return `rnxsim do tap-id ${shellEscape(n.testID)}`
1108
+ const text = typeof n.text === 'string' ? n.text.trim() : ''
1109
+ if (text.length > 0 && text.length <= 80)
1110
+ return `rnxsim do tap-text ${shellEscape(text)}`
1111
+ const x =
1112
+ Math.round(((n.absolutePosition?.x ?? 0) + (n.layout?.width ?? 0) / 2) * 10) / 10
1113
+ const y =
1114
+ Math.round(((n.absolutePosition?.y ?? 0) + (n.layout?.height ?? 0) / 2) * 10) / 10
1115
+ return `rnxsim do tap ${x} ${y}`
1116
+ }
1117
+
1118
+ function shellEscape(value: string): string {
1119
+ if (/^[A-Za-z0-9_./@:-]+$/.test(value)) return value
1120
+ return `'${value.replace(/'/g, `'\\''`)}'`
1121
+ }
1122
+
1123
+ // ─── wait ───
1124
+
1125
+ export interface ReadyProbe {
1126
+ flag: unknown
1127
+ at: number
1128
+ nodes: number
1129
+ targets: number
1130
+ liveFrameActive: boolean
1131
+ liveFrameChannels: number
1132
+ liveFramePublishes: number
1133
+ errors: number
1134
+ loadingText: string
1135
+ externalReady: boolean | null
1136
+ externalStatus: string
1137
+ externalError: string
1138
+ // entry-require throw that bundle-loader suppressed-and-continued past.
1139
+ // diagnostic only — never gates readiness.
1140
+ suppressedEntryError: string
1141
+ }
1142
+
1143
+ export const WAIT_READY_PROBE = `(async () => {
1144
+ const t = window.__sootsimTest
1145
+ let nodes = 0
1146
+ try { nodes = (await t?.getNodeCount?.()) || 0 } catch {}
1147
+ let targets = 0
1148
+ let liveFrameActive = false
1149
+ let liveFrameChannels = 0
1150
+ let liveFramePublishes = 0
1151
+ let loadingText = ''
1152
+ const cleanText = (value) =>
1153
+ typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''
1154
+ const loadingSignal = (value) => {
1155
+ const text = cleanText(value)
1156
+ if (!text) return ''
1157
+ return /^(opening app\\b|looking for your app\\b|building bundle\\b|preparing rnxsim runtime\\b|loading native bundle\\b|loading rnx stubs\\b|loading bundle loader\\b|loading native stubs\\b|loading compat bridge\\b|loading react-native shim\\b|loading react bridge\\b|loading reconciler\\b|loading replacement modules\\b|injecting rnx modules\\b|warming sqlite engine\\b|capturing root component\\b|reloading app\\b|loading(?:[.!…]*)?$|connecting(?:[.!…]*)?$)/i.test(text)
1158
+ ? text
1159
+ : ''
1160
+ }
1161
+ try {
1162
+ const loading = window.__sootsimConnectRNLoading
1163
+ if (loading && loading.active === true) {
1164
+ loadingText = [loading.title, loading.detail].map(cleanText).filter(Boolean).join(' ')
1165
+ }
1166
+ } catch {}
1167
+ try {
1168
+ if (typeof t?.listInspectable === 'function') {
1169
+ const list = await t.listInspectable({})
1170
+ if (Array.isArray(list)) {
1171
+ const screenW = window.innerWidth || 0
1172
+ const screenH = window.innerHeight || 0
1173
+ const isVisible = (n) => {
1174
+ const layout = n?.layout
1175
+ if (!layout || layout.width <= 0 || layout.height <= 0) return false
1176
+ const abs = n?.absolutePosition || n?.absolute
1177
+ if (!abs) return true
1178
+ const device = n?.device || {}
1179
+ const w = Number(device.width) || screenW
1180
+ const h = Number(device.height) || screenH
1181
+ return (
1182
+ abs.x + layout.width > 0 &&
1183
+ abs.y + layout.height > 0 &&
1184
+ abs.x < w &&
1185
+ abs.y < h
1186
+ )
1187
+ }
1188
+ const hasContentSignal = (n) => {
1189
+ const role = n?.accessibilityRole || n?.role
1190
+ const label = cleanText(n?.accessibilityLabel || n?.label)
1191
+ const handlers = Array.isArray(n?.handlers) ? n.handlers : []
1192
+ const text = cleanText(n?.text)
1193
+ const testID = cleanText(n?.testID || n?.testId)
1194
+ const loading = loadingSignal(text) || loadingSignal(label) || loadingSignal(testID)
1195
+ if (!loadingText && loading) {
1196
+ loadingText = loading
1197
+ }
1198
+ if (handlers.length > 0) return true
1199
+ if (role) return true
1200
+ if (n?.isTextInput) return true
1201
+ if (text) return true
1202
+ if (label) return true
1203
+ if (testID && !/^splash|loading/i.test(testID)) return true
1204
+ return false
1205
+ }
1206
+ targets = list.filter(n => isVisible(n) && hasContentSignal(n)).length
1207
+ }
1208
+ }
1209
+ } catch {}
1210
+ try {
1211
+ const stats = await t?.getImageLoaderStats?.()
1212
+ const presentation = stats?.liveFramePresentation
1213
+ liveFrameChannels = Number(presentation?.activeChannels) || 0
1214
+ liveFramePublishes = Number(presentation?.publishes) || 0
1215
+ const previous = window.__sootsimWaitReadyLiveFramePublishes
1216
+ liveFrameActive =
1217
+ liveFrameChannels > 0 &&
1218
+ typeof previous === 'number' &&
1219
+ liveFramePublishes > previous
1220
+ window.__sootsimWaitReadyLiveFramePublishes = liveFramePublishes
1221
+ } catch {}
1222
+ let externalReady = null
1223
+ let externalStatus = ''
1224
+ let externalError = ''
1225
+ let suppressedEntryError = ''
1226
+ try {
1227
+ const getExternalAppState = t?.getExternalAppState
1228
+ const external = typeof getExternalAppState === 'function'
1229
+ ? await Promise.race([
1230
+ getExternalAppState(),
1231
+ new Promise((resolve) => setTimeout(
1232
+ () => resolve({
1233
+ state: {
1234
+ ready: false,
1235
+ loading: true,
1236
+ status: 'waiting for tenant worker...',
1237
+ },
1238
+ }),
1239
+ 1000,
1240
+ )),
1241
+ ])
1242
+ : null
1243
+ const state = external?.state
1244
+ if (state && typeof state === 'object') {
1245
+ externalReady = state.ready === true
1246
+ if (typeof state.status === 'string') externalStatus = state.status
1247
+ if (typeof state.error === 'string') externalError = state.error
1248
+ }
1249
+ if (!externalError && Array.isArray(external?.entryErrors)) {
1250
+ // suppressed-and-continued entry errors (bundle-loader's patched __r
1251
+ // swallows entry throws and moves on) must NOT gate readiness — the
1252
+ // app frequently runs fine after one (e.g. metro's "Unknown named
1253
+ // module" probing noise). they surface separately as a diagnostic
1254
+ // for the timeout path.
1255
+ const isReal = (item) => item && typeof item.message === 'string' && item.message.trim()
1256
+ const fatal = external.entryErrors.find((item) => isReal(item) && item.suppressed !== true)
1257
+ if (fatal) externalError = fatal.message
1258
+ const suppressed = external.entryErrors.find((item) => isReal(item) && item.suppressed === true)
1259
+ if (suppressed) suppressedEntryError = suppressed.message
1260
+ }
1261
+ // once the app has errored its last status ("injecting sootsim
1262
+ // modules...") is stale — reporting it makes a dead app read as one that
1263
+ // is still booting.
1264
+ if (externalError) loadingText = ''
1265
+ else if (externalReady === false && externalStatus) loadingText = externalStatus
1266
+ } catch {}
1267
+ let errors = 0
1268
+ try { errors = window.__sootsimConsole?.count?.()?.errors ?? 0 } catch {}
1269
+ return {
1270
+ flag: (window).__sootsimExternalAppReady,
1271
+ at: (window).__sootsimExternalAppReadyAt || 0,
1272
+ nodes,
1273
+ targets,
1274
+ liveFrameActive,
1275
+ liveFrameChannels,
1276
+ liveFramePublishes,
1277
+ errors,
1278
+ loadingText,
1279
+ externalReady,
1280
+ externalStatus,
1281
+ externalError,
1282
+ suppressedEntryError,
1283
+ }
1284
+ })()`
1285
+
1286
+ export const READY_CONTENT_NODE_FLOOR = 100
1287
+ export const READY_NODE_STABLE_MS = 750
1288
+ export const WAIT_READY_PROGRESS_INTERVAL_MS = 2000
1289
+ // how often the readiness probe re-runs. shared so `sootsim wait ready` and the
1290
+ // flow runner's launchApp wait resolve a ready app with the same latency.
1291
+ export const READY_PROBE_POLL_MS = 150
1292
+
1293
+ export function readyProbeHasContent(
1294
+ probe: Pick<ReadyProbe, 'nodes' | 'targets'> &
1295
+ Partial<Pick<ReadyProbe, 'liveFrameActive'>>,
1296
+ ) {
1297
+ return (
1298
+ probe.liveFrameActive || probe.targets > 0 || probe.nodes >= READY_CONTENT_NODE_FLOOR
1299
+ )
1300
+ }
1301
+
1302
+ export function readyProbeHasTargetContent(
1303
+ probe: Pick<ReadyProbe, 'targets'> & Partial<Pick<ReadyProbe, 'liveFrameActive'>>,
1304
+ ) {
1305
+ return probe.liveFrameActive || probe.targets > 0
1306
+ }
1307
+
1308
+ export interface WaitReadyStatus {
1309
+ ready: boolean
1310
+ elapsedMs: number
1311
+ nodes: number
1312
+ targets: number
1313
+ liveFrameActive: boolean
1314
+ liveFrameChannels: number
1315
+ liveFramePublishes: number
1316
+ flag: unknown
1317
+ loadingText: string
1318
+ externalReady: boolean | null
1319
+ externalStatus: string
1320
+ externalError: string
1321
+ suppressedEntryError: string
1322
+ errors: number
1323
+ // non-empty when the probe itself stopped reaching the sim (tab closed,
1324
+ // sim never connected) — the wait failed on the bridge, not on the app.
1325
+ bridgeError: string
1326
+ }
1327
+
1328
+ export interface WaitReadyOptions {
1329
+ progressIntervalMs?: number
1330
+ onProgress?: (status: WaitReadyStatus) => void
1331
+ }
1332
+
1333
+ export function waitReadyReason(
1334
+ status: Pick<
1335
+ WaitReadyStatus,
1336
+ | 'externalError'
1337
+ | 'loadingText'
1338
+ | 'externalReady'
1339
+ | 'flag'
1340
+ | 'targets'
1341
+ | 'suppressedEntryError'
1342
+ > & { bridgeError?: string },
1343
+ ): string {
1344
+ if (status.bridgeError) {
1345
+ return `sim unreachable: ${status.bridgeError} — the target tab is closed or no sim is connected (check \`rnxsim list\`, reopen with \`rnxsim open\`)`
1346
+ }
1347
+ const base = status.externalError
1348
+ ? `guest app errored: ${status.externalError}`
1349
+ : status.loadingText
1350
+ ? `still showing "${status.loadingText}"`
1351
+ : status.externalReady === false
1352
+ ? 'guest app is still loading'
1353
+ : status.flag !== true && status.targets > 0
1354
+ ? 'native content is rendered but the ready signal has not settled'
1355
+ : status.flag !== true
1356
+ ? 'guest app has not emitted sootsim:externalAppReady'
1357
+ : status.targets <= 0
1358
+ ? 'ready flag emitted but no visible app content is inspectable yet'
1359
+ : 'node tree is still changing'
1360
+ // suppressed entry errors never gate readiness, but on a stall they are
1361
+ // often the actual diagnosis — surface them as a hint.
1362
+ return !status.externalError && status.suppressedEntryError
1363
+ ? `${base} (suppressed entry error: ${status.suppressedEntryError})`
1364
+ : base
1365
+ }
1366
+
1367
+ // `wait ready` — block until the guest app bundle has mounted and painted.
1368
+ // the persistent `__sootsimExternalAppReady` flag is necessary but not enough:
1369
+ // under the shell renderer the flag can fire before the shell has materialized
1370
+ // the tenant tree, and the node count can then jump through placeholder
1371
+ // counts before the app has finished publishing its first real surface. require
1372
+ // visible inspectable content (or a larger non-placeholder tree) and a brief
1373
+ // stable count. polling is caller-side so the timeout is decoupled from the
1374
+ // bridge's per-command timeout (cold guest bundles routinely need 20-30s).
1375
+ export async function inspectWaitReady(
1376
+ bridge: InspectBridge,
1377
+ timeoutMs = 20_000,
1378
+ options: WaitReadyOptions = {},
1379
+ ): Promise<WaitReadyStatus> {
1380
+ const start = Date.now()
1381
+ const deadline = start + timeoutMs
1382
+ const progressIntervalMs = options.progressIntervalMs ?? WAIT_READY_PROGRESS_INTERVAL_MS
1383
+ let nextProgressAt = start + progressIntervalMs
1384
+ let lastNodes = -1
1385
+ let nodeStableSince = start
1386
+ // the wait keeps polling through bridge errors — the target sim may still
1387
+ // be launching (`sootsim open` running concurrently) — but the latest error
1388
+ // is surfaced in progress lines and the timeout reason so a dead sim reads
1389
+ // as "sim unreachable", not as misleading zero-node app state.
1390
+ let bridgeError = ''
1391
+ let last: ReadyProbe = {
1392
+ flag: undefined,
1393
+ at: 0,
1394
+ nodes: 0,
1395
+ targets: 0,
1396
+ liveFrameActive: false,
1397
+ liveFrameChannels: 0,
1398
+ liveFramePublishes: 0,
1399
+ errors: 0,
1400
+ loadingText: '',
1401
+ externalReady: null,
1402
+ externalStatus: '',
1403
+ externalError: '',
1404
+ suppressedEntryError: '',
1405
+ }
1406
+ while (Date.now() < deadline) {
1407
+ try {
1408
+ last = (await bridge.send({ type: 'evaluate', code: WAIT_READY_PROBE })) ?? last
1409
+ bridgeError = ''
1410
+ } catch (error) {
1411
+ bridgeError = error instanceof Error ? error.message : String(error)
1412
+ // launching can make an unreachable sim transient, but target
1413
+ // ambiguity cannot resolve until the caller chooses a sim.
1414
+ if (
1415
+ bridgeError.startsWith('multiple sims are connected:') ||
1416
+ bridgeError.startsWith('saved sim ')
1417
+ ) {
1418
+ throw error
1419
+ }
1420
+ }
1421
+ const now = Date.now()
1422
+ const status: WaitReadyStatus = {
1423
+ ready: false,
1424
+ elapsedMs: now - start,
1425
+ nodes: last.nodes,
1426
+ targets: last.targets,
1427
+ liveFrameActive: last.liveFrameActive,
1428
+ liveFrameChannels: last.liveFrameChannels,
1429
+ liveFramePublishes: last.liveFramePublishes,
1430
+ flag: last.flag,
1431
+ loadingText: last.loadingText,
1432
+ externalReady: last.externalReady,
1433
+ externalStatus: last.externalStatus,
1434
+ externalError: last.externalError,
1435
+ suppressedEntryError: last.suppressedEntryError,
1436
+ errors: last.errors,
1437
+ bridgeError,
1438
+ }
1439
+ if (last.nodes !== lastNodes) {
1440
+ lastNodes = last.nodes
1441
+ nodeStableSince = now
1442
+ }
1443
+ const hasReadySignal = last.flag === true && readyProbeHasContent(last)
1444
+ const hasRenderedContentWithoutReadySignal =
1445
+ last.flag !== true && readyProbeHasTargetContent(last)
1446
+ if (
1447
+ (hasReadySignal || hasRenderedContentWithoutReadySignal) &&
1448
+ last.externalReady !== false &&
1449
+ !last.externalError &&
1450
+ !last.loadingText
1451
+ ) {
1452
+ if (now - nodeStableSince >= READY_NODE_STABLE_MS) {
1453
+ return {
1454
+ ...status,
1455
+ ready: true,
1456
+ }
1457
+ }
1458
+ }
1459
+ // a guest app that reported a load error is DONE — it paints a red box and
1460
+ // stays there until something reloads it (the reload path clears `error`
1461
+ // atomically). polling out the rest of the budget only delays the same
1462
+ // answer, and it is why a boot failure used to cost 90s before the caller
1463
+ // was told anything.
1464
+ if (last.externalError) return status
1465
+ if (options.onProgress && progressIntervalMs > 0 && now >= nextProgressAt) {
1466
+ options.onProgress(status)
1467
+ do {
1468
+ nextProgressAt += progressIntervalMs
1469
+ } while (now >= nextProgressAt)
1470
+ }
1471
+ await new Promise((r) => setTimeout(r, READY_PROBE_POLL_MS))
1472
+ }
1473
+ const status: WaitReadyStatus = {
1474
+ ready: false,
1475
+ elapsedMs: Date.now() - start,
1476
+ nodes: last.nodes,
1477
+ targets: last.targets,
1478
+ liveFrameActive: last.liveFrameActive,
1479
+ liveFrameChannels: last.liveFrameChannels,
1480
+ liveFramePublishes: last.liveFramePublishes,
1481
+ flag: last.flag,
1482
+ loadingText: last.loadingText,
1483
+ externalReady: last.externalReady,
1484
+ externalStatus: last.externalStatus,
1485
+ externalError: last.externalError,
1486
+ suppressedEntryError: last.suppressedEntryError,
1487
+ errors: last.errors,
1488
+ bridgeError,
1489
+ }
1490
+ if (options.onProgress && progressIntervalMs > 0 && Date.now() >= nextProgressAt) {
1491
+ options.onProgress(status)
1492
+ }
1493
+ return status
1494
+ }
1495
+
1496
+ // `wait selector` — block until a node with the given testID is present and
1497
+ // laid out. the polling loop runs inside the evaluated code (one round-trip).
1498
+ export async function inspectWaitSelector(
1499
+ bridge: InspectBridge,
1500
+ testId: string,
1501
+ timeoutMs = 5000,
1502
+ opts: { gone?: boolean } = {},
1503
+ ): Promise<{ found: boolean; node?: any; elapsed: number }> {
1504
+ // gone-mode inverts the predicate: resolve (found:true) when the node is
1505
+ // confirmed absent from the active visible inspect tree. this is the
1506
+ // positive "the gate/screen left" signal — e.g. waiting for an access-gate
1507
+ // button to disappear after a successful sign-in instead of re-tapping it.
1508
+ // an indeterminate bridge (test hooks not ready yet) never counts as gone,
1509
+ // and visible absence is debounced across two polls so a transient tree swap
1510
+ // doesn't resolve early. `gone` only steers the predicate; the return shape
1511
+ // is the same as presence-mode (callers/the handler don't need a gone flag).
1512
+ const gone = opts.gone === true
1513
+ const result = await bridge.send(
1514
+ {
1515
+ type: 'evaluate',
1516
+ code: `(async () => {
1517
+ const start = Date.now()
1518
+ const deadline = start + ${timeoutMs}
1519
+ const gone = ${gone}
1520
+ const ID = ${JSON.stringify(testId)}
1521
+ const present = (node) => !!(node && node.layout && node.layout.width > 0 && node.layout.height > 0)
1522
+ const find = async () => {
1523
+ const t = window.__sootsimTest
1524
+ if (!t) return undefined // bridge not ready — indeterminate, not "gone"
1525
+ try {
1526
+ // gone-mode confirms a gate/screen LEFT — it only needs structural
1527
+ // existence + layout, NOT the full-tree occlusion pass (pruneHidden).
1528
+ // that pass (computeOccludedInspectNodeIds) walks the whole tree and
1529
+ // does O(n²)-ish rect-occlusion testing EVERY poll; on a large
1530
+ // post-login app tree it costs seconds per poll, so --gone can never
1531
+ // get two clean "absent" polls inside budget and falsely reports the
1532
+ // gate as still blocked — the root cause of the 3pc PR-preview outage.
1533
+ // presence-mode keeps pruneHidden so "appeared" still means
1534
+ // visibly-reachable. a departed gate is unmounted or zero-sized, so the
1535
+ // cheap structural+layout check is correct here and ~O(n) cheap.
1536
+ const matches = await t.queryAll?.(gone ? { hasId: ID } : { hasId: ID, pruneHidden: true })
1537
+ if (!Array.isArray(matches)) return undefined
1538
+ return matches.find(present) || null
1539
+ } catch {
1540
+ return undefined
1541
+ }
1542
+ }
1543
+ let goneStreak = 0
1544
+ while (Date.now() < deadline) {
1545
+ const node = await find()
1546
+ if (gone) {
1547
+ if (node === null) {
1548
+ goneStreak += 1
1549
+ if (goneStreak >= 2) return { found: true, elapsed: Date.now() - start }
1550
+ } else {
1551
+ goneStreak = 0
1552
+ }
1553
+ } else if (present(node)) {
1554
+ return { found: true, node, elapsed: Date.now() - start }
1555
+ }
1556
+ await new Promise((r) => setTimeout(r, 80))
1557
+ }
1558
+ return { found: false, elapsed: Date.now() - start }
1559
+ })()`,
1560
+ },
1561
+ {
1562
+ timeoutMs: timeoutMs + 1_000,
1563
+ },
1564
+ )
1565
+ return (result ?? { found: false, elapsed: timeoutMs }) as {
1566
+ found: boolean
1567
+ node?: any
1568
+ elapsed: number
1569
+ }
1570
+ }
1571
+
1572
+ // ─── console errors / warnings ───
1573
+
1574
+ // a captured console entry. `__sootsimConsole` records each console.error /
1575
+ // console.warn with its args and (for errors) a stack. `source` is set when
1576
+ // the entry came from the engine observability store (e.g. 'render-worker')
1577
+ // so the reader can tell page-realm errors from forwarded tenant-worker ones.
1578
+ export interface ConsoleEntry {
1579
+ timestamp: number
1580
+ args: unknown[]
1581
+ stack?: string
1582
+ source?: string
1583
+ [key: string]: unknown
1584
+ }
1585
+
1586
+ // in-page merge of the two error/warning sources, evaluated in the preview
1587
+ // realm. `level` is 'error' | 'warn'.
1588
+ //
1589
+ // why two sources: `__sootsimConsole` (the ws-bridge ring buffer) only sees a
1590
+ // tenant render-worker error if the worker→host `{type:'log'}` re-emit makes it
1591
+ // through the host realm's monkey-patched `console.error` — and only when
1592
+ // ws-bridge's `setupConsoleCapture()` ran (gated on localhost/electron). The
1593
+ // engine observability store (`__sootsimObservability.logs`) captures the SAME
1594
+ // worker errors via two unconditional paths — the worker's own BroadcastChannel
1595
+ // capture and the host's `recordForwardedWorkerLog('render-worker', …)` — and
1596
+ // carries an explicit `source`/`level`. Reading only `__sootsimConsole` is how
1597
+ // a native render-worker crash (e.g. a tamagui "Missing theme" thrown by the
1598
+ // One root error boundary in the tenant worker) can be live and screaming in
1599
+ // the driver's console while the agent's `sootsim_errors` reports nothing.
1600
+ // Merge both, dedup, so worker + root-error-boundary failures always surface.
1601
+ function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
1602
+ const getter = level === 'error' ? 'getErrors' : 'getWarnings'
1603
+ return `(() => {
1604
+ const out = []
1605
+ const seen = new Set()
1606
+ const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
1607
+ const key = (ts, args) => Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
1608
+ const push = (e) => {
1609
+ if (!e) return
1610
+ const ts = typeof e.timestamp === 'number' ? e.timestamp : (typeof e.ts === 'number' ? e.ts : 0)
1611
+ const k = key(ts, e.args)
1612
+ if (seen.has(k)) return
1613
+ seen.add(k)
1614
+ out.push({ timestamp: ts, args: Array.isArray(e.args) ? e.args : [e.args], stack: e.stack || undefined, source: e.source || undefined })
1615
+ }
1616
+ // page-realm ws-bridge buffer first (richest stacks for page-origin errors)
1617
+ try { for (const e of (window.__sootsimConsole?.${getter}(${limit}) || [])) push(e) } catch {}
1618
+ // engine observability store — the always-on render-worker / forwarded path
1619
+ try {
1620
+ const obs = window.__sootsimObservability
1621
+ const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
1622
+ for (const e of snap) if (e && e.level === ${JSON.stringify(level)}) push(e)
1623
+ } catch {}
1624
+ return out.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)).slice(-${limit})
1625
+ })()`
1626
+ }
1627
+
1628
+ // `get errors` — the most recent captured `console.error` entries, merged from
1629
+ // the ws-bridge buffer and the engine observability store so forwarded
1630
+ // tenant-worker errors (render-worker / root-error-boundary) are never missed.
1631
+ export async function inspectErrors(
1632
+ bridge: InspectBridge,
1633
+ limit = 20,
1634
+ ): Promise<ConsoleEntry[]> {
1635
+ const result = await bridge.send({
1636
+ type: 'evaluate',
1637
+ code: consoleEntriesEval('error', limit),
1638
+ })
1639
+ return Array.isArray(result) ? (result as ConsoleEntry[]) : []
1640
+ }
1641
+
1642
+ // `get warnings` — the most recent captured `console.warn` entries, merged from
1643
+ // both capture sources (see inspectErrors).
1644
+ export async function inspectWarnings(
1645
+ bridge: InspectBridge,
1646
+ limit = 20,
1647
+ ): Promise<ConsoleEntry[]> {
1648
+ const result = await bridge.send({
1649
+ type: 'evaluate',
1650
+ code: consoleEntriesEval('warn', limit),
1651
+ })
1652
+ return Array.isArray(result) ? (result as ConsoleEntry[]) : []
1653
+ }
1654
+
1655
+ // merged error/warning COUNT across both capture sources, evaluated in the
1656
+ // preview realm. used by the CLI's proactive "console: N errors" notice and
1657
+ // the `get state` diagnostics so the count agrees with what inspectErrors /
1658
+ // inspectWarnings actually return — otherwise a render-worker-only error would
1659
+ // read as `0 errors` and the agent would never be nudged to inspect it.
1660
+ export const MERGED_CONSOLE_COUNT_EVAL = `(() => {
1661
+ const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
1662
+ const key = (lvl, ts, args) => lvl + '|' + Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
1663
+ const seen = new Set()
1664
+ let errors = 0
1665
+ let warnings = 0
1666
+ const add = (lvl, ts, args) => {
1667
+ if (lvl !== 'error' && lvl !== 'warn') return
1668
+ const k = key(lvl, ts, args)
1669
+ if (seen.has(k)) return
1670
+ seen.add(k)
1671
+ if (lvl === 'error') errors++
1672
+ else warnings++
1673
+ }
1674
+ try {
1675
+ const c = window.__sootsimConsole
1676
+ for (const e of (c?.getErrors?.(200) || [])) add('error', e?.timestamp ?? e?.ts, e?.args)
1677
+ for (const e of (c?.getWarnings?.(200) || [])) add('warn', e?.timestamp ?? e?.ts, e?.args)
1678
+ } catch {}
1679
+ try {
1680
+ const obs = window.__sootsimObservability
1681
+ const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
1682
+ for (const e of snap) if (e) add(e.level, e.ts, e.args)
1683
+ } catch {}
1684
+ return { errors, warnings, total: errors + warnings }
1685
+ })()`
1686
+
1687
+ // `get errors clear` — drop BOTH captured buffers so a follow-up read starts
1688
+ // clean (the ws-bridge ring buffer and the engine observability log store).
1689
+ export async function clearConsole(bridge: InspectBridge): Promise<void> {
1690
+ await bridge.send({
1691
+ type: 'evaluate',
1692
+ code: 'window.__sootsimConsole?.clear(); window.__sootsimObservability?.logs?.clear?.(); "cleared"',
1693
+ })
1694
+ }
1695
+
1696
+ // ─── network requests ───
1697
+
1698
+ // `get requests` — the captured fetch / XHR entries. `failed` restricts to
1699
+ // unsuccessful / errored requests (the CLI's default); pass `failed: false` for
1700
+ // every request. backed by the test bridge's request observability store.
1701
+ export async function inspectRequests(
1702
+ bridge: InspectBridge,
1703
+ opts: { failed?: boolean; limit?: number } = {},
1704
+ ): Promise<SootSimRequestEntry[]> {
1705
+ const limit = opts.limit ?? 20
1706
+ const method = opts.failed === false ? 'getRequests' : 'getFailedRequests'
1707
+ const result = await bridge.send({
1708
+ type: 'call',
1709
+ path: `__sootsimTest.${method}`,
1710
+ args: [limit],
1711
+ })
1712
+ return Array.isArray(result) ? (result as SootSimRequestEntry[]) : []
1713
+ }
1714
+
1715
+ // `get requests clear` — drop the captured request buffer.
1716
+ export async function clearRequests(bridge: InspectBridge): Promise<void> {
1717
+ await bridge.send({ type: 'call', path: '__sootsimTest.clearRequests', args: [] })
1718
+ }
1719
+
1720
+ // ─── scroll state ───
1721
+
1722
+ export interface ScrollState {
1723
+ scrollOffsetX?: number
1724
+ scrollOffsetY?: number
1725
+ offsetX?: number
1726
+ offsetY?: number
1727
+ [k: string]: unknown
1728
+ }
1729
+
1730
+ // the scroll state of whichever scrollable sits under a screen coordinate.
1731
+ // used to verify a swipe / scroll actually moved content — read it before
1732
+ // and after the gesture and compare the offsets.
1733
+ export async function inspectScrollStateAt(
1734
+ bridge: InspectBridge,
1735
+ x: number,
1736
+ y: number,
1737
+ ): Promise<ScrollState | null> {
1738
+ const result = await bridge.send({
1739
+ type: 'call',
1740
+ path: '__sootsimTest.getScrollStateAt',
1741
+ args: [x, y],
1742
+ })
1743
+ if (!result || typeof result !== 'object') return null
1744
+ const state = result as ScrollState
1745
+ return {
1746
+ ...state,
1747
+ scrollOffsetX:
1748
+ typeof state.scrollOffsetX === 'number' ? state.scrollOffsetX : state.offsetX,
1749
+ scrollOffsetY:
1750
+ typeof state.scrollOffsetY === 'number' ? state.scrollOffsetY : state.offsetY,
1751
+ }
1752
+ }
1753
+
1754
+ // ─── console logs ───
1755
+
1756
+ export type LogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
1757
+
1758
+ export interface LogEntry {
1759
+ id: string
1760
+ source: string
1761
+ level: LogLevel
1762
+ ts: number
1763
+ args: string[]
1764
+ stack: string | null
1765
+ }
1766
+
1767
+ export interface LogFilterOptions {
1768
+ level?: ReadonlySet<LogLevel> | null
1769
+ filter?: string | null
1770
+ // engine-internal `[sootsim]` bootstrap logs are hidden unless this is set.
1771
+ showInternal?: boolean
1772
+ }
1773
+
1774
+ // `logs` — the full captured console snapshot (log / info / warn / error /
1775
+ // debug) from the engine observability store. when the observability bridge has
1776
+ // not installed yet, return an empty snapshot so validation tools don't turn a
1777
+ // missing diagnostics buffer into the app failure being diagnosed.
1778
+ export async function inspectLogs(bridge: InspectBridge): Promise<LogEntry[]> {
1779
+ const res = (await bridge.send({
1780
+ type: 'evaluate',
1781
+ code: `(() => {
1782
+ const obs = window.__sootsimObservability;
1783
+ if (!obs) return { ok: false };
1784
+ return { ok: true, entries: obs.logs.getSnapshot() };
1785
+ })()`,
1786
+ })) as { ok: boolean; entries?: LogEntry[] } | null
1787
+ if (!res || !res.ok) return []
1788
+ return res.entries ?? []
1789
+ }
1790
+
1791
+ // `logs clear` — drop the engine's captured log buffer.
1792
+ export async function clearLogs(bridge: InspectBridge): Promise<void> {
1793
+ await bridge.send({
1794
+ type: 'evaluate',
1795
+ code: 'window.__sootsimObservability?.logs.clear(); "cleared"',
1796
+ })
1797
+ }
1798
+
1799
+ function isForwardedWorkerLogTwin(a: LogEntry, b: LogEntry): boolean {
1800
+ if (a.source === b.source) return false
1801
+ if (a.level !== b.level) return false
1802
+ if (Math.abs(a.ts - b.ts) > 1000) return false
1803
+ if (a.args.length !== b.args.length) return false
1804
+ const sources = new Set([a.source, b.source])
1805
+ if (!sources.has('sootsim-worker')) return false
1806
+ if (!sources.has('render-worker') && !sources.has('forwarded-render-worker')) {
1807
+ return false
1808
+ }
1809
+ return a.args.every((arg, index) => arg === b.args[index])
1810
+ }
1811
+
1812
+ // shared log filtering — engine-internal `[sootsim]` noise hidden by default,
1813
+ // optional level set + message substring. used by the CLI `logs` verb and the
1814
+ // agent so both see the same filtered view.
1815
+ export function filterLogEntries(
1816
+ entries: LogEntry[],
1817
+ opts: LogFilterOptions = {},
1818
+ ): LogEntry[] {
1819
+ let out: LogEntry[] = []
1820
+ for (const entry of entries) {
1821
+ if (out.some((candidate) => isForwardedWorkerLogTwin(candidate, entry))) {
1822
+ continue
1823
+ }
1824
+ out.push(entry)
1825
+ }
1826
+ if (!opts.showInternal) {
1827
+ out = out.filter((e) => {
1828
+ const first = e.args[0]
1829
+ return !(typeof first === 'string' && first.startsWith('[rnx]'))
1830
+ })
1831
+ }
1832
+ if (opts.level) out = out.filter((e) => opts.level!.has(e.level))
1833
+ if (opts.filter) {
1834
+ const lf = opts.filter.toLowerCase()
1835
+ out = out.filter((e) => e.args.join(' ').toLowerCase().includes(lf))
1836
+ }
1837
+ return out
1838
+ }
1839
+
1840
+ // ─── timeline / what-happened ───
1841
+
1842
+ // `what-happened --summary` — counts by kind since the query window.
1843
+ export async function inspectTimelineSummary(
1844
+ bridge: InspectBridge,
1845
+ query: SootSimTimelineQuery,
1846
+ ): Promise<SootSimTimelineSummary> {
1847
+ return (await bridge.send({
1848
+ type: 'call',
1849
+ path: 'SootSim.bridges.timeline.summary',
1850
+ args: [query],
1851
+ })) as SootSimTimelineSummary
1852
+ }
1853
+
1854
+ // `what-happened` — the recent timeline events for the query window.
1855
+ export async function inspectTimelineRecent(
1856
+ bridge: InspectBridge,
1857
+ query: SootSimTimelineQuery,
1858
+ ): Promise<SootSimTimelineQueryResult> {
1859
+ return (await bridge.send({
1860
+ type: 'call',
1861
+ path: 'SootSim.bridges.timeline.recent',
1862
+ args: [query],
1863
+ })) as SootSimTimelineQueryResult
1864
+ }
1865
+
1866
+ // a blocking native-UI surface (iOS Alert / ActionSheet) currently presented
1867
+ // by the shell worker. `label` is the human word for the CLI note; `title` is
1868
+ // the surface's title text when it has one.
1869
+ export interface OpenNativeUISurface {
1870
+ label: string
1871
+ title: string | null
1872
+ }
1873
+
1874
+ // detect blocking native-UI surfaces that are currently open, computed from the
1875
+ // timeline. the shell native-UI provider records a `show` then a `resolve`
1876
+ // event per surface (both DEFAULT_ON kinds, so no `timeline start` is needed),
1877
+ // so the latest event of each kind tells whether it's still up. the CLI surfaces
1878
+ // this because these surfaces live in the shell worker — invisible to `describe`
1879
+ // (which reads the tenant tree) — yet an open modal silently swallows every
1880
+ // tap/drag/type meant for the app underneath, which reads as "the action did
1881
+ // nothing." returns [] on any read error; this is an advisory note, never fatal.
1882
+ export async function detectOpenNativeUI(
1883
+ bridge: InspectBridge,
1884
+ ): Promise<OpenNativeUISurface[]> {
1885
+ let events: SootSimTimelineEvent[]
1886
+ try {
1887
+ const res = await inspectTimelineRecent(bridge, {
1888
+ kinds: ['alert', 'actionsheet'],
1889
+ limit: 80,
1890
+ })
1891
+ events = res.events
1892
+ } catch {
1893
+ return []
1894
+ }
1895
+ // events arrive time-ordered; keep the latest event per kind.
1896
+ const latest = new Map<string, SootSimTimelineEvent>()
1897
+ for (const ev of events) latest.set(ev.kind, ev)
1898
+ const out: OpenNativeUISurface[] = []
1899
+ const check = (kind: string, label: string) => {
1900
+ const ev = latest.get(kind)
1901
+ if (!ev || !ev.data || typeof ev.data !== 'object') return
1902
+ // timeline payloads are plain JSON records (see formatTimelinePayload)
1903
+ const d = ev.data as Record<string, unknown>
1904
+ if (d.phase !== 'show') return
1905
+ out.push({ label, title: typeof d.title === 'string' ? d.title : null })
1906
+ }
1907
+ check('alert', 'Alert')
1908
+ check('actionsheet', 'ActionSheet')
1909
+ return out
1910
+ }
1911
+
1912
+ // advance a caller's cursor past the events it has consumed, so the next
1913
+ // `what-happened` for the same cursor key shows only newer events.
1914
+ export async function inspectTimelineAdvanceCursor(
1915
+ bridge: InspectBridge,
1916
+ cursorKey: string,
1917
+ watermark: number,
1918
+ ): Promise<void> {
1919
+ await bridge.send({
1920
+ type: 'call',
1921
+ path: 'SootSim.bridges.timeline.cursorAdvance',
1922
+ args: [cursorKey, watermark],
1923
+ })
1924
+ }
1925
+
1926
+ // ─── keyboard ───
1927
+
1928
+ export interface KeyboardState {
1929
+ visible: boolean
1930
+ spec: {
1931
+ keyboardType: string
1932
+ returnKeyType: string
1933
+ secureTextEntry: boolean
1934
+ enablesReturnKeyAutomatically: boolean
1935
+ autoCapitalize: string
1936
+ autoCorrect: boolean
1937
+ keyboardAppearance: string
1938
+ accessoryBarId: string | null
1939
+ currentTextIsEmpty: boolean
1940
+ } | null
1941
+ mode: string
1942
+ shifted: boolean
1943
+ capsLock: boolean
1944
+ accessoryBarId: string | null
1945
+ }
1946
+
1947
+ // `get keyboard` — the live iOS keyboard state from `__sootsimKeyboard`,
1948
+ // populated by shell-worker `keyboard.layout` broadcasts. returns the raw
1949
+ // payload or `{ error }` when the keyboard bridge has not installed yet.
1950
+ export async function inspectKeyboard(
1951
+ bridge: InspectBridge,
1952
+ ): Promise<KeyboardState | { error: string }> {
1953
+ const result = (await bridge.send({
1954
+ type: 'evaluate',
1955
+ code: `(() => {
1956
+ const kb = window.__sootsimKeyboard
1957
+ if (!kb || typeof kb.getLayout !== 'function') {
1958
+ return { error: 'keyboard bridge getLayout() not available' }
1959
+ }
1960
+ return kb.getLayout()
1961
+ })()`,
1962
+ })) as KeyboardState | { error: string }
1963
+ return result ?? { error: 'keyboard bridge returned no result' }
1964
+ }
1965
+
1966
+ // ─── shell state ───
1967
+
1968
+ // true when a `SootSim.bridges.mainShell.*` call failed because the shell
1969
+ // bridge has not installed yet (boot race) — distinct from a real error.
1970
+ export function isShellCommandUnavailable(error: unknown): boolean {
1971
+ const message = error instanceof Error ? error.message : String(error)
1972
+ return (
1973
+ message.includes('call target not found: SootSim.bridges.mainShell') ||
1974
+ message.includes('test bridge unavailable before app-in-worker boot')
1975
+ )
1976
+ }
1977
+
1978
+ // read `mainShell.getState()`, retrying past the boot race for up to
1979
+ // `readyTimeoutMs`. shared by the CLI's inspect verbs and the agent.
1980
+ export async function getShellState(
1981
+ bridge: InspectBridge,
1982
+ readyTimeoutMs = 0,
1983
+ ): Promise<Record<string, any> | null> {
1984
+ const deadline = Date.now() + Math.max(0, readyTimeoutMs)
1985
+ while (true) {
1986
+ try {
1987
+ return (await bridge.send({
1988
+ type: 'call',
1989
+ path: 'SootSim.bridges.mainShell.getState',
1990
+ args: [],
1991
+ })) as Record<string, any> | null
1992
+ } catch (error) {
1993
+ if (!isShellCommandUnavailable(error) || Date.now() >= deadline) throw error
1994
+ await new Promise((r) => setTimeout(r, 50))
1995
+ }
1996
+ }
1997
+ }
1998
+
1999
+ // ─── screens ───
2000
+
2001
+ export interface ScreenEntry {
2002
+ id: string
2003
+ routeName?: string
2004
+ isActive: boolean
2005
+ headerHeight: number
2006
+ largeTitleState?: string
2007
+ }
2008
+
2009
+ export interface NavSnapshot {
2010
+ screens: ScreenEntry[]
2011
+ activeScreenId: string | null
2012
+ transitionPhase: string
2013
+ activeTransitionCount: number
2014
+ activeHeaderHeight: number
2015
+ activeLargeTitleState?: string
2016
+ }
2017
+
2018
+ export interface ScreensReport {
2019
+ shell: Record<string, any> | null
2020
+ nav: NavSnapshot | null
2021
+ route: SootSimRouteInfo | null
2022
+ keyboard: {
2023
+ visible: boolean
2024
+ mode?: string
2025
+ spec?: { keyboardType?: string; returnKeyType?: string } | null
2026
+ } | null
2027
+ }
2028
+
2029
+ // `get screens` — the navigation stack + shell state + keyboard, a "what
2030
+ // screen am I on?" dashboard. nav + keyboard are read in one evaluate so the
2031
+ // snapshot is consistent; shell state is a best-effort separate call.
2032
+ export async function inspectScreens(bridge: InspectBridge): Promise<ScreensReport> {
2033
+ const payload = (await bridge.send({
2034
+ type: 'evaluate',
2035
+ code: `(async () => {
2036
+ const test = window.__sootsimTest
2037
+ const kb = window.__sootsimKeyboard
2038
+ const navSnap =
2039
+ test && typeof test.getNavigationSnapshot === 'function'
2040
+ ? await test.getNavigationSnapshot()
2041
+ : null
2042
+ const route =
2043
+ test && typeof test.getRouteInfo === 'function'
2044
+ ? await test.getRouteInfo()
2045
+ : null
2046
+ const keyboard =
2047
+ kb && typeof kb.getLayout === 'function'
2048
+ ? (() => {
2049
+ const layout = kb.getLayout()
2050
+ return layout ? {
2051
+ visible: layout.visible,
2052
+ mode: layout.mode,
2053
+ spec: layout.spec
2054
+ ? {
2055
+ keyboardType: layout.spec.keyboardType,
2056
+ returnKeyType: layout.spec.returnKeyType,
2057
+ }
2058
+ : null,
2059
+ } : null
2060
+ })()
2061
+ : null
2062
+ return { nav: navSnap, route, keyboard }
2063
+ })()`,
2064
+ })) as {
2065
+ nav: NavSnapshot | null
2066
+ route: SootSimRouteInfo | null
2067
+ keyboard: ScreensReport['keyboard']
2068
+ } | null
2069
+ const shell = await getShellState(bridge, 500).catch(() => null)
2070
+ return {
2071
+ shell,
2072
+ nav: payload?.nav ?? null,
2073
+ route: payload?.route ?? null,
2074
+ keyboard: payload?.keyboard ?? null,
2075
+ }
2076
+ }
2077
+
2078
+ // ─── debug channels ───
2079
+
2080
+ // the `window.__sootsimDebug` channels the engine instruments. `all` is a
2081
+ // meta-target accepted by enable/disable.
2082
+ export const DEBUG_CHANNELS = [
2083
+ 'portals',
2084
+ 'sheets',
2085
+ 'layout',
2086
+ 'onlayout',
2087
+ 'animated',
2088
+ 'render',
2089
+ 'touch',
2090
+ 'yoga',
2091
+ ] as const
2092
+
2093
+ export type DebugChannel = (typeof DEBUG_CHANNELS)[number]
2094
+
2095
+ // `debug status` — which debug channels are currently enabled + their counts.
2096
+ export async function inspectDebugStatus(bridge: InspectBridge): Promise<unknown> {
2097
+ return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.status()' })
2098
+ }
2099
+
2100
+ // `debug flags` — the engine's debug feature flags.
2101
+ export async function inspectDebugFlags(bridge: InspectBridge): Promise<unknown> {
2102
+ return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.flags()' })
2103
+ }
2104
+
2105
+ // `debug find sheets|portals` — locate all live sheet / portal nodes.
2106
+ // routed through the test bridge (not the page __sootsimDebug global) so the
2107
+ // walk runs against the worker's live tree — the page-local tree is empty
2108
+ // under the worker renderer.
2109
+ export async function inspectDebugFind(
2110
+ bridge: InspectBridge,
2111
+ target: 'sheets' | 'portals' | 'boundaries',
2112
+ ): Promise<unknown> {
2113
+ return bridge.send({
2114
+ type: 'evaluate',
2115
+ code: `window.__sootsimTest.debugFind(${JSON.stringify(target)})`,
2116
+ })
2117
+ }
2118
+
2119
+ // `debug recent` — recent debug events, optionally filtered to one channel.
2120
+ // only channels enabled via `setDebugChannels` record events.
2121
+ export async function inspectDebugRecent(
2122
+ bridge: InspectBridge,
2123
+ channel?: string,
2124
+ limit = 50,
2125
+ ): Promise<unknown> {
2126
+ const code =
2127
+ channel && channel !== 'all'
2128
+ ? `window.__sootsimDebug.recent(${JSON.stringify(channel)}, ${limit})`
2129
+ : `window.__sootsimDebug.recent(undefined, ${limit})`
2130
+ return bridge.send({ type: 'evaluate', code })
2131
+ }
2132
+
2133
+ // `debug enable|disable <channels>` — turn debug channels on or off. returns
2134
+ // the resulting active-channel set. an empty list with `disable` clears all.
2135
+ export async function setDebugChannels(
2136
+ bridge: InspectBridge,
2137
+ action: 'enable' | 'disable',
2138
+ channels: string[],
2139
+ ): Promise<unknown> {
2140
+ const args =
2141
+ channels.length > 0
2142
+ ? channels.map((c) => JSON.stringify(c)).join(', ')
2143
+ : action === 'disable'
2144
+ ? "'all'"
2145
+ : ''
2146
+ return bridge.send({
2147
+ type: 'evaluate',
2148
+ code: `window.__sootsimDebug.${action}(${args})`,
2149
+ })
2150
+ }
2151
+
2152
+ // ─── memory ───
2153
+
2154
+ export interface ImageLoaderStats {
2155
+ cacheEntries: number
2156
+ cachePixelBytes: number
2157
+ cachePixelBudget: number
2158
+ cacheMaxEntries: number
2159
+ pendingFetches: number
2160
+ pendingBytes: number
2161
+ failedUris: number
2162
+ snapshots: number
2163
+ liveFrames: number
2164
+ }
2165
+
2166
+ export interface WorkerHeapStats {
2167
+ usedJSHeapSize: number
2168
+ totalJSHeapSize: number
2169
+ jsHeapSizeLimit: number
2170
+ }
2171
+
2172
+ export interface EngineObjectCounts {
2173
+ pictures: number
2174
+ rasterImages: number
2175
+ paragraphs: number
2176
+ yogaNodes: number
2177
+ }
2178
+
2179
+ export interface WorkerMemorySample {
2180
+ // total nodes ever registered in the worker's handleToNode map — a
2181
+ // monotonically climbing value while the on-screen tree is stable means
2182
+ // discarded subtrees are being retained.
2183
+ nodesRegistered: number | null
2184
+ // registered nodes not reachable from a root container, by count and type
2185
+ nodesDetached: number | null
2186
+ detachedTypes: Record<string, number> | null
2187
+ objects: EngineObjectCounts | null
2188
+ activeNativeAnimations: number | null
2189
+ imageLoader: ImageLoaderStats | null
2190
+ workerHeap: WorkerHeapStats | null
2191
+ // canvaskit's wasm linear memory in this worker. every worker that loads
2192
+ // canvaskit allocates its own, no js-heap counter includes it, and on webkit
2193
+ // (where performance.memory does not exist) it is the only number available.
2194
+ wasmHeapBytes: number | null
2195
+ }
2196
+
2197
+ export interface MemoryReport {
2198
+ tenant: WorkerMemorySample | null
2199
+ shell: WorkerMemorySample | null
2200
+ compositor: WorkerMemorySample | null
2201
+ hostHeap: WorkerHeapStats | null
2202
+ }
2203
+
2204
+ // `get memory` / `debug memory` — per-worker object counts + image-loader
2205
+ // cache + JS heaps. tenant numbers ride the queryStats memory block; the
2206
+ // shell worker answers via __sootsimShellMemory, and the compositor — which
2207
+ // paints home + app:one + app:two, and therefore holds every app-surface
2208
+ // picture and raster image — answers through its stats channel.
2209
+ // performance.memory is chrome-only, null on webkit.
2210
+ export async function inspectMemory(bridge: InspectBridge): Promise<MemoryReport> {
2211
+ const result = (await bridge.send({
2212
+ type: 'evaluate',
2213
+ code: `(async () => {
2214
+ const host = window.__sootsimRenderHost
2215
+ const stats = host?.queryStats ? await host.queryStats() : null
2216
+ const shellRaw = window.__sootsimShellMemory
2217
+ ? await window.__sootsimShellMemory()
2218
+ : null
2219
+ const shell = shellRaw && !shellRaw.error ? shellRaw : null
2220
+ // the compositor paints home + app:one + app:two, so every app-surface
2221
+ // picture and raster image is counted here and nowhere else.
2222
+ let compositor = null
2223
+ try {
2224
+ const cs = await window.__sootsimCompositor?.getStats?.(false, true)
2225
+ compositor = cs?.memory ?? null
2226
+ } catch {}
2227
+ const normalize = (m) =>
2228
+ m
2229
+ ? {
2230
+ nodesRegistered: m.nodesRegistered ?? null,
2231
+ nodesDetached: m.nodesDetached ?? null,
2232
+ detachedTypes: m.detachedTypes ?? null,
2233
+ objects: m.objects ?? null,
2234
+ activeNativeAnimations: m.activeNativeAnimations ?? null,
2235
+ imageLoader: m.imageLoader ?? null,
2236
+ workerHeap: m.workerHeap ?? null,
2237
+ wasmHeapBytes: m.wasmHeapBytes ?? null,
2238
+ }
2239
+ : null
2240
+ const hostMem = performance.memory
2241
+ ? {
2242
+ usedJSHeapSize: performance.memory.usedJSHeapSize,
2243
+ totalJSHeapSize: performance.memory.totalJSHeapSize,
2244
+ jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
2245
+ }
2246
+ : null
2247
+ return {
2248
+ tenant: normalize(stats?.memory),
2249
+ shell: normalize(shell),
2250
+ compositor: normalize(compositor),
2251
+ hostHeap: hostMem,
2252
+ }
2253
+ })()`,
2254
+ })) as MemoryReport | null
2255
+ return result ?? { tenant: null, shell: null, compositor: null, hostHeap: null }
2256
+ }
2257
+
2258
+ // one-line counts, ordered by descriptive priority. shared by the CLI's
2259
+ // `what-happened --summary` and the agent's sootsim_what_happened.
2260
+ export function formatTimelineSummary(summary: SootSimTimelineSummary): string {
2261
+ if (summary.total === 0) return 'nothing recorded'
2262
+ const parts: string[] = []
2263
+ const ORDER = [
2264
+ 'error',
2265
+ 'warning',
2266
+ 'console',
2267
+ 'fetch',
2268
+ 'toast',
2269
+ 'alert',
2270
+ 'actionsheet',
2271
+ 'picker',
2272
+ 'notification',
2273
+ 'screen',
2274
+ 'route',
2275
+ 'keyboard',
2276
+ 'app-launch',
2277
+ 'shell',
2278
+ 'scroll',
2279
+ 'gesture',
2280
+ 'text-input',
2281
+ 'react-commit',
2282
+ 'animation',
2283
+ 'reanimated',
2284
+ ]
2285
+ const seen = new Set<string>()
2286
+ for (const k of ORDER) {
2287
+ const n = summary.byKind[k]
2288
+ if (n) {
2289
+ parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
2290
+ seen.add(k)
2291
+ }
2292
+ }
2293
+ for (const [k, n] of Object.entries(summary.byKind)) {
2294
+ if (!seen.has(k) && n) parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
2295
+ }
2296
+ return parts.join(' · ')
2297
+ }
2298
+
2299
+ // high-frequency render/layout/scroll kinds that drown the meaningful
2300
+ // events. both the `what-happened` CLI and the sootsim_what_happened agent
2301
+ // tool hide these by default so a single tap doesn't get buried under 1000+
2302
+ // react-commit rows. callers opt back in explicitly.
2303
+ export const NOISY_TIMELINE_KINDS: ReadonlySet<string> = new Set([
2304
+ 'react-commit',
2305
+ 'layout',
2306
+ 'scroll',
2307
+ ])
2308
+
2309
+ function formatTimelineRelativeTime(t: number, anchor: number | null): string {
2310
+ if (anchor === null) return new Date(t).toLocaleTimeString()
2311
+ const dt = (t - anchor) / 1000
2312
+ const sign = dt >= 0 ? '+' : ''
2313
+ return `${sign}${dt.toFixed(2)}s`
2314
+ }
2315
+
2316
+ // payload rendering per timeline-event kind. one exhaustive switch over
2317
+ // SootSimTimelineKind so adding a kind forces a formatter decision rather
2318
+ // than silently leaving it JSON-dumped. shared by the `what-happened` CLI and
2319
+ // the sootsim_what_happened agent tool — one renderer, two transports.
2320
+ export function formatTimelinePayload(
2321
+ kind: SootSimTimelineKind,
2322
+ d: Record<string, unknown>,
2323
+ ): string {
2324
+ switch (kind) {
2325
+ case 'app-launch':
2326
+ return d.phase === 'launch'
2327
+ ? `launch ${d.appName ?? d.toAppId ?? ''}`
2328
+ : `dismiss ${d.appName ?? d.fromAppId ?? ''} → ${d.toAppId ?? ''}`
2329
+ case 'toast':
2330
+ return `"${d.text ?? ''}"${d.durationMs ? ` (${d.durationMs}ms)` : ''}`
2331
+ case 'keyboard':
2332
+ return `${d.phase ?? '?'}${d.heightPx ? ` h=${d.heightPx}` : ''}${d.mode ? ` ${d.mode}` : ''}`
2333
+ case 'screen':
2334
+ return `${d.phase ?? '?'} ${d.name ?? d.activeName ?? ''}`
2335
+ case 'flow-step':
2336
+ return `${d.status ?? '?'} ${d.stepName ?? 'step'}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
2337
+ case 'route':
2338
+ return `${d.phase ?? '?'} ${d.path ?? d.pathname ?? ''}`
2339
+ case 'alert':
2340
+ case 'actionsheet':
2341
+ case 'picker':
2342
+ return `${d.phase ?? '?'} ${d.title ?? d.message ?? ''}`
2343
+ case 'notification':
2344
+ return `${d.title ?? ''}${d.body ? ` — ${d.body}` : ''}`
2345
+ case 'fetch':
2346
+ return `${d.method ?? 'GET'} ${d.url ?? ''}${d.status ? ` -> ${d.status}` : ''}`
2347
+ case 'console':
2348
+ case 'console-log':
2349
+ return `${d.level ?? 'log'}: ${(d.message ?? '').toString().slice(0, 120)}`
2350
+ case 'shell':
2351
+ return `${d.event ?? d.type ?? d.phase ?? ''}`
2352
+ case 'scroll':
2353
+ return `${d.phase ?? '?'} ${d.target ?? ''}`
2354
+ case 'gesture':
2355
+ return `${d.phase ?? '?'} ${d.type ?? ''}`
2356
+ case 'text-input':
2357
+ return `${d.phase ?? '?'}${d.value !== undefined ? ` "${String(d.value).slice(0, 40)}"` : ''}`
2358
+ case 'layout':
2359
+ return `${d.kind ?? '?'} ${d.testID ?? d.type ?? ''}${
2360
+ d.skipped ? ` skipped:${d.reason ?? 'unknown'}` : ''
2361
+ }`
2362
+ case 'react-commit': {
2363
+ const slowest = d.slowest as
2364
+ | { displayName?: unknown; durationMs?: unknown }
2365
+ | null
2366
+ | undefined
2367
+ return `${d.fiberCount ?? '?'} fibers ${d.durationMs ?? '?'}ms${
2368
+ slowest?.displayName
2369
+ ? ` · ${slowest.displayName} ${slowest.durationMs ?? '?'}ms`
2370
+ : ''
2371
+ }`
2372
+ }
2373
+ case 'frame':
2374
+ return `${d.totalMs ?? '?'}ms${d.renderMs ? ` · render ${d.renderMs}ms` : ''}${
2375
+ d.layoutMs ? ` · layout ${d.layoutMs}ms` : ''
2376
+ }${d.copyMs ? ` · copy ${d.copyMs}ms` : ''}`
2377
+ case 'reanimated':
2378
+ case 'animation':
2379
+ return `${d.kind ?? ''} ${d.target ?? ''}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
2380
+ }
2381
+ return ''
2382
+ }
2383
+
2384
+ // one compact line per event: ` +0.42s shell [fetch] GET /api -> 200`.
2385
+ // pass anchor = first event's `t` for relative times, or null for wall-clock.
2386
+ export function formatTimelineEvent(
2387
+ event: SootSimTimelineEvent,
2388
+ anchor: number | null,
2389
+ ): string {
2390
+ const ts = formatTimelineRelativeTime(event.t, anchor).padStart(8)
2391
+ const ctx = event.context.padEnd(6)
2392
+ const kind = `[${event.kind}]`.padEnd(15)
2393
+ const d = event.data as Record<string, unknown> | null
2394
+ const payload = d && typeof d === 'object' ? formatTimelinePayload(event.kind, d) : ''
2395
+ return ` ${ts} ${ctx} ${kind} ${payload}`
2396
+ }
2397
+
2398
+ // render an event list as compact lines, anchored to the first event so times
2399
+ // read +0.00s, +0.13s, … . the agent tool and CLI both call this rather than
2400
+ // JSON-dumping the events array.
2401
+ export function formatTimelineEvents(events: readonly SootSimTimelineEvent[]): string {
2402
+ if (!events.length) return ''
2403
+ const anchor = events[0]?.t ?? null
2404
+ return events.map((e) => formatTimelineEvent(e, anchor)).join('\n')
2405
+ }