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,4519 @@
1
+ // sootsim inspect — dump node tree, accessibility info, query nodes
2
+ // connects to the sootsim WS bridge (no Playwright)
3
+
4
+ import { renderCliCommandHelp, renderCliGroupHelp } from '@sootsim/skills/cli/renderers'
5
+ import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
6
+ import { DEFAULT_SOOTSIM_SHELL_URL } from '../../src/cli-constants'
7
+ import { resetGuestAppStateEval } from '../app-state-reset'
8
+ import { printBridgeStateSummary } from '../bridge-diagnostics'
9
+ import {
10
+ buildResolveRectEval,
11
+ buildSampleColorEval,
12
+ type SampleRect,
13
+ } from '../browser-evals'
14
+ import { getCliIdentityKey } from '../current-sim'
15
+ import { rememberFlowCandidate } from '../flow-session'
16
+ import { maybeHint } from '../hints'
17
+ import { shouldPrintInspectNotice } from '../inspect-notice-state'
18
+ import { TOP_LEVEL_RUNTIME_COMMANDS } from '../parse-args'
19
+ import {
20
+ callInBridge,
21
+ callInBridgeWrite,
22
+ checkSimHealth,
23
+ createBridge,
24
+ createBridgeFromParsed,
25
+ evalInBridge,
26
+ parseBridgeCliArgs,
27
+ printBridgeWorldNotice,
28
+ type WsBridge,
29
+ } from '../ws-bridge'
30
+ import {
31
+ isTapSuccess,
32
+ tapBest,
33
+ tapById,
34
+ tapByText,
35
+ tapCoordinates,
36
+ tapResolvedTarget,
37
+ type TapTextOptions,
38
+ } from './inspect/actions'
39
+ import {
40
+ clearConsole,
41
+ clearLogs,
42
+ clearRequests,
43
+ detectOpenNativeUI,
44
+ filterLogEntries,
45
+ inspectAccessibilityTree,
46
+ inspectErrors,
47
+ inspectLogs,
48
+ inspectRequests,
49
+ inspectWarnings,
50
+ MERGED_CONSOLE_COUNT_EVAL,
51
+ shouldSkipAutoSettleForInspectPick,
52
+ type LogEntry,
53
+ type LogLevel,
54
+ type OpenNativeUISurface,
55
+ } from './inspect/core'
56
+ import { runCountSubcommand } from './inspect/count'
57
+ import { runDescribeSubcommand } from './inspect/describe'
58
+ import { isAgentEnv } from './inspect/env'
59
+ import { runFindSubcommand } from './inspect/find'
60
+ import { runGetLayoutSubcommand } from './inspect/get-layout'
61
+ import { runKeyboardSubcommand } from './inspect/keyboard'
62
+ import { runListSubcommand } from './inspect/list'
63
+ import { runMemorySubcommand } from './inspect/memory'
64
+ import {
65
+ SECURE_TEXT_REDACTION,
66
+ isSecureKeyboardState,
67
+ redactKeyboardStateForOutput,
68
+ redactTextForKeyboardState,
69
+ } from './inspect/redaction'
70
+ import { readTargetFlag, resolveTargetCoords } from './inspect/resolve-target'
71
+ import { runScreensSubcommand } from './inspect/screens'
72
+ import { runSettleSubcommand } from './inspect/settle'
73
+ import { waitForSootsimIdle } from './inspect/settling'
74
+ import {
75
+ callShellCommand,
76
+ callShellCommandWhenReady,
77
+ callTestBridge,
78
+ formatLogTimestamp,
79
+ getShellState,
80
+ isShellCommandUnavailable,
81
+ maybeWaitForStartedScreenTransitions,
82
+ pollForReloadReady,
83
+ printJson,
84
+ printWarn,
85
+ type ReloadReadyResult,
86
+ sleep,
87
+ SCREEN_TRANSITION_PREFLIGHT,
88
+ waitForBridgeConnected,
89
+ wantsJson,
90
+ } from './inspect/shared'
91
+ import { runSleepSubcommand } from './inspect/sleep'
92
+ import { runTreeSubcommand } from './inspect/tree'
93
+ import { runUrlSubcommand } from './inspect/url'
94
+ import { runWaitSubcommand } from './inspect/wait'
95
+ import { runWaitEventSubcommand } from './inspect/wait-event'
96
+ import { runWaitIdleSubcommand } from './inspect/wait-idle'
97
+ import { runWaitReadySubcommand } from './inspect/wait-ready'
98
+ import { runWaitSelectorSubcommand } from './inspect/wait-selector'
99
+ import { printMissingSimHint, printUnknownSimHint } from './no-bridge-hint'
100
+ import type { PerformResult, PerformStep } from '../../src/bridge-contract'
101
+ import type {
102
+ SootSimRequestEntry,
103
+ SootSimScreenTransitionWaitResult,
104
+ SootSimScrollPerformanceTrace,
105
+ } from '@sootsim/globals'
106
+
107
+ interface InspectOptions {
108
+ port?: number
109
+ verbose?: boolean
110
+ timeoutMs?: number
111
+ internalPerfCommand?: 'shell' | 'scroll'
112
+ }
113
+
114
+ // shape of entries in the shared observability store — mirrors
115
+ // packages/sootsim-engine/src/observability/types.ts NetworkEntry. duplicated
116
+ // here so this command doesn't cross-import from engine internals.
117
+ interface NetworkEntryPayload {
118
+ id: string
119
+ source: string
120
+ kind: 'fetch' | 'xhr' | 'resource'
121
+ method: string
122
+ url: string
123
+ displayUrl: string
124
+ startTs: number
125
+ durationMs: number | null
126
+ status: number | null
127
+ statusText: string | null
128
+ ok: boolean
129
+ error: string | null
130
+ size: number | null
131
+ type: string | null
132
+ }
133
+
134
+ function formatNetworkSize(bytes: number | null): string {
135
+ if (bytes == null) return '—'
136
+ if (bytes < 1024) return `${bytes}B`
137
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`
138
+ return `${(bytes / 1024 / 1024).toFixed(1)}M`
139
+ }
140
+
141
+ function formatNetworkDuration(ms: number | null): string {
142
+ if (ms == null) return ' …'
143
+ if (ms < 1000) return `${ms}ms`.padStart(5)
144
+ return `${(ms / 1000).toFixed(2)}s`.padStart(5)
145
+ }
146
+
147
+ // when a tap-id / tap-text query misses, walk the live tree's testIDs and
148
+ // emit the canonical "recover a wedged sim" hint. shared by the genuine-dead
149
+ // paths in the failure handler.
150
+ function printRecoverHint(): void {
151
+ process.stderr.write(
152
+ ` the sim is not responding. recover it with:\n` +
153
+ ` rnxsim close --sim <id> # force-close the wedged sim\n` +
154
+ ` rnxsim list # confirm it's gone\n`,
155
+ )
156
+ }
157
+
158
+ // cheap liveness probe: a command timing out does NOT prove the sim is dead.
159
+ // `describe` on a big screen (rainbow's loaded home is hundreds of nodes)
160
+ // blows the 15s default while `get errors` answers instantly — same sim. send
161
+ // a trivial eval on a short budget; if it answers, the sim is alive and the
162
+ // previous command just needs a narrower scope or a bigger timeout.
163
+ // send a one-step `perform` batch and flatten the result back to the
164
+ // `{ ok, value, error? }` shape these subcommands have always printed. the
165
+ // engine executes the step in-page and emits the agent-cursor action itself.
166
+ async function performSingleStep(
167
+ bridge: WsBridge,
168
+ step: PerformStep,
169
+ ): Promise<{ ok: boolean; value?: unknown; error?: string }> {
170
+ const result: PerformResult = await bridge.send({ type: 'perform', steps: [step] })
171
+ const stepResult = result?.steps?.[0]
172
+ const error = stepResult?.error ?? result?.error
173
+ return {
174
+ ok: result?.ok === true,
175
+ value: stepResult?.value,
176
+ ...(error ? { error } : {}),
177
+ }
178
+ }
179
+
180
+ async function probeSimResponsive(bridge: WsBridge): Promise<boolean> {
181
+ try {
182
+ await bridge.send({ type: 'evaluate', code: '1' }, { timeoutMs: 3000 })
183
+ return true
184
+ } catch {
185
+ return false
186
+ }
187
+ }
188
+
189
+ // surface the 5 closest candidates so callers can fix the typo without
190
+ // dumping the full tree. uses a cheap prefix + Levenshtein score so a
191
+ // trailing digit or near-miss casing still bubbles to the top.
192
+ async function printSimilarTestIds(bridge: WsBridge, query: string): Promise<void> {
193
+ try {
194
+ const ids = (await bridge.send({
195
+ type: 'evaluate',
196
+ code: `(async () => {
197
+ const t = window.__sootsimTest
198
+ if (!t || typeof t.queryAll !== 'function') return []
199
+ try {
200
+ // queryAll is async in render-worker mode (the canonical mode now)
201
+ // because the call has to round-trip the worker — must await it.
202
+ // a sync result is still handled fine since await on a non-Promise
203
+ // is a no-op.
204
+ const nodes = await t.queryAll({})
205
+ if (!Array.isArray(nodes)) return []
206
+ const out = new Set()
207
+ for (const n of nodes) {
208
+ const id = n && (n.testID || n.id)
209
+ if (typeof id === 'string' && id) out.add(id)
210
+ }
211
+ return Array.from(out)
212
+ } catch {
213
+ return []
214
+ }
215
+ })()`,
216
+ })) as string[] | null
217
+ if (!Array.isArray(ids) || ids.length === 0) return
218
+ const q = query.toLowerCase()
219
+ const ranked = ids
220
+ .map((id) => ({
221
+ id,
222
+ score: scoreIdSimilarity(q, id.toLowerCase()),
223
+ }))
224
+ .filter((entry) => entry.score < q.length + 4) // drop obvious non-matches
225
+ .sort((a, b) => a.score - b.score)
226
+ .slice(0, 5)
227
+ if (ranked.length === 0) return
228
+ console.error(` similar testIDs:`)
229
+ for (const entry of ranked) {
230
+ console.error(` ${entry.id}`)
231
+ }
232
+ } catch {
233
+ // if the suggestion path fails the user still got the "not found" msg
234
+ }
235
+ }
236
+
237
+ function scoreIdSimilarity(needle: string, candidate: string): number {
238
+ if (candidate === needle) return 0
239
+ if (candidate.includes(needle)) return 1
240
+ if (needle.includes(candidate)) return 2
241
+ // tiny prefix/suffix bonus so `nav-back` ranks above unrelated long names
242
+ // when querying `back`.
243
+ let prefix = 0
244
+ while (
245
+ prefix < needle.length &&
246
+ prefix < candidate.length &&
247
+ needle[prefix] === candidate[prefix]
248
+ ) {
249
+ prefix += 1
250
+ }
251
+ return levenshtein(needle, candidate) - prefix
252
+ }
253
+
254
+ function levenshtein(a: string, b: string): number {
255
+ if (a === b) return 0
256
+ if (!a.length) return b.length
257
+ if (!b.length) return a.length
258
+ // standard two-row dp: prev[] is the previous row, cur[] is the row being
259
+ // computed; swap pointers per outer iteration. the earlier in-place rolling
260
+ // version overwrote prev[j-1] twice per cell and never set prev[0]=i, so
261
+ // distance(`kitten`,`sitting`) returned 8 instead of 3 and the fuzzy-suggest
262
+ // ranking was effectively random.
263
+ let prev = new Array<number>(b.length + 1)
264
+ let cur = new Array<number>(b.length + 1)
265
+ for (let j = 0; j <= b.length; j++) prev[j] = j
266
+ for (let i = 1; i <= a.length; i++) {
267
+ cur[0] = i
268
+ for (let j = 1; j <= b.length; j++) {
269
+ cur[j] = Math.min(
270
+ prev[j] + 1,
271
+ cur[j - 1] + 1,
272
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
273
+ )
274
+ }
275
+ const tmp = prev
276
+ prev = cur
277
+ cur = tmp
278
+ }
279
+ return prev[b.length]
280
+ }
281
+
282
+ function formatNetworkStatus(e: NetworkEntryPayload): string {
283
+ if (e.error) return 'err'
284
+ if (e.status == null) return ' … '
285
+ return String(e.status)
286
+ }
287
+
288
+ function describeReloadReadyBlocker(result: ReloadReadyResult): string {
289
+ if (result.externalError) return `guest app errored: ${result.externalError}`
290
+ if (result.loadingText) return `still showing "${result.loadingText}"`
291
+ if (result.externalReady === false) return 'guest app is still loading'
292
+ if (result.flag !== true) return 'guest app has not emitted sootsim:externalAppReady'
293
+ if (result.targets <= 0) {
294
+ return 'ready flag emitted but no visible app content is inspectable yet'
295
+ }
296
+ return 'node tree is still changing'
297
+ }
298
+
299
+ function printNetworkRow(e: NetworkEntryPayload) {
300
+ const time = formatLogTimestamp(e.startTs)
301
+ const status = formatNetworkStatus(e).padEnd(3)
302
+ const method = e.method.padEnd(5)
303
+ const size = formatNetworkSize(e.size).padStart(6)
304
+ const dur = formatNetworkDuration(e.durationMs)
305
+ console.log(` [${time}] ${status} ${method} ${size} ${dur} ${e.displayUrl}`)
306
+ if (e.error) console.log(` error: ${e.error}`)
307
+ }
308
+
309
+ function printNetworkDetail(e: NetworkEntryPayload) {
310
+ const rows: [string, string][] = [
311
+ ['id', e.id],
312
+ ['source', e.source],
313
+ ['kind', e.kind],
314
+ ['method', e.method],
315
+ [
316
+ 'status',
317
+ e.error ? `error: ${e.error}` : `${e.status ?? '—'} ${e.statusText ?? ''}`.trim(),
318
+ ],
319
+ ['url', e.url],
320
+ ['started', formatLogTimestamp(e.startTs)],
321
+ ['duration', formatNetworkDuration(e.durationMs).trim()],
322
+ ['size', formatNetworkSize(e.size)],
323
+ ['content-type', e.type ?? '—'],
324
+ ]
325
+ for (const [k, v] of rows) console.log(` ${k.padEnd(13)} ${v}`)
326
+ }
327
+
328
+ // ANSI colors for the `logs` verb. the LogEntry / LogLevel types + filtering
329
+ // live in inspect/core.ts so the CLI and the agent share one definition.
330
+ const LOG_LEVEL_COLOR: Record<LogLevel, string> = {
331
+ error: '\x1b[31m', // red
332
+ warn: '\x1b[33m', // yellow
333
+ info: '\x1b[36m', // cyan
334
+ debug: '\x1b[35m', // magenta
335
+ log: '\x1b[37m', // white
336
+ }
337
+ const ANSI_RESET = '\x1b[0m'
338
+ const ANSI_DIM = '\x1b[2m'
339
+
340
+ function printLogRow(e: LogEntry, useColor: boolean) {
341
+ const time = formatLogTimestamp(e.ts)
342
+ const levelTag = e.level.toUpperCase().padEnd(5)
343
+ const msg = e.args.join(' ')
344
+ if (useColor) {
345
+ const col = LOG_LEVEL_COLOR[e.level]
346
+ console.log(
347
+ ` ${ANSI_DIM}[${time}]${ANSI_RESET} ${col}${levelTag}${ANSI_RESET} ${msg}`,
348
+ )
349
+ } else {
350
+ console.log(` [${time}] ${levelTag} ${msg}`)
351
+ }
352
+ if (e.stack && e.level === 'error') {
353
+ const lines = e.stack.split('\n').slice(0, 5)
354
+ for (const line of lines) console.log(` ${line.trim()}`)
355
+ }
356
+ }
357
+
358
+ const SWITCHER_SETTLE_GRACE_MS = 120
359
+
360
+ // parse sample-color / screenshot rect args. supports several shapes so the
361
+ // command is friendly for both humans and scripted agents:
362
+ // <x> <y> single pixel (w=h=1)
363
+ // <x> <y> <w> <h> positional area
364
+ // --area x,y,w,h comma-separated area
365
+ // --x n --y n --w n --h n
366
+ // --id <testID> snap to node's bounding box
367
+ // --text <text> snap to node matching text
368
+ // returns null when no rect flags are present — callers then either fall
369
+ // back to a full-canvas capture (screenshot) or report an argument error
370
+ // (sample-color).
371
+ async function resolveSampleRect(
372
+ args: string[],
373
+ bridge: WsBridge,
374
+ ): Promise<SampleRect | null> {
375
+ const idArg = args.find((_, i) => args[i - 1] === '--id')
376
+ const textArg = args.find((_, i) => args[i - 1] === '--text')
377
+ if (idArg || textArg) {
378
+ const node = await bridge.send({
379
+ type: 'evaluate',
380
+ code: buildResolveRectEval({ id: idArg, text: textArg }),
381
+ })
382
+ if (!node) {
383
+ throw new Error(
384
+ idArg ? `no node with id "${idArg}"` : `no node matching text "${textArg}"`,
385
+ )
386
+ }
387
+ const { x, y, w, h } = node as SampleRect
388
+ return { x, y, w, h }
389
+ }
390
+
391
+ const areaArg = args.find((_, i) => args[i - 1] === '--area')
392
+ if (areaArg) {
393
+ const parts = areaArg.split(',').map((p) => Number(p.trim()))
394
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
395
+ throw new Error(`--area expects x,y,w,h (got "${areaArg}")`)
396
+ }
397
+ const [x, y, w, h] = parts
398
+ return { x, y, w, h }
399
+ }
400
+
401
+ const flagged = (name: string): number | null => {
402
+ const value = args.find((_, i) => args[i - 1] === name)
403
+ if (value == null) return null
404
+ const n = Number(value)
405
+ return Number.isFinite(n) ? n : null
406
+ }
407
+ const fx = flagged('--x')
408
+ const fy = flagged('--y')
409
+ const fw = flagged('--w')
410
+ const fh = flagged('--h')
411
+ if (fx != null || fy != null || fw != null || fh != null) {
412
+ return { x: fx ?? 0, y: fy ?? 0, w: fw ?? 1, h: fh ?? 1 }
413
+ }
414
+
415
+ // positional: "<verb> <x> <y> [w] [h]"
416
+ const positional = args.filter(
417
+ (a, i) =>
418
+ i > 0 &&
419
+ !a.startsWith('-') &&
420
+ args[i - 1] !== '--output' &&
421
+ args[i - 1] !== '--area' &&
422
+ args[i - 1] !== '--id' &&
423
+ args[i - 1] !== '--text' &&
424
+ args[i - 1] !== '--x' &&
425
+ args[i - 1] !== '--y' &&
426
+ args[i - 1] !== '--w' &&
427
+ args[i - 1] !== '--h',
428
+ )
429
+ const nums = positional.map(Number).filter((n) => Number.isFinite(n))
430
+ if (nums.length >= 2) {
431
+ const [x, y, w = 1, h = 1] = nums
432
+ return { x, y, w, h }
433
+ }
434
+
435
+ return null
436
+ }
437
+
438
+ function printRenderProfile(worker: string, rp: Record<string, any> | undefined) {
439
+ if (!rp || typeof rp !== 'object') return
440
+ console.log(``)
441
+ console.log(` render profile — ${worker} (per painted frame):`)
442
+ console.log(` node visits: ${rp.nodeVisitsPerFrame}`)
443
+ console.log(
444
+ ` boundaries: ${rp.recordsPerFrame} records (${rp.avgBoundaryRecordMs}ms) / ${rp.replaysPerFrame} replays`,
445
+ )
446
+ const recordCauses = [
447
+ rp.boundaryRecordsInvalidated ? `invalidated ${rp.boundaryRecordsInvalidated}` : '',
448
+ rp.boundaryRecordsOrigin ? `moved ${rp.boundaryRecordsOrigin}` : '',
449
+ rp.boundaryRecordsScheme ? `first-record ${rp.boundaryRecordsScheme}` : '',
450
+ ]
451
+ .filter(Boolean)
452
+ .join(' · ')
453
+ if (recordCauses) console.log(` why recorded: ${recordCauses}`)
454
+ // idle work, so it is a total for the capture, not a per-frame average.
455
+ if (rp.prewarmRecords) {
456
+ console.log(
457
+ ` pre-recorded: ${rp.prewarmRecords} boundaries at idle (${Number(rp.prewarmRecordMs).toFixed(1)}ms total)`,
458
+ )
459
+ }
460
+ console.log(
461
+ ` raster tier: ${rp.rasterPromotionsPerFrame} promotions / ${rp.rasterBlitsPerFrame} blits`,
462
+ )
463
+ const rejects = [
464
+ rp.rasterRejectRebuild ? `rebuild ${rp.rasterRejectRebuild}` : '',
465
+ rp.rasterRejectLinear ? `linear ${rp.rasterRejectLinear}` : '',
466
+ rp.rasterRejectAnimated ? `animated ${rp.rasterRejectAnimated}` : '',
467
+ rp.rasterRejectTransform ? `transform ${rp.rasterRejectTransform}` : '',
468
+ rp.rasterRejectOpacity ? `opacity ${rp.rasterRejectOpacity}` : '',
469
+ rp.rasterRejectCacheable ? `cacheable ${rp.rasterRejectCacheable}` : '',
470
+ rp.rasterRejectBounds ? `bounds ${rp.rasterRejectBounds}` : '',
471
+ rp.rasterRejectPixels ? `pixels ${rp.rasterRejectPixels}` : '',
472
+ rp.rasterRejectBudget ? `budget ${rp.rasterRejectBudget}` : '',
473
+ rp.rasterWarming ? `warming ${rp.rasterWarming}` : '',
474
+ ]
475
+ .filter(Boolean)
476
+ .join(' · ')
477
+ if (rejects) {
478
+ console.log(` raster skips: ${rejects} (total across run)`)
479
+ if (rp.rasterRejectAlsoBlocked) {
480
+ console.log(
481
+ ` ${''.padEnd(12)} ${rp.rasterRejectAlsoBlocked} of those also fail a later gate — widening the named gate cannot promote them`,
482
+ )
483
+ }
484
+ }
485
+ console.log(` blur: ${rp.avgBlurMs}ms`)
486
+ if (rp.glassDownsampleActive || rp.glassBackdropCacheMisses) {
487
+ console.log(
488
+ ` glass: ${rp.glassDownsampleFactor}x downsample (${rp.glassDownsampledDraws} draws) · cache ${rp.glassBackdropCacheHits} hits / ${rp.glassBackdropCacheMisses} misses / ${rp.glassBackdropCacheInvalidations} invalidations`,
489
+ )
490
+ }
491
+ console.log(
492
+ ` draw calls: text ${rp.textDrawsPerFrame} · image ${rp.imageDrawsPerFrame} · path ${rp.pathDrawsPerFrame} · saveLayer ${rp.saveLayersPerFrame}`,
493
+ )
494
+ }
495
+
496
+ // pretty-print the shell frame profile payload (__sootsimShellPerf.stop()):
497
+ // per-painted-frame timing from the shell worker merged with the render
498
+ // profile counters. numbers arrive pre-rounded from the worker. also used by
499
+ // `sootsim maestro test --profile`, which records the same payload.
500
+ export function printShellPerfReport(result: Record<string, any>) {
501
+ const fps = result.avgMs > 0 ? (1000 / result.avgMs).toFixed(1) : '?'
502
+ const jank = result.jank && typeof result.jank === 'object' ? result.jank : null
503
+ console.log(` shell frame profile:\n`)
504
+ if (jank) {
505
+ const compositor = Array.isArray(jank.compositor) ? jank.compositor : []
506
+ const compositorJank = compositor.reduce(
507
+ (total: number, surface: Record<string, any>) => total + (surface.jankPaints ?? 0),
508
+ 0,
509
+ )
510
+ console.log(
511
+ ` health: ${jank.detected ? 'jank or cadence gaps observed' : 'no jank or cadence gaps observed'} · shell ${jank.shell?.jankFrames ?? 0} · compositor ${compositorJank} · host clock gaps ${jank.hostGaps ?? 0}`,
512
+ )
513
+ console.log(` series: independent worker clocks, no per-frame pairing`)
514
+ }
515
+ console.log(
516
+ ` painted frames: ${result.frames}${result.skippedFrames ? ` (+${result.skippedFrames} skipped idle ticks)` : ''}`,
517
+ )
518
+ console.log(
519
+ ` shell work avg: ${result.avgMs}ms (${fps} fps) · worst observed ${result.maxMs}ms`,
520
+ )
521
+ console.log(` shell p50/95/99:${result.p50} / ${result.p95} / ${result.p99} ms`)
522
+ if (jank) {
523
+ console.log(
524
+ ` shell jank: ${jank.shell?.jankFrames ?? 0}/${jank.shell?.sampledFrames ?? 0} (${jank.shell?.jankPct ?? 0}%) >16.67ms · max ${jank.shell?.maxMs ?? 0}ms`,
525
+ )
526
+ for (const surface of Array.isArray(jank.compositor) ? jank.compositor : []) {
527
+ console.log(
528
+ ` ${String(surface.surfaceId).padEnd(12)} ${surface.jankPaints}/${surface.sampledPaints} compositor paints (${surface.jankPct}%) >16.67ms · max ${surface.maxMs}ms`,
529
+ )
530
+ }
531
+ } else {
532
+ console.log(
533
+ ` jank: ${result.jankFrames} frames (${result.jankPct}%) >16.67ms`,
534
+ )
535
+ }
536
+ console.log(
537
+ ` avg per frame: overlay ${result.avgOverlayMs}ms · aux ${result.avgAuxMs}ms · layout ${result.avgLayoutMs}ms`,
538
+ )
539
+ const cadence = result.cadence
540
+ if (cadence && typeof cadence === 'object') {
541
+ console.log(``)
542
+ console.log(` cadence (frame delivery, ${cadence.vsyncTicks} vsync ticks):`)
543
+ console.log(
544
+ ` display clock: ${cadence.displayHz}hz · host rAF interval p50 ${cadence.hostIntervalP50} / p95 ${cadence.hostIntervalP95} / max ${cadence.hostIntervalMax} ms${cadence.hostGaps ? ` · ${cadence.hostGaps} gaps >1.5x (host rAF starved)` : ''}`,
545
+ )
546
+ console.log(
547
+ ` delivery lag: p50 ${cadence.deliveryLagP50} / p95 ${cadence.deliveryLagP95} / max ${cadence.deliveryLagMax} ms (vsync postMessage → shell receipt)`,
548
+ )
549
+ console.log(
550
+ ` paint interval: p50 ${cadence.paintIntervalP50} / p95 ${cadence.paintIntervalP95} / max ${cadence.paintIntervalMax} ms${cadence.idleBreaks ? ` · ${cadence.idleBreaks} idle breaks excluded` : ''}`,
551
+ )
552
+ }
553
+ const compositorCadence = result.compositorCadence
554
+ if (compositorCadence && typeof compositorCadence === 'object') {
555
+ console.log(
556
+ ` compositor rAF: p50 ${compositorCadence.p50} / p95 ${compositorCadence.p95} / max ${compositorCadence.max} ms · ${compositorCadence.gaps} gaps >1.5x`,
557
+ )
558
+ const printPhase = (label: string, summary: Record<string, any> | undefined) => {
559
+ if (!summary?.count) return
560
+ console.log(
561
+ ` ${label}: ${summary.count} samples · p50 ${summary.p50.toFixed(2)} / p95 ${summary.p95.toFixed(2)} / max ${summary.max.toFixed(2)} ms`,
562
+ )
563
+ }
564
+ printPhase('engine-empty rAF', compositorCadence.phases?.uninterruptedEngineEmpty)
565
+ printPhase('rAF with CanvasKit flush', compositorCadence.phases?.withCanvaskitFlush)
566
+ printPhase('rAF after CanvasKit flush', compositorCadence.phases?.afterCanvaskitFlush)
567
+ printPhase('CanvasKit submission', compositorCadence.canvaskitSubmission)
568
+ }
569
+ const surfaces = Array.isArray(result.auxSurfaces) ? result.auxSurfaces : []
570
+ if (surfaces.length > 0) {
571
+ console.log(``)
572
+ console.log(` surfaces (last 100 frames):`)
573
+ for (const s of surfaces) {
574
+ console.log(
575
+ ` ${String(s.surfaceId).padEnd(10)} ${String(s.frames).padStart(4)} paints avg ${s.avgMs}ms (layout ${s.avgLayoutMs}, render ${s.avgRenderMs}, flush ${s.avgFlushMs}) max ${s.maxMs}ms`,
576
+ )
577
+ if (
578
+ typeof s.gpuCacheBytes === 'number' &&
579
+ typeof s.gpuCacheLimitBytes === 'number'
580
+ ) {
581
+ console.log(
582
+ ` ${''.padEnd(10)} gpu cache ${(s.gpuCacheBytes / 1024 / 1024).toFixed(1)}MB/${(s.gpuCacheLimitBytes / 1024 / 1024).toFixed(1)}MB`,
583
+ )
584
+ }
585
+ }
586
+ }
587
+ // two profiles, two workers: the shell paints chrome/overlays, the
588
+ // compositor paints home + app:one + app:two. guest-app scroll work is all
589
+ // in the compositor one — reading the shell's raster counters for an app
590
+ // workload is how a live raster tier reads as dead.
591
+ printRenderProfile('shell worker', result.renderProfile)
592
+ printRenderProfile('compositor worker', result.compositorRenderProfile)
593
+ const worst = Array.isArray(result.worstFrames) ? result.worstFrames : []
594
+ if (worst.length > 0) {
595
+ console.log(``)
596
+ console.log(` worst observations (shell and compositor series are unpaired):`)
597
+ for (const frame of worst) {
598
+ if (frame.source === 'compositor') {
599
+ console.log(
600
+ ` ${String(frame.totalMs).padStart(7)}ms compositor ${frame.surfaceId} paint ${frame.paint} · layout ${frame.layoutMs} · render ${frame.renderMs} · flush ${frame.flushMs}`,
601
+ )
602
+ continue
603
+ }
604
+ const surfaceBits = (frame.auxSurfaces ?? [])
605
+ .map(
606
+ (s: Record<string, any>) =>
607
+ `${s.surfaceId} ${s.totalMs}ms (layout ${s.layoutMs}, render ${s.renderMs})`,
608
+ )
609
+ .join(' · ')
610
+ console.log(
611
+ ` ${String(frame.totalMs).padStart(7)}ms overlay ${frame.overlayMs} · aux ${frame.auxMs} · layout ${frame.layoutMs}${surfaceBits ? ` [${surfaceBits}]` : ''}`,
612
+ )
613
+ }
614
+ }
615
+ }
616
+
617
+ // core bridge-facing helpers moved to ./inspect/shared — this file now just
618
+ // imports them (see top-of-file imports).
619
+
620
+ async function waitForShellState(
621
+ bridge: WsBridge,
622
+ timeoutMs: number,
623
+ predicate: (state: Record<string, any> | null) => boolean,
624
+ ) {
625
+ const deadline = Date.now() + timeoutMs
626
+ let state = await getShellState(bridge, timeoutMs)
627
+ while (true) {
628
+ if (predicate(state)) return { settled: true, state }
629
+ if (Date.now() >= deadline) return { settled: false, state }
630
+ await sleep(16)
631
+ state = await getShellState(bridge)
632
+ }
633
+ }
634
+
635
+ async function getKeyboardState(bridge: WsBridge) {
636
+ // focusedInput is sourced from __sootsimTest.getFocusedNode(), which reads
637
+ // through the focus-keyboard-runtime (P2). previously this scanned
638
+ // test.queryAll({}) for `n.style._focused` — a flag nothing in the engine
639
+ // actually sets — so the cli always reported focusedInput: null, even
640
+ // when an input was focused. that silently misled the 2026-04-17 bluesky
641
+ // tap-routing investigation by ~30 minutes.
642
+ //
643
+ // phase + frame now also come from the runtime snapshot (same single
644
+ // source) so cli consumers see the same state as KeyboardAvoidingView
645
+ // and the keyboard-controller compat stub.
646
+ return bridge
647
+ .send({
648
+ type: 'evaluate',
649
+ code: `(async () => {
650
+ const kb = window.__sootsimKeyboard
651
+ const test = window.__sootsimTest
652
+ if (!kb) return { error: 'keyboard bridge not available' }
653
+ const layout = typeof kb.getLayout === 'function' ? kb.getLayout() : null
654
+ const secureTextEntry =
655
+ !!layout?.spec?.secureTextEntry && layout?.spec?.keyboardType !== 'visible-password'
656
+ const visible = kb.isVisible()
657
+ const mode = kb.getMode()
658
+ let focused = null
659
+ if (test && typeof test.getFocusedNode === 'function') {
660
+ try {
661
+ focused = await test.getFocusedNode()
662
+ } catch {}
663
+ }
664
+ let runtimeSnapshot = null
665
+ if (test && typeof test.getFocusKeyboardSnapshot === 'function') {
666
+ try {
667
+ runtimeSnapshot = await test.getFocusKeyboardSnapshot()
668
+ } catch {}
669
+ }
670
+ return {
671
+ visible,
672
+ mode,
673
+ layout,
674
+ hostedEditorFocused:
675
+ typeof kb.hasHostedEditorFocus === 'function' && kb.hasHostedEditorFocus(),
676
+ focusedInput: focused ? {
677
+ nodeId: focused.nodeId ?? null,
678
+ testID: focused.testID || null,
679
+ id: focused.id || null,
680
+ placeholder: focused.placeholder || null,
681
+ secureTextEntry,
682
+ text: secureTextEntry ? ${JSON.stringify(SECURE_TEXT_REDACTION)} : (focused.text || null),
683
+ } : null,
684
+ phase: runtimeSnapshot?.keyboard?.phase ?? null,
685
+ frame: runtimeSnapshot?.keyboard?.frame ?? null,
686
+ focusedRect: runtimeSnapshot?.focused?.rect ?? null,
687
+ }
688
+ })()`,
689
+ })
690
+ .then((state) => redactKeyboardStateForOutput(state as any)) as Promise<{
691
+ visible?: boolean
692
+ mode?: string
693
+ layout?: Record<string, unknown> | null
694
+ hostedEditorFocused?: boolean
695
+ focusedInput?: Record<string, unknown> | null
696
+ phase?: string | null
697
+ frame?: Record<string, number> | null
698
+ focusedRect?: Record<string, number> | null
699
+ error?: string
700
+ }>
701
+ }
702
+
703
+ function keyboardStateMatchesTarget(
704
+ state: Awaited<ReturnType<typeof getKeyboardState>>,
705
+ targetId: string | null,
706
+ secureTextEntry: boolean | null,
707
+ ) {
708
+ if (!state.visible) return false
709
+ const focused = state.focusedInput as
710
+ | { testID?: string | null; id?: string | null }
711
+ | null
712
+ | undefined
713
+ if (targetId && focused) {
714
+ const matched = focused.testID === targetId || focused.id === targetId
715
+ if (!matched) return false
716
+ }
717
+ if (
718
+ secureTextEntry !== null &&
719
+ isSecureKeyboardState(state as any) !== secureTextEntry
720
+ ) {
721
+ return false
722
+ }
723
+ return true
724
+ }
725
+
726
+ async function waitForKeyboardVisible(
727
+ bridge: WsBridge,
728
+ timeoutMs = 600,
729
+ opts: { targetId?: string | null; secureTextEntry?: boolean | null } = {},
730
+ ) {
731
+ const deadline = Date.now() + timeoutMs
732
+ while (Date.now() <= deadline) {
733
+ const state = await getKeyboardState(bridge)
734
+ if (
735
+ keyboardStateMatchesTarget(
736
+ state,
737
+ opts.targetId ?? null,
738
+ opts.secureTextEntry ?? null,
739
+ )
740
+ ) {
741
+ return state
742
+ }
743
+ await sleep(30)
744
+ }
745
+ return getKeyboardState(bridge)
746
+ }
747
+
748
+ async function requireVisualKeyboard(bridge: WsBridge, actionLabel: string) {
749
+ const state = await getKeyboardState(bridge)
750
+ if (state.visible && (state.focusedInput || state.hostedEditorFocused)) return state
751
+ if (state.visible) {
752
+ console.error(
753
+ ` ${actionLabel} requires a focused editable control. the iOS keyboard is visible, but no input owns focus.`,
754
+ )
755
+ process.exit(1)
756
+ }
757
+ console.error(
758
+ ` ${actionLabel} requires the iOS keyboard to be visible. focus an input first with rnxsim do tap-id/tap-text or rnxsim do type-into.`,
759
+ )
760
+ process.exit(1)
761
+ }
762
+
763
+ async function runShellVisualCommand(
764
+ bridge: WsBridge,
765
+ action: 'appearance' | 'lock' | 'shake',
766
+ value?: string,
767
+ ) {
768
+ if (action === 'appearance') {
769
+ return bridge.send({
770
+ type: 'evaluate',
771
+ code: `(async () => {
772
+ const requested = ${JSON.stringify(value ?? 'toggle')}
773
+ // the engine owns toggle + auto resolution (settingsStore is the single
774
+ // source of truth). never infer the current scheme client-side — the old
775
+ // documentElement '#333333' sniff is never set in embedded / shell-chrome
776
+ // contexts, so 'toggle' read 'light' forever and stuck on the boot scheme.
777
+ window.postMessage({ type: 'contrast-action', action: 'set-appearance', value: requested }, '*')
778
+ // let the engine's message handler apply + publish window.__sootsimColorScheme
779
+ await new Promise((r) => setTimeout(r, 60))
780
+ const applied = window.__sootsimColorScheme?.resolved
781
+ ?? (window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light')
782
+ const setting = window.__sootsimColorScheme?.setting ?? requested
783
+ return { ok: true, requested, setting, applied }
784
+ })()`,
785
+ })
786
+ }
787
+
788
+ if (action === 'lock') {
789
+ return bridge.send({
790
+ type: 'evaluate',
791
+ code: `(async () => {
792
+ const toggleLock = window.SootSim?.bridges?.mainShell?.toggleLock
793
+ if (typeof toggleLock !== 'function') {
794
+ throw new Error('rnx mainShell.toggleLock bridge unavailable')
795
+ }
796
+ await toggleLock()
797
+ return { ok: true, action: 'lock' }
798
+ })()`,
799
+ })
800
+ }
801
+
802
+ return bridge.send({
803
+ type: 'evaluate',
804
+ code: `(async () => {
805
+ window.dispatchEvent(new CustomEvent('sootsim:shake'))
806
+ return { ok: true, action: 'shake' }
807
+ })()`,
808
+ })
809
+ }
810
+
811
+ function keyCodeToVisualKey(code: string) {
812
+ const direct: Record<string, string> = {
813
+ Enter: 'return',
814
+ NumpadEnter: 'return',
815
+ Backspace: 'delete',
816
+ Delete: 'delete',
817
+ Space: 'space',
818
+ ShiftLeft: 'shift',
819
+ ShiftRight: 'shift',
820
+ }
821
+ if (direct[code]) return direct[code]
822
+ const digit = code.match(/^Digit([0-9])$/)
823
+ if (digit) return digit[1]
824
+ const key = code.match(/^Key([A-Z])$/)
825
+ if (key) return key[1].toLowerCase()
826
+ return null
827
+ }
828
+
829
+ function getInspectCommandName(subcommand: string, directSubcommand: string | null) {
830
+ return directSubcommand === subcommand
831
+ ? `rnxsim ${subcommand}`
832
+ : `rnxsim inspect ${subcommand}`
833
+ }
834
+
835
+ function getInspectUsage(
836
+ subcommand: string,
837
+ tail: string,
838
+ directSubcommand: string | null,
839
+ ) {
840
+ return ` usage: ${getInspectCommandName(subcommand, directSubcommand)}${tail ? ` ${tail}` : ''}`
841
+ }
842
+
843
+ function normalizeRecordedText(value: unknown) {
844
+ if (typeof value !== 'string') return null
845
+ const text = value.replace(/\s+/g, ' ').trim()
846
+ return text ? text.slice(0, 80) : null
847
+ }
848
+
849
+ function normalizeRecordedId(...values: unknown[]) {
850
+ for (const value of values) {
851
+ if (typeof value !== 'string') continue
852
+ const id = value.trim()
853
+ if (id) return id
854
+ }
855
+ return null
856
+ }
857
+
858
+ function readNodeIdFlag(args: string[]): number | null {
859
+ const idx = args.indexOf('--node-id')
860
+ if (idx < 0) return null
861
+ const raw = args[idx + 1]
862
+ if (!raw) return null
863
+ const nodeId = Number(raw)
864
+ return Number.isInteger(nodeId) && nodeId > 0 ? nodeId : null
865
+ }
866
+
867
+ async function recordInspectAction(
868
+ source: string,
869
+ step: Record<string, unknown>,
870
+ summary: string,
871
+ ) {
872
+ const result = rememberFlowCandidate({ source, step, summary })
873
+ // when a flow draft is active, make the pending-candidate model visible —
874
+ // otherwise a sequence of `do` actions silently overwrites each other and
875
+ // `maestro end` exports far fewer steps than the user expects (F13-4).
876
+ if (!result.active) return
877
+ if (result.replaced) {
878
+ console.error(
879
+ ` draft: replaced unkept action "${result.replaced.summary}" — ` +
880
+ '`maestro keep` commits one action at a time',
881
+ )
882
+ } else {
883
+ console.error(' draft: action pending — `rnxsim maestro keep` to commit')
884
+ }
885
+ }
886
+
887
+ function buildTapCandidateFromResult(
888
+ x: number,
889
+ y: number,
890
+ result: any,
891
+ ): { step: Record<string, unknown>; summary: string } | null {
892
+ if (!result || result.hit === false) return null
893
+
894
+ const responderId = normalizeRecordedId(result.responderTestID, result.testID)
895
+ if (responderId) {
896
+ return {
897
+ step: { tapOn: { id: responderId } },
898
+ summary: `tap #${responderId}`,
899
+ }
900
+ }
901
+
902
+ const text = normalizeRecordedText(result.text)
903
+ if (text) {
904
+ return {
905
+ step: { tapOn: text },
906
+ summary: `tap "${text}"`,
907
+ }
908
+ }
909
+
910
+ return {
911
+ step: { tapAtCoords: { x, y } },
912
+ summary: `tap @${Math.round(x)},${Math.round(y)}`,
913
+ }
914
+ }
915
+
916
+ function buildTapCandidateFromNode(
917
+ query: string,
918
+ node: any,
919
+ mode: 'text' | 'id',
920
+ ): { step: Record<string, unknown>; summary: string } {
921
+ const stableId = normalizeRecordedId(node?.testID, node?.id)
922
+ if (stableId) {
923
+ return {
924
+ step: { tapOn: { id: stableId } },
925
+ summary: `tap #${stableId}`,
926
+ }
927
+ }
928
+
929
+ if (mode === 'id') {
930
+ return {
931
+ step: { tapOn: { id: query } },
932
+ summary: `tap #${query}`,
933
+ }
934
+ }
935
+
936
+ return {
937
+ step: { tapOn: query },
938
+ summary: `tap "${query}"`,
939
+ }
940
+ }
941
+
942
+ export async function runInspect(args: string[], opts: InspectOptions) {
943
+ // strip a leading grouping verb — `get`, `do`, and (when forwarded from
944
+ // runDebug) `debug`. these are organizational prefixes and shouldn't leak
945
+ // into subcommand dispatch. e.g.:
946
+ // sootsim get tree → effectiveArgs = ['tree']
947
+ // sootsim do tap 100 200 → effectiveArgs = ['tap', '100', '200']
948
+ // sootsim debug state shell → effectiveArgs = ['state', 'shell']
949
+ const verbPrefix =
950
+ args[0] === 'get' || args[0] === 'do' || args[0] === 'debug' || args[0] === 'wait'
951
+ ? args[0]
952
+ : null
953
+ const effectiveArgs = verbPrefix ? args.slice(1) : args
954
+
955
+ const parsed = parseBridgeCliArgs(effectiveArgs, {
956
+ port: opts.port,
957
+ commandTimeoutMs: opts.timeoutMs,
958
+ stripBooleanFlags: [
959
+ '--verbose',
960
+ '-v',
961
+ '--help',
962
+ '-h',
963
+ '--clear-state',
964
+ '--json',
965
+ '--all',
966
+ '--watch',
967
+ '-w',
968
+ '--strict',
969
+ '--no-wait',
970
+ // `find --verbose` / `find --dump` emits full node JSON per result.
971
+ '--dump',
972
+ // `network` command flags — boolean toggles
973
+ '--failed',
974
+ '--slow',
975
+ '--tail',
976
+ '-f',
977
+ // `find --interactive-targets` (alias `find --actions`) — ranked tappable list
978
+ '--interactive-targets',
979
+ '--actions',
980
+ // `logs` command: include engine-internal [sootsim] debug messages
981
+ '--internal',
982
+ // `describe` boolean flags — without these they leak into `positional`
983
+ // and get mistaken for a text filter (`describe --compact` then matches
984
+ // nothing). value-taking describe flags live in stripValueFlags below.
985
+ '--compact',
986
+ '--no-xy',
987
+ '--no-clipped',
988
+ '--include-occluded',
989
+ ],
990
+ stripValueFlags: [
991
+ '--output',
992
+ '--nth',
993
+ '--index',
994
+ '--testid',
995
+ '--test-id',
996
+ '--text',
997
+ '--node-id',
998
+ '--max-ms',
999
+ // `network` / `logs` command flags that take a value
1000
+ '--filter',
1001
+ '--limit',
1002
+ '--level',
1003
+ // `network --slow` slow-threshold (ms). default 1000.
1004
+ '--threshold',
1005
+ // `wait event` filters
1006
+ '--equals',
1007
+ '--since',
1008
+ // `describe` value flags — narrowing selectors
1009
+ '--testid-like',
1010
+ '--only',
1011
+ '--subtree',
1012
+ ],
1013
+ })
1014
+ const positional = parsed.positional
1015
+ const subcommand = positional[0]
1016
+ // used by inspectCommand() below to format usage/error strings with the
1017
+ // same prefix the user actually typed. when `inspect` is the entrypoint
1018
+ // (no prefix), we prefix with `inspect` so the instructions are valid.
1019
+ const invocationPrefix: 'get' | 'do' | 'debug' | 'wait' | 'inspect' =
1020
+ verbPrefix === 'get' ||
1021
+ verbPrefix === 'do' ||
1022
+ verbPrefix === 'debug' ||
1023
+ verbPrefix === 'wait'
1024
+ ? verbPrefix
1025
+ : 'inspect'
1026
+ const isTopLevel =
1027
+ typeof effectiveArgs[0] === 'string' &&
1028
+ TOP_LEVEL_RUNTIME_COMMANDS.has(effectiveArgs[0])
1029
+ const topLevelCommand = isTopLevel ? effectiveArgs[0] : null
1030
+ const inspectCommand = (name: string) =>
1031
+ opts.internalPerfCommand && name === 'perf'
1032
+ ? `rnxsim perf ${opts.internalPerfCommand}`
1033
+ : isTopLevel && name === effectiveArgs[0]
1034
+ ? `rnxsim ${name}`
1035
+ : `rnxsim ${invocationPrefix} ${name}`
1036
+ const inspectUsage = (name: string, tail: string) =>
1037
+ ` usage: ${inspectCommand(name)}${tail ? ` ${tail}` : ''}`
1038
+
1039
+ if (!subcommand || args.includes('--help') || args.includes('-h')) {
1040
+ // all help output comes from the registry in packages/sootsim-skills
1041
+ // so `sootsim do --help`, `sootsim get --help`, the website docs, and
1042
+ // the Contrast skill markdown stay in lock-step. a drift check in
1043
+ // test/sootsimCliRegistry.test.ts fails CI if a verb lands without a
1044
+ // matching registry entry.
1045
+ const docContext = {
1046
+ bridgePort: DEFAULT_SOOTSIM_BRIDGE_PORT,
1047
+ defaultShellUrl: DEFAULT_SOOTSIM_SHELL_URL,
1048
+ }
1049
+
1050
+ // grouping-verb entry — clusters by subgroup (targeting, text input,
1051
+ // gestures, lifecycle, ...) so related verbs read as a visual family.
1052
+ if (
1053
+ invocationPrefix === 'do' ||
1054
+ invocationPrefix === 'get' ||
1055
+ invocationPrefix === 'debug' ||
1056
+ invocationPrefix === 'wait'
1057
+ ) {
1058
+ const groupHelp = renderCliGroupHelp(invocationPrefix, docContext)
1059
+ if (groupHelp) {
1060
+ console.log(`${groupHelp}\n`)
1061
+ process.exit(0)
1062
+ }
1063
+ }
1064
+
1065
+ if (topLevelCommand === 'shell') {
1066
+ const shellHelp = renderCliCommandHelp('shell', docContext)
1067
+ if (shellHelp) {
1068
+ console.log(`${shellHelp}\n`)
1069
+ process.exit(0)
1070
+ }
1071
+ }
1072
+
1073
+ // catch-all `sootsim inspect --help` — the inspect command meta +
1074
+ // each group's listing stitched together.
1075
+ const inspectMeta = renderCliCommandHelp('inspect', docContext)
1076
+ const groupSections = ['do', 'get', 'debug', 'wait']
1077
+ .map((g) => renderCliGroupHelp(g, docContext))
1078
+ .filter((s): s is string => s != null)
1079
+ .join('\n\n')
1080
+ console.log(`${inspectMeta ?? ''}\n\n${groupSections}\n`)
1081
+ process.exit(0)
1082
+ }
1083
+
1084
+ const wsPort = parsed.wsPort
1085
+ const simId = parsed.simId
1086
+ const simIdSource = parsed.simIdSource
1087
+ const commandTimeoutMs = parsed.commandTimeoutMs
1088
+
1089
+ if (invocationPrefix === 'get' && subcommand === 'diagnosis') {
1090
+ const diagnosisIndex = effectiveArgs.indexOf('diagnosis')
1091
+ const diagnosisArgs =
1092
+ diagnosisIndex >= 0
1093
+ ? [
1094
+ ...effectiveArgs.slice(0, diagnosisIndex),
1095
+ ...effectiveArgs.slice(diagnosisIndex + 1),
1096
+ ]
1097
+ : effectiveArgs
1098
+ const { runDiagnose } = await import('./diagnose')
1099
+ const code = await runDiagnose(['recent', ...diagnosisArgs], opts)
1100
+ process.exitCode = code
1101
+ return
1102
+ }
1103
+
1104
+ if (invocationPrefix === 'do' && subcommand === 'scan') {
1105
+ const { runCamera } = await import('./camera')
1106
+ // runCamera returns a code instead of exiting, so `do scan` has to carry
1107
+ // it out. dropping it made a failed scan exit 0.
1108
+ process.exitCode = await runCamera(effectiveArgs, { port: opts.port })
1109
+ return
1110
+ }
1111
+
1112
+ // `sootsim list --drivers` — pure local registry view, no bridge needed.
1113
+ // short-circuit here so users can discover drivers even when no sootsim
1114
+ // session is running.
1115
+ if (
1116
+ subcommand === 'list' &&
1117
+ effectiveArgs.some((a) => a === '--drivers' || a === '-D')
1118
+ ) {
1119
+ const { buildDriverListRows } = await import('../drivers')
1120
+ const rows = buildDriverListRows()
1121
+ console.log(` available drivers (${rows.length}):\n`)
1122
+ const idWidth = Math.max(...rows.map((r) => r.id.length), 6)
1123
+ const kindWidth = Math.max(...rows.map((r) => r.kind.length), 4)
1124
+ for (const row of rows) {
1125
+ const status = row.available ? '✓' : '✗'
1126
+ const idCol = row.id.padEnd(idWidth)
1127
+ const kindCol = row.kind.padEnd(kindWidth)
1128
+ console.log(` ${status} ${idCol} ${kindCol} ${row.description}`)
1129
+ if (row.available && row.detail) {
1130
+ console.log(` ${row.detail}`)
1131
+ } else if (!row.available && row.reason) {
1132
+ console.log(` unavailable: ${row.reason}`)
1133
+ }
1134
+ }
1135
+ return
1136
+ }
1137
+
1138
+ const bridge = createBridgeFromParsed(parsed)
1139
+ const noticeScope = simId || 'default'
1140
+ const consoleSummarySkip = new Set([
1141
+ 'errors',
1142
+ 'warnings',
1143
+ 'requests',
1144
+ 'js',
1145
+ 'eval',
1146
+ 'reload',
1147
+ 'globals',
1148
+ 'perf',
1149
+ 'storage-clear',
1150
+ // bridge-level commands — they don't touch a specific sim, so skip the
1151
+ // per-sim console/request probe (which would otherwise claim a lease
1152
+ // and show "call · evaluated page" in the sim overlay).
1153
+ 'list',
1154
+ 'wait',
1155
+ 'sleep',
1156
+ ])
1157
+
1158
+ // failed-request dumps used to print the entire response body verbatim.
1159
+ // a backend 500 ships a ~1.5KB `<!DOCTYPE html>…` error page, repeated
1160
+ // per failed request, which buries the actual step result and makes
1161
+ // flow/maestro output unparseable (QA F20-3). collapse an HTML body to a
1162
+ // one-line summary and hard-cap everything else.
1163
+ const RESPONSE_BODY_MAX = 200
1164
+ function formatResponseBodyForLog(body: string): string {
1165
+ const flat = body.replace(/\s+/g, ' ').trim()
1166
+ if (!flat) return ''
1167
+ const looksHtml = /^<(!doctype html|html|\?xml)|<body[\s>]/i.test(flat)
1168
+ if (looksHtml) {
1169
+ const title = /<title[^>]*>([^<]+)<\/title>/i.exec(body)?.[1]?.trim()
1170
+ const firstText = /<body[^>]*>([\s\S]*?)<\//i
1171
+ .exec(body)?.[1]
1172
+ ?.replace(/<[^>]+>/g, ' ')
1173
+ .replace(/\s+/g, ' ')
1174
+ .trim()
1175
+ .slice(0, 80)
1176
+ const gist = title || firstText || 'html error page'
1177
+ return `<html ${body.length}B> "${gist}" (body elided — add --json for the full payload)`
1178
+ }
1179
+ if (flat.length <= RESPONSE_BODY_MAX) return flat
1180
+ return `${flat.slice(0, RESPONSE_BODY_MAX)}… (+${flat.length - RESPONSE_BODY_MAX} more bytes)`
1181
+ }
1182
+
1183
+ function formatRequestMessage(entry: SootSimRequestEntry) {
1184
+ const target = entry.displayUrl || entry.url
1185
+ if (entry.status != null) {
1186
+ return `${entry.method} ${target} -> ${entry.status}${entry.statusText ? ` ${entry.statusText}` : ''}`
1187
+ }
1188
+ if (entry.error) {
1189
+ return `${entry.method} ${target} -> ${entry.error}`
1190
+ }
1191
+ return `${entry.method} ${target}`
1192
+ }
1193
+
1194
+ // batched post-command probe: one bridge roundtrip for both console + request
1195
+ // counts. used by the default post-command notice loop so non-skip commands
1196
+ // don't pay 2 separate roundtrips. also usable by subcommands (e.g. describe)
1197
+ // that want to fold the probe into their own eval.
1198
+ type SummaryCounts = {
1199
+ console: { errors: number; warnings: number; total: number } | null
1200
+ requests: { failed: number; total: number } | null
1201
+ }
1202
+ // after a write command briefly poll in the sim until layout is stable.
1203
+ // agents get a longer budget (400ms) to catch short transitions;
1204
+ // interactive users get 200ms to stay snappy. logs on timeout so it's
1205
+ // obvious when the next CLI call may see mid-animation state.
1206
+ async function autoSettleAfterWrite(b: WsBridge): Promise<void> {
1207
+ // budget is a CAP, not a fixed wait: waitForSootsimIdle drains any
1208
+ // screen transition then returns the instant layout is stable. longer
1209
+ // transitions should use an explicit `do settle` or `wait idle`.
1210
+ const budgetMs = isAgentEnv() ? 400 : 200
1211
+ try {
1212
+ const { settled, elapsed } = await waitForSootsimIdle({
1213
+ bridge: b,
1214
+ maxMs: budgetMs,
1215
+ pollMs: 32,
1216
+ stablePolls: 2,
1217
+ })
1218
+ if (!settled) {
1219
+ process.stderr.write(
1220
+ ` ⚠ auto-wait timed out after ${elapsed ?? budgetMs}ms — next command may see mid-animation state. use \`rnxsim do settle\` for a longer wait.\n`,
1221
+ )
1222
+ }
1223
+ } catch {
1224
+ // best-effort — never fail the command because of a post-wait probe.
1225
+ }
1226
+ }
1227
+
1228
+ function printTapFailure(label: string, outcome: { attempts: number; result: any }) {
1229
+ if (outcome.result?.reason === 'offscreen') {
1230
+ const { x, y, screen } = outcome.result
1231
+ console.error(
1232
+ ` tap failed: ${label} resolved to (${Math.round(x)},${Math.round(y)}), outside the ${screen?.width}x${screen?.height} screen`,
1233
+ )
1234
+ console.error(
1235
+ ' the node exists but is scrolled out of view — scroll it on-screen first (rnxsim do scroll / swipe), then tap.',
1236
+ )
1237
+ return
1238
+ }
1239
+ console.error(
1240
+ ` tap failed: ${label} stayed visible but did not receive a hittable press after ${outcome.attempts} attempt${outcome.attempts === 1 ? '' : 's'}`,
1241
+ )
1242
+ if (outcome.result) {
1243
+ console.error(` last result: ${JSON.stringify(outcome.result)}`)
1244
+ }
1245
+ }
1246
+
1247
+ async function fetchSummaryCounts(): Promise<SummaryCounts> {
1248
+ try {
1249
+ const raw = await bridge.send({
1250
+ type: 'evaluate',
1251
+ // console count merges the ws-bridge buffer + observability store (see
1252
+ // MERGED_CONSOLE_COUNT_EVAL) so the proactive notice agrees with what
1253
+ // `get errors` returns for forwarded render-worker failures.
1254
+ code: `(() => ({
1255
+ console: ${MERGED_CONSOLE_COUNT_EVAL},
1256
+ requests: window.__sootsimTest?.getRequestCounts?.() || null,
1257
+ }))()`,
1258
+ })
1259
+ return (raw as SummaryCounts) || { console: null, requests: null }
1260
+ } catch {
1261
+ return { console: null, requests: null }
1262
+ }
1263
+ }
1264
+
1265
+ async function printRequestSummary(
1266
+ opts: { includeTail?: boolean; counts?: SummaryCounts['requests'] } = {},
1267
+ ) {
1268
+ const counts =
1269
+ opts.counts !== undefined
1270
+ ? opts.counts
1271
+ : await callTestBridge<{ failed?: number; total?: number }>(
1272
+ bridge,
1273
+ 'getRequestCounts',
1274
+ )
1275
+ if (!counts || typeof counts !== 'object') return
1276
+
1277
+ const failed = Math.max(0, Number(counts.failed) || 0)
1278
+ if (failed === 0) return
1279
+ if (
1280
+ !opts.includeTail &&
1281
+ !shouldPrintInspectNotice('requests', noticeScope, String(failed))
1282
+ ) {
1283
+ return
1284
+ }
1285
+
1286
+ console.log(`\n network: ${failed} failed request${failed === 1 ? '' : 's'}`)
1287
+ console.log(` inspect: ${inspectCommand('requests')} 5`)
1288
+
1289
+ if (!opts.includeTail) return
1290
+
1291
+ const recentFailed = await callTestBridge<SootSimRequestEntry[]>(
1292
+ bridge,
1293
+ 'getFailedRequests',
1294
+ 5,
1295
+ )
1296
+ if (!Array.isArray(recentFailed) || recentFailed.length === 0) return
1297
+
1298
+ console.log('\n recent failed requests:\n')
1299
+ for (const entry of recentFailed) {
1300
+ const time = formatLogTimestamp(entry.timestamp)
1301
+ console.log(` [${time}] ${formatRequestMessage(entry)}`)
1302
+ if (entry.responseBody) {
1303
+ console.log(` ${formatResponseBodyForLog(entry.responseBody)}`)
1304
+ } else if (entry.error) {
1305
+ console.log(` ${entry.error}`)
1306
+ }
1307
+ }
1308
+ }
1309
+
1310
+ // proactive note when a blocking native-UI surface (Alert / ActionSheet) was
1311
+ // open at the moment a write command ran. these live in the shell worker, so
1312
+ // `describe` (tenant tree) can't see them — but they swallow every tap/drag/
1313
+ // type meant for the app underneath, so a `do` that "did nothing" is usually a
1314
+ // modal eating the input. fed from a pre-action capture so the note still
1315
+ // fires when the action itself dismissed the modal (e.g. a tap on a button).
1316
+ function printNativeUISummary(open: OpenNativeUISurface[]) {
1317
+ for (const ui of open) {
1318
+ const title = ui.title ? ` (“${ui.title}”)` : ''
1319
+ console.error(
1320
+ `\n note: ${ui.label} is open${title} — taps/drags hit it, not the app`,
1321
+ )
1322
+ }
1323
+ }
1324
+
1325
+ async function printConsoleSummary(
1326
+ opts: { includeTail?: boolean; counts?: SummaryCounts['console'] } = {},
1327
+ ) {
1328
+ const counts =
1329
+ opts.counts !== undefined
1330
+ ? opts.counts
1331
+ : await bridge.send({
1332
+ type: 'evaluate',
1333
+ code: MERGED_CONSOLE_COUNT_EVAL,
1334
+ })
1335
+ if (!counts || typeof counts !== 'object') return
1336
+
1337
+ const c = counts as { errors?: number; warnings?: number }
1338
+ const errors = Math.max(0, Number(c.errors) || 0)
1339
+ const warnings = Math.max(0, Number(c.warnings) || 0)
1340
+ if (errors === 0 && warnings === 0) return
1341
+ if (
1342
+ !opts.includeTail &&
1343
+ !shouldPrintInspectNotice('console', noticeScope, `${errors}:${warnings}`)
1344
+ ) {
1345
+ return
1346
+ }
1347
+
1348
+ const parts = []
1349
+ if (errors > 0) parts.push(`${errors} console error${errors === 1 ? '' : 's'}`)
1350
+ if (warnings > 0)
1351
+ parts.push(`${warnings} console warning${warnings === 1 ? '' : 's'}`)
1352
+ console.error(`\n console: ${parts.join(', ')}`)
1353
+ console.error(` inspect: ${inspectCommand('errors')} 5`)
1354
+ if (warnings > 0) {
1355
+ console.error(` inspect: ${inspectCommand('warnings')} 5`)
1356
+ }
1357
+
1358
+ if (!opts.includeTail || errors === 0) return
1359
+
1360
+ // merged tail (ws-bridge buffer + observability) so a render-worker-only
1361
+ // error is shown, not just dropped to "N errors" with an empty tail.
1362
+ const recentErrors = await inspectErrors(bridge, 5)
1363
+ if (!Array.isArray(recentErrors) || recentErrors.length === 0) return
1364
+
1365
+ console.error('\n recent console errors:\n')
1366
+ for (const entry of recentErrors) {
1367
+ const time = formatLogTimestamp(entry.timestamp)
1368
+ const msg = Array.isArray(entry.args)
1369
+ ? entry.args
1370
+ .map((value: any) =>
1371
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
1372
+ )
1373
+ .join(' ')
1374
+ : String(entry)
1375
+ console.error(` [${time}] ${msg}`)
1376
+ }
1377
+ }
1378
+
1379
+ // cursor footer — replaces the old "console: N errors" line with a delta
1380
+ // summary of every timeline kind since this CLI identity's previous call.
1381
+ // empty → no output (clean), so commands stay quiet when nothing has
1382
+ // happened. priority-ordered kinds keep the most important signals
1383
+ // (errors, toasts, screens) leftmost in the one-liner.
1384
+ const CURSOR_FOOTER_ORDER: readonly string[] = [
1385
+ 'console',
1386
+ 'fetch',
1387
+ 'toast',
1388
+ 'alert',
1389
+ 'notification',
1390
+ 'screen',
1391
+ 'app-launch',
1392
+ 'keyboard',
1393
+ 'route',
1394
+ 'actionsheet',
1395
+ 'picker',
1396
+ 'shell',
1397
+ 'scroll',
1398
+ 'gesture',
1399
+ 'text-input',
1400
+ 'animation',
1401
+ 'reanimated',
1402
+ ]
1403
+ async function printCursorFooter(b: WsBridge): Promise<void> {
1404
+ const cliKey = getCliIdentityKey()
1405
+ // single eval roundtrip: summary buckets by kind plus a console
1406
+ // level breakdown (errors vs warnings). agents care about that
1407
+ // distinction enough that the footer should show it directly. one
1408
+ // call to keep the post-command overhead at ~1 bridge hop.
1409
+ type FooterPayload = {
1410
+ summary: { total: number; byKind: Record<string, number>; lastAt: number | null }
1411
+ consoleSplit: { error: number; warn: number } | null
1412
+ } | null
1413
+ let payload: FooterPayload = null
1414
+ try {
1415
+ payload = await evalInBridge<FooterPayload>(
1416
+ b,
1417
+ `(() => {
1418
+ const tl = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.timeline
1419
+ if (!tl || typeof tl.summary !== 'function') return null
1420
+ const cursorKey = ${JSON.stringify(cliKey)}
1421
+ const summary = tl.summary({ sinceCursor: cursorKey })
1422
+ let consoleSplit = null
1423
+ if (summary && summary.byKind && summary.byKind.console) {
1424
+ const events = tl.recent({ sinceCursor: cursorKey, kinds: 'console', limit: 100000 }).events
1425
+ consoleSplit = { error: 0, warn: 0 }
1426
+ for (const ev of events) {
1427
+ const lvl = ev && ev.data && ev.data.level
1428
+ if (lvl === 'error') consoleSplit.error++
1429
+ else if (lvl === 'warn') consoleSplit.warn++
1430
+ }
1431
+ }
1432
+ return summary ? { summary, consoleSplit } : null
1433
+ })()`,
1434
+ )
1435
+ } catch {
1436
+ return
1437
+ }
1438
+ if (!payload || !payload.summary || !payload.summary.total) return
1439
+ const byKind = payload.summary.byKind ?? {}
1440
+ const parts: string[] = []
1441
+ const seen = new Set<string>()
1442
+ for (const kind of CURSOR_FOOTER_ORDER) {
1443
+ const n = byKind[kind]
1444
+ if (!n) continue
1445
+ seen.add(kind)
1446
+ if (kind === 'console' && payload.consoleSplit) {
1447
+ const { error, warn } = payload.consoleSplit
1448
+ if (error > 0) parts.push(`${error} error${error === 1 ? '' : 's'}`)
1449
+ if (warn > 0) parts.push(`${warn} warning${warn === 1 ? '' : 's'}`)
1450
+ } else {
1451
+ parts.push(`${n} ${kind}${n === 1 ? '' : 's'}`)
1452
+ }
1453
+ }
1454
+ for (const [kind, n] of Object.entries(byKind)) {
1455
+ if (!seen.has(kind) && n) parts.push(`${n} ${kind}${n === 1 ? '' : 's'}`)
1456
+ }
1457
+ if (parts.length === 0) return
1458
+ console.error(`\n since last: ${parts.join(' · ')} — rnxsim what-happened`)
1459
+ // advance cursor so the next CLI call only sees *new* events.
1460
+ if (payload.summary.lastAt) {
1461
+ try {
1462
+ await callInBridge(
1463
+ b,
1464
+ 'SootSim.bridges.timeline.cursorAdvance',
1465
+ cliKey,
1466
+ payload.summary.lastAt,
1467
+ )
1468
+ } catch {
1469
+ // best-effort — never fail the command because the cursor
1470
+ // couldn't be advanced.
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ // warn if target sim is hidden — animations and rAF are throttled,
1476
+ // coordinates are wrong, launch transitions never complete.
1477
+ // only check for commands that interact with the canvas.
1478
+ const writeCommands = new Set([
1479
+ 'tap',
1480
+ 'double-tap',
1481
+ 'tap-text',
1482
+ 'tap-id',
1483
+ 'type',
1484
+ 'type-into',
1485
+ 'key',
1486
+ 'key-sequence',
1487
+ 'keycode',
1488
+ 'drag',
1489
+ 'swipe',
1490
+ 'long-press',
1491
+ 'touch',
1492
+ 'gesture',
1493
+ 'pinch',
1494
+ 'scroll',
1495
+ 'shell',
1496
+ 'storage-clear',
1497
+ ])
1498
+ const screenTransitionAwareCommands = new Set([
1499
+ 'a11y',
1500
+ 'capture',
1501
+ 'count',
1502
+ 'double-tap',
1503
+ 'drag',
1504
+ 'find',
1505
+ 'gesture',
1506
+ 'layout',
1507
+ 'long-press',
1508
+ 'node',
1509
+ 'pinch',
1510
+ 'sample-color',
1511
+ 'scroll',
1512
+ 'screenshot',
1513
+ 'swipe',
1514
+ 'tap',
1515
+ 'tap-id',
1516
+ 'tap-text',
1517
+ 'touch',
1518
+ 'tree',
1519
+ 'type-into',
1520
+ ])
1521
+ const verboseTransitions =
1522
+ (args.includes('--verbose') || args.includes('-v')) && !args.includes('--json')
1523
+ if (invocationPrefix === 'do' && subcommand === 'shell') {
1524
+ console.error(' `rnxsim do shell` was removed. use `rnxsim shell ...` instead.')
1525
+ process.exit(1)
1526
+ }
1527
+ if (writeCommands.has(subcommand)) {
1528
+ await checkSimHealth(bridge)
1529
+ }
1530
+ if (screenTransitionAwareCommands.has(subcommand)) {
1531
+ await maybeWaitForStartedScreenTransitions(bridge, {
1532
+ verbose: verboseTransitions,
1533
+ })
1534
+ }
1535
+
1536
+ try {
1537
+ // capture any blocking native-UI modal BEFORE running a write command, so
1538
+ // the trailing note reflects the state when the action was attempted even
1539
+ // if the action itself dismissed the modal (e.g. a tap that hit a button).
1540
+ const nativeUIBeforeWrite = writeCommands.has(subcommand)
1541
+ ? await detectOpenNativeUI(bridge)
1542
+ : []
1543
+ switch (subcommand) {
1544
+ case 'list': {
1545
+ // name the bridge world so dev-bridge vs daemon is never ambiguous
1546
+ // (the two can coexist; `list` used to silently flap between them).
1547
+ printBridgeWorldNotice(parsed.wsPort)
1548
+ await runListSubcommand({ bridge, simId, args: effectiveArgs })
1549
+ break
1550
+ }
1551
+
1552
+ case 'tree': {
1553
+ await runTreeSubcommand({ bridge, args: effectiveArgs, positional })
1554
+ break
1555
+ }
1556
+
1557
+ case 'a11y': {
1558
+ const nodes = await inspectAccessibilityTree(bridge)
1559
+ if (!Array.isArray(nodes) || nodes.length === 0) {
1560
+ console.log(' no accessible nodes found')
1561
+ break
1562
+ }
1563
+ if (args.includes('--json')) {
1564
+ console.log(JSON.stringify(nodes, null, 2))
1565
+ } else {
1566
+ console.log(` accessibility tree (${nodes.length} nodes):\n`)
1567
+ for (const n of nodes) {
1568
+ const parts: string[] = []
1569
+ parts.push(`[${n.role}]`)
1570
+ if (n.label) {
1571
+ const label = n.label.length > 50 ? n.label.slice(0, 47) + '...' : n.label
1572
+ parts.push(`"${label}"`)
1573
+ }
1574
+ if (n.hint) parts.push(`(hint: "${n.hint}")`)
1575
+ if (n.testID) parts.push(`#${n.testID}`)
1576
+ if (n.state) {
1577
+ const flags: string[] = []
1578
+ if (n.state.disabled) flags.push('disabled')
1579
+ if (n.state.selected) flags.push('selected')
1580
+ if (n.state.checked === true) flags.push('checked')
1581
+ if (n.state.checked === 'mixed') flags.push('mixed')
1582
+ if (n.state.busy) flags.push('busy')
1583
+ if (n.state.expanded === true) flags.push('expanded')
1584
+ if (n.state.expanded === false) flags.push('collapsed')
1585
+ if (flags.length) parts.push(`{${flags.join(', ')}}`)
1586
+ }
1587
+ if (n.position) parts.push(`@(${n.position.x},${n.position.y})`)
1588
+ if (n.size) parts.push(`${n.size.w}x${n.size.h}`)
1589
+ console.log(' ' + parts.join(' '))
1590
+ }
1591
+ }
1592
+ break
1593
+ }
1594
+
1595
+ case 'find': {
1596
+ await runFindSubcommand({ bridge, args, effectiveArgs, positional, inspectUsage })
1597
+ break
1598
+ }
1599
+
1600
+ case 'count': {
1601
+ await runCountSubcommand(bridge, { args: effectiveArgs })
1602
+ break
1603
+ }
1604
+
1605
+ case 'keyboard': {
1606
+ await runKeyboardSubcommand(bridge, {
1607
+ json: args.includes('--json'),
1608
+ })
1609
+ break
1610
+ }
1611
+
1612
+ case 'screens': {
1613
+ await runScreensSubcommand(bridge, {
1614
+ json: args.includes('--json'),
1615
+ })
1616
+ break
1617
+ }
1618
+
1619
+ case 'memory': {
1620
+ await runMemorySubcommand(bridge, { args: effectiveArgs })
1621
+ break
1622
+ }
1623
+
1624
+ case 'wait': {
1625
+ await runWaitSubcommand({
1626
+ wsPort,
1627
+ commandTimeoutMs,
1628
+ simId,
1629
+ simIdSource,
1630
+ positional,
1631
+ })
1632
+ break
1633
+ }
1634
+
1635
+ case 'sleep': {
1636
+ await runSleepSubcommand({ positional, inspectUsage })
1637
+ break
1638
+ }
1639
+
1640
+ case 'settle': {
1641
+ await runSettleSubcommand({ bridge, args, positional })
1642
+ break
1643
+ }
1644
+
1645
+ case 'ready': {
1646
+ await runWaitReadySubcommand({ bridge, args })
1647
+ break
1648
+ }
1649
+
1650
+ case 'idle': {
1651
+ await runWaitIdleSubcommand({ bridge, args, positional })
1652
+ break
1653
+ }
1654
+
1655
+ case 'selector': {
1656
+ await runWaitSelectorSubcommand({ bridge, args, positional, inspectUsage })
1657
+ break
1658
+ }
1659
+
1660
+ case 'event': {
1661
+ // sootsim wait event <kind> — timeline-backed wait. delegates
1662
+ // matcher + polling to runWaitEventSubcommand so the same
1663
+ // logic is reachable from `sootsim wait event` regardless of
1664
+ // verb-group prefix (and the same shape is testable on its
1665
+ // own).
1666
+ await runWaitEventSubcommand({ bridge, args, positional, inspectUsage })
1667
+ break
1668
+ }
1669
+
1670
+ case 'layout': {
1671
+ const id = positional[1]
1672
+ // bare `sootsim get layout` (no id) measures visible elements on the
1673
+ // screen by bounding box. --styling opts into box-model/style detail via
1674
+ // the shared get-layout kernel the agent's app_get_layout also uses.
1675
+ // passing an id keeps the single-node {x, y, width, height} lookup.
1676
+ if (!id) {
1677
+ await runGetLayoutSubcommand({ bridge, args: effectiveArgs })
1678
+ break
1679
+ }
1680
+ // use async for worker mode bridge
1681
+ const layout = await bridge.send({
1682
+ type: 'evaluate',
1683
+ code: `(async () => await window.__sootsimTest.getLayout(${JSON.stringify(id)}))()`,
1684
+ })
1685
+ console.log(JSON.stringify(layout, null, 2))
1686
+ break
1687
+ }
1688
+
1689
+ case 'capture':
1690
+ case 'screenshot': {
1691
+ const outputArg = args.find((_, i) => args[i - 1] === '--output')
1692
+ const outputPath = outputArg || '/tmp/sootsim-inspect.png'
1693
+ const rect = await resolveSampleRect(args, bridge)
1694
+
1695
+ const request: { type: 'screenshot'; crop?: SampleRect } = { type: 'screenshot' }
1696
+ if (rect) request.crop = rect
1697
+ const dataUrl: string = await bridge.send(request)
1698
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1699
+ if (rect) {
1700
+ console.log(` area: x=${rect.x} y=${rect.y} w=${rect.w} h=${rect.h}`)
1701
+ }
1702
+ const fs = await import('fs')
1703
+ fs.writeFileSync(outputPath, Buffer.from(base64, 'base64'))
1704
+ console.log(` saved: ${outputPath}`)
1705
+ break
1706
+ }
1707
+
1708
+ case 'sample-color': {
1709
+ const rect = await resolveSampleRect(args, bridge)
1710
+ if (!rect) {
1711
+ console.error(
1712
+ inspectUsage(
1713
+ 'sample-color',
1714
+ '<x> <y> [w] [h] | --id <testID> | --text <text>',
1715
+ ),
1716
+ )
1717
+ console.error(
1718
+ ' samples an averaged color from the canvas. coords are logical rnx units.',
1719
+ )
1720
+ process.exit(1)
1721
+ }
1722
+ const result = await bridge.send({
1723
+ type: 'evaluate',
1724
+ code: buildSampleColorEval(rect),
1725
+ })
1726
+ if (args.includes('--json')) {
1727
+ console.log(JSON.stringify(result, null, 2))
1728
+ } else {
1729
+ const { r, g, b, a, hex, samples } = result as {
1730
+ r: number
1731
+ g: number
1732
+ b: number
1733
+ a: number
1734
+ hex: string
1735
+ samples: number
1736
+ }
1737
+ const rectLabel =
1738
+ rect.w === 1 && rect.h === 1
1739
+ ? `@(${rect.x},${rect.y})`
1740
+ : `@(${rect.x},${rect.y}) ${rect.w}x${rect.h}`
1741
+ console.log(
1742
+ ` ${hex} rgba(${r}, ${g}, ${b}, ${a}) ${rectLabel} ${samples} samples`,
1743
+ )
1744
+ }
1745
+ break
1746
+ }
1747
+
1748
+ case 'node': {
1749
+ const matcher = positional[1]
1750
+ if (!matcher) {
1751
+ console.error(inspectUsage('node', '<matcher>'))
1752
+ console.error(' resolves testID, id, then text — dumps full node info as JSON')
1753
+ process.exit(1)
1754
+ }
1755
+ // resolve in order: testID → id → text. first hit wins, includes the
1756
+ // resolved transform and a parent chain so agents can skip the usual
1757
+ // __sootsimRoot walk.
1758
+ const result = await bridge.send({
1759
+ type: 'evaluate',
1760
+ code: `(async () => {
1761
+ const t = window.__sootsimTest
1762
+ const q = ${JSON.stringify(matcher)}
1763
+ let node = null
1764
+ let via = null
1765
+ if (t.findByTestId) { node = await t.findByTestId(q); if (node) via = 'testID' }
1766
+ if (!node && t.findById) { node = await t.findById(q); if (node) via = 'id' }
1767
+ if (!node && t.findByText) { node = await t.findByText(q); if (node) via = 'text' }
1768
+ if (!node) return { matcher: q, found: false }
1769
+
1770
+ // read the resolved transform (if any) off the style — useful
1771
+ // because canvas nodes often animate via transform and describe
1772
+ // output strips that.
1773
+ const transform =
1774
+ node.style && Array.isArray(node.style.transform)
1775
+ ? node.style.transform
1776
+ : node.style && node.style.transform
1777
+ ? node.style.transform
1778
+ : null
1779
+
1780
+ // parent chain — walk up from the node so the JSON dump is
1781
+ // self-describing.
1782
+ const parentChain = []
1783
+ const root = window.__sootsimRoot
1784
+ if (root && node.id != null) {
1785
+ const findPath = (n, targetId, path) => {
1786
+ if (!n) return null
1787
+ if (n.id === targetId) return path
1788
+ if (n.children) {
1789
+ for (const child of n.children) {
1790
+ const nextPath = [
1791
+ ...path,
1792
+ {
1793
+ type: n.type || 'view',
1794
+ testID: n.props?.testID || null,
1795
+ text: n.text || null,
1796
+ },
1797
+ ]
1798
+ const found = findPath(child, targetId, nextPath)
1799
+ if (found) return found
1800
+ }
1801
+ }
1802
+ return null
1803
+ }
1804
+ const chain = findPath(root, node.id, [])
1805
+ if (chain) parentChain.push(...chain)
1806
+ }
1807
+
1808
+ return {
1809
+ matcher: q,
1810
+ found: true,
1811
+ resolvedVia: via,
1812
+ node,
1813
+ transform,
1814
+ parentChain,
1815
+ }
1816
+ })()`,
1817
+ })
1818
+ console.log(JSON.stringify(result, null, 2))
1819
+ break
1820
+ }
1821
+
1822
+ case 'tap': {
1823
+ let x = Number(positional[1])
1824
+ let y = Number(positional[2])
1825
+ const target = readTargetFlag(args, positional)
1826
+ if (target) {
1827
+ const outcome = await tapResolvedTarget(bridge, {
1828
+ agent: isAgentEnv(),
1829
+ textFallback: target.mode === 'text' ? target.value : undefined,
1830
+ resolve: async () => {
1831
+ const resolved = await resolveTargetCoords(bridge, target)
1832
+ if (!resolved) return null
1833
+ return {
1834
+ cx: resolved.x,
1835
+ cy: resolved.y,
1836
+ match: {
1837
+ id: target.mode === 'testid' ? target.value : (resolved.id ?? null),
1838
+ testID:
1839
+ target.mode === 'testid' ? target.value : (resolved.testID ?? null),
1840
+ text: target.mode === 'text' ? target.value : (resolved.text ?? null),
1841
+ type: resolved.type ?? null,
1842
+ },
1843
+ target: {
1844
+ id: resolved.id ?? null,
1845
+ testID: resolved.testID ?? null,
1846
+ text: resolved.text ?? null,
1847
+ type: resolved.type ?? null,
1848
+ },
1849
+ }
1850
+ },
1851
+ })
1852
+ const payload = outcome.payload
1853
+ if (!payload || typeof payload.cx !== 'number') {
1854
+ console.error(` not found: ${target.value}`)
1855
+ if (target.mode === 'testid') {
1856
+ maybeHint('wait-selector-for-missing-testid', target.value)
1857
+ }
1858
+ process.exit(1)
1859
+ }
1860
+ if (!isTapSuccess(outcome.result)) {
1861
+ printTapFailure(`${target.mode} "${target.value}"`, outcome)
1862
+ process.exit(1)
1863
+ }
1864
+ const candidate = buildTapCandidateFromResult(
1865
+ payload.cx,
1866
+ payload.cy,
1867
+ outcome.result,
1868
+ )
1869
+ if (candidate) {
1870
+ await recordInspectAction('inspect tap', candidate.step, candidate.summary)
1871
+ }
1872
+ console.log(
1873
+ JSON.stringify(
1874
+ {
1875
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
1876
+ ...outcome.result,
1877
+ },
1878
+ null,
1879
+ 2,
1880
+ ),
1881
+ )
1882
+ break
1883
+ }
1884
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1885
+ console.error(
1886
+ inspectUsage('tap', '<testid> | <x> <y> | --testid <id> | --text <t>'),
1887
+ )
1888
+ process.exit(1)
1889
+ }
1890
+ const result = await tapCoordinates(bridge, x, y)
1891
+ const candidate = buildTapCandidateFromResult(x, y, result)
1892
+ if (candidate) {
1893
+ await recordInspectAction('inspect tap', candidate.step, candidate.summary)
1894
+ }
1895
+ console.log(JSON.stringify(result, null, 2))
1896
+ break
1897
+ }
1898
+
1899
+ case 'drag':
1900
+ case 'swipe': {
1901
+ const fromX = Number(positional[1])
1902
+ const fromY = Number(positional[2])
1903
+ const toX = Number(positional[3])
1904
+ const toY = Number(positional[4])
1905
+ const defaultSteps = subcommand === 'swipe' ? 10 : 12
1906
+ const defaultStepMs = subcommand === 'swipe' ? 8 : 16
1907
+ const steps = positional[5] ? Number(positional[5]) : defaultSteps
1908
+ const stepMs = positional[6] ? Number(positional[6]) : defaultStepMs
1909
+ if (
1910
+ !Number.isFinite(fromX) ||
1911
+ !Number.isFinite(fromY) ||
1912
+ !Number.isFinite(toX) ||
1913
+ !Number.isFinite(toY) ||
1914
+ !Number.isFinite(steps) ||
1915
+ !Number.isFinite(stepMs)
1916
+ ) {
1917
+ console.error(inspectUsage(subcommand, '<x1> <y1> <x2> <y2> [steps] [stepMs]'))
1918
+ process.exit(1)
1919
+ }
1920
+ const result = await performSingleStep(bridge, {
1921
+ type: 'drag',
1922
+ fromX,
1923
+ fromY,
1924
+ toX,
1925
+ toY,
1926
+ steps: Math.max(1, Math.round(steps)),
1927
+ stepMs: Math.max(0, Math.round(stepMs)),
1928
+ })
1929
+ if (result?.ok) {
1930
+ const duration = Math.max(
1931
+ 1,
1932
+ Math.round(Math.max(1, steps) * Math.max(0, stepMs)),
1933
+ )
1934
+ await recordInspectAction(
1935
+ `inspect ${subcommand}`,
1936
+ {
1937
+ swipe: {
1938
+ start: `${fromX}, ${fromY}`,
1939
+ end: `${toX}, ${toY}`,
1940
+ duration,
1941
+ },
1942
+ },
1943
+ `${subcommand} ${fromX},${fromY} -> ${toX},${toY}`,
1944
+ )
1945
+ }
1946
+ console.log(JSON.stringify(result, null, 2))
1947
+ break
1948
+ }
1949
+
1950
+ case 'pinch': {
1951
+ const fromX1 = Number(positional[1])
1952
+ const fromY1 = Number(positional[2])
1953
+ const fromX2 = Number(positional[3])
1954
+ const fromY2 = Number(positional[4])
1955
+ const toX1 = Number(positional[5])
1956
+ const toY1 = Number(positional[6])
1957
+ const toX2 = Number(positional[7])
1958
+ const toY2 = Number(positional[8])
1959
+ const steps = positional[9] ? Number(positional[9]) : 12
1960
+ const stepMs = positional[10] ? Number(positional[10]) : 16
1961
+ if (
1962
+ !Number.isFinite(fromX1) ||
1963
+ !Number.isFinite(fromY1) ||
1964
+ !Number.isFinite(fromX2) ||
1965
+ !Number.isFinite(fromY2) ||
1966
+ !Number.isFinite(toX1) ||
1967
+ !Number.isFinite(toY1) ||
1968
+ !Number.isFinite(toX2) ||
1969
+ !Number.isFinite(toY2) ||
1970
+ !Number.isFinite(steps) ||
1971
+ !Number.isFinite(stepMs)
1972
+ ) {
1973
+ console.error(
1974
+ inspectUsage(
1975
+ 'pinch',
1976
+ "<x1> <y1> <x2> <y2> <x1'> <y1'> <x2'> <y2'> [steps] [stepMs]",
1977
+ ),
1978
+ )
1979
+ process.exit(1)
1980
+ }
1981
+ const result = await performSingleStep(bridge, {
1982
+ type: 'pinch',
1983
+ fromX1,
1984
+ fromY1,
1985
+ fromX2,
1986
+ fromY2,
1987
+ toX1,
1988
+ toY1,
1989
+ toX2,
1990
+ toY2,
1991
+ steps: Math.max(1, Math.round(steps)),
1992
+ stepMs: Math.max(0, Math.round(stepMs)),
1993
+ })
1994
+ if (result?.ok) {
1995
+ await recordInspectAction(
1996
+ 'inspect pinch',
1997
+ {
1998
+ pinch: {
1999
+ from: [fromX1, fromY1, fromX2, fromY2],
2000
+ to: [toX1, toY1, toX2, toY2],
2001
+ steps: Math.max(1, Math.round(steps)),
2002
+ stepMs: Math.max(0, Math.round(stepMs)),
2003
+ },
2004
+ },
2005
+ `pinch (${fromX1},${fromY1}) (${fromX2},${fromY2}) -> (${toX1},${toY1}) (${toX2},${toY2})`,
2006
+ )
2007
+ }
2008
+ console.log(JSON.stringify(result, null, 2))
2009
+ break
2010
+ }
2011
+
2012
+ case 'tap-text': {
2013
+ const query = positional[1]
2014
+ if (!query) {
2015
+ console.error(inspectUsage('tap-text', '<text>'))
2016
+ process.exit(1)
2017
+ }
2018
+
2019
+ const findFlagValue = (name: string): string | null => {
2020
+ const i = args.indexOf(name)
2021
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : null
2022
+ }
2023
+ const hasBoolFlag = (name: string): boolean => args.includes(name)
2024
+
2025
+ const nthArg = findFlagValue('--nth') ?? findFlagValue('--index')
2026
+ const nthIndex = nthArg !== null ? Number(nthArg) : null
2027
+ if (nthIndex !== null && !Number.isFinite(nthIndex)) {
2028
+ console.error(` --nth/--index requires an integer, got: ${nthArg}`)
2029
+ process.exit(1)
2030
+ }
2031
+ const within = findFlagValue('--within')
2032
+ const roleFlag = findFlagValue('--role')
2033
+ const exact = hasBoolFlag('--exact')
2034
+ const first = hasBoolFlag('--first')
2035
+ const minYArg = findFlagValue('--min-y')
2036
+ const maxYArg = findFlagValue('--max-y')
2037
+ const minXArg = findFlagValue('--min-x')
2038
+ const maxXArg = findFlagValue('--max-x')
2039
+ for (const [flag, val] of [
2040
+ ['--min-y', minYArg],
2041
+ ['--max-y', maxYArg],
2042
+ ['--min-x', minXArg],
2043
+ ['--max-x', maxXArg],
2044
+ ] as const) {
2045
+ if (val !== null && !Number.isFinite(Number(val))) {
2046
+ console.error(` ${flag} requires a number, got: ${val}`)
2047
+ process.exit(1)
2048
+ }
2049
+ }
2050
+ const nearIdx = args.indexOf('--near')
2051
+ let near: { x: number; y: number } | null = null
2052
+ if (nearIdx >= 0) {
2053
+ const nx = Number(args[nearIdx + 1])
2054
+ const ny = Number(args[nearIdx + 2])
2055
+ if (!Number.isFinite(nx) || !Number.isFinite(ny)) {
2056
+ console.error(' --near requires two numbers: --near <x> <y>')
2057
+ process.exit(1)
2058
+ }
2059
+ near = { x: nx, y: ny }
2060
+ }
2061
+
2062
+ const textOptions: TapTextOptions = {
2063
+ exact,
2064
+ role: roleFlag,
2065
+ within,
2066
+ minX: minXArg !== null ? Number(minXArg) : null,
2067
+ maxX: maxXArg !== null ? Number(maxXArg) : null,
2068
+ minY: minYArg !== null ? Number(minYArg) : null,
2069
+ maxY: maxYArg !== null ? Number(maxYArg) : null,
2070
+ near,
2071
+ nth: nthIndex,
2072
+ first,
2073
+ }
2074
+
2075
+ const outcome = await tapByText(bridge, query, textOptions, {
2076
+ agent: isAgentEnv(),
2077
+ })
2078
+ const payload = outcome.payload
2079
+
2080
+ if (payload?.error === 'bridge-not-ready') {
2081
+ console.error(' rnxsim test bridge not ready')
2082
+ process.exit(1)
2083
+ }
2084
+
2085
+ if (payload?.ambiguous) {
2086
+ const candidatesOut = payload.candidates as Array<{
2087
+ idx: number
2088
+ nodeId: number | null
2089
+ type: string | null
2090
+ testID: string | null
2091
+ text: string
2092
+ abs: { x: number; y: number } | null
2093
+ layout: { width: number; height: number } | null
2094
+ ancestorTestIDs: string[]
2095
+ }>
2096
+ console.error(` ambiguous: ${payload.total} matches for "${query}"`)
2097
+ for (const c of candidatesOut) {
2098
+ const loc = c.abs ? `@(${Math.round(c.abs.x)},${Math.round(c.abs.y)})` : ''
2099
+ const size = c.layout ? ` ${c.layout.width}x${c.layout.height}` : ''
2100
+ const tid = c.testID ? ` #${c.testID}` : ''
2101
+ const text = c.text ? ` "${c.text}"` : ''
2102
+ const ancestors =
2103
+ c.ancestorTestIDs.length > 0
2104
+ ? ` within ${c.ancestorTestIDs
2105
+ .slice(0, 3)
2106
+ .map((id) => `#${id}`)
2107
+ .join(' > ')}`
2108
+ : ''
2109
+ console.error(
2110
+ ` [${c.idx}] <${c.type}>${text}${tid} ${loc}${size}${ancestors}`,
2111
+ )
2112
+ }
2113
+ if (payload.total > candidatesOut.length) {
2114
+ console.error(` ... and ${payload.total - candidatesOut.length} more`)
2115
+ }
2116
+ console.error(' pick one:')
2117
+ console.error(
2118
+ ' --nth <index> pick the nth match (top-to-bottom, left-to-right; negatives from end)',
2119
+ )
2120
+ console.error(' --within <testID> narrow to descendants of a node')
2121
+ console.error(' --min-y / --max-y geometric filter (pixels, absolute)')
2122
+ console.error(' --min-x / --max-x geometric filter (pixels, absolute)')
2123
+ console.error(' --near <x> <y> pick the closest match to a point')
2124
+ console.error(
2125
+ ' --exact exact text match (default is substring)',
2126
+ )
2127
+ console.error(' --role <role> narrow to accessibilityRole')
2128
+ console.error(
2129
+ ' --first keep the old pick-first-silently behavior',
2130
+ )
2131
+ process.exit(2)
2132
+ }
2133
+
2134
+ if (payload?.nthOutOfRange) {
2135
+ console.error(
2136
+ ` not found: nth ${payload.nth} of ${payload.total} match${payload.total === 1 ? '' : 'es'} for "${query}"`,
2137
+ )
2138
+ process.exit(1)
2139
+ }
2140
+
2141
+ if (!payload || typeof payload.cx !== 'number') {
2142
+ console.error(` not found: ${query}`)
2143
+ process.exit(1)
2144
+ }
2145
+ if (!isTapSuccess(outcome.result)) {
2146
+ printTapFailure(`text "${query}"`, outcome)
2147
+ process.exit(1)
2148
+ }
2149
+ const candidate = buildTapCandidateFromNode(
2150
+ query,
2151
+ {
2152
+ id: payload.target?.id ?? null,
2153
+ testID: payload.target?.testID ?? null,
2154
+ type: payload.target?.type ?? null,
2155
+ cx: payload.cx,
2156
+ cy: payload.cy,
2157
+ },
2158
+ 'text',
2159
+ )
2160
+ await recordInspectAction('inspect tap-text', candidate.step, candidate.summary)
2161
+ console.log(
2162
+ JSON.stringify(
2163
+ {
2164
+ matched: payload.match,
2165
+ tapped: {
2166
+ nodeId: payload.target?.nodeId ?? null,
2167
+ id: payload.target?.id ?? null,
2168
+ testID: payload.target?.testID ?? null,
2169
+ type: payload.target?.type ?? null,
2170
+ cx: payload.cx,
2171
+ cy: payload.cy,
2172
+ },
2173
+ ...(payload.strategy && payload.strategy !== 'matched-node'
2174
+ ? { strategy: payload.strategy }
2175
+ : {}),
2176
+ // expose the disambiguation outcome so agents can confirm which
2177
+ // of the N matches was tapped, or notice that only one exists.
2178
+ ...(payload.total > 1 || nthIndex !== null
2179
+ ? { nth: { index: payload.idx, total: payload.total } }
2180
+ : {}),
2181
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2182
+ result: outcome.result,
2183
+ },
2184
+ null,
2185
+ 2,
2186
+ ),
2187
+ )
2188
+ break
2189
+ }
2190
+
2191
+ case 'tap-best': {
2192
+ // sootsim do tap-best <query> — try tap-id, then tap-text. the
2193
+ // common agent ergonomic problem: the agent has a label like
2194
+ // "Create expense" and doesn't know whether that's a testID
2195
+ // or visible text. with tap-text it fails when the only
2196
+ // match is a testID; with tap-id it fails when the only
2197
+ // match is visible text. tap-best collapses both into one
2198
+ // command and prints which strategy hit.
2199
+ //
2200
+ // tap-best does NOT subsume tap-text's disambiguation flags
2201
+ // (--nth, --within, --near, --min-y) — those are still on
2202
+ // tap-text directly. tap-best is the "I don't care which
2203
+ // strategy works as long as one does" flavour.
2204
+ const query = positional[1]
2205
+ if (!query) {
2206
+ console.error(inspectUsage('tap-best', '<query>'))
2207
+ process.exit(1)
2208
+ }
2209
+ const outcome = await tapBest(bridge, query, { agent: isAgentEnv() })
2210
+ const payload = outcome.payload as
2211
+ | {
2212
+ strategy: 'testid' | 'text'
2213
+ node: {
2214
+ nodeId: number | null
2215
+ id: string | null
2216
+ testID: string | null
2217
+ type: string | null
2218
+ text: string | null
2219
+ }
2220
+ cx: number
2221
+ cy: number
2222
+ }
2223
+ | { strategy: 'none' }
2224
+ | { error: string }
2225
+ | null
2226
+ if (!payload) {
2227
+ console.error(
2228
+ ` tap-best: no testID or visible text matched "${query}". try \`rnxsim find --interactive-targets\` to list candidates.`,
2229
+ )
2230
+ process.exit(1)
2231
+ }
2232
+ if ('error' in payload) {
2233
+ console.error(` ${payload.error}`)
2234
+ process.exit(1)
2235
+ }
2236
+ if (payload.strategy === 'none') {
2237
+ console.error(
2238
+ ` tap-best: no testID or visible text matched "${query}". try \`rnxsim find --interactive-targets\` to list candidates.`,
2239
+ )
2240
+ process.exit(1)
2241
+ }
2242
+ const node = payload.node
2243
+ if (!isTapSuccess(outcome.result)) {
2244
+ printTapFailure(`best "${query}"`, outcome)
2245
+ process.exit(1)
2246
+ }
2247
+ const candidate = buildTapCandidateFromNode(
2248
+ query,
2249
+ {
2250
+ id: node.id,
2251
+ testID: node.testID,
2252
+ type: node.type,
2253
+ cx: payload.cx,
2254
+ cy: payload.cy,
2255
+ },
2256
+ payload.strategy === 'testid' ? 'id' : 'text',
2257
+ )
2258
+ await recordInspectAction('inspect tap-best', candidate.step, candidate.summary)
2259
+ console.log(
2260
+ JSON.stringify(
2261
+ {
2262
+ matched: {
2263
+ strategy: payload.strategy,
2264
+ nodeId: node.nodeId,
2265
+ id: node.id,
2266
+ testID: node.testID,
2267
+ type: node.type,
2268
+ text: node.text,
2269
+ },
2270
+ tapped: { cx: payload.cx, cy: payload.cy },
2271
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2272
+ result: outcome.result,
2273
+ },
2274
+ null,
2275
+ 2,
2276
+ ),
2277
+ )
2278
+ break
2279
+ }
2280
+
2281
+ case 'tap-id': {
2282
+ const query = positional[1]
2283
+ if (!query) {
2284
+ console.error(inspectUsage('tap-id', '<id>'))
2285
+ process.exit(1)
2286
+ }
2287
+
2288
+ const outcome = await tapById(bridge, query, { agent: isAgentEnv() })
2289
+ const payload = outcome.payload
2290
+ if (!payload || typeof payload.cx !== 'number') {
2291
+ console.error(` not found: ${query}`)
2292
+ // when the testID didn't match, fall back to a fuzzy list of
2293
+ // nearby testIDs in the live tree so the agent can pick the
2294
+ // intended one without dumping the whole tree first.
2295
+ await printSimilarTestIds(bridge, query)
2296
+ process.exit(1)
2297
+ }
2298
+ if (!isTapSuccess(outcome.result)) {
2299
+ printTapFailure(`id "${query}"`, outcome)
2300
+ process.exit(1)
2301
+ }
2302
+ const candidate = buildTapCandidateFromNode(
2303
+ query,
2304
+ {
2305
+ id: payload.target?.id ?? null,
2306
+ testID: payload.target?.testID ?? null,
2307
+ type: payload.target?.type ?? null,
2308
+ cx: payload.cx,
2309
+ cy: payload.cy,
2310
+ },
2311
+ 'id',
2312
+ )
2313
+ await recordInspectAction('inspect tap-id', candidate.step, candidate.summary)
2314
+ console.log(
2315
+ JSON.stringify(
2316
+ {
2317
+ matched: payload.match,
2318
+ tapped: {
2319
+ nodeId: payload.target?.nodeId ?? null,
2320
+ id: payload.target?.id ?? null,
2321
+ testID: payload.target?.testID ?? null,
2322
+ type: payload.target?.type ?? null,
2323
+ cx: payload.cx,
2324
+ cy: payload.cy,
2325
+ },
2326
+ ...(payload.strategy && payload.strategy !== 'matched-node'
2327
+ ? { strategy: payload.strategy }
2328
+ : {}),
2329
+ ...(outcome.attempts > 1 ? { attempts: outcome.attempts } : {}),
2330
+ result: outcome.result,
2331
+ },
2332
+ null,
2333
+ 2,
2334
+ ),
2335
+ )
2336
+ break
2337
+ }
2338
+
2339
+ case 'type-into': {
2340
+ const targetId = positional[1]
2341
+ const text = positional.slice(2).join(' ')
2342
+ if (!targetId || !text) {
2343
+ console.error(inspectUsage('type-into', '<id> <text>'))
2344
+ process.exit(1)
2345
+ }
2346
+ // step 1: find the element
2347
+ const tiArg = JSON.stringify(targetId)
2348
+ const tiPayload = await bridge.send({
2349
+ type: 'evaluate',
2350
+ code: `(async () => {
2351
+ const t = window.__sootsimTest
2352
+ if (!t) return null
2353
+ const n = await (t.findByTestId(${tiArg}) || t.findById(${tiArg}))
2354
+ if (!n || !n.absolutePosition || !n.layout) return null
2355
+ return {
2356
+ cx: n.absolutePosition.x + (n.layout.width || 0) / 2,
2357
+ cy: n.absolutePosition.y + (n.layout.height || 0) / 2,
2358
+ id: n.id,
2359
+ testID: n.testID,
2360
+ type: n.type,
2361
+ isTextInput: !!n.isTextInput,
2362
+ secureTextEntry: !!n.secureTextEntry,
2363
+ placeholder: n.placeholder || null,
2364
+ }
2365
+ })()`,
2366
+ })
2367
+ if (!tiPayload || typeof tiPayload.cx !== 'number') {
2368
+ console.error(` not found: ${targetId}`)
2369
+ process.exit(1)
2370
+ }
2371
+ if (!tiPayload.isTextInput) {
2372
+ console.error(` warning: ${targetId} is not a text input (isTextInput: false)`)
2373
+ }
2374
+ // step 2: tap to focus
2375
+ const tapResult = await bridge.send({
2376
+ type: 'tap',
2377
+ x: tiPayload.cx,
2378
+ y: tiPayload.cy,
2379
+ target: {
2380
+ id: tiPayload.id ?? targetId,
2381
+ testID: tiPayload.testID ?? targetId,
2382
+ text: null,
2383
+ type: tiPayload.type ?? null,
2384
+ },
2385
+ })
2386
+ const targetSecureTextEntry = tiPayload.secureTextEntry === true
2387
+ const keyboardState = await waitForKeyboardVisible(bridge, 1000, {
2388
+ targetId,
2389
+ secureTextEntry: targetSecureTextEntry,
2390
+ })
2391
+ if (!keyboardState.visible) {
2392
+ console.error(` keyboard did not open after tapping ${targetId}`)
2393
+ process.exit(1)
2394
+ }
2395
+ // step 2.5: confirm the tap actually focused the requested input.
2396
+ // the keyboard being visible is necessary but not sufficient — a
2397
+ // previous input might still hold focus if the tap got routed to a
2398
+ // Pressable wrapper ancestor (see the 2026-04-17 bluesky regression).
2399
+ // reading getKeyboardState → __sootsimTest.getFocusedNode() fails
2400
+ // fast on that mismatch instead of typing into the wrong input.
2401
+ const focusCheck = keyboardState.focusedInput as
2402
+ | { testID?: string | null; id?: string | null }
2403
+ | null
2404
+ | undefined
2405
+ if (focusCheck) {
2406
+ const matched = focusCheck.testID === targetId || focusCheck.id === targetId
2407
+ if (!matched) {
2408
+ console.error(
2409
+ ` focus routing mismatch after tap: requested ${JSON.stringify(targetId)} but focus is on ${JSON.stringify(
2410
+ focusCheck.testID ?? focusCheck.id ?? null,
2411
+ )}. did the tap land on an outer Pressable wrapper?`,
2412
+ )
2413
+ process.exit(1)
2414
+ }
2415
+ }
2416
+ // step 3: type through the visible keyboard so key pop animations render
2417
+ await bridge.send({ type: 'keyboard', action: 'type', text })
2418
+ const secureTextEntry =
2419
+ targetSecureTextEntry || isSecureKeyboardState(keyboardState as any)
2420
+ const outputText = secureTextEntry
2421
+ ? SECURE_TEXT_REDACTION
2422
+ : redactTextForKeyboardState(text, keyboardState as any)
2423
+ const focusedInput =
2424
+ secureTextEntry && keyboardState.focusedInput
2425
+ ? {
2426
+ ...keyboardState.focusedInput,
2427
+ secureTextEntry: true,
2428
+ text:
2429
+ typeof keyboardState.focusedInput.text === 'string'
2430
+ ? SECURE_TEXT_REDACTION
2431
+ : keyboardState.focusedInput.text,
2432
+ }
2433
+ : (keyboardState.focusedInput ?? null)
2434
+ await recordInspectAction(
2435
+ 'inspect type-into',
2436
+ { tapOn: { id: targetId }, inputText: outputText },
2437
+ outputText === SECURE_TEXT_REDACTION
2438
+ ? `type-into #${targetId} ${SECURE_TEXT_REDACTION}`
2439
+ : `type-into #${targetId} ${JSON.stringify(text)}`,
2440
+ )
2441
+ console.log(
2442
+ JSON.stringify(
2443
+ {
2444
+ target: targetId,
2445
+ isTextInput: tiPayload.isTextInput,
2446
+ secureTextEntry,
2447
+ keyboardOpened: keyboardState.visible ?? tapResult?.keyboardOpened ?? false,
2448
+ focusedInput,
2449
+ typed: outputText,
2450
+ },
2451
+ null,
2452
+ 2,
2453
+ ),
2454
+ )
2455
+ break
2456
+ }
2457
+
2458
+ case 'type': {
2459
+ const text = positional.slice(1).join(' ')
2460
+ if (!text) {
2461
+ console.error(inspectUsage('type', '<text>'))
2462
+ process.exit(1)
2463
+ }
2464
+ const keyboardState = await requireVisualKeyboard(bridge, 'type')
2465
+ await bridge.send({ type: 'keyboard', action: 'type', text })
2466
+ const outputText = redactTextForKeyboardState(text, keyboardState as any)
2467
+ await recordInspectAction(
2468
+ 'inspect type',
2469
+ { inputText: outputText },
2470
+ outputText === SECURE_TEXT_REDACTION
2471
+ ? `type ${SECURE_TEXT_REDACTION}`
2472
+ : `type ${JSON.stringify(text)}`,
2473
+ )
2474
+ console.log(
2475
+ outputText === SECURE_TEXT_REDACTION
2476
+ ? ` typed: ${SECURE_TEXT_REDACTION}`
2477
+ : ` typed: ${JSON.stringify(text)}`,
2478
+ )
2479
+ break
2480
+ }
2481
+
2482
+ case 'key': {
2483
+ const name = positional[1]
2484
+ if (!name) {
2485
+ console.error(inspectUsage('key', '<name>'))
2486
+ process.exit(1)
2487
+ }
2488
+ await requireVisualKeyboard(bridge, 'key')
2489
+ await bridge.send({ type: 'keyboard', action: 'press', text: name })
2490
+ await recordInspectAction('inspect key', { pressKey: name }, `key ${name}`)
2491
+ console.log(` pressed: ${name}`)
2492
+ break
2493
+ }
2494
+
2495
+ case 'key-sequence': {
2496
+ const keys = positional.slice(1)
2497
+ if (keys.length === 0) {
2498
+ console.error(inspectUsage('key-sequence', '<key> [<key> ...]'))
2499
+ process.exit(1)
2500
+ }
2501
+ await requireVisualKeyboard(bridge, 'key-sequence')
2502
+ for (const key of keys) {
2503
+ await bridge.send({ type: 'keyboard', action: 'press', text: key })
2504
+ }
2505
+ await recordInspectAction(
2506
+ 'inspect key-sequence',
2507
+ { pressKey: keys.join(' ') },
2508
+ `key-sequence ${keys.join(' ')}`,
2509
+ )
2510
+ console.log(` pressed: ${keys.join(', ')}`)
2511
+ break
2512
+ }
2513
+
2514
+ case 'keycode': {
2515
+ const codes = positional.slice(1)
2516
+ if (codes.length === 0) {
2517
+ console.error(inspectUsage('keycode', '<code> [<code> ...]'))
2518
+ process.exit(1)
2519
+ }
2520
+ const keys = codes.map((code) => ({ code, key: keyCodeToVisualKey(code) }))
2521
+ const unsupported = keys.filter((entry) => !entry.key)
2522
+ if (unsupported.length > 0) {
2523
+ console.error(
2524
+ ` unsupported keycode(s): ${unsupported.map((entry) => entry.code).join(', ')}`,
2525
+ )
2526
+ process.exit(1)
2527
+ }
2528
+ await requireVisualKeyboard(bridge, 'keycode')
2529
+ for (const entry of keys) {
2530
+ await bridge.send({ type: 'keyboard', action: 'press', text: entry.key })
2531
+ }
2532
+ await recordInspectAction(
2533
+ 'inspect keycode',
2534
+ { pressKey: keys.map((entry) => entry.key).join(' ') },
2535
+ `keycode ${codes.join(' ')}`,
2536
+ )
2537
+ console.log(` pressed: ${codes.join(', ')}`)
2538
+ break
2539
+ }
2540
+
2541
+ case 'dispatch': {
2542
+ const ch = positional[1]
2543
+ if (!ch) {
2544
+ console.error(inspectUsage('dispatch', '<char>'))
2545
+ process.exit(1)
2546
+ }
2547
+ await bridge.send({ type: 'keyboard', action: 'dispatchKey', text: ch })
2548
+ await recordInspectAction(
2549
+ 'inspect dispatch',
2550
+ { dispatchKey: ch },
2551
+ `dispatch ${JSON.stringify(ch)}`,
2552
+ )
2553
+ console.log(` dispatched: ${ch}`)
2554
+ break
2555
+ }
2556
+
2557
+ case 'dismiss': {
2558
+ await bridge.send({ type: 'keyboard', action: 'dismiss' })
2559
+ await recordInspectAction(
2560
+ 'inspect dismiss',
2561
+ { hideKeyboard: true },
2562
+ 'dismiss keyboard',
2563
+ )
2564
+ console.log(' keyboard dismissed')
2565
+ break
2566
+ }
2567
+
2568
+ case 'double-tap': {
2569
+ let x = Number(positional[1])
2570
+ let y = Number(positional[2])
2571
+ const target = readTargetFlag(args, positional)
2572
+ if (target) {
2573
+ const resolved = await resolveTargetCoords(bridge, target)
2574
+ if (!resolved) {
2575
+ console.error(` not found: ${target.value}`)
2576
+ if (target.mode === 'testid') {
2577
+ maybeHint('wait-selector-for-missing-testid', target.value)
2578
+ }
2579
+ process.exit(1)
2580
+ }
2581
+ x = resolved.x
2582
+ y = resolved.y
2583
+ }
2584
+ const gapMs = positional[3] ? Number(positional[3]) : 80
2585
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(gapMs)) {
2586
+ console.error(
2587
+ inspectUsage('double-tap', '<testid> | <x> <y> [gapMs] | --testid <id>'),
2588
+ )
2589
+ process.exit(1)
2590
+ }
2591
+ const waitMs = Math.max(0, Math.round(gapMs))
2592
+ const single = await performSingleStep(bridge, {
2593
+ type: 'doubleTap',
2594
+ x,
2595
+ y,
2596
+ gapMs: waitMs,
2597
+ })
2598
+ const result = { ...single, gapMs: waitMs }
2599
+ if (result?.ok) {
2600
+ await recordInspectAction(
2601
+ 'inspect double-tap',
2602
+ { doubleTapAtCoords: { x, y, gapMs: waitMs } },
2603
+ `double-tap @${x},${y}`,
2604
+ )
2605
+ }
2606
+ console.log(JSON.stringify(result, null, 2))
2607
+ break
2608
+ }
2609
+
2610
+ case 'long-press': {
2611
+ let x = Number(positional[1])
2612
+ let y = Number(positional[2])
2613
+ const target = readTargetFlag(args, positional)
2614
+ let resolvedTarget: {
2615
+ id?: string | null
2616
+ testID?: string | null
2617
+ text?: string | null
2618
+ type?: string | null
2619
+ } | null = null
2620
+ if (target) {
2621
+ const resolved = await resolveTargetCoords(bridge, target)
2622
+ if (!resolved) {
2623
+ console.error(` not found: ${target.value}`)
2624
+ if (target.mode === 'testid') {
2625
+ maybeHint('wait-selector-for-missing-testid', target.value)
2626
+ }
2627
+ process.exit(1)
2628
+ }
2629
+ x = resolved.x
2630
+ y = resolved.y
2631
+ resolvedTarget = {
2632
+ id: resolved.id ?? null,
2633
+ testID: resolved.testID ?? null,
2634
+ text: resolved.text ?? null,
2635
+ type: resolved.type ?? null,
2636
+ }
2637
+ }
2638
+ const durationArg = target ? positional[1] : positional[3]
2639
+ const durationMs = durationArg ? Number(durationArg) : 600
2640
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(durationMs)) {
2641
+ console.error(
2642
+ inspectUsage('long-press', '<testid> | <x> <y> [durationMs] | --testid <id>'),
2643
+ )
2644
+ process.exit(1)
2645
+ }
2646
+ const roundedDurationMs = Math.max(0, Math.round(durationMs))
2647
+ const result = await bridge.send({
2648
+ type: 'longPress',
2649
+ x,
2650
+ y,
2651
+ durationMs: roundedDurationMs,
2652
+ target: resolvedTarget,
2653
+ })
2654
+ if (result?.ok) {
2655
+ await recordInspectAction(
2656
+ 'inspect long-press',
2657
+ { tapAtCoords: { x, y } },
2658
+ `long-press @${x},${y}`,
2659
+ )
2660
+ }
2661
+ console.log(JSON.stringify(result, null, 2))
2662
+ break
2663
+ }
2664
+
2665
+ case 'touch': {
2666
+ const phase = positional[1]
2667
+ const x = Number(positional[2])
2668
+ const y = Number(positional[3])
2669
+ const pointerId = positional[4] ? Number(positional[4]) : 999
2670
+ const method =
2671
+ phase === 'down'
2672
+ ? 'touchDown'
2673
+ : phase === 'move'
2674
+ ? 'touchMove'
2675
+ : phase === 'up'
2676
+ ? 'touchUp'
2677
+ : phase === 'cancel'
2678
+ ? 'touchCancel'
2679
+ : null
2680
+ if (!method) {
2681
+ console.error(
2682
+ inspectUsage('touch', '<down|move|up|cancel> <x> <y> [pointerId]'),
2683
+ )
2684
+ process.exit(1)
2685
+ }
2686
+ if (phase !== 'cancel' && (!Number.isFinite(x) || !Number.isFinite(y))) {
2687
+ console.error(
2688
+ inspectUsage('touch', '<down|move|up|cancel> <x> <y> [pointerId]'),
2689
+ )
2690
+ process.exit(1)
2691
+ }
2692
+ // the engine's perform executor emits the matching agent-cursor
2693
+ // action (tap for down, move for move) natively.
2694
+ const roundedPointerId = Math.max(1, Math.round(pointerId))
2695
+ const result = await performSingleStep(
2696
+ bridge,
2697
+ method === 'touchCancel'
2698
+ ? { type: method, pointerId: roundedPointerId }
2699
+ : { type: method, x, y, pointerId: roundedPointerId },
2700
+ )
2701
+ if (result?.ok && phase !== 'cancel') {
2702
+ await recordInspectAction(
2703
+ 'inspect touch',
2704
+ { tapAtCoords: { x, y } },
2705
+ `touch ${phase} @${x},${y}`,
2706
+ )
2707
+ }
2708
+ console.log(JSON.stringify(result, null, 2))
2709
+ break
2710
+ }
2711
+
2712
+ case 'gesture': {
2713
+ const GESTURE_PRESETS = [
2714
+ 'scroll-up',
2715
+ 'scroll-down',
2716
+ 'scroll-left',
2717
+ 'scroll-right',
2718
+ 'swipe-from-left-edge',
2719
+ 'swipe-from-right-edge',
2720
+ 'swipe-from-top-edge',
2721
+ 'swipe-from-bottom-edge',
2722
+ ] as const
2723
+ const preset = positional[1]
2724
+ const durationMs = positional[2] ? Number(positional[2]) : 220
2725
+ if (!preset || !Number.isFinite(durationMs)) {
2726
+ console.error(inspectUsage('gesture', '<preset> [durationMs]'))
2727
+ console.error(` presets: ${GESTURE_PRESETS.join(', ')}`)
2728
+ process.exit(1)
2729
+ }
2730
+ if (!(GESTURE_PRESETS as readonly string[]).includes(preset)) {
2731
+ console.error(` unknown gesture preset: ${preset}`)
2732
+ console.error(` presets: ${GESTURE_PRESETS.join(', ')}`)
2733
+ process.exit(1)
2734
+ }
2735
+ const frame = await bridge.send({
2736
+ type: 'evaluate',
2737
+ code: `(async () => {
2738
+ const spec = globalThis.__sootsimDeviceSpec || {}
2739
+ return {
2740
+ width: spec.width || window.innerWidth || 393,
2741
+ height: spec.height || window.innerHeight || 852,
2742
+ statusBarHeight: spec.statusBarHeight || 0,
2743
+ homeIndicatorHeight: spec.homeIndicatorHeight || 0,
2744
+ }
2745
+ })()`,
2746
+ })
2747
+ const width = Number(frame?.width) || 393
2748
+ const height = Number(frame?.height) || 852
2749
+ const statusBarHeight = Number(frame?.statusBarHeight) || 0
2750
+ const homeIndicatorHeight = Number(frame?.homeIndicatorHeight) || 0
2751
+ const centerX = Math.round(width / 2)
2752
+ const centerY = Math.round(height / 2)
2753
+ const topInset = Math.max(24, statusBarHeight + 18)
2754
+ const bottomInset = Math.max(24, homeIndicatorHeight + 18)
2755
+ const edgeInset = 18
2756
+ const travelY = Math.min(220, Math.round(height * 0.24))
2757
+ const travelX = Math.min(180, Math.round(width * 0.32))
2758
+ let fromX = centerX
2759
+ let fromY = centerY
2760
+ let toX = centerX
2761
+ let toY = centerY
2762
+ switch (preset) {
2763
+ case 'scroll-up':
2764
+ fromY = centerY + Math.round(travelY / 2)
2765
+ toY = centerY - Math.round(travelY / 2)
2766
+ break
2767
+ case 'scroll-down':
2768
+ fromY = centerY - Math.round(travelY / 2)
2769
+ toY = centerY + Math.round(travelY / 2)
2770
+ break
2771
+ case 'scroll-left':
2772
+ fromX = centerX + Math.round(travelX / 2)
2773
+ toX = centerX - Math.round(travelX / 2)
2774
+ break
2775
+ case 'scroll-right':
2776
+ fromX = centerX - Math.round(travelX / 2)
2777
+ toX = centerX + Math.round(travelX / 2)
2778
+ break
2779
+ case 'swipe-from-left-edge':
2780
+ fromX = edgeInset
2781
+ fromY = centerY
2782
+ toX = Math.min(width - edgeInset, edgeInset + travelX)
2783
+ break
2784
+ case 'swipe-from-right-edge':
2785
+ fromX = width - edgeInset
2786
+ fromY = centerY
2787
+ toX = Math.max(edgeInset, width - edgeInset - travelX)
2788
+ break
2789
+ case 'swipe-from-top-edge':
2790
+ fromX = centerX
2791
+ fromY = topInset
2792
+ toY = Math.min(height - bottomInset, topInset + travelY)
2793
+ break
2794
+ case 'swipe-from-bottom-edge':
2795
+ fromX = centerX
2796
+ fromY = height - bottomInset
2797
+ toY = Math.max(topInset, height - bottomInset - travelY)
2798
+ break
2799
+ // no default — `preset` is validated against GESTURE_PRESETS above.
2800
+ }
2801
+ const steps = Math.max(8, Math.round(durationMs / 16))
2802
+ const stepMs = Math.max(1, Math.round(durationMs / steps))
2803
+ const result = await performSingleStep(bridge, {
2804
+ type: 'drag',
2805
+ fromX,
2806
+ fromY,
2807
+ toX,
2808
+ toY,
2809
+ steps,
2810
+ stepMs,
2811
+ })
2812
+ if (result?.ok) {
2813
+ await recordInspectAction(
2814
+ 'inspect gesture',
2815
+ {
2816
+ swipe: {
2817
+ start: `${fromX}, ${fromY}`,
2818
+ end: `${toX}, ${toY}`,
2819
+ duration: Math.max(1, Math.round(durationMs)),
2820
+ },
2821
+ },
2822
+ `gesture ${preset}`,
2823
+ )
2824
+ }
2825
+ console.log(
2826
+ JSON.stringify(
2827
+ { preset, from: { x: fromX, y: fromY }, to: { x: toX, y: toY }, result },
2828
+ null,
2829
+ 2,
2830
+ ),
2831
+ )
2832
+ break
2833
+ }
2834
+
2835
+ case 'scroll': {
2836
+ const target = readTargetFlag(args)
2837
+ const nodeId = readNodeIdFlag(args)
2838
+ const id =
2839
+ target?.mode === 'testid' ? target.value : nodeId == null ? positional[1] : null
2840
+ const offset = target || nodeId != null ? 1 : 2
2841
+ const x = Number(positional[offset])
2842
+ const y = Number(positional[offset + 1])
2843
+ if ((!id && nodeId == null) || !Number.isFinite(x) || !Number.isFinite(y)) {
2844
+ console.error(
2845
+ inspectUsage(
2846
+ 'scroll',
2847
+ '<id> <x> <y> | --testid <id> <x> <y> | --node-id <nodeId> <x> <y>',
2848
+ ),
2849
+ )
2850
+ process.exit(1)
2851
+ }
2852
+ // look up node position first so agent cursor can animate there
2853
+ const scrollNode = await bridge.send({
2854
+ type: 'evaluate',
2855
+ code: `(async () => {
2856
+ const t = window.__sootsimTest
2857
+ if (!t) return null
2858
+ const n = ${
2859
+ nodeId != null
2860
+ ? `await t.inspectByNodeId(${JSON.stringify(nodeId)})`
2861
+ : `await t.findByTestId(${JSON.stringify(id)})
2862
+ || await t.findById(${JSON.stringify(id)})`
2863
+ }
2864
+ if (!n || !n.absolutePosition || !n.layout) return null
2865
+ return {
2866
+ cx: n.absolutePosition.x + (n.layout.width || 0) / 2,
2867
+ cy: n.absolutePosition.y + (n.layout.height || 0) / 2,
2868
+ }
2869
+ })()`,
2870
+ })
2871
+ const result = await callTestBridge<{
2872
+ ok: boolean
2873
+ via?: string
2874
+ reason?: string
2875
+ }>(bridge, 'scrollTo', nodeId != null ? { nodeId } : id, x, y, false)
2876
+ if (result?.ok) {
2877
+ const targetSummary = nodeId != null ? `node ${nodeId}` : `#${id}`
2878
+ await recordInspectAction(
2879
+ 'inspect scroll',
2880
+ { scrollTo: { ...(nodeId != null ? { nodeId } : { id }), x, y } },
2881
+ `scroll ${targetSummary} -> ${x},${y}`,
2882
+ )
2883
+ }
2884
+ console.log(
2885
+ JSON.stringify(
2886
+ {
2887
+ ...result,
2888
+ ...(scrollNode ? { at: { x: scrollNode.cx, y: scrollNode.cy } } : {}),
2889
+ },
2890
+ null,
2891
+ 2,
2892
+ ),
2893
+ )
2894
+ break
2895
+ }
2896
+
2897
+ case 'state': {
2898
+ const stateCmd = positional[1]
2899
+ // `sootsim get state` (no sub-arg) → compact runtime dashboard.
2900
+ // `sootsim debug state <kind>` continues to dispatch the raw dumps.
2901
+ if (verbPrefix === 'get' && !stateCmd) {
2902
+ const base = await callTestBridge<Record<string, any>>(
2903
+ bridge,
2904
+ 'getRuntimeState',
2905
+ )
2906
+ const diag = await bridge.send({
2907
+ type: 'evaluate',
2908
+ code: MERGED_CONSOLE_COUNT_EVAL,
2909
+ })
2910
+ if (base && typeof base === 'object' && base.diagnostics) {
2911
+ base.diagnostics.errors = diag?.errors ?? 0
2912
+ base.diagnostics.warnings = diag?.warnings ?? 0
2913
+ }
2914
+ if (base && typeof base === 'object' && base.shell == null) {
2915
+ try {
2916
+ const shell = await getShellState(bridge)
2917
+ if (shell) base.shell = shell
2918
+ } catch {}
2919
+ }
2920
+ console.log(JSON.stringify(base, null, 2))
2921
+ break
2922
+ }
2923
+ if (!stateCmd || stateCmd === '--help' || stateCmd === '-h') {
2924
+ console.log(`
2925
+ ${inspectCommand('state')} — dump raw runtime state
2926
+
2927
+ subcommands:
2928
+ shell dump shell transition/layout state
2929
+ worker dump render-worker host/animation state
2930
+ keyboard dump keyboard visibility, mode, and focused input
2931
+ ownership dump surface ownership + pointerOnSurface delivery stats
2932
+ worklets dump tenant + shell worklet-runtime slot/handler counts
2933
+ scroll-input dump shell-owned scroll gesture and momentum state
2934
+ scroll-mirror dump shell scroll registry entries and offsets
2935
+ node <id> dump raw node info by id or testID
2936
+ scroll <id> dump scroll metrics and runtime state
2937
+ scroll-hit <x> <y> dump the nearest scroll ancestor at coordinates
2938
+ hit <x> <y> dump the hit-test ancestry at coordinates
2939
+ gesture <x> <y> dump gesture routing/debug info at coordinates
2940
+ gesture-seam dump shell-hosted gesture recognizer state/events
2941
+
2942
+ examples:
2943
+ ${inspectCommand('state')} shell
2944
+ ${inspectCommand('state')} worker
2945
+ ${inspectCommand('state')} keyboard
2946
+ ${inspectCommand('state')} ownership
2947
+ ${inspectCommand('state')} worklets
2948
+ ${inspectCommand('state')} scroll-input
2949
+ ${inspectCommand('state')} scroll-mirror
2950
+ ${inspectCommand('state')} node photos
2951
+ ${inspectCommand('state')} scroll feed
2952
+ ${inspectCommand('state')} scroll-hit 360 420
2953
+ ${inspectCommand('state')} hit 200 720
2954
+ `)
2955
+ break
2956
+ }
2957
+
2958
+ let result: unknown
2959
+ switch (stateCmd) {
2960
+ case 'shell':
2961
+ result = await getShellState(bridge, 500)
2962
+ break
2963
+ case 'worker':
2964
+ result = await callInBridge(bridge, '__sootsimRenderHost.queryStats')
2965
+ break
2966
+ case 'ownership':
2967
+ // plan P1: one authoritative read model for surface ownership
2968
+ // + pointerOnSurface delivery observability. bridges through
2969
+ // __sootsimRenderHost.getOwnershipSnapshot which reads the
2970
+ // shell scene, registered aux surfaces, and
2971
+ // pointer-delivery.ts stats.
2972
+ result = await bridge.send({
2973
+ type: 'evaluate',
2974
+ code: `(() => {
2975
+ const h = window.__sootsimRenderHost
2976
+ if (!h || typeof h.getOwnershipSnapshot !== 'function') {
2977
+ return { error: 'getOwnershipSnapshot not available' }
2978
+ }
2979
+ return h.getOwnershipSnapshot()
2980
+ })()`,
2981
+ })
2982
+ break
2983
+ case 'keyboard':
2984
+ // focused input sourced from __sootsimTest.getFocusedNode() —
2985
+ // single authoritative source. getLayout() ships the full
2986
+ // keyboard state bundle (spec, mode, shifted, capsLock, accessory).
2987
+ result = await bridge.send({
2988
+ type: 'evaluate',
2989
+ code: `(async () => {
2990
+ const kb = window.__sootsimKeyboard
2991
+ const test = window.__sootsimTest
2992
+ if (!kb) return { error: 'keyboard bridge not available' }
2993
+ const layout = typeof kb.getLayout === 'function' ? kb.getLayout() : null
2994
+ const secureTextEntry =
2995
+ !!layout?.spec?.secureTextEntry && layout?.spec?.keyboardType !== 'visible-password'
2996
+ const visible = kb.isVisible()
2997
+ const mode = kb.getMode()
2998
+ let focused = null
2999
+ if (test && typeof test.getFocusedNode === 'function') {
3000
+ try {
3001
+ focused = await test.getFocusedNode()
3002
+ } catch {}
3003
+ }
3004
+ return {
3005
+ visible,
3006
+ mode,
3007
+ layout,
3008
+ focusedInput: focused ? {
3009
+ nodeId: focused.nodeId ?? null,
3010
+ testID: focused.testID || null,
3011
+ id: focused.id || null,
3012
+ placeholder: focused.placeholder || null,
3013
+ secureTextEntry,
3014
+ text: secureTextEntry ? ${JSON.stringify(SECURE_TEXT_REDACTION)} : (focused.text || null),
3015
+ } : null,
3016
+ }
3017
+ })()`,
3018
+ })
3019
+ result = redactKeyboardStateForOutput(result as any)
3020
+ break
3021
+ case 'node': {
3022
+ const id = positional[2]
3023
+ if (!id) {
3024
+ console.error(` usage: ${inspectCommand('state')} node <id>`)
3025
+ process.exit(1)
3026
+ }
3027
+ result =
3028
+ (await callTestBridge(bridge, 'findByTestId', id)) ||
3029
+ (await callTestBridge(bridge, 'findById', id))
3030
+ break
3031
+ }
3032
+ case 'scroll': {
3033
+ const id = positional[2]
3034
+ if (!id) {
3035
+ console.error(` usage: ${inspectCommand('state')} scroll <id>`)
3036
+ process.exit(1)
3037
+ }
3038
+ result = await callTestBridge(bridge, 'getScrollState', id)
3039
+ break
3040
+ }
3041
+ case 'scroll-hit': {
3042
+ const x = Number(positional[2])
3043
+ const y = Number(positional[3])
3044
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3045
+ console.error(` usage: ${inspectCommand('state')} scroll-hit <x> <y>`)
3046
+ process.exit(1)
3047
+ }
3048
+ result = await callTestBridge(bridge, 'getScrollStateAt', x, y)
3049
+ break
3050
+ }
3051
+ case 'hit': {
3052
+ const x = Number(positional[2])
3053
+ const y = Number(positional[3])
3054
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3055
+ console.error(` usage: ${inspectCommand('state')} hit <x> <y>`)
3056
+ process.exit(1)
3057
+ }
3058
+ result = await callTestBridge(bridge, 'debugHitAt', x, y)
3059
+ break
3060
+ }
3061
+ case 'gesture': {
3062
+ const x = Number(positional[2])
3063
+ const y = Number(positional[3])
3064
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
3065
+ console.error(` usage: ${inspectCommand('state')} gesture <x> <y>`)
3066
+ process.exit(1)
3067
+ }
3068
+ result = await callTestBridge(bridge, 'debugGestureAt', x, y)
3069
+ break
3070
+ }
3071
+ case 'gesture-seam':
3072
+ result = await callInBridge(
3073
+ bridge,
3074
+ 'SootSim.bridges.mainShell.callTestBridge',
3075
+ 'getShellGestureSeamDebug',
3076
+ )
3077
+ break
3078
+ case 'worklets': {
3079
+ // both worklet runtimes, side by side. the shell runtime is where
3080
+ // shell-sourced events (keyboard, scroll) are dispatched from, so a
3081
+ // handler the tenant holds locally instead of forwarding will never
3082
+ // fire. compare `eventHandlerNames` across the two to see which
3083
+ // side a given event is waiting on.
3084
+ const [tenant, shell] = await Promise.all([
3085
+ callTestBridge(bridge, 'getWorkletSlotStats'),
3086
+ callInBridge(
3087
+ bridge,
3088
+ 'SootSim.bridges.mainShell.callTestBridge',
3089
+ 'getWorkletSlotStats',
3090
+ ),
3091
+ ])
3092
+ result = { tenant, shell }
3093
+ break
3094
+ }
3095
+ case 'scroll-input':
3096
+ result = await callInBridge(
3097
+ bridge,
3098
+ 'SootSim.bridges.mainShell.callTestBridge',
3099
+ 'getShellScrollInputDebug',
3100
+ )
3101
+ break
3102
+ case 'scroll-mirror':
3103
+ result = await callInBridge(
3104
+ bridge,
3105
+ 'SootSim.bridges.mainShell.callTestBridge',
3106
+ 'getScrollMirrorDebug',
3107
+ )
3108
+ break
3109
+ default:
3110
+ console.error(` unknown state subcommand: ${stateCmd}`)
3111
+ process.exit(1)
3112
+ }
3113
+ console.log(JSON.stringify(result, null, 2))
3114
+ break
3115
+ }
3116
+
3117
+ case 'shell': {
3118
+ const shellCmd = positional[1]
3119
+ if (!shellCmd || shellCmd === '--help' || shellCmd === '-h') {
3120
+ console.log(`
3121
+ ${inspectCommand('shell')} — run built-in shell commands
3122
+
3123
+ subcommands:
3124
+ launch <appId> [waitMs] [--clear-state]
3125
+ launch app and wait for settled shell state
3126
+ home [waitMs] go home and wait for settled shell state
3127
+ switcher [waitMs] open switcher and wait for settled shell state
3128
+ open-card <appId> [waitMs]
3129
+ open a specific switcher card and wait for app settle
3130
+ appearance <light|dark|auto|toggle>
3131
+ update simulator appearance
3132
+ lock toggle device lock state
3133
+ shake trigger the simulator shake gesture
3134
+
3135
+ examples:
3136
+ ${inspectCommand('shell')} launch photos
3137
+ ${inspectCommand('shell')} launch rn --clear-state
3138
+ ${inspectCommand('shell')} launch photos 1500
3139
+ ${inspectCommand('shell')} home 500
3140
+ ${inspectCommand('shell')} switcher 800
3141
+ ${inspectCommand('shell')} open-card clock 800
3142
+ ${inspectCommand('shell')} appearance dark
3143
+ ${inspectCommand('shell')} lock
3144
+ `)
3145
+ break
3146
+ }
3147
+
3148
+ const usesSettleMs =
3149
+ shellCmd === 'launch' ||
3150
+ shellCmd === 'open-card' ||
3151
+ shellCmd === 'home' ||
3152
+ shellCmd === 'switcher'
3153
+ const settleMsRaw =
3154
+ shellCmd === 'launch' || shellCmd === 'open-card'
3155
+ ? positional[3]
3156
+ : positional[2]
3157
+ const settleMs = settleMsRaw ? Number(settleMsRaw) : 350
3158
+ if (usesSettleMs && (!Number.isFinite(settleMs) || settleMs < 0)) {
3159
+ console.error(
3160
+ inspectUsage(
3161
+ 'shell',
3162
+ shellCmd === 'launch' || shellCmd === 'open-card'
3163
+ ? '<launch|open-card> <appId> [settleMs]'
3164
+ : '<home|switcher> [settleMs]',
3165
+ ),
3166
+ )
3167
+ process.exit(1)
3168
+ }
3169
+
3170
+ let ok = false
3171
+ let settled = false
3172
+ let state: Record<string, any> | null = null
3173
+ const clearState = args.includes('--clear-state')
3174
+ if (shellCmd === 'launch') {
3175
+ const appId = positional[2]
3176
+ if (!appId) {
3177
+ console.error(
3178
+ inspectUsage('shell', 'launch <appId> [settleMs] [--clear-state]'),
3179
+ )
3180
+ process.exit(1)
3181
+ }
3182
+ if (clearState) {
3183
+ await bridge.send({
3184
+ type: 'evaluate',
3185
+ code: resetGuestAppStateEval(true),
3186
+ })
3187
+ }
3188
+ ok = !!(await callShellCommandWhenReady(bridge, 'launchApp', settleMs, appId))
3189
+ ;({ settled, state } = await waitForShellState(
3190
+ bridge,
3191
+ Math.round(settleMs),
3192
+ (currentState) =>
3193
+ !!currentState &&
3194
+ currentState.state === 'app' &&
3195
+ currentState.activeApp === appId &&
3196
+ currentState.showSwitcher === false &&
3197
+ currentState.switcherPhase === 'idle' &&
3198
+ typeof currentState.launchProgress === 'number' &&
3199
+ currentState.launchProgress >= 0.98,
3200
+ ))
3201
+ if (ok) {
3202
+ const step = clearState
3203
+ ? { launchApp: { clearState: true } }
3204
+ : { launchApp: {} }
3205
+ const summary = clearState ? 'launch app (clear state)' : 'launch app'
3206
+ await recordInspectAction('inspect shell launch', step, summary)
3207
+ }
3208
+ } else if (shellCmd === 'home') {
3209
+ ok = !!(await callShellCommandWhenReady(bridge, 'goHome', settleMs))
3210
+ ;({ settled, state } = await waitForShellState(
3211
+ bridge,
3212
+ Math.round(settleMs),
3213
+ (currentState) =>
3214
+ !!currentState &&
3215
+ currentState.state === 'home' &&
3216
+ currentState.activeApp == null &&
3217
+ currentState.showSwitcher === false &&
3218
+ currentState.switcherPhase === 'idle' &&
3219
+ typeof currentState.launchProgress === 'number' &&
3220
+ currentState.launchProgress >= 0.98,
3221
+ ))
3222
+ } else if (shellCmd === 'switcher') {
3223
+ ok = !!(await callShellCommandWhenReady(bridge, 'openSwitcher', settleMs))
3224
+ ;({ settled, state } = await waitForShellState(
3225
+ bridge,
3226
+ Math.round(settleMs),
3227
+ (currentState) =>
3228
+ !!currentState &&
3229
+ currentState.state === 'app' &&
3230
+ currentState.showSwitcher === true &&
3231
+ currentState.switcherPhase === 'idle' &&
3232
+ typeof currentState.zoomLevel === 'number' &&
3233
+ Math.abs(currentState.zoomLevel) <= 0.02 &&
3234
+ typeof currentState.horizontalZoom === 'number' &&
3235
+ Math.abs(currentState.horizontalZoom) <= 0.02,
3236
+ ))
3237
+ if (settled) {
3238
+ await sleep(SWITCHER_SETTLE_GRACE_MS)
3239
+ state = await getShellState(bridge)
3240
+ }
3241
+ } else if (shellCmd === 'open-card') {
3242
+ const appId = positional[2]
3243
+ if (!appId) {
3244
+ console.error(inspectUsage('shell', 'open-card <appId> [settleMs]'))
3245
+ process.exit(1)
3246
+ }
3247
+ ok = !!(await callShellCommandWhenReady(
3248
+ bridge,
3249
+ 'openSwitcherCard',
3250
+ settleMs,
3251
+ appId,
3252
+ ))
3253
+ ;({ settled, state } = await waitForShellState(
3254
+ bridge,
3255
+ Math.round(settleMs),
3256
+ (currentState) =>
3257
+ !!currentState &&
3258
+ currentState.state === 'app' &&
3259
+ currentState.activeApp === appId &&
3260
+ currentState.showSwitcher === false &&
3261
+ currentState.switcherPhase === 'idle' &&
3262
+ typeof currentState.zoomLevel === 'number' &&
3263
+ currentState.zoomLevel >= 0.98 &&
3264
+ typeof currentState.horizontalZoom === 'number' &&
3265
+ currentState.horizontalZoom >= 0.98,
3266
+ ))
3267
+ if (ok) {
3268
+ await recordInspectAction(
3269
+ 'inspect shell open-card',
3270
+ { openSwitcherCard: { appId } },
3271
+ `open switcher card ${appId}`,
3272
+ )
3273
+ }
3274
+ } else if (shellCmd === 'appearance') {
3275
+ const mode = positional[2]
3276
+ if (!mode || !['light', 'dark', 'auto', 'toggle'].includes(mode)) {
3277
+ console.error(inspectUsage('shell', 'appearance <light|dark|auto|toggle>'))
3278
+ process.exit(1)
3279
+ }
3280
+ const result = await runShellVisualCommand(bridge, 'appearance', mode)
3281
+ ok = !!result?.ok
3282
+ state = { appearance: result }
3283
+ // human-readable confirmation line. without this the only output
3284
+ // is the multi-line JSON blob below, whose last line is a bare
3285
+ // `}` — useless when scanned or piped through `tail -1` (F13-8).
3286
+ if (ok) {
3287
+ const applied = (result as { applied?: string } | null)?.applied ?? mode
3288
+ console.log(` appearance: ${applied}`)
3289
+ }
3290
+ } else if (shellCmd === 'lock' || shellCmd === 'shake') {
3291
+ const result = await runShellVisualCommand(bridge, shellCmd)
3292
+ ok = !!result?.ok
3293
+ state = { [shellCmd]: result }
3294
+ } else {
3295
+ console.error(` unknown shell subcommand: ${shellCmd}`)
3296
+ process.exit(1)
3297
+ }
3298
+ console.log(JSON.stringify({ ok, settled, state }, null, 2))
3299
+ break
3300
+ }
3301
+
3302
+ case 'url': {
3303
+ await runUrlSubcommand(bridge, { args: effectiveArgs })
3304
+ break
3305
+ }
3306
+
3307
+ case 'reload': {
3308
+ // reload waits for the guest app's `sootsim:externalAppReady` signal
3309
+ // (not just a live ws connection), with a 10s budget. fatal bundle
3310
+ // failures surface as console errors that pile up fast — bail early
3311
+ // in that case instead of waiting the full budget.
3312
+ const READY_TIMEOUT_MS = 10000
3313
+ const ERROR_GRACE_MS = 3000
3314
+ let issuedReload = false
3315
+ let inPlaceReload = false
3316
+ try {
3317
+ await bridge.send({
3318
+ type: 'evaluate',
3319
+ code: 'window.__sootsimConsole?.clear()',
3320
+ })
3321
+ const reloadResult = await bridge.send({
3322
+ type: 'evaluate',
3323
+ code: `;(async () => {
3324
+ // in-place guest reload is only valid while the page still runs
3325
+ // the engine build the server would serve NOW. the shell dev
3326
+ // server has no HMR client, so a long-lived sim tab otherwise
3327
+ // keeps a stale engine forever while builds churn underneath it.
3328
+ // compare the inlined engine manifest against a fresh fetch of
3329
+ // this page's html; any drift means the whole page must reload.
3330
+ let engineStale = false
3331
+ try {
3332
+ const loaded = document.getElementById('__sootsim-engine-manifest')?.textContent
3333
+ if (loaded) {
3334
+ const res = await fetch(location.href, { cache: 'no-store' })
3335
+ const html = await res.text()
3336
+ const m = html.match(/<script[^>]*id="__sootsim-engine-manifest"[^>]*>([^<]*)<\\/script>/)
3337
+ if (m && m[1] !== loaded) engineStale = true
3338
+ }
3339
+ } catch {}
3340
+ const reloadExternalApp = window.SootSim?.bridges?.hotRemount?.reloadExternalApp
3341
+ if (!engineStale && typeof reloadExternalApp === 'function') {
3342
+ reloadExternalApp()
3343
+ return { kind: 'external-app' }
3344
+ }
3345
+ window.location.reload()
3346
+ return { kind: 'page', engineStale }
3347
+ })()`,
3348
+ })
3349
+ inPlaceReload = !!reloadResult && reloadResult.kind === 'external-app'
3350
+ issuedReload = true
3351
+ if (reloadResult && reloadResult.engineStale) {
3352
+ console.log(' engine build changed since page load — full page reload')
3353
+ }
3354
+ } catch {
3355
+ // the sim may already be in the middle of reloading; fall through to reconnect wait
3356
+ }
3357
+ console.log(' reloading...')
3358
+ let finalBridge: WsBridge | null = bridge
3359
+ let readyResult: ReloadReadyResult | null = null
3360
+ if (inPlaceReload) {
3361
+ readyResult = await pollForReloadReady(bridge, {
3362
+ timeoutMs: READY_TIMEOUT_MS,
3363
+ errorGraceMs: ERROR_GRACE_MS,
3364
+ })
3365
+ } else {
3366
+ // full page reload — wait for a fresh bridge before polling for ready.
3367
+ // engine-drift escalation made page reloads the COMMON case during
3368
+ // engine iteration, and a full engine boot runs 5-8s unloaded and
3369
+ // well past 10s on a busy box — a 10s reconnect budget here reads
3370
+ // as "bridge-reconnect fails" exactly when someone is rebuilding.
3371
+ const PAGE_RELOAD_TIMEOUT_MS = 30000
3372
+ if (issuedReload) await sleep(300)
3373
+ const reconnected = await waitForBridgeConnected(
3374
+ wsPort,
3375
+ commandTimeoutMs,
3376
+ simId,
3377
+ { timeoutMs: PAGE_RELOAD_TIMEOUT_MS, simIdSource },
3378
+ )
3379
+ if (reconnected) {
3380
+ finalBridge = reconnected
3381
+ readyResult = await pollForReloadReady(reconnected, {
3382
+ timeoutMs: PAGE_RELOAD_TIMEOUT_MS,
3383
+ errorGraceMs: ERROR_GRACE_MS,
3384
+ })
3385
+ } else {
3386
+ console.log(
3387
+ ` ⚠ reload: bridge never reconnected within ${PAGE_RELOAD_TIMEOUT_MS}ms`,
3388
+ )
3389
+ finalBridge = null
3390
+ }
3391
+ }
3392
+ if (readyResult) {
3393
+ if (readyResult.ready) {
3394
+ const suffix =
3395
+ readyResult.source === 'nodes-fallback'
3396
+ ? ' (no ready signal, node-count fallback)'
3397
+ : ''
3398
+ console.log(
3399
+ ` ready in ${readyResult.elapsedMs}ms: ${readyResult.nodes} nodes${suffix}`,
3400
+ )
3401
+ } else if (readyResult.source === 'error-bail') {
3402
+ console.log(
3403
+ ` ⚠ reload bailed after ${readyResult.elapsedMs}ms: ${readyResult.errors} console error(s), ready signal never fired`,
3404
+ )
3405
+ } else {
3406
+ const reason = describeReloadReadyBlocker(readyResult)
3407
+ console.log(
3408
+ ` ⚠ reload timed out after ${readyResult.elapsedMs}ms — ${reason} (nodes: ${readyResult.nodes}, targets: ${readyResult.targets}, errors: ${readyResult.errors})`,
3409
+ )
3410
+ }
3411
+ }
3412
+ // dump captured console errors for context, regardless of outcome.
3413
+ // merged read so a render-worker / root-error-boundary mount crash
3414
+ // (forwarded only through the observability store) is shown here.
3415
+ if (finalBridge) {
3416
+ try {
3417
+ const errors = await inspectErrors(finalBridge, 10)
3418
+ if (finalBridge !== bridge) {
3419
+ finalBridge.close()
3420
+ }
3421
+ if (Array.isArray(errors) && errors.length > 0) {
3422
+ console.log(`\n ⚠ ${errors.length} error(s) during mount:\n`)
3423
+ for (const e of errors) {
3424
+ const msg = e.args
3425
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
3426
+ .join(' ')
3427
+ console.log(` ${msg}`)
3428
+ if (e.stack) {
3429
+ const lines = e.stack.split('\n').slice(0, 2)
3430
+ for (const line of lines) {
3431
+ console.log(` ${line.trim()}`)
3432
+ }
3433
+ }
3434
+ }
3435
+ }
3436
+ } catch {
3437
+ // ignore errors checking for errors
3438
+ }
3439
+ }
3440
+ if (readyResult && !readyResult.ready) {
3441
+ process.exitCode = 1
3442
+ }
3443
+ break
3444
+ }
3445
+
3446
+ case 'storage-clear': {
3447
+ const READY_TIMEOUT_MS = 10000
3448
+ const ERROR_GRACE_MS = 3000
3449
+ await clearConsole(bridge)
3450
+ const cleared = await bridge.send({
3451
+ type: 'evaluate',
3452
+ code: resetGuestAppStateEval(true),
3453
+ })
3454
+ if (cleared !== true) {
3455
+ if (wantsJson(effectiveArgs)) {
3456
+ printJson({
3457
+ cleared: false,
3458
+ ready: false,
3459
+ error: 'external app reload bridge is unavailable',
3460
+ })
3461
+ } else {
3462
+ console.error(
3463
+ ' storage clear failed: external app reload bridge is unavailable',
3464
+ )
3465
+ }
3466
+ process.exitCode = 1
3467
+ break
3468
+ }
3469
+
3470
+ const readyResult = await pollForReloadReady(bridge, {
3471
+ timeoutMs: READY_TIMEOUT_MS,
3472
+ errorGraceMs: ERROR_GRACE_MS,
3473
+ })
3474
+
3475
+ if (wantsJson(effectiveArgs)) {
3476
+ printJson({ cleared: true, ready: readyResult.ready, reload: readyResult })
3477
+ if (!readyResult.ready) process.exitCode = 1
3478
+ break
3479
+ }
3480
+
3481
+ if (readyResult.ready) {
3482
+ const suffix =
3483
+ readyResult.source === 'nodes-fallback'
3484
+ ? ' (no ready signal, node-count fallback)'
3485
+ : ''
3486
+ console.log(
3487
+ ` cleared tenant storage; ready in ${readyResult.elapsedMs}ms: ${readyResult.nodes} nodes${suffix}`,
3488
+ )
3489
+ } else if (readyResult.source === 'error-bail') {
3490
+ console.log(
3491
+ ` ⚠ storage clear reloaded but bailed after ${readyResult.elapsedMs}ms: ${readyResult.errors} console error(s), ready signal never fired`,
3492
+ )
3493
+ } else {
3494
+ const reason = describeReloadReadyBlocker(readyResult)
3495
+ console.log(
3496
+ ` ⚠ storage clear reloaded but timed out after ${readyResult.elapsedMs}ms — ${reason} (nodes: ${readyResult.nodes}, targets: ${readyResult.targets}, errors: ${readyResult.errors})`,
3497
+ )
3498
+ }
3499
+
3500
+ try {
3501
+ const errors = await inspectErrors(bridge, 10)
3502
+ if (Array.isArray(errors) && errors.length > 0) {
3503
+ console.log(`\n ⚠ ${errors.length} error(s) during mount:\n`)
3504
+ for (const e of errors) {
3505
+ const msg = e.args
3506
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
3507
+ .join(' ')
3508
+ console.log(` ${msg}`)
3509
+ if (e.stack) {
3510
+ const lines = e.stack.split('\n').slice(0, 2)
3511
+ for (const line of lines) {
3512
+ console.log(` ${line.trim()}`)
3513
+ }
3514
+ }
3515
+ }
3516
+ }
3517
+ } catch {
3518
+ // ignore errors checking for errors
3519
+ }
3520
+ if (!readyResult.ready) {
3521
+ process.exitCode = 1
3522
+ }
3523
+ break
3524
+ }
3525
+
3526
+ case 'eval':
3527
+ case 'js': {
3528
+ const code = positional.slice(1).join(' ')
3529
+ if (!code) {
3530
+ console.error(inspectUsage('js', '<javascript>'))
3531
+ console.error('')
3532
+ console.error(' runs the snippet in the engine realm. SootSim is the')
3533
+ console.error(' canonical state surface — reach into it directly.')
3534
+ console.error('')
3535
+ console.error(' examples:')
3536
+ console.error(
3537
+ ` ${inspectCommand('js')} SootSim.bridges.test.findByText("Sign in")`,
3538
+ )
3539
+ console.error(
3540
+ ` ${inspectCommand('js')} SootSim.bridges.debug.snapshot("before")`,
3541
+ )
3542
+ console.error(
3543
+ ` ${inspectCommand('js')} SootSim.bridges.keyboard.type("hello")`,
3544
+ )
3545
+ console.error(` ${inspectCommand('js')} SootSim.state.root.children.length`)
3546
+ process.exit(1)
3547
+ }
3548
+ // --tenant: evaluate inside the TENANT worker's global scope (guest
3549
+ // bundle, metro module table, compat stubs) via the test bridge's
3550
+ // evalInTenant escape hatch, instead of the host/engine realm.
3551
+ if (args.includes('--tenant')) {
3552
+ // the flag itself lands in the joined positional tail — drop it
3553
+ const tenantCode = positional
3554
+ .slice(1)
3555
+ .filter((t) => t !== '--tenant')
3556
+ .join(' ')
3557
+ const result = await bridge.send({
3558
+ type: 'evaluate',
3559
+ code: `(async () => SootSim.bridges.test.evalInTenant(${JSON.stringify(tenantCode)}))()`,
3560
+ })
3561
+ console.log(JSON.stringify(result, null, 2))
3562
+ break
3563
+ }
3564
+ // wrap in async to support await in worker mode. no identifier
3565
+ // rewriting — the snippet runs verbatim so tokens like `root`,
3566
+ // `debug`, `test` refer to whatever the user actually typed.
3567
+ let expandedCode = code
3568
+ if (!expandedCode.startsWith('(async')) {
3569
+ expandedCode = `(async () => ${expandedCode})()`
3570
+ }
3571
+ const result = await bridge.send({ type: 'evaluate', code: expandedCode })
3572
+ console.log(JSON.stringify(result, null, 2))
3573
+
3574
+ // suggest CLI commands for common verbose eval patterns
3575
+ const lc = code.toLowerCase()
3576
+ const suggestions: string[] = []
3577
+ if (lc.includes('sootsim:gohome') || lc.includes('gohome'))
3578
+ suggestions.push('rnxsim shell home')
3579
+ if (lc.includes('sootsim:appswitcher') || lc.includes('appswitcher'))
3580
+ suggestions.push('rnxsim shell switcher')
3581
+ if (lc.includes('keyboard.isvisible') || lc.includes('keyboard.getmode'))
3582
+ suggestions.push('rnxsim debug state keyboard')
3583
+ if (lc.includes('interact.tap')) suggestions.push('rnxsim do tap <x> <y>')
3584
+ if (lc.includes('keyboard.type')) suggestions.push('rnxsim do type <text>')
3585
+ if (lc.includes('keyboard.press') || lc.includes('keyboard.dispatchkey'))
3586
+ suggestions.push('rnxsim do key <name>')
3587
+ if (lc.includes('keyboard.dismiss')) suggestions.push('rnxsim do dismiss')
3588
+ if (lc.includes('dumptree')) suggestions.push('rnxsim get tree')
3589
+ if (lc.includes('dumpaccessibilitytree')) suggestions.push('rnxsim get a11y')
3590
+ if (lc.includes('getnodecount')) suggestions.push('rnxsim get count')
3591
+ if (lc.includes('findbytext')) suggestions.push('rnxsim find <text>')
3592
+ if (lc.includes('findbytestid') || lc.includes('findbyid'))
3593
+ suggestions.push('rnxsim find --testid <id>')
3594
+ if (lc.includes('document.hidden'))
3595
+ suggestions.push('rnxsim debug state keyboard (includes tab health)')
3596
+ if (suggestions.length > 0) {
3597
+ maybeHint('prefer-cli-over-eval', suggestions)
3598
+ }
3599
+
3600
+ break
3601
+ }
3602
+
3603
+ case 'globals': {
3604
+ // show all available sootsim globals and their methods
3605
+ const info = await bridge.send({
3606
+ type: 'evaluate',
3607
+ code: `(async () => {
3608
+ const globals = {}
3609
+
3610
+ // test bridge (proxy in worker mode)
3611
+ const testMethods = [
3612
+ 'findById', 'findByTestId', 'findByText', 'findByLabel', 'findByRole',
3613
+ 'findAllByRole', 'findByA11yState', 'findAllByA11yState', 'findByHint',
3614
+ 'findPressable', 'getStyle', 'getLayout', 'getAbsolutePosition', 'isVisible',
3615
+ 'queryAll', 'dumpTree', 'dumpAccessibilityTree', 'getNodeCount', 'getShellState',
3616
+ 'getScrollState', 'getScrollStateAt', 'scrollTo', 'waitForTree',
3617
+ 'waitForScreenTransitions', 'debugByText', 'debugByTestId', 'debugHitAt',
3618
+ 'debugGestureAt'
3619
+ ]
3620
+ globals['test (→ __sootsimTest)'] = testMethods
3621
+
3622
+ // debug
3623
+ if (window.__sootsimDebug) {
3624
+ globals['debug (→ __sootsimDebug)'] = Object.keys(window.__sootsimDebug)
3625
+ }
3626
+
3627
+ // interact
3628
+ if (window.__sootsimInteract) {
3629
+ globals['interact (→ __sootsimInteract)'] = Object.keys(window.__sootsimInteract)
3630
+ }
3631
+
3632
+ // keyboard
3633
+ if (window.__sootsimKeyboard) {
3634
+ globals['keyboard (→ __sootsimKeyboard)'] = Object.keys(window.__sootsimKeyboard)
3635
+ }
3636
+
3637
+ // other globals
3638
+ globals['other'] = [
3639
+ 'root (→ __sootsimRoot) - live node tree',
3640
+ 'render() (→ __sootsimForceRender) - force re-render'
3641
+ ]
3642
+
3643
+ return globals
3644
+ })()`,
3645
+ })
3646
+ console.log(' rnx JS API:\n')
3647
+ for (const [name, methods] of Object.entries(info as Record<string, string[]>)) {
3648
+ console.log(` ${name}:`)
3649
+ for (const m of methods) {
3650
+ console.log(` .${m}`)
3651
+ }
3652
+ console.log('')
3653
+ }
3654
+ console.log(` use: ${inspectCommand('js')} <expression>`)
3655
+ console.log(` example: ${inspectCommand('js')} test.findByText("Sign in")`)
3656
+ break
3657
+ }
3658
+
3659
+ case 'describe': {
3660
+ await runDescribeSubcommand({ bridge, args, positional })
3661
+ break
3662
+ }
3663
+
3664
+ case 'perf': {
3665
+ if (invocationPrefix === 'debug' && !opts.internalPerfCommand) {
3666
+ console.error(' `rnxsim debug perf` was removed. use `rnxsim perf shell ...`.')
3667
+ process.exit(1)
3668
+ }
3669
+ const perfCmd = positional[1]
3670
+ if (opts.internalPerfCommand === 'scroll') {
3671
+ if (!perfCmd || perfCmd === '--help' || perfCmd === '-h') {
3672
+ console.log(`
3673
+ ${inspectCommand('perf')} <start|stop> [options]
3674
+
3675
+ records per-frame scroll offsets from the tenant worker, shell worker,
3676
+ and compositor worker. timestamps use the shared wall clock, while frame
3677
+ sequences remain separate because the workers do not share a frame id.
3678
+
3679
+ options:
3680
+ --limit <n> maximum samples retained per layer (default 6000)
3681
+ --json emit every per-frame sample on stop
3682
+
3683
+ examples:
3684
+ ${inspectCommand('perf')} start
3685
+ # ... perform consecutive swipes ...
3686
+ ${inspectCommand('perf')} stop
3687
+ ${inspectCommand('perf')} stop --json
3688
+ `)
3689
+ break
3690
+ }
3691
+ if (perfCmd === 'start') {
3692
+ const limitValue = effectiveArgs.find(
3693
+ (_, index) => effectiveArgs[index - 1] === '--limit',
3694
+ )
3695
+ const parsedLimit = limitValue === undefined ? 6000 : Number(limitValue)
3696
+ if (!Number.isFinite(parsedLimit) || parsedLimit < 120) {
3697
+ console.error(' error: --limit must be a number of at least 120')
3698
+ process.exit(1)
3699
+ }
3700
+ const result = await bridge.send({
3701
+ type: 'evaluate',
3702
+ code: `(async () => {
3703
+ const perf = window.SootSim?.bridges?.scrollPerf
3704
+ if (!perf) return { error: 'scroll performance profile unavailable' }
3705
+ await perf.start(${Math.floor(parsedLimit)})
3706
+ return { started: true }
3707
+ })()`,
3708
+ })
3709
+ if (result?.error) {
3710
+ console.error(` error: ${result.error}`)
3711
+ process.exit(1)
3712
+ }
3713
+ console.log(
3714
+ ` scroll profiling started: perform consecutive swipes, then run '${inspectCommand('perf')} stop'`,
3715
+ )
3716
+ break
3717
+ }
3718
+ if (perfCmd === 'stop') {
3719
+ const result = await bridge.send({
3720
+ type: 'evaluate',
3721
+ code: `(async () => {
3722
+ const perf = window.SootSim?.bridges?.scrollPerf
3723
+ if (!perf) return { error: 'scroll performance profile unavailable' }
3724
+ return await perf.stop()
3725
+ })()`,
3726
+ })
3727
+ if (result?.error) {
3728
+ console.error(` error: ${result.error}`)
3729
+ process.exit(1)
3730
+ }
3731
+ const trace: SootSimScrollPerformanceTrace = result
3732
+ if (wantsJson(effectiveArgs)) {
3733
+ printJson(trace)
3734
+ break
3735
+ }
3736
+ console.log(` scroll performance trace:\n`)
3737
+ console.log(
3738
+ ` duration: ${trace.stoppedAt - trace.startedAt}ms (worker series are timestamp-aligned, not frame-paired)`,
3739
+ )
3740
+ console.log(
3741
+ ` samples: tenant ${trace.layers.tenant.length} · shell ${trace.layers.shell.length} · compositor ${trace.layers.compositor.length}`,
3742
+ )
3743
+ console.log(``)
3744
+ console.log(
3745
+ ` offset and phase changes (--json includes every per-frame sample):`,
3746
+ )
3747
+ console.log(
3748
+ ` t(ms) layer surface node offsetY slotY phase paint`,
3749
+ )
3750
+ const changedSamples: SootSimScrollPerformanceTrace['layers']['tenant'] = []
3751
+ for (const samples of Object.values(trace.layers)) {
3752
+ const lastByNode = new Map<string, string>()
3753
+ for (const sample of samples) {
3754
+ const key = `${sample.surfaceId}:${sample.nodeId}`
3755
+ const signature = `${sample.offsetY}:${sample.slotY ?? ''}:${sample.phase ?? ''}`
3756
+ if (lastByNode.get(key) === signature) continue
3757
+ lastByNode.set(key, signature)
3758
+ changedSamples.push(sample)
3759
+ }
3760
+ }
3761
+ changedSamples.sort((a, b) => a.t - b.t || a.seq - b.seq)
3762
+ for (const sample of changedSamples) {
3763
+ console.log(
3764
+ ` ${String(sample.t - trace.startedAt).padStart(5)} ${sample.layer.padEnd(10)} ${sample.surfaceId.padEnd(8)} ${String(sample.nodeId).padStart(5)} ${sample.offsetY.toFixed(2).padStart(8)} ${sample.slotY === undefined ? ' -' : sample.slotY.toFixed(2).padStart(8)} ${(sample.phase ?? '-').padEnd(8)} ${sample.paint === undefined ? '-' : sample.paint}`,
3765
+ )
3766
+ }
3767
+ break
3768
+ }
3769
+ console.error(` unknown scroll perf command: ${perfCmd}`)
3770
+ process.exit(1)
3771
+ }
3772
+ if (!perfCmd || perfCmd === '--help' || perfCmd === '-h') {
3773
+ console.log(`
3774
+ ${inspectCommand('perf')} — shell frame profiling (the worker that paints)
3775
+
3776
+ records per-painted-frame timing in the shell worker — the surface that
3777
+ actually draws the app's pixels — merged with render-profile counters
3778
+ (node visits, boundary records/replays, raster tier, blur ms, draw calls).
3779
+
3780
+ subcommands:
3781
+ start begin recording (clears prior frames + counters)
3782
+ stop stop recording and report results
3783
+ transition <e> profile a shell transition (goHome, appSwitcher, lockScreen)
3784
+
3785
+ stop and transition accept --json for the full machine-readable payload.
3786
+
3787
+ examples:
3788
+ ${inspectCommand('perf')} start
3789
+ # ... interact with the app ...
3790
+ ${inspectCommand('perf')} stop
3791
+ ${inspectCommand('perf')} stop --json
3792
+ ${inspectCommand('perf')} transition goHome
3793
+ `)
3794
+ break
3795
+ }
3796
+
3797
+ switch (perfCmd) {
3798
+ case 'start': {
3799
+ const result = await bridge.send({
3800
+ type: 'evaluate',
3801
+ code: `(() => {
3802
+ if (!window.__sootsimShellPerf) {
3803
+ return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
3804
+ }
3805
+ window.__sootsimShellPerf.start()
3806
+ return { started: true }
3807
+ })()`,
3808
+ })
3809
+ if (result?.error) {
3810
+ console.error(` error: ${result.error}`)
3811
+ process.exit(1)
3812
+ }
3813
+ console.log(
3814
+ ` shell profiling started — interact with the app, then run '${inspectCommand('perf')} stop'`,
3815
+ )
3816
+ break
3817
+ }
3818
+
3819
+ case 'stop': {
3820
+ const result = await bridge.send({
3821
+ type: 'evaluate',
3822
+ code: `(async () => {
3823
+ if (!window.__sootsimShellPerf) {
3824
+ return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
3825
+ }
3826
+ return await window.__sootsimShellPerf.stop()
3827
+ })()`,
3828
+ })
3829
+ if (result?.error) {
3830
+ console.error(` error: ${result.error}`)
3831
+ if (result.error === 'timeout') {
3832
+ console.error(
3833
+ ' (shell worker did not answer within 5s — is a sim loaded?)',
3834
+ )
3835
+ }
3836
+ process.exit(1)
3837
+ }
3838
+ if (wantsJson(effectiveArgs)) {
3839
+ printJson(result)
3840
+ break
3841
+ }
3842
+ printShellPerfReport(result)
3843
+ break
3844
+ }
3845
+
3846
+ case 'transition': {
3847
+ const event = positional[2]
3848
+ const validEvents = ['goHome', 'appSwitcher', 'lockScreen']
3849
+ if (!event || !validEvents.includes(event)) {
3850
+ console.log(`
3851
+ ${inspectCommand('perf')} transition <event> — profile a shell transition
3852
+
3853
+ events:
3854
+ goHome swipe-to-home animation
3855
+ appSwitcher app switcher card animation
3856
+ lockScreen lock screen transition
3857
+
3858
+ note: uses 600ms capture window — may need --timeout 10000 flag
3859
+
3860
+ examples:
3861
+ ${inspectCommand('perf')} transition goHome --timeout 10000
3862
+ ${inspectCommand('perf')} transition appSwitcher
3863
+ `)
3864
+ break
3865
+ }
3866
+
3867
+ const eventName = `sootsim:${event}`
3868
+ // transition profiling needs a longer timeout due to the 600ms
3869
+ // wait. these are progress/framing lines, so keep them on stderr —
3870
+ // stdout must stay a clean JSON document under --json.
3871
+ printWarn(` profiling ${event} transition...`)
3872
+ printWarn(` (use --timeout 10000 if this times out)`)
3873
+
3874
+ const result = await bridge.send({
3875
+ type: 'evaluate',
3876
+ code: `(async () => {
3877
+ if (!window.__sootsimShellPerf) {
3878
+ return { error: 'shell frame profile unavailable (__sootsimShellPerf missing on the page)' }
3879
+ }
3880
+ window.__sootsimShellPerf.start()
3881
+ // give a frame for profiling to engage before the event
3882
+ await new Promise(r => requestAnimationFrame(() => r(undefined)))
3883
+ window.dispatchEvent(new Event('${eventName}'))
3884
+ // shell animations are ~300-500ms; fixed timing avoids complex
3885
+ // animation-end detection
3886
+ await new Promise(r => setTimeout(r, 600))
3887
+ return await window.__sootsimShellPerf.stop()
3888
+ })()`,
3889
+ })
3890
+ if (result?.error) {
3891
+ console.error(` error: ${result.error}`)
3892
+ process.exit(1)
3893
+ }
3894
+ if (wantsJson(effectiveArgs)) {
3895
+ printJson(result)
3896
+ break
3897
+ }
3898
+ printWarn(` ${event} transition profiled:`)
3899
+ printShellPerfReport(result)
3900
+ break
3901
+ }
3902
+
3903
+ default:
3904
+ console.error(` unknown perf subcommand: ${perfCmd}`)
3905
+ console.error(` valid: start, stop, transition`)
3906
+ // people reach for `perf --reset` (or `reset`) out of habit, but
3907
+ // `perf start` already clears the frame buffer — there is no
3908
+ // separate reset step. say so instead of just erroring.
3909
+ if (/^--?reset$/.test(perfCmd)) {
3910
+ console.error(
3911
+ ` note: 'perf start' already clears prior frames — no reset needed`,
3912
+ )
3913
+ }
3914
+ if (perfCmd === 'stats' || perfCmd === 'frames' || perfCmd === 'worst') {
3915
+ console.error(
3916
+ ` note: the tenant sampler (stats/frames/worst) was removed — it hardcoded`,
3917
+ )
3918
+ console.error(
3919
+ ` layout/render/copy to zero because the shell worker owns every real paint.`,
3920
+ )
3921
+ console.error(
3922
+ ` use 'perf start' / 'perf stop' (worst frames are in the stop report).`,
3923
+ )
3924
+ }
3925
+ process.exit(1)
3926
+ }
3927
+ break
3928
+ }
3929
+ case 'errors': {
3930
+ const subcmd = positional[1]
3931
+ if (subcmd === 'clear') {
3932
+ await clearConsole(bridge)
3933
+ if (wantsJson(effectiveArgs)) printJson({ cleared: true })
3934
+ else console.log(' error buffer cleared')
3935
+ break
3936
+ }
3937
+ const limit = subcmd ? Number(subcmd) : 20
3938
+ const list = await inspectErrors(bridge, limit)
3939
+ if (wantsJson(effectiveArgs)) {
3940
+ printJson(list)
3941
+ break
3942
+ }
3943
+ if (list.length === 0) {
3944
+ console.log(' no errors captured')
3945
+ break
3946
+ }
3947
+ console.log(` ${list.length} error(s):\n`)
3948
+ for (const e of list) {
3949
+ const time = formatLogTimestamp(e.timestamp)
3950
+ const msg = e.args
3951
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
3952
+ .join(' ')
3953
+ console.log(` [${time}] ${msg}`)
3954
+ if (e.stack) {
3955
+ const lines = e.stack.split('\n').slice(0, 3)
3956
+ for (const line of lines) {
3957
+ console.log(` ${line.trim()}`)
3958
+ }
3959
+ }
3960
+ }
3961
+ break
3962
+ }
3963
+
3964
+ case 'warnings': {
3965
+ const limit = positional[1] ? Number(positional[1]) : 20
3966
+ const list = await inspectWarnings(bridge, limit)
3967
+ if (wantsJson(effectiveArgs)) {
3968
+ printJson(list)
3969
+ break
3970
+ }
3971
+ if (list.length === 0) {
3972
+ console.log(' no warnings captured')
3973
+ break
3974
+ }
3975
+ console.log(` ${list.length} warning(s):\n`)
3976
+ for (const w of list) {
3977
+ const time = formatLogTimestamp(w.timestamp)
3978
+ const msg = w.args
3979
+ .map((a: any) => (typeof a === 'object' ? JSON.stringify(a) : a))
3980
+ .join(' ')
3981
+ console.log(` [${time}] ${msg}`)
3982
+ }
3983
+ break
3984
+ }
3985
+
3986
+ case 'animations': {
3987
+ const anims =
3988
+ (await callTestBridge<Array<Record<string, any>>>(bridge, 'listAnimations')) ??
3989
+ []
3990
+ if (args.includes('--json')) {
3991
+ console.log(JSON.stringify(anims, null, 2))
3992
+ break
3993
+ }
3994
+ if (anims.length === 0) {
3995
+ console.log(' no active animations')
3996
+ break
3997
+ }
3998
+ console.log(` ${anims.length} active animation(s):\n`)
3999
+ for (const a of anims) {
4000
+ const realm = String(a.realm ?? 'tenant').padEnd(6)
4001
+ const kind = String(a.kind).padEnd(10)
4002
+ // a native-driver registration without an advertised graph has no
4003
+ // knowable endpoints or progress; print what is real, not NaN.
4004
+ const range =
4005
+ typeof a.from === 'number' && typeof a.to === 'number'
4006
+ ? `${a.from.toFixed(2)}→${a.to.toFixed(2)}`
4007
+ : '—'
4008
+ const cur = Number(a.current ?? 0).toFixed(2)
4009
+ const pct =
4010
+ typeof a.progress === 'number' ? `${Math.round(a.progress * 100)}%` : '—'
4011
+ const ms = `${Math.round(a.elapsedMs ?? 0)}ms`
4012
+ const tags = [
4013
+ a.loop ? 'loop' : null,
4014
+ a.layoutBound ? 'layout' : null,
4015
+ a.remoteDriven ? 'remote-driven' : a.graphBacked ? 'graph' : null,
4016
+ a.visible === false ? 'offscreen' : null,
4017
+ ].filter(Boolean)
4018
+ const tag = tags.length > 0 ? ` [${tags.join(' ')}]` : ''
4019
+ console.log(
4020
+ ` #${a.id} ${realm} ${kind} ${range.padEnd(14)} cur=${cur.padEnd(7)} ${pct.padStart(4)} ${ms}${tag}`,
4021
+ )
4022
+ }
4023
+ break
4024
+ }
4025
+
4026
+ case 'animation': {
4027
+ const raw = positional[1]
4028
+ if (!raw || raw === '--help' || raw === '-h') {
4029
+ console.error(` usage: ${inspectCommand('animation')} <id>`)
4030
+ process.exit(1)
4031
+ }
4032
+ const id = Number(raw)
4033
+ if (!Number.isFinite(id)) {
4034
+ console.error(` invalid id: ${raw}`)
4035
+ process.exit(1)
4036
+ }
4037
+ const result = await callTestBridge(bridge, 'getAnimation', id)
4038
+ console.log(JSON.stringify(result, null, 2))
4039
+ break
4040
+ }
4041
+
4042
+ case 'stop-animation': {
4043
+ const raw = positional[1]
4044
+ if (!raw || raw === '--help' || raw === '-h') {
4045
+ console.error(` usage: ${inspectCommand('stop-animation')} <id|all>`)
4046
+ process.exit(1)
4047
+ }
4048
+ const target: number | 'all' = raw === 'all' ? 'all' : Number(raw)
4049
+ if (target !== 'all' && !Number.isFinite(target)) {
4050
+ console.error(` invalid id: ${raw}`)
4051
+ process.exit(1)
4052
+ }
4053
+ const count = await callTestBridge<number>(bridge, 'stopAnimation', target)
4054
+ console.log(` stopped ${count ?? 0} animation(s)`)
4055
+ break
4056
+ }
4057
+
4058
+ case 'requests': {
4059
+ const subcmd = positional[1]
4060
+ if (subcmd === 'clear') {
4061
+ await clearRequests(bridge)
4062
+ if (wantsJson(effectiveArgs)) printJson({ cleared: true })
4063
+ else console.log(' request buffer cleared')
4064
+ break
4065
+ }
4066
+
4067
+ const showAll = subcmd === 'all'
4068
+ const rawLimit = showAll ? positional[2] : subcmd
4069
+ const limit = rawLimit ? Number(rawLimit) : 20
4070
+ const list = await inspectRequests(bridge, { failed: !showAll, limit })
4071
+ if (wantsJson(effectiveArgs)) {
4072
+ printJson(list)
4073
+ break
4074
+ }
4075
+ if (list.length === 0) {
4076
+ console.log(
4077
+ showAll ? ' no requests captured' : ' no failed requests captured',
4078
+ )
4079
+ break
4080
+ }
4081
+
4082
+ console.log(` ${list.length} ${showAll ? 'request(s)' : 'failed request(s)'}:\n`)
4083
+ for (const entry of list) {
4084
+ const time = formatLogTimestamp(entry.timestamp)
4085
+ console.log(` [${time}] ${formatRequestMessage(entry)}`)
4086
+ if (entry.responseBody) {
4087
+ console.log(` ${entry.responseBody}`)
4088
+ } else if (entry.error) {
4089
+ console.log(` ${entry.error}`)
4090
+ }
4091
+ }
4092
+ break
4093
+ }
4094
+
4095
+ case 'network': {
4096
+ // sootsim network — live request inspector backed by the shared
4097
+ // observability store (installed at engine boot, fed by worker-side
4098
+ // fetch + XHR capture over BroadcastChannel).
4099
+ //
4100
+ // shapes:
4101
+ // sootsim network [limit] last N entries (default 20)
4102
+ // sootsim network --failed only non-2xx / errored
4103
+ // sootsim network --slow only requests slower than --threshold (default 1000ms)
4104
+ // sootsim network --threshold <ms> customize slow threshold for --slow
4105
+ // sootsim network --filter <substring> url substring filter
4106
+ // sootsim network --json raw json output
4107
+ // sootsim network tail | -f follow mode (polls)
4108
+ // sootsim network get <id> detail for one entry
4109
+ // sootsim network clear drop the buffer
4110
+ const sub = positional[1]
4111
+
4112
+ // manual flag scan — parseBridgeCliArgs already stripped these
4113
+ // from positional but didn't give us the values, so re-walk the
4114
+ // raw input to pull --filter / --limit / --failed / --slow / --threshold / --tail / -f / --json.
4115
+ let filter: string | null = null
4116
+ let limitFlag: number | null = null
4117
+ let failedOnly = false
4118
+ let slowOnly = false
4119
+ let slowThresholdMs = 1000
4120
+ let tail = false
4121
+ let jsonOut = false
4122
+ for (let i = 0; i < effectiveArgs.length; i++) {
4123
+ const a = effectiveArgs[i]
4124
+ if (a === '--filter') {
4125
+ filter = effectiveArgs[i + 1] ?? null
4126
+ i++
4127
+ } else if (a === '--limit') {
4128
+ const n = Number(effectiveArgs[i + 1])
4129
+ if (Number.isFinite(n)) limitFlag = n
4130
+ i++
4131
+ } else if (a === '--threshold') {
4132
+ const n = Number(effectiveArgs[i + 1])
4133
+ if (Number.isFinite(n) && n > 0) slowThresholdMs = n
4134
+ i++
4135
+ } else if (a === '--failed') failedOnly = true
4136
+ else if (a === '--slow') slowOnly = true
4137
+ else if (a === '--tail' || a === '-f') tail = true
4138
+ else if (a === '--json') jsonOut = true
4139
+ }
4140
+
4141
+ if (sub === 'clear') {
4142
+ await bridge.send({
4143
+ type: 'evaluate',
4144
+ code: 'window.__sootsimObservability?.network.clear(); "cleared"',
4145
+ })
4146
+ console.log(' network buffer cleared')
4147
+ break
4148
+ }
4149
+
4150
+ if (sub === 'get') {
4151
+ const id = positional[2]
4152
+ if (!id) {
4153
+ console.error(' usage: rnxsim network get <id>')
4154
+ process.exit(1)
4155
+ }
4156
+ const entry = (await bridge.send({
4157
+ type: 'evaluate',
4158
+ code: `(() => {
4159
+ const obs = window.__sootsimObservability;
4160
+ if (!obs) return null;
4161
+ return obs.network.getSnapshot().find(e => e.id === ${JSON.stringify(id)}) || null;
4162
+ })()`,
4163
+ })) as NetworkEntryPayload | null
4164
+ if (!entry) {
4165
+ console.error(` no entry with id ${id}`)
4166
+ process.exit(1)
4167
+ }
4168
+ if (jsonOut) {
4169
+ console.log(JSON.stringify(entry, null, 2))
4170
+ } else {
4171
+ printNetworkDetail(entry)
4172
+ }
4173
+ break
4174
+ }
4175
+
4176
+ // list / tail mode share the same fetch + format path. default
4177
+ // limit is 20; tail mode uses a larger buffer window (200) so
4178
+ // spikes don't blow past the ring.
4179
+ const limit = limitFlag ?? (tail ? 200 : sub ? Number(sub) : 20)
4180
+ if (!Number.isFinite(limit)) {
4181
+ console.error(
4182
+ ` invalid limit: ${sub} — \`network\` takes a numeric count (e.g. ${inspectCommand('network')} 100).\n` +
4183
+ ` to target a specific sim, use \`--sim ${sub}\` instead.`,
4184
+ )
4185
+ process.exit(1)
4186
+ }
4187
+
4188
+ const fetchEntries = async () => {
4189
+ const entries = (await bridge.send({
4190
+ type: 'evaluate',
4191
+ code: `(() => {
4192
+ const obs = window.__sootsimObservability;
4193
+ if (!obs) return { ok: false };
4194
+ return { ok: true, entries: obs.network.getSnapshot() };
4195
+ })()`,
4196
+ })) as { ok: boolean; entries?: NetworkEntryPayload[] }
4197
+ if (!entries || !entries.ok) {
4198
+ throw new Error('observability bridge not installed — is the engine running?')
4199
+ }
4200
+ return entries.entries ?? []
4201
+ }
4202
+
4203
+ const applyFilter = (all: NetworkEntryPayload[]) => {
4204
+ let out = all
4205
+ if (failedOnly) {
4206
+ out = out.filter((e) => !!e.error || (e.status != null && e.status >= 400))
4207
+ }
4208
+ if (slowOnly) {
4209
+ // only completed requests can be classified as slow — durationMs
4210
+ // is null until a response (or error) arrives. don't include
4211
+ // in-flight requests in the slow list (they may finish fast).
4212
+ out = out.filter(
4213
+ (e) => e.durationMs != null && e.durationMs >= slowThresholdMs,
4214
+ )
4215
+ }
4216
+ if (filter) {
4217
+ const lf = filter.toLowerCase()
4218
+ out = out.filter((e) => (e.displayUrl || e.url).toLowerCase().includes(lf))
4219
+ }
4220
+ // when --slow is on without --tail, sort by durationMs descending
4221
+ // so the slowest requests print first regardless of finish order.
4222
+ if (slowOnly && !tail) {
4223
+ out = [...out].sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0))
4224
+ }
4225
+ return out
4226
+ }
4227
+
4228
+ if (!tail) {
4229
+ const all = await fetchEntries()
4230
+ const filtered = applyFilter(all).slice(-limit)
4231
+ if (jsonOut) {
4232
+ console.log(JSON.stringify(filtered, null, 2))
4233
+ break
4234
+ }
4235
+ if (filtered.length === 0) {
4236
+ if (all.length === 0) {
4237
+ console.log(' no network requests captured')
4238
+ } else if (slowOnly) {
4239
+ console.log(
4240
+ ` no requests slower than ${slowThresholdMs}ms (${all.length} total — try --threshold <ms>)`,
4241
+ )
4242
+ } else {
4243
+ console.log(' no matching requests')
4244
+ }
4245
+ break
4246
+ }
4247
+ if (slowOnly) {
4248
+ console.log(
4249
+ ` ${filtered.length} request(s) slower than ${slowThresholdMs}ms (sorted by duration desc):\n`,
4250
+ )
4251
+ } else {
4252
+ console.log(` ${filtered.length} request(s):\n`)
4253
+ }
4254
+ for (const e of filtered) printNetworkRow(e)
4255
+ break
4256
+ }
4257
+
4258
+ // tail mode — poll every 250ms, track the set of completed ids
4259
+ // we've already printed, and emit new entries as they land. only
4260
+ // prints *completed* entries (durationMs != null) so each request
4261
+ // renders once with its final status, not twice.
4262
+ console.log(' tailing network (ctrl-c to stop)...\n')
4263
+ const seen = new Set<string>()
4264
+ let running = true
4265
+ const stop = () => {
4266
+ running = false
4267
+ }
4268
+ process.on('SIGINT', stop)
4269
+ try {
4270
+ while (running) {
4271
+ const all = await fetchEntries()
4272
+ const filtered = applyFilter(all)
4273
+ for (const e of filtered) {
4274
+ if (e.durationMs == null) continue
4275
+ if (seen.has(e.id)) continue
4276
+ seen.add(e.id)
4277
+ if (jsonOut) console.log(JSON.stringify(e))
4278
+ else printNetworkRow(e)
4279
+ }
4280
+ await sleep(250)
4281
+ }
4282
+ } finally {
4283
+ process.off('SIGINT', stop)
4284
+ }
4285
+ break
4286
+ }
4287
+
4288
+ case 'logs': {
4289
+ // sootsim logs — live console inspector backed by the shared
4290
+ // observability log store (fed by worker-side console capture over
4291
+ // BroadcastChannel). mirrors `sootsim network`:
4292
+ //
4293
+ // sootsim logs [limit] last N entries (default 50)
4294
+ // sootsim logs --level error,warn filter by level (csv)
4295
+ // sootsim logs --filter <str> message substring filter
4296
+ // sootsim logs --internal include engine-internal [sootsim] logs (hidden by default)
4297
+ // sootsim logs --json raw json output
4298
+ // sootsim logs tail | -f follow mode (polls)
4299
+ // sootsim logs clear drop the buffer
4300
+ const sub = positional[1]
4301
+
4302
+ let filter: string | null = null
4303
+ let limitFlag: number | null = null
4304
+ let levelCsv: string | null = null
4305
+ let tail = false
4306
+ let jsonOut = false
4307
+ let showInternal = false
4308
+ for (let i = 0; i < effectiveArgs.length; i++) {
4309
+ const a = effectiveArgs[i]
4310
+ if (a === '--filter') {
4311
+ filter = effectiveArgs[i + 1] ?? null
4312
+ i++
4313
+ } else if (a === '--limit') {
4314
+ const n = Number(effectiveArgs[i + 1])
4315
+ if (Number.isFinite(n)) limitFlag = n
4316
+ i++
4317
+ } else if (a === '--level') {
4318
+ levelCsv = effectiveArgs[i + 1] ?? null
4319
+ i++
4320
+ } else if (a === '--tail' || a === '-f') tail = true
4321
+ else if (a === '--json') jsonOut = true
4322
+ else if (a === '--internal' || a === '--all') showInternal = true
4323
+ }
4324
+
4325
+ const levelFilter: ReadonlySet<LogLevel> | null = levelCsv
4326
+ ? new Set(
4327
+ levelCsv
4328
+ .split(',')
4329
+ .map((s) => s.trim())
4330
+ .filter(
4331
+ (s): s is LogLevel =>
4332
+ s === 'log' ||
4333
+ s === 'info' ||
4334
+ s === 'warn' ||
4335
+ s === 'error' ||
4336
+ s === 'debug',
4337
+ ),
4338
+ )
4339
+ : null
4340
+
4341
+ if (sub === 'clear') {
4342
+ await clearLogs(bridge)
4343
+ console.log(' log buffer cleared')
4344
+ break
4345
+ }
4346
+
4347
+ const useColor = !jsonOut && process.stdout.isTTY === true
4348
+ const limit = limitFlag ?? (tail ? 500 : sub ? Number(sub) : 50)
4349
+ if (!Number.isFinite(limit)) {
4350
+ console.error(
4351
+ ` invalid limit: ${sub} — \`logs\` takes a numeric count (e.g. ${inspectCommand('logs')} 100).\n` +
4352
+ ` to target a specific sim, use \`--sim ${sub}\` instead.`,
4353
+ )
4354
+ process.exit(1)
4355
+ }
4356
+
4357
+ const fetchEntries = () => inspectLogs(bridge)
4358
+
4359
+ // engine-internal logs (bundle-loader setup, HMR trap messages, etc.)
4360
+ // are noise when debugging guest-app behavior — filterLogEntries hides
4361
+ // them by default; pass --internal to include them.
4362
+ const applyFilter = (all: LogEntry[]) =>
4363
+ filterLogEntries(all, {
4364
+ level: levelFilter,
4365
+ filter,
4366
+ showInternal,
4367
+ })
4368
+
4369
+ if (!tail) {
4370
+ const all = await fetchEntries()
4371
+ const filtered = applyFilter(all).slice(-limit)
4372
+ if (jsonOut) {
4373
+ console.log(JSON.stringify(filtered, null, 2))
4374
+ break
4375
+ }
4376
+ if (filtered.length === 0) {
4377
+ console.log(all.length === 0 ? ' no logs captured' : ' no matching logs')
4378
+ break
4379
+ }
4380
+ console.log(` ${filtered.length} log(s):\n`)
4381
+ for (const e of filtered) printLogRow(e, useColor)
4382
+ break
4383
+ }
4384
+
4385
+ console.log(' tailing logs (ctrl-c to stop)...\n')
4386
+ const seen = new Set<string>()
4387
+ let running = true
4388
+ const stop = () => {
4389
+ running = false
4390
+ }
4391
+ process.on('SIGINT', stop)
4392
+ try {
4393
+ while (running) {
4394
+ const all = await fetchEntries()
4395
+ const filtered = applyFilter(all)
4396
+ for (const e of filtered) {
4397
+ if (seen.has(e.id)) continue
4398
+ seen.add(e.id)
4399
+ if (jsonOut) console.log(JSON.stringify(e))
4400
+ else printLogRow(e, useColor)
4401
+ }
4402
+ await sleep(250)
4403
+ }
4404
+ } finally {
4405
+ process.off('SIGINT', stop)
4406
+ }
4407
+ break
4408
+ }
4409
+
4410
+ default:
4411
+ console.error(` unknown subcommand: ${subcommand}`)
4412
+ process.exit(1)
4413
+ }
4414
+
4415
+ // auto-settle after any write command so the next CLI call doesn't see
4416
+ // mid-transition state. short deadline — if the app has a perpetual
4417
+ // background animation, we bail to layout-only stability via the same
4418
+ // logic `do settle` uses. opt-out: --no-wait or SOOTSIM_NO_AUTO_WAIT=1.
4419
+ if (
4420
+ writeCommands.has(subcommand) &&
4421
+ !args.includes('--no-wait') &&
4422
+ process.env.SOOTSIM_NO_AUTO_WAIT !== '1' &&
4423
+ !(await shouldSkipAutoSettleForInspectPick(bridge, subcommand))
4424
+ ) {
4425
+ await autoSettleAfterWrite(bridge)
4426
+ }
4427
+
4428
+ // framing prose (this note, the cursor footer, hints, console summaries)
4429
+ // goes to stderr, never stdout. a `--json` flag is not a usable signal for
4430
+ // that: `do tap-id`, `do tap-text`, and the rest of the write verbs print a
4431
+ // JSON document by default with no flag at all, so gating on the flag left
4432
+ // exactly those commands emitting `{...}` followed by `since last: …` and
4433
+ // breaking every parser. keeping payload on stdout and prose on stderr is
4434
+ // the only rule that holds for both.
4435
+ // the native-UI note flags a blocking Alert/ActionSheet that was up when an
4436
+ // interaction verb ran — it silently eats taps/drags meant for the app, the
4437
+ // single most confusing "my `do` did nothing" failure.
4438
+ if (nativeUIBeforeWrite.length > 0) {
4439
+ printNativeUISummary(nativeUIBeforeWrite)
4440
+ }
4441
+
4442
+ // cursor footer reads `bridges.timeline.summary` keyed on the agent's
4443
+ // stable cliSessionKey so every CLI call shows only what's *new* since the
4444
+ // previous call from the same agent (not a global count). after printing,
4445
+ // advance the cursor past the rendered window so the next call starts
4446
+ // fresh.
4447
+ if (!consoleSummarySkip.has(subcommand)) {
4448
+ try {
4449
+ await printCursorFooter(bridge)
4450
+ } catch {
4451
+ // ignore footer errors; the main command already succeeded
4452
+ }
4453
+ }
4454
+ } catch (err: any) {
4455
+ const message = err instanceof Error ? err.message : String(err)
4456
+ console.error(` ${subcommand ?? 'inspect'} failed: ${message}`)
4457
+ const unknownSim = /^no sim connected with id ([^;]+)(?:; connected sims: .+)?$/.exec(
4458
+ message,
4459
+ )
4460
+ // a failure whose own message proves the sim/bridge is unresponsive must
4461
+ // NOT trigger the secondary diagnostic round-trips below — each of them
4462
+ // issues its own bridge command, which against the same dead sim just
4463
+ // re-incurs the full per-command timeout. three sequential probes turn a
4464
+ // single 15s timeout into ~60s of apparent hang (QA F19-1). there is
4465
+ // nothing useful to fetch from a sim that just timed out, so skip them.
4466
+ const timedOut = /^command timed out after (\d+)s$/.exec(message)
4467
+ // a disconnected / never-connected bridge is genuinely gone; recover is
4468
+ // right and probing it would just re-incur a full timeout.
4469
+ const disconnected =
4470
+ message.startsWith('sim disconnected:') ||
4471
+ message.startsWith('bridge never reconnected') ||
4472
+ message.startsWith('could not connect to ws://')
4473
+ if (unknownSim) {
4474
+ await printUnknownSimHint(bridge, wsPort, unknownSim[1])
4475
+ } else if (/^no sim connected$/.test(message)) {
4476
+ printMissingSimHint(wsPort)
4477
+ } else if (timedOut) {
4478
+ // a command timeout alone does not mean a wedged sim — a large screen
4479
+ // can blow the budget on `describe`'s tree serialization while the sim
4480
+ // answers everything else fine. probe once (short budget); if it's
4481
+ // alive, point at narrowing/raising the budget instead of force-closing
4482
+ // a healthy sim.
4483
+ const alive = await probeSimResponsive(bridge)
4484
+ if (alive) {
4485
+ const cmd = subcommand ?? 'describe'
4486
+ process.stderr.write(
4487
+ ` the sim is still responsive — '${cmd}' just exceeded the ${timedOut[1]}s command budget.\n` +
4488
+ ` the screen's node tree is large; narrow the query or raise the budget:\n` +
4489
+ ` rnxsim ${cmd} --testid <id> # scope to one subtree\n` +
4490
+ ` rnxsim find --testid <id> # targeted single-node lookup\n` +
4491
+ ` rnxsim ${cmd} --timeout 60000 # raise per-command budget (ms)\n`,
4492
+ )
4493
+ } else {
4494
+ printRecoverHint()
4495
+ }
4496
+ } else if (disconnected) {
4497
+ printRecoverHint()
4498
+ } else {
4499
+ try {
4500
+ await printBridgeStateSummary(bridge)
4501
+ } catch {
4502
+ // ignore secondary bridge errors while already failing the command
4503
+ }
4504
+ try {
4505
+ await printConsoleSummary({ includeTail: true })
4506
+ } catch {
4507
+ // ignore secondary bridge errors while already failing the command
4508
+ }
4509
+ try {
4510
+ await printRequestSummary({ includeTail: true })
4511
+ } catch {
4512
+ // ignore secondary request summary errors while already failing the command
4513
+ }
4514
+ }
4515
+ process.exit(1)
4516
+ } finally {
4517
+ bridge.close()
4518
+ }
4519
+ }