rnxsim 0.1.366 → 0.1.367

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 (239) hide show
  1. package/README.md +14 -2
  2. package/cli/bin.ts +18 -0
  3. package/cli/bridge-flow-runner.ts +14 -151
  4. package/cli/commands/assert.ts +1 -1
  5. package/cli/commands/box/checkout-plane.ts +126 -0
  6. package/cli/commands/box/rnx-command.ts +68 -0
  7. package/cli/commands/box.ts +200 -0
  8. package/cli/commands/control.ts +12 -32
  9. package/cli/commands/do-chain.ts +412 -0
  10. package/cli/commands/flow.ts +0 -10
  11. package/cli/commands/inspect/core.ts +302 -725
  12. package/cli/commands/inspect/describe.ts +3 -76
  13. package/cli/commands/inspect/find.ts +3 -6
  14. package/cli/commands/inspect/get-layout.ts +14 -1
  15. package/cli/commands/inspect/resolve-target.ts +41 -47
  16. package/cli/commands/inspect/shared.ts +28 -123
  17. package/cli/commands/inspect/tree.ts +6 -15
  18. package/cli/commands/inspect/wait-ready.ts +2 -2
  19. package/cli/commands/inspect/wait-selector.ts +4 -8
  20. package/cli/commands/inspect.ts +23 -17
  21. package/cli/commands/install-desktop.ts +44 -7
  22. package/cli/commands/maestro-generate.ts +0 -2
  23. package/cli/commands/platform.ts +68 -0
  24. package/cli/commands/state.ts +5 -2
  25. package/cli/outbound-endpoints.ts +6 -1
  26. package/cli/parse-args.ts +13 -2
  27. package/cli/ws-bridge.ts +30 -2
  28. package/detox/index.ts +34 -17
  29. package/dist-cli/bin.js +5 -5
  30. package/dist-cli/chunks/{agent-SO6IOPRK.js → agent-EKKIAKP5.js} +2 -2
  31. package/dist-cli/chunks/{agent-wrapper-PMYCB2KO.js → agent-wrapper-MHLQUGA6.js} +2 -2
  32. package/dist-cli/chunks/{app-fonts-LULQGFY7.js → app-fonts-HN6ADRTQ.js} +2 -2
  33. package/dist-cli/chunks/{assert-WGNCD4ZU.js → assert-IMR6QCN6.js} +3 -3
  34. package/dist-cli/chunks/{auth-G7CCHH3M.js → auth-27LPMQNH.js} +2 -2
  35. package/dist-cli/chunks/{beta-N4LXL5TO.js → beta-6RXBB5AI.js} +2 -2
  36. package/dist-cli/chunks/box-DORLF3GP.js +1709 -0
  37. package/dist-cli/chunks/{camera-4JHCYBQS.js → camera-2G7JZ5DL.js} +2 -2
  38. package/dist-cli/chunks/{chunk-2UXU53MG.js → chunk-3QZJLNYY.js} +1 -1
  39. package/dist-cli/chunks/{chunk-YXXKSL6R.js → chunk-3VBVEDDA.js} +1 -1
  40. package/dist-cli/chunks/{chunk-ZJIS3CTW.js → chunk-4BWGLTKU.js} +2 -2
  41. package/dist-cli/chunks/chunk-4XSNO374.js +423 -0
  42. package/dist-cli/chunks/{chunk-RYIMSKDO.js → chunk-5DGMFUZG.js} +2 -2
  43. package/dist-cli/chunks/{chunk-M3BNCF7E.js → chunk-6DBS5EGX.js} +2 -2
  44. package/dist-cli/chunks/{chunk-ONORMKWX.js → chunk-6SO6I3D5.js} +2 -2
  45. package/dist-cli/chunks/chunk-75EFOPXE.js +32 -0
  46. package/dist-cli/chunks/{chunk-5NMCF5VF.js → chunk-A5AJFVOF.js} +1 -1
  47. package/dist-cli/chunks/{chunk-N3UC7HIX.js → chunk-AM2ODYAN.js} +1 -1
  48. package/dist-cli/chunks/{chunk-I6KIM6PN.js → chunk-AVOROGA3.js} +1 -1
  49. package/dist-cli/chunks/chunk-AXMMLO7D.js +130 -0
  50. package/dist-cli/chunks/{chunk-ORWP2E7R.js → chunk-BSEXEYNX.js} +2 -2
  51. package/dist-cli/chunks/{chunk-NQFJOCSF.js → chunk-CM6ZKZW7.js} +2 -2
  52. package/dist-cli/chunks/{chunk-ZMJ75EDG.js → chunk-DNVABCEC.js} +2 -2
  53. package/dist-cli/chunks/{chunk-2S7BKUP2.js → chunk-E33A4AIR.js} +2 -2
  54. package/dist-cli/chunks/{chunk-GXAB52K7.js → chunk-EFMOXYDD.js} +2 -2
  55. package/dist-cli/chunks/{chunk-TIAEYO6I.js → chunk-EV4P5UBP.js} +2 -2
  56. package/dist-cli/chunks/{chunk-JHXETDVC.js → chunk-G5IUIGGC.js} +1 -1
  57. package/dist-cli/chunks/chunk-GMLOCV3E.js +312 -0
  58. package/dist-cli/chunks/{chunk-ZLVRYXZ7.js → chunk-GRN2FHVW.js} +1 -1
  59. package/dist-cli/chunks/{chunk-A5FXX5PJ.js → chunk-HFFXPUEP.js} +1 -1
  60. package/dist-cli/chunks/chunk-HG6T4OOR.js +4 -0
  61. package/dist-cli/chunks/{chunk-OW5XHAA4.js → chunk-HNT2OXPZ.js} +1 -1
  62. package/dist-cli/chunks/chunk-IPEURNLI.js +24 -0
  63. package/dist-cli/chunks/chunk-IR44SFBA.js +5 -0
  64. package/dist-cli/chunks/chunk-JMSLXZFW.js +4 -0
  65. package/dist-cli/chunks/{chunk-EFR5DVNP.js → chunk-JPXQKLNH.js} +1 -1
  66. package/dist-cli/chunks/chunk-K3EC6JDE.js +4 -0
  67. package/dist-cli/chunks/{chunk-YGMEDJKJ.js → chunk-KHX65HAU.js} +2 -2
  68. package/dist-cli/chunks/{chunk-EQK5MJCU.js → chunk-LWOVKHRC.js} +2 -2
  69. package/dist-cli/chunks/{chunk-5OK3LCEJ.js → chunk-MQN5DWTI.js} +2 -2
  70. package/dist-cli/chunks/{chunk-TTASWLKM.js → chunk-N3BXQEUF.js} +1 -1
  71. package/dist-cli/chunks/{chunk-UCHW7ZHR.js → chunk-N5OSV26B.js} +2 -2
  72. package/dist-cli/chunks/chunk-PHNURIH3.js +4 -0
  73. package/dist-cli/chunks/{chunk-AFV32WZT.js → chunk-PP2KNVXP.js} +3 -3
  74. package/dist-cli/chunks/{chunk-XJU2QIRY.js → chunk-PROKNJVY.js} +2 -2
  75. package/dist-cli/chunks/chunk-QXLLY4IM.js +7 -0
  76. package/dist-cli/chunks/{chunk-HT6GFZOZ.js → chunk-RNQTOGM7.js} +1 -1
  77. package/dist-cli/chunks/{chunk-CN6XYYEA.js → chunk-RPNKKVBN.js} +27 -27
  78. package/dist-cli/chunks/{chunk-LN3RNW6X.js → chunk-RSNLI2NH.js} +2 -2
  79. package/dist-cli/chunks/{chunk-YCCCPFWT.js → chunk-RZGHV2WI.js} +2 -2
  80. package/dist-cli/chunks/chunk-SGGL3AFV.js +497 -0
  81. package/dist-cli/chunks/chunk-SH5ETJKG.js +4 -0
  82. package/dist-cli/chunks/{chunk-77ZHQJZR.js → chunk-T6J4YP56.js} +2 -2
  83. package/dist-cli/chunks/{chunk-MGDSJZ37.js → chunk-TWUE7WF5.js} +2 -2
  84. package/dist-cli/chunks/{chunk-SA6XVI55.js → chunk-U44VEQRN.js} +1 -1
  85. package/dist-cli/chunks/{chunk-IZKHOM37.js → chunk-URLVQHC3.js} +1 -1
  86. package/dist-cli/chunks/{chunk-GAJQSITE.js → chunk-VANZFTUE.js} +1 -1
  87. package/dist-cli/chunks/{chunk-EGAGZHO6.js → chunk-VB2YBH46.js} +1 -1
  88. package/dist-cli/chunks/{chunk-H23KWIKA.js → chunk-VFU2SJ7M.js} +2 -2
  89. package/dist-cli/chunks/{chunk-34IQM22N.js → chunk-VUBJ6FXU.js} +15 -15
  90. package/dist-cli/chunks/{chunk-NM2L2QV4.js → chunk-W5PATILQ.js} +2 -2
  91. package/dist-cli/chunks/chunk-WI5V4TYG.js +38 -0
  92. package/dist-cli/chunks/{chunk-6PL2CCGP.js → chunk-XTRHQAOV.js} +2 -2
  93. package/dist-cli/chunks/chunk-XWUTJLBM.js +27 -0
  94. package/dist-cli/chunks/{chunk-5DAIA77C.js → chunk-XYRUQQUR.js} +2 -2
  95. package/dist-cli/chunks/chunk-YBFB3RM7.js +4 -0
  96. package/dist-cli/chunks/chunk-YGQVQ2VG.js +4 -0
  97. package/dist-cli/chunks/{chunk-CTG7HBCA.js → chunk-YINACV32.js} +1 -1
  98. package/dist-cli/chunks/chunk-YV7ERO2V.js +5 -0
  99. package/dist-cli/chunks/{cleanup-KRIATKFH.js → cleanup-GVFHQ3KH.js} +2 -2
  100. package/dist-cli/chunks/cli-version-DPX5MA2M.js +4 -0
  101. package/dist-cli/chunks/{compat-6PTQ3QEA.js → compat-AYRTXYAM.js} +2 -2
  102. package/dist-cli/chunks/{config-ETGDUANO.js → config-K7LOOV62.js} +2 -2
  103. package/dist-cli/chunks/connect-H3CNY4IO.js +4 -0
  104. package/dist-cli/chunks/control-SMHZ2CF5.js +4 -0
  105. package/dist-cli/chunks/daemon-TEZUNI73.js +4 -0
  106. package/dist-cli/chunks/{debug-WIIB6ZKC.js → debug-ELGHG57T.js} +3 -3
  107. package/dist-cli/chunks/{desktop-TD7Q5LNB.js → desktop-4G7FK3MP.js} +3 -3
  108. package/dist-cli/chunks/{detox-7GNR33VM.js → detox-JKXGEJIZ.js} +2 -2
  109. package/dist-cli/chunks/{device-QOR3PGUF.js → device-IV3AR6KG.js} +2 -2
  110. package/dist-cli/chunks/{diagnose-RRCBLP6E.js → diagnose-3ERGF7EW.js} +2 -2
  111. package/dist-cli/chunks/{disk-cleanup-MJUGJPXT.js → disk-cleanup-ALFEVZ4B.js} +2 -2
  112. package/dist-cli/chunks/do-chain-K2B4OHM7.js +5 -0
  113. package/dist-cli/chunks/drivers-KJYXA3M3.js +4 -0
  114. package/dist-cli/chunks/env-2KF6IYZE.js +4 -0
  115. package/dist-cli/chunks/{film-D7Y64LID.js → film-UQXJFAD5.js} +3 -3
  116. package/dist-cli/chunks/flow-ASTUH56E.js +4 -0
  117. package/dist-cli/chunks/help-SNNYFELA.js +4 -0
  118. package/dist-cli/chunks/{hidden-runtime-alias-JGURVR2Z.js → hidden-runtime-alias-VJCWCFVO.js} +2 -2
  119. package/dist-cli/chunks/{home-paths-4ICFIGXO.js → home-paths-EGUZZ6J6.js} +2 -2
  120. package/dist-cli/chunks/inspect-XANAADQT.js +4 -0
  121. package/dist-cli/chunks/install-desktop-JKQA7UY6.js +4 -0
  122. package/dist-cli/chunks/{login-RU33QR2G.js → login-6KQGD7HK.js} +2 -2
  123. package/dist-cli/chunks/{logout-2IUG3Y5J.js → logout-2FCOVLBC.js} +2 -2
  124. package/dist-cli/chunks/{maestro-2MXTGG7I.js → maestro-HPSSEVJD.js} +3 -3
  125. package/dist-cli/chunks/{maestro-generate-2PYIFGLR.js → maestro-generate-KQQ6JHMR.js} +5 -5
  126. package/dist-cli/chunks/{mode-3DAPG3HF.js → mode-SH7KRZET.js} +2 -2
  127. package/dist-cli/chunks/{optional-demo-registry-BWSDJF2Z.js → optional-demo-registry-NAMCAVFX.js} +2 -2
  128. package/dist-cli/chunks/{perf-2TKPWB4N.js → perf-LWKAHXUL.js} +2 -2
  129. package/dist-cli/chunks/{permissions-5CSMHYNA.js → permissions-EZTKWTXV.js} +2 -2
  130. package/dist-cli/chunks/platform-KSOJIVN5.js +6 -0
  131. package/dist-cli/chunks/privacy-EJULWP7L.js +4 -0
  132. package/dist-cli/chunks/{record-I2HJCFP3.js → record-2IC7BAQN.js} +3 -3
  133. package/dist-cli/chunks/{report-issue-A4FZWBOC.js → report-issue-MMX6HOQO.js} +2 -2
  134. package/dist-cli/chunks/reset-PKFZA4NC.js +4 -0
  135. package/dist-cli/chunks/resolve-target-AFKU3BRE.js +4 -0
  136. package/dist-cli/chunks/runtime-XHWWPOIN.js +4 -0
  137. package/dist-cli/chunks/{screenshot-command-UFW5BFHP.js → screenshot-command-JYUXDVSA.js} +3 -3
  138. package/dist-cli/chunks/{screenshot-layers-5BIAL4YN.js → screenshot-layers-OGRY6AA3.js} +2 -2
  139. package/dist-cli/chunks/{screenshots-capture-EHYS3BV2.js → screenshots-capture-QHDN65XS.js} +2 -2
  140. package/dist-cli/chunks/{serve-O4FDM3AC.js → serve-W4BKAG4J.js} +3 -3
  141. package/dist-cli/chunks/setup-WJPMPSAC.js +4 -0
  142. package/dist-cli/chunks/sim-client-UOQGFBPO.js +4 -0
  143. package/dist-cli/chunks/{skills-ZK55SY32.js → skills-OPPXOD63.js} +2 -2
  144. package/dist-cli/chunks/state-TDAN7BOM.js +14 -0
  145. package/dist-cli/chunks/{storage-WP33ISEM.js → storage-CFB2YGLY.js} +2 -2
  146. package/dist-cli/chunks/store-4TEED4ZU.js +4 -0
  147. package/dist-cli/chunks/telemetry-S6N7CV6G.js +4 -0
  148. package/dist-cli/chunks/{timeline-DKGW5S4I.js → timeline-ASOOVP4C.js} +2 -2
  149. package/dist-cli/chunks/{upgrade-LL7G4B7G.js → upgrade-6MVJISHG.js} +2 -2
  150. package/dist-cli/chunks/upload-CBNIRFEW.js +4 -0
  151. package/dist-cli/chunks/{version-YLWR5G4F.js → version-5GS7CWRL.js} +2 -2
  152. package/dist-cli/chunks/{web-QELZ4CP6.js → web-QRRMYMV4.js} +2 -2
  153. package/dist-cli/chunks/welcome-33FXMAMB.js +5 -0
  154. package/dist-cli/chunks/{what-happened-MIOMZWM5.js → what-happened-GAZJGKXX.js} +2 -2
  155. package/dist-cli/chunks/ws-bridge-DIXUAPLQ.js +4 -0
  156. package/dist-lib/agent-daemon-client.cjs +1 -1
  157. package/dist-lib/agent-events.cjs +1 -1
  158. package/dist-lib/agent-identity.cjs +1 -1
  159. package/dist-lib/agent-sessions.cjs +1 -1
  160. package/dist-lib/attached-projects.cjs +1 -1
  161. package/dist-lib/auth/shared-session.cjs +1 -1
  162. package/dist-lib/backend-origin.cjs +1 -1
  163. package/dist-lib/beta.cjs +1 -1
  164. package/dist-lib/beta.mjs +1 -1
  165. package/dist-lib/bridge-constants.cjs +1 -1
  166. package/dist-lib/bridge-contract-input.cjs +336 -0
  167. package/dist-lib/bridge-contract-input.mjs +303 -0
  168. package/dist-lib/bridge-contract.cjs +29 -1
  169. package/dist-lib/bridge-contract.mjs +19 -0
  170. package/dist-lib/cli-constants.cjs +1 -1
  171. package/dist-lib/config.cjs +1 -1
  172. package/dist-lib/detox/index.cjs +32 -18
  173. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  174. package/dist-lib/home-paths.cjs +1 -1
  175. package/dist-lib/host/bridge-host.cjs +5 -1
  176. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  177. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  178. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  179. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  180. package/dist-lib/host/websocket-proxy.cjs +1 -1
  181. package/dist-lib/index.cjs +214 -594
  182. package/dist-lib/jump-to-source-babel.cjs +1 -1
  183. package/dist-lib/menu.cjs +1 -1
  184. package/dist-lib/menu.mjs +1 -1
  185. package/dist-lib/metro-production-bundle.cjs +1 -1
  186. package/dist-lib/metro-production-bundle.mjs +1 -1
  187. package/dist-lib/metro.cjs +1 -1
  188. package/dist-lib/profiles.cjs +1 -1
  189. package/dist-lib/public-brand.cjs +1 -1
  190. package/dist-lib/react-native-host-modules.cjs +1 -1
  191. package/dist-lib/react-native-host-modules.mjs +1 -1
  192. package/dist-lib/render-mode.cjs +1 -1
  193. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  194. package/dist-lib/sdk.cjs +273 -657
  195. package/dist-lib/sdk.mjs +271 -652
  196. package/dist-lib/skills.cjs +190 -287
  197. package/dist-lib/vite.cjs +1 -1
  198. package/package.json +9 -6
  199. package/skills/contrast/SKILL.md +3 -0
  200. package/skills/rnx-debug/SKILL.md +8 -0
  201. package/skills/rnx-test/SKILL.md +21 -0
  202. package/src/bridge-contract-input.ts +359 -0
  203. package/src/bridge-contract.ts +121 -1
  204. package/src/host/bridge-host.ts +7 -12
  205. package/src/index.ts +7 -0
  206. package/src/sim-client.ts +12 -3
  207. package/dist-cli/chunks/chunk-3X6IBUBX.js +0 -38
  208. package/dist-cli/chunks/chunk-7B37IZBN.js +0 -15
  209. package/dist-cli/chunks/chunk-DMPF2D24.js +0 -4
  210. package/dist-cli/chunks/chunk-DW37EVX5.js +0 -767
  211. package/dist-cli/chunks/chunk-DWFIH7CB.js +0 -32
  212. package/dist-cli/chunks/chunk-GUI3PDH2.js +0 -27
  213. package/dist-cli/chunks/chunk-KLF3K6ZZ.js +0 -4
  214. package/dist-cli/chunks/chunk-M5QCGLXR.js +0 -944
  215. package/dist-cli/chunks/chunk-M6SKQGAN.js +0 -5
  216. package/dist-cli/chunks/chunk-NAYLPOC5.js +0 -24
  217. package/dist-cli/chunks/chunk-VIND4SP3.js +0 -124
  218. package/dist-cli/chunks/chunk-XXNLYR64.js +0 -5
  219. package/dist-cli/chunks/chunk-ZFM24DOX.js +0 -4
  220. package/dist-cli/chunks/cli-version-SYGNHGFR.js +0 -4
  221. package/dist-cli/chunks/control-RBTNOLQM.js +0 -4
  222. package/dist-cli/chunks/daemon-N3SC5DK6.js +0 -4
  223. package/dist-cli/chunks/drivers-W6E3BZH4.js +0 -4
  224. package/dist-cli/chunks/flow-7QAE2V5Y.js +0 -4
  225. package/dist-cli/chunks/help-NLD6KWVG.js +0 -4
  226. package/dist-cli/chunks/inspect-C347BG52.js +0 -4
  227. package/dist-cli/chunks/install-desktop-YR6TWZYK.js +0 -4
  228. package/dist-cli/chunks/privacy-PWWC2BXO.js +0 -4
  229. package/dist-cli/chunks/reset-Y3PBPFYA.js +0 -4
  230. package/dist-cli/chunks/runtime-IC6NVVGA.js +0 -4
  231. package/dist-cli/chunks/setup-Y4QGSJBM.js +0 -4
  232. package/dist-cli/chunks/state-KFLAZNEY.js +0 -14
  233. package/dist-cli/chunks/store-VEE22CAY.js +0 -4
  234. package/dist-cli/chunks/telemetry-PZV4BISJ.js +0 -4
  235. package/dist-cli/chunks/upload-Y4C3YZDK.js +0 -4
  236. package/dist-cli/chunks/welcome-ZPKA5PZJ.js +0 -5
  237. package/dist-lib/mcp.cjs +0 -673
  238. package/dist-lib/mcp.mjs +0 -641
  239. package/src/mcp.ts +0 -791
@@ -1,7 +1,7 @@
1
1
  // transport-agnostic sootsim inspect-verb kernels.
2
2
  //
3
- // each function here builds the JS a verb runs against a sootsim instance and
4
- // returns *structured data* — no arg parsing, no stdout, no formatting. the
3
+ // each function here sends the smallest bridge command its verb needs and
4
+ // returns *structured data* — no arg parsing or stdout. the
5
5
  // CLI subcommand handlers wrap these with their WS bridge + text formatting;
6
6
  // the in-browser agent wraps them with an in-process bridge over its preview
7
7
  // window. one implementation, two transports — the DRY core of the inspect
@@ -11,6 +11,16 @@
11
11
  // dependency is the `InspectBridge.send` seam below, which both transports
12
12
  // satisfy (the CLI's `WsBridge` already has a matching `send`).
13
13
 
14
+ import type { SemanticWaitResult } from '../../../src/bridge-contract'
15
+ export {
16
+ readyProbeHasContent,
17
+ readyProbeHasTargetContent,
18
+ } from '../../../src/bridge-contract'
19
+ import type {
20
+ SimSemanticNode,
21
+ SimSemanticQueryResult,
22
+ SimSemanticSelector,
23
+ } from '../../../src/bridge-contract'
14
24
  import type {
15
25
  SootSimRouteInfo,
16
26
  SootSimRequestEntry,
@@ -38,8 +48,7 @@ export function resolveMaxMsFlag(args: string[], fallbackMs: number): number {
38
48
  }
39
49
 
40
50
  // the minimal bridge an inspect kernel needs: a JSON command channel into a
41
- // sootsim instance. `{ type: 'evaluate', code }` evaluates JS in the page;
42
- // `{ type: 'call', path, args }` invokes a method by dotted path.
51
+ // sootsim instance.
43
52
  export interface InspectBridge {
44
53
  send(
45
54
  cmd: { type: string; [k: string]: any },
@@ -84,34 +93,23 @@ export async function shouldSkipAutoSettleForInspectPick(
84
93
  export async function inspectNodeCount(
85
94
  bridge: InspectBridge,
86
95
  ): Promise<{ nodes: number }> {
87
- const count = await bridge.send({
88
- type: 'evaluate',
89
- code: '(async () => await window.__sootsimTest.getNodeCount())()',
96
+ const result: unknown = await bridge.send({
97
+ type: 'query',
98
+ query: { kind: 'count' },
90
99
  })
91
- return { nodes: typeof count === 'number' ? count : 0 }
100
+ return { nodes: isSemanticQueryResult(result) ? result.total : 0 }
92
101
  }
93
102
 
94
- // `get tree` — raw node tree dump. goes through the merged test bridge
95
- // (shell + tenant). `tree` is returned raw so each frontend formats it.
96
- // `format: 'boxes'` asks the engine for the structured rect tree (a JSON
97
- // string on the wire) and parses it here.
103
+ // `get tree` — pruned visible semantic tree. formatting stays client-side.
98
104
  export async function inspectTree(
99
105
  bridge: InspectBridge,
100
106
  depth = 5,
101
- opts?: { format?: 'boxes' },
102
107
  ): Promise<{ depth: number; tree: unknown }> {
103
- const optsArg = opts?.format ? `, ${JSON.stringify(opts)}` : ''
104
- const tree = await bridge.send({
105
- type: 'evaluate',
106
- code: `(async () => await window.__sootsimTest.dumpTree(${depth}${optsArg}))()`,
108
+ const result: unknown = await bridge.send({
109
+ type: 'query',
110
+ query: { kind: 'tree', depth },
107
111
  })
108
- if (opts?.format === 'boxes' && typeof tree === 'string') {
109
- try {
110
- return { depth, tree: JSON.parse(tree) }
111
- } catch {
112
- return { depth, tree }
113
- }
114
- }
112
+ const tree = isSemanticQueryResult(result) ? result.nodes : []
115
113
  return { depth, tree }
116
114
  }
117
115
 
@@ -132,7 +130,6 @@ export interface DescribeDumpOptions {
132
130
  subtreeRoot?: string
133
131
  compact: boolean
134
132
  hideXy: boolean
135
- includeOccluded?: boolean
136
133
  // machine consumers keep the whole string; the terminal dump clips it
137
134
  fullText?: boolean
138
135
  // depth for the recursive dump. omitted means the default 12. setting it
@@ -141,130 +138,142 @@ export interface DescribeDumpOptions {
141
138
  maxDepth?: number
142
139
  }
143
140
 
144
- export interface DescribeKeyboardLayout {
145
- visible?: boolean
146
- spec?: {
147
- autoCapitalize?: string
148
- keyboardType?: string
149
- returnKeyType?: string
150
- }
151
- mode?: string
152
- shifted?: boolean
153
- capsLock?: boolean
154
- accessoryBarId?: string | null
155
- }
156
-
157
141
  export interface DescribeResult {
158
142
  tree?: string
159
- shell?: {
160
- state?: string
161
- activeApp?: string
162
- showSwitcher?: boolean
163
- switcherPhase?: string
164
- } | null
165
143
  nodeCount?: number
166
- keyboard?: DescribeKeyboardLayout | null
167
144
  }
168
145
 
169
- // `describe` the visible-UI tree with layout + style annotations, plus shell
170
- // + keyboard state. the dump itself lives in the engine's test-bridge
171
- // `dumpTree`; this kernel just bundles the one evaluate call. callers own
172
- // arg-parsing, preflight, hints, watch loop, and formatting.
173
- export async function inspectDescribe(
174
- bridge: InspectBridge,
175
- dumpOpts: DescribeDumpOptions,
176
- ): Promise<DescribeResult> {
177
- // resolve the depth here rather than inside the eval string: it is a typed
178
- // decision, and the generated code stays a literal call the caller can read.
179
- const dumpDepth = dumpOpts.maxDepth ?? 12
180
- const code = `(async () => {
181
- const t = window.__sootsimTest
182
- const mainShell = window.SootSim?.bridges?.mainShell
183
- const kb = window.__sootsimKeyboard
184
- if (!t) return { error: 'no test bridge' }
146
+ function isSemanticGeometry(value: unknown): boolean {
147
+ return (
148
+ value !== null &&
149
+ typeof value === 'object' &&
150
+ [
151
+ Reflect.get(value, 'x'),
152
+ Reflect.get(value, 'y'),
153
+ Reflect.get(value, 'width'),
154
+ Reflect.get(value, 'height'),
155
+ ].every((coordinate) =>
156
+ typeof coordinate === 'number' ? Number.isFinite(coordinate) : false,
157
+ )
158
+ )
159
+ }
185
160
 
186
- let shell = null
187
- try {
188
- shell = typeof mainShell?.getState === 'function' ? await mainShell.getState() : null
189
- } catch {}
161
+ export function isSimSemanticTree(value: unknown): value is SimSemanticNode[] {
162
+ if (!Array.isArray(value)) return false
163
+ return value.every((node) => {
164
+ if (
165
+ node === null ||
166
+ typeof node !== 'object' ||
167
+ !Number.isInteger(Reflect.get(node, 'nodeId')) ||
168
+ typeof Reflect.get(node, 'type') !== 'string' ||
169
+ !isSemanticGeometry(Reflect.get(node, 'geometry'))
170
+ ) {
171
+ return false
172
+ }
173
+ for (const key of ['testID', 'text', 'role', 'label']) {
174
+ const value = Reflect.get(node, key)
175
+ if (value !== undefined && typeof value !== 'string') return false
176
+ }
177
+ const pressable = Reflect.get(node, 'pressable')
178
+ if (pressable !== undefined && pressable !== true) return false
179
+ const children = Reflect.get(node, 'children')
180
+ return children === undefined || isSimSemanticTree(children)
181
+ })
182
+ }
183
+
184
+ function isSemanticQueryResult(value: unknown): value is SimSemanticQueryResult {
185
+ const total =
186
+ value !== null && typeof value === 'object' ? Reflect.get(value, 'total') : undefined
187
+ return (
188
+ value !== null &&
189
+ typeof value === 'object' &&
190
+ typeof total === 'number' &&
191
+ Number.isInteger(total) &&
192
+ total >= 0 &&
193
+ isSimSemanticTree(Reflect.get(value, 'nodes'))
194
+ )
195
+ }
190
196
 
191
- const opts = ${JSON.stringify(dumpOpts)}
192
- const needsRecursiveDump =
193
- opts.verbose || opts.subtreeRoot || opts.includeOccluded || opts.maxDepth != null
194
- const globToRegex = (glob) => {
195
- const escaped = String(glob).replace(/[.+^$(){}|[\\]\\\\]/g, '\\\\$&')
196
- const body = escaped.replace(/\\*/g, '.*').replace(/\\?/g, '.')
197
- return new RegExp('^' + body + '$', 'i')
197
+ function globRegex(glob: string): RegExp {
198
+ const escaped = glob.replace(/[.+^$(){}|[\]\\]/g, '\\$&')
199
+ return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i')
200
+ }
201
+
202
+ export function formatSemanticTree(
203
+ nodes: readonly SimSemanticNode[],
204
+ opts: Partial<DescribeDumpOptions> = {},
205
+ ): string {
206
+ const filter = opts.filter?.toLowerCase() ?? ''
207
+ const testIDRegex = opts.testIdLike ? globRegex(opts.testIdLike) : null
208
+ const onlyRegex = opts.onlyGlob ? globRegex(opts.onlyGlob) : null
209
+ const lines: string[] = []
210
+ const visit = (node: SimSemanticNode, indent: number): void => {
211
+ const joined = [node.role, node.label, node.text, node.testID]
212
+ .filter((value): value is string => typeof value === 'string')
213
+ .join(' ')
214
+ const matches =
215
+ (!filter || joined.toLowerCase().includes(filter)) &&
216
+ (!testIDRegex || testIDRegex.test(node.testID ?? '')) &&
217
+ (!onlyRegex || onlyRegex.test(joined))
218
+ if (matches) {
219
+ const parts = [
220
+ node.role ? `[${node.role}]` : node.pressable ? '[tap]' : `<${node.type}>`,
221
+ ]
222
+ if (node.text) {
223
+ const text =
224
+ opts.fullText || node.text.length <= 50
225
+ ? node.text
226
+ : `${node.text.slice(0, 49).trimEnd()}…`
227
+ parts.push(JSON.stringify(text))
198
228
  }
199
- // clipping a describe line is a DISPLAY decision. opts.fullText (set by
200
- // \`describe --json\`) keeps the real string, so a bundle-eval stack or a
201
- // red-box message reaches the caller whole instead of ending in an
202
- // ellipsis nothing downstream can undo.
203
- const clipForDisplay = (value, max) => {
204
- const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''
205
- if (opts.fullText || text.length <= max) return text
206
- return text.slice(0, max - 1).trimEnd() + '…'
229
+ if (node.testID) parts.push(`#${node.testID}`)
230
+ if (node.label && node.label !== node.text) {
231
+ parts.push(`label=${JSON.stringify(node.label)}`)
207
232
  }
208
- const formatInspectableTree = async () => {
209
- if (typeof t.listInspectable !== 'function') return null
210
- const list = await t.listInspectable({})
211
- if (!Array.isArray(list)) return null
212
- const filter = typeof opts.filter === 'string' ? opts.filter.toLowerCase() : ''
213
- const testIdRegex = opts.testIdLike ? globToRegex(opts.testIdLike) : null
214
- const onlyRegex = opts.onlyGlob ? globToRegex(opts.onlyGlob) : null
215
- const lines = []
216
- let matched = 0
217
- for (const node of list) {
218
- const testID = node?.testID || node?.testId || ''
219
- const role = node?.role || ''
220
- const label = node?.accessibilityLabel || node?.label || ''
221
- const text = node?.text || ''
222
- if (testIdRegex && !testIdRegex.test(testID)) continue
223
- const joined = [role, label, text, testID].filter(Boolean).join(' ')
224
- if (onlyRegex && !onlyRegex.test(joined)) continue
225
- if (filter && !joined.toLowerCase().includes(filter)) continue
226
- matched++
227
-
228
- const type = String(node?.type || 'node').toLowerCase()
229
- const handlers = Array.isArray(node?.handlers) ? node.handlers : []
230
- const parts = []
231
- if (role) parts.push('[' + role + ']')
232
- else if (handlers.length > 0) parts.push('[tap]')
233
- else parts.push('<' + type + '>')
234
- const visibleText = clipForDisplay(text, 50)
235
- if (visibleText) parts.push(JSON.stringify(visibleText))
236
- if (testID) parts.push('#' + testID)
237
- if (label && label !== text) {
238
- parts.push('label=' + JSON.stringify(clipForDisplay(label, 50)))
239
- }
240
- if (!opts.hideXy) {
241
- const abs = node?.absolute || {}
242
- const layout = node?.layout || {}
243
- const x = Math.round(Number(abs.x) || 0)
244
- const y = Math.round(Number(abs.y) || 0)
245
- const w = Math.round(Number(layout.width) || 0)
246
- const h = Math.round(Number(layout.height) || 0)
247
- parts.push('@(' + x + ',' + y + ') ' + w + 'x' + h)
248
- }
249
- if (handlers.length > 0) parts.push('(tap)')
250
- lines.push(' ' + parts.join(' '))
251
- }
252
- return { tree: lines.join('\\n'), matched }
233
+ if (!opts.hideXy) {
234
+ const geometry = node.geometry
235
+ parts.push(
236
+ `@(${Math.round(geometry.x)},${Math.round(geometry.y)}) ${Math.round(geometry.width)}x${Math.round(geometry.height)}`,
237
+ )
253
238
  }
239
+ if (node.pressable) parts.push('(tap)')
240
+ lines.push(`${' '.repeat(indent + 1)}${parts.join(' ')}`)
241
+ }
242
+ for (const child of node.children ?? []) visit(child, indent + 1)
243
+ }
244
+ let roots = nodes
245
+ if (opts.subtreeRoot) {
246
+ const pending = [...nodes]
247
+ let found: SimSemanticNode | undefined
248
+ while (pending.length > 0 && !found) {
249
+ const candidate = pending.shift()
250
+ if (!candidate) continue
251
+ if (candidate.testID === opts.subtreeRoot) found = candidate
252
+ else pending.push(...(candidate.children ?? []))
253
+ }
254
+ if (!found) return `__SUBTREE_NOT_FOUND__:${opts.subtreeRoot}`
255
+ roots = [found]
256
+ }
257
+ for (const root of roots) visit(root, 0)
258
+ return lines.join('\n')
259
+ }
254
260
 
255
- let tree = ''
256
- const compactResult = needsRecursiveDump ? null : await formatInspectableTree()
257
- if (compactResult) {
258
- tree = compactResult.tree
259
- } else {
260
- tree = await t.dumpTree(${dumpDepth}, opts)
261
- }
262
- const nodeCount = (await t.getNodeCount?.()) || 0
263
- const keyboard = kb && typeof kb.getLayout === 'function' ? kb.getLayout() : null
264
- return { tree, shell, nodeCount, keyboard }
265
- })()`
266
- const result = (await bridge.send({ type: 'evaluate', code })) as DescribeResult | null
267
- return result ?? {}
261
+ // `describe` — format the visible structured tree for humans. callers own
262
+ // arg-parsing, preflight, hints, and the watch loop.
263
+ export async function inspectDescribe(
264
+ bridge: InspectBridge,
265
+ dumpOpts: DescribeDumpOptions,
266
+ ): Promise<DescribeResult> {
267
+ const dumpDepth = dumpOpts.maxDepth ?? 12
268
+ const result: unknown = await bridge.send({
269
+ type: 'query',
270
+ query: { kind: 'tree', depth: dumpDepth },
271
+ })
272
+ if (!isSemanticQueryResult(result)) return {}
273
+ return {
274
+ tree: formatSemanticTree(result.nodes, dumpOpts),
275
+ nodeCount: result.total,
276
+ }
268
277
  }
269
278
 
270
279
  // ─── get-layout ───
@@ -463,10 +472,11 @@ export interface LayoutElement {
463
472
  text?: string
464
473
  // on-screen bounding box in device/viewport px (x/y are absolute).
465
474
  box: LayoutBox
466
- // box-model fields are zero-filled unless the caller opts into styling.
467
- borderRadius: number
468
- padding: LayoutPadding
469
- fontSize: number
475
+ // box-model fields are available only when the caller opts into styling.
476
+ // measured zero remains distinct from an unavailable field.
477
+ borderRadius?: number
478
+ padding?: LayoutPadding
479
+ fontSize?: number
470
480
  // styling — so a text-only agent (no vision) can still reason about visual
471
481
  // quality: theme correctness (is this screen actually emerald?), contrast
472
482
  // (color vs bg), washed-out opacity, garbled/struck text, clipping
@@ -621,9 +631,6 @@ export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string
621
631
  type: typeof n.type === 'string' ? n.type : 'node',
622
632
  text,
623
633
  box: { x: round(abs.x), y: round(abs.y), w: round(layout.width), h: round(layout.height) },
624
- borderRadius: 0,
625
- padding: { t: 0, r: 0, b: 0, l: 0 },
626
- fontSize: 0,
627
634
  }
628
635
  if (!includeStyle) return base
629
636
  // computedStyle has the resolved values; style is the curated subset —
@@ -702,6 +709,34 @@ export async function inspectGetLayout(
702
709
  bridge: InspectBridge,
703
710
  opts: LayoutExtractionOptions = {},
704
711
  ): Promise<LayoutElement[]> {
712
+ if (!opts.styling) {
713
+ const result: unknown = await bridge.send({
714
+ type: 'query',
715
+ query: { kind: 'find', selector: { visible: true } },
716
+ })
717
+ if (!isSemanticQueryResult(result)) return []
718
+ const filter = opts.filter?.trim().toLowerCase() ?? ''
719
+ return result.nodes
720
+ .filter((node) => {
721
+ if (!filter) return true
722
+ return [node.text, node.testID, node.role, node.type].some(
723
+ (value) => typeof value === 'string' && value.toLowerCase().includes(filter),
724
+ )
725
+ })
726
+ .map((node) => ({
727
+ nodeId: node.nodeId,
728
+ ...(node.testID ? { testID: node.testID } : {}),
729
+ ...(node.role ? { role: node.role } : {}),
730
+ type: node.type,
731
+ ...(node.text ? { text: node.text } : {}),
732
+ box: {
733
+ x: Math.round(node.geometry.x),
734
+ y: Math.round(node.geometry.y),
735
+ w: Math.round(node.geometry.width),
736
+ h: Math.round(node.geometry.height),
737
+ },
738
+ }))
739
+ }
705
740
  const raw = await bridge.send({
706
741
  type: 'evaluate',
707
742
  code: layoutGetNativeEval(opts),
@@ -726,13 +761,17 @@ export function formatLayoutElements(
726
761
  ? `[${el.role}]`
727
762
  : `<${el.type}>`
728
763
  const parts = [ident, `@(${el.box.x},${el.box.y})`, `${el.box.w}x${el.box.h}`]
729
- if (el.borderRadius > 0) parts.push(`radius:${el.borderRadius}`)
764
+ if (el.borderRadius !== undefined && el.borderRadius > 0) {
765
+ parts.push(`radius:${el.borderRadius}`)
766
+ }
730
767
  const p = el.padding
731
- if (p.t || p.r || p.b || p.l) {
768
+ if (p && (p.t || p.r || p.b || p.l)) {
732
769
  const uniform = p.t === p.r && p.r === p.b && p.b === p.l
733
770
  parts.push(uniform ? `pad:${p.t}` : `pad:${p.t},${p.r},${p.b},${p.l}`)
734
771
  }
735
- if (el.fontSize > 0) parts.push(`font:${el.fontSize}`)
772
+ if (el.fontSize !== undefined && el.fontSize > 0) {
773
+ parts.push(`font:${el.fontSize}`)
774
+ }
736
775
  // styling is opt-in (the `styling` flag): the default rows stay scannable
737
776
  // for UI inspection, and a no-vision agent asks for styling when judging
738
777
  // visual quality (contrast, washed-out, garbled/struck, clipping, flat).
@@ -780,89 +819,38 @@ export interface AccessibilityTreeNode {
780
819
  size: { w: number; h: number } | null
781
820
  }
782
821
 
783
- const INSPECT_A11Y_CODE = `(async () => {
784
- const t = window.__sootsimTest
785
- if (!t) return []
786
-
787
- const readLayout = (n) => n?.layout || null
788
- const readAbs = (n) => n?.absolutePosition || n?.absolute || null
789
- const readRole = (n) => n?.accessibilityRole || n?.role || null
790
- const readLabel = (n) => n?.accessibilityLabel || n?.label || n?.text || null
791
- const readHint = (n) => n?.accessibilityHint || n?.hint || null
792
- const readState = (n) => n?.accessibilityState || n?.state || null
793
- const readTestId = (n) => n?.testID || n?.testId || null
794
- const readType = (n) => typeof n?.type === 'string' ? n.type.toLowerCase() : n?.type
795
- const readPressable = (n) => {
796
- if (n?.pressable) return true
797
- const handlers = Array.isArray(n?.handlers) ? n.handlers : []
798
- const role = readRole(n)
799
- return handlers.length > 0 || role === 'button' || role === 'link' || role === 'tab'
800
- }
801
- const isTextInput = (n) => n?.isTextInput === true
802
- const isTextNode = (n) => readType(n) === 'text'
803
- const isVisibleTarget = (n) => {
804
- const layout = readLayout(n)
805
- if (!layout || layout.width <= 0 || layout.height <= 0) return false
806
- const abs = readAbs(n)
807
- if (!abs) return true
808
- const device = n?.device || {}
809
- const screenW = Number(device.width) || window.innerWidth || 0
810
- const screenH = Number(device.height) || window.innerHeight || 0
811
- return (
812
- abs.x + layout.width > 0 &&
813
- abs.y + layout.height > 0 &&
814
- abs.x < screenW &&
815
- abs.y < screenH
816
- )
817
- }
818
- const hasAccessibleSignal = (n) => {
819
- const role = readRole(n)
820
- const label = readLabel(n)
821
- const hint = readHint(n)
822
- if (role) return true
823
- if (hint) return true
824
- if (isTextInput(n)) return true
825
- if (readPressable(n)) return true
826
- if (isTextNode(n) && n?.text) return true
827
- if (typeof label === 'string' && label.length <= 30 && label !== n?.text) return true
828
- return false
829
- }
830
- const normalize = (n) => {
831
- const layout = readLayout(n)
832
- const abs = readAbs(n)
833
- const role = readRole(n) || (readPressable(n) ? 'button' : isTextInput(n) ? 'textfield' : isTextNode(n) ? 'statictext' : 'none')
834
- return {
835
- role,
836
- label: readLabel(n),
837
- hint: readHint(n),
838
- state: readState(n),
839
- testID: readTestId(n),
840
- position: abs ? { x: Math.round(abs.x), y: Math.round(abs.y) } : null,
841
- size: layout ? { w: Math.round(layout.width), h: Math.round(layout.height) } : null,
842
- }
843
- }
844
-
845
- if (typeof t.listInspectable === 'function') {
846
- const list = await t.listInspectable({})
847
- if (Array.isArray(list)) {
848
- return list.filter(n => isVisibleTarget(n) && hasAccessibleSignal(n)).map(normalize)
849
- }
850
- }
851
-
852
- const all = await t.queryAll({ pruneHidden: true })
853
- return all
854
- .filter(n => isVisibleTarget(n) && hasAccessibleSignal(n))
855
- .map(normalize)
856
- })()`
857
-
858
- // `get a11y` — flat accessibility summary for the currently visible SootSim
859
- // surface. listInspectable is preferred because it already resolves the active
860
- // shell/tenant surface and excludes covered launcher/app-switcher content.
822
+ // `get a11y` flat accessibility summary projected from the visible semantic
823
+ // tree, which already excludes hidden and covered content.
861
824
  export async function inspectAccessibilityTree(
862
825
  bridge: InspectBridge,
863
826
  ): Promise<AccessibilityTreeNode[]> {
864
- const nodes = await bridge.send({ type: 'evaluate', code: INSPECT_A11Y_CODE })
865
- return Array.isArray(nodes) ? (nodes as AccessibilityTreeNode[]) : []
827
+ const result: unknown = await bridge.send({
828
+ type: 'query',
829
+ query: { kind: 'find', selector: { visible: true } },
830
+ })
831
+ if (!isSemanticQueryResult(result)) return []
832
+ return result.nodes.flatMap((node) => {
833
+ if (!node.role && !node.label && !node.text && !node.pressable) return []
834
+ return [
835
+ {
836
+ role:
837
+ node.role ??
838
+ (node.pressable ? 'button' : node.type === 'text' ? 'statictext' : 'none'),
839
+ label: node.label ?? node.text ?? null,
840
+ hint: null,
841
+ state: null,
842
+ testID: node.testID ?? null,
843
+ position: {
844
+ x: Math.round(node.geometry.x),
845
+ y: Math.round(node.geometry.y),
846
+ },
847
+ size: {
848
+ w: Math.round(node.geometry.width),
849
+ h: Math.round(node.geometry.height),
850
+ },
851
+ },
852
+ ]
853
+ })
866
854
  }
867
855
 
868
856
  // ─── find ───
@@ -886,164 +874,32 @@ export interface FindQuery {
886
874
  interactive?: boolean
887
875
  }
888
876
 
889
- const FIND_INSPECTABLE_TARGETS = `
890
- const fromInspectable = async () => {
891
- if (typeof t.listInspectable !== 'function') return null
892
- const list = await t.listInspectable({})
893
- if (!Array.isArray(list)) return null
894
- return list.map((n) => {
895
- const role = n.accessibilityRole ?? n.role
896
- const label = n.accessibilityLabel ?? n.label
897
- const layout = n.layout
898
- const absolutePosition = n.absolutePosition ?? n.absolute
899
- const screenW = Number(n.device?.width) || window.innerWidth || 0
900
- const screenH = Number(n.device?.height) || window.innerHeight || 0
901
- const visibleFrame = layout && absolutePosition
902
- ? {
903
- x: Math.max(0, absolutePosition.x),
904
- y: Math.max(0, absolutePosition.y),
905
- width: Math.max(
906
- 0,
907
- Math.min(screenW, absolutePosition.x + layout.width) -
908
- Math.max(0, absolutePosition.x),
909
- ),
910
- height: Math.max(
911
- 0,
912
- Math.min(screenH, absolutePosition.y + layout.height) -
913
- Math.max(0, absolutePosition.y),
914
- ),
915
- }
916
- : null
917
- const handlers = Array.isArray(n.handlers) ? n.handlers : []
918
- const pressable =
919
- handlers.length > 0 ||
920
- role === 'button' ||
921
- role === 'link' ||
922
- role === 'tab'
923
- return {
924
- type: typeof n.type === 'string' ? n.type.toLowerCase() : n.type,
925
- id: n.id ?? n.nodeId,
926
- nodeId: n.nodeId,
927
- testID: n.testID ?? n.testId,
928
- text: n.text,
929
- layout,
930
- absolutePosition,
931
- visibleFrame,
932
- style: n.computedStyle ?? n.style ?? {},
933
- childCount: n.childCount ?? 0,
934
- pressed: n.pressed ?? false,
935
- pressable,
936
- isTextInput: n.isTextInput ?? false,
937
- accessible: n.accessible ?? true,
938
- accessibilityLabel: label,
939
- accessibilityRole: role,
940
- accessibilityHint: n.accessibilityHint,
941
- accessibilityState: n.accessibilityState,
942
- accessibilityValue: n.accessibilityValue,
943
- }
944
- })
945
- }
946
- const isVisibleTarget = (n) => {
947
- if (!n?.layout) return false
948
- const frame = n.visibleFrame
949
- if (frame) return frame.width > 0 && frame.height > 0
950
- const abs = n.absolutePosition
951
- if (!abs) return n.layout.width > 0 && n.layout.height > 0
952
- const screenW = window.innerWidth || 0
953
- const screenH = window.innerHeight || 0
954
- return (
955
- abs.x + n.layout.width > 0 &&
956
- abs.y + n.layout.height > 0 &&
957
- abs.x < screenW &&
958
- abs.y < screenH
959
- )
960
- }
961
- `
962
-
963
- // resolve a find query to its mode + the JS to evaluate. precedence matches
877
+ // resolve a find query to its mode + structured selector. precedence matches
964
878
  // the CLI: testid → role → type → pressable → interactive → visible → text.
965
879
  // returns null when nothing was asked for (caller prints usage).
966
- export function resolveFindMode(q: FindQuery): { mode: FindMode; code: string } | null {
880
+ export function resolveFindMode(
881
+ q: FindQuery,
882
+ ): { mode: FindMode; selector: SimSemanticSelector } | null {
967
883
  if (q.testId) {
968
- return {
969
- mode: 'testid',
970
- code: `(async () => {
971
- const t = window.__sootsimTest
972
- if (!t) return null
973
- return (await t.findByTestId(${JSON.stringify(q.testId)})) || (await t.findById(${JSON.stringify(q.testId)}))
974
- })()`,
975
- }
884
+ return { mode: 'testid', selector: { testID: q.testId } }
976
885
  }
977
886
  if (q.role) {
978
- return {
979
- mode: 'role',
980
- code: `(async () => {
981
- const t = window.__sootsimTest
982
- if (!t) return []
983
- return await t.queryAll({ hasRole: ${JSON.stringify(q.role)}, pruneHidden: true })
984
- })()`,
985
- }
887
+ return { mode: 'role', selector: { role: q.role } }
986
888
  }
987
889
  if (q.type) {
988
- return {
989
- mode: 'type',
990
- code: `(async () => {
991
- const t = window.__sootsimTest
992
- if (!t) return []
993
- return await t.queryAll({ type: ${JSON.stringify(q.type)}, pruneHidden: true })
994
- })()`,
995
- }
890
+ return { mode: 'type', selector: { type: q.type } }
996
891
  }
997
892
  if (q.pressable) {
998
- return {
999
- mode: 'pressable',
1000
- code: `(async () => {
1001
- const t = window.__sootsimTest
1002
- if (!t) return []
1003
- ${FIND_INSPECTABLE_TARGETS}
1004
- const inspectable = await fromInspectable()
1005
- if (inspectable) return inspectable.filter(n => n.pressable && isVisibleTarget(n))
1006
- const all = await t.queryAll({ pruneHidden: true })
1007
- return all.filter(n => n.pressable && isVisibleTarget(n))
1008
- })()`,
1009
- }
893
+ return { mode: 'pressable', selector: { pressable: true } }
1010
894
  }
1011
895
  if (q.interactive) {
1012
- return {
1013
- mode: 'interactive-targets',
1014
- code: `(async () => {
1015
- const t = window.__sootsimTest
1016
- if (!t) return []
1017
- ${FIND_INSPECTABLE_TARGETS}
1018
- const inspectable = await fromInspectable()
1019
- if (inspectable) {
1020
- return inspectable.filter(n => n.pressable && isVisibleTarget(n))
1021
- }
1022
- const all = await t.queryAll({ pruneHidden: true })
1023
- return all.filter(n => n.pressable && isVisibleTarget(n))
1024
- })()`,
1025
- }
896
+ return { mode: 'interactive-targets', selector: { pressable: true } }
1026
897
  }
1027
898
  if (q.visible) {
1028
- return {
1029
- mode: 'visible',
1030
- code: `(async () => {
1031
- const t = window.__sootsimTest
1032
- if (!t) return []
1033
- const all = await t.queryAll({ pruneHidden: true })
1034
- return all.filter(n => n.layout && n.layout.width > 0 && n.layout.height > 0)
1035
- })()`,
1036
- }
899
+ return { mode: 'visible', selector: { visible: true } }
1037
900
  }
1038
901
  if (q.text) {
1039
- return {
1040
- mode: 'text',
1041
- code: `(async () => {
1042
- const t = window.__sootsimTest
1043
- if (!t) return null
1044
- return await t.findByText(${JSON.stringify(q.text)})
1045
- })()`,
1046
- }
902
+ return { mode: 'text', selector: { text: q.text } }
1047
903
  }
1048
904
  return null
1049
905
  }
@@ -1057,8 +913,27 @@ export async function inspectFind(
1057
913
  ): Promise<{ mode: FindMode; result: unknown } | null> {
1058
914
  const resolved = resolveFindMode(q)
1059
915
  if (!resolved) return null
1060
- const result = await bridge.send({ type: 'evaluate', code: resolved.code })
1061
- return { mode: resolved.mode, result }
916
+ const result: unknown = await bridge.send({
917
+ type: 'query',
918
+ query: { kind: 'find', selector: resolved.selector },
919
+ })
920
+ if (!isSemanticQueryResult(result)) return { mode: resolved.mode, result: [] }
921
+ const nodes = result.nodes.map(
922
+ (node): InteractiveNode => ({
923
+ type: node.type,
924
+ nodeId: node.nodeId,
925
+ ...(node.testID ? { testID: node.testID } : {}),
926
+ ...(node.text ? { text: node.text } : {}),
927
+ ...(node.pressable ? { pressable: true } : {}),
928
+ ...(node.role ? { accessibilityRole: node.role } : {}),
929
+ ...(node.label ? { accessibilityLabel: node.label } : {}),
930
+ absolutePosition: { x: node.geometry.x, y: node.geometry.y },
931
+ layout: { width: node.geometry.width, height: node.geometry.height },
932
+ }),
933
+ )
934
+ const resultValue =
935
+ resolved.mode === 'testid' || resolved.mode === 'text' ? (nodes[0] ?? null) : nodes
936
+ return { mode: resolved.mode, result: resultValue }
1062
937
  }
1063
938
 
1064
939
  export interface InteractiveNode {
@@ -1121,189 +996,6 @@ function shellEscape(value: string): string {
1121
996
 
1122
997
  // ─── wait ───
1123
998
 
1124
- export interface ReadyProbe {
1125
- flag: unknown
1126
- at: number
1127
- nodes: number
1128
- targets: number
1129
- liveFrameActive: boolean
1130
- liveFrameChannels: number
1131
- liveFramePublishes: number
1132
- errors: number
1133
- loadingText: string
1134
- externalReady: boolean | null
1135
- externalStatus: string
1136
- externalError: string
1137
- // entry-require throw that bundle-loader suppressed-and-continued past.
1138
- // diagnostic only — never gates readiness.
1139
- suppressedEntryError: string
1140
- }
1141
-
1142
- export const WAIT_READY_PROBE = `(async () => {
1143
- const t = window.__sootsimTest
1144
- let nodes = 0
1145
- try { nodes = (await t?.getNodeCount?.()) || 0 } catch {}
1146
- let targets = 0
1147
- let liveFrameActive = false
1148
- let liveFrameChannels = 0
1149
- let liveFramePublishes = 0
1150
- let loadingText = ''
1151
- const cleanText = (value) =>
1152
- typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''
1153
- const loadingSignal = (value) => {
1154
- const text = cleanText(value)
1155
- if (!text) return ''
1156
- return /^(opening app\\b|looking for your app\\b|building bundle\\b|preparing rnx runtime\\b|loading native bundle\\b|loading rnx stubs\\b|loading bundle loader\\b|loading native stubs\\b|loading compat bridge\\b|loading react-native shim\\b|loading react bridge\\b|loading reconciler\\b|loading replacement modules\\b|injecting rnx modules\\b|warming sqlite engine\\b|capturing root component\\b|reloading app\\b|loading(?:[.!…]*)?$|connecting(?:[.!…]*)?$)/i.test(text)
1157
- ? text
1158
- : ''
1159
- }
1160
- try {
1161
- const loading = window.__sootsimConnectRNLoading
1162
- if (loading && loading.active === true) {
1163
- loadingText = [loading.title, loading.detail].map(cleanText).filter(Boolean).join(' ')
1164
- }
1165
- } catch {}
1166
- try {
1167
- if (typeof t?.listInspectable === 'function') {
1168
- const list = await t.listInspectable({})
1169
- if (Array.isArray(list)) {
1170
- const screenW = window.innerWidth || 0
1171
- const screenH = window.innerHeight || 0
1172
- const isVisible = (n) => {
1173
- const layout = n?.layout
1174
- if (!layout || layout.width <= 0 || layout.height <= 0) return false
1175
- const abs = n?.absolutePosition || n?.absolute
1176
- if (!abs) return true
1177
- const device = n?.device || {}
1178
- const w = Number(device.width) || screenW
1179
- const h = Number(device.height) || screenH
1180
- return (
1181
- abs.x + layout.width > 0 &&
1182
- abs.y + layout.height > 0 &&
1183
- abs.x < w &&
1184
- abs.y < h
1185
- )
1186
- }
1187
- const hasContentSignal = (n) => {
1188
- const role = n?.accessibilityRole || n?.role
1189
- const label = cleanText(n?.accessibilityLabel || n?.label)
1190
- const handlers = Array.isArray(n?.handlers) ? n.handlers : []
1191
- const text = cleanText(n?.text)
1192
- const testID = cleanText(n?.testID || n?.testId)
1193
- const loading = loadingSignal(text) || loadingSignal(label) || loadingSignal(testID)
1194
- if (!loadingText && loading) {
1195
- loadingText = loading
1196
- }
1197
- if (handlers.length > 0) return true
1198
- if (role) return true
1199
- if (n?.isTextInput) return true
1200
- if (text) return true
1201
- if (label) return true
1202
- if (testID && !/^splash|loading/i.test(testID)) return true
1203
- return false
1204
- }
1205
- targets = list.filter(n => isVisible(n) && hasContentSignal(n)).length
1206
- }
1207
- }
1208
- } catch {}
1209
- try {
1210
- const stats = await t?.getImageLoaderStats?.()
1211
- const presentation = stats?.liveFramePresentation
1212
- liveFrameChannels = Number(presentation?.activeChannels) || 0
1213
- liveFramePublishes = Number(presentation?.publishes) || 0
1214
- const previous = window.__sootsimWaitReadyLiveFramePublishes
1215
- liveFrameActive =
1216
- liveFrameChannels > 0 &&
1217
- typeof previous === 'number' &&
1218
- liveFramePublishes > previous
1219
- window.__sootsimWaitReadyLiveFramePublishes = liveFramePublishes
1220
- } catch {}
1221
- let externalReady = null
1222
- let externalStatus = ''
1223
- let externalError = ''
1224
- let suppressedEntryError = ''
1225
- try {
1226
- const getExternalAppState = t?.getExternalAppState
1227
- const external = typeof getExternalAppState === 'function'
1228
- ? await Promise.race([
1229
- getExternalAppState(),
1230
- new Promise((resolve) => setTimeout(
1231
- () => resolve({
1232
- state: {
1233
- ready: false,
1234
- loading: true,
1235
- status: 'waiting for tenant worker...',
1236
- },
1237
- }),
1238
- 1000,
1239
- )),
1240
- ])
1241
- : null
1242
- const state = external?.state
1243
- if (state && typeof state === 'object') {
1244
- externalReady = state.ready === true
1245
- if (typeof state.status === 'string') externalStatus = state.status
1246
- if (typeof state.error === 'string') externalError = state.error
1247
- }
1248
- if (!externalError && Array.isArray(external?.entryErrors)) {
1249
- // suppressed-and-continued entry errors (bundle-loader's patched __r
1250
- // swallows entry throws and moves on) must NOT gate readiness — the
1251
- // app frequently runs fine after one (e.g. metro's "Unknown named
1252
- // module" probing noise). they surface separately as a diagnostic
1253
- // for the timeout path.
1254
- const isReal = (item) => item && typeof item.message === 'string' && item.message.trim()
1255
- const fatal = external.entryErrors.find((item) => isReal(item) && item.suppressed !== true)
1256
- if (fatal) externalError = fatal.message
1257
- const suppressed = external.entryErrors.find((item) => isReal(item) && item.suppressed === true)
1258
- if (suppressed) suppressedEntryError = suppressed.message
1259
- }
1260
- // once the app has errored its last status ("injecting sootsim
1261
- // modules...") is stale — reporting it makes a dead app read as one that
1262
- // is still booting.
1263
- if (externalError) loadingText = ''
1264
- else if (externalReady === false && externalStatus) loadingText = externalStatus
1265
- } catch {}
1266
- let errors = 0
1267
- try { errors = window.__sootsimConsole?.count?.()?.errors ?? 0 } catch {}
1268
- return {
1269
- flag: (window).__sootsimExternalAppReady,
1270
- at: (window).__sootsimExternalAppReadyAt || 0,
1271
- nodes,
1272
- targets,
1273
- liveFrameActive,
1274
- liveFrameChannels,
1275
- liveFramePublishes,
1276
- errors,
1277
- loadingText,
1278
- externalReady,
1279
- externalStatus,
1280
- externalError,
1281
- suppressedEntryError,
1282
- }
1283
- })()`
1284
-
1285
- export const READY_CONTENT_NODE_FLOOR = 100
1286
- export const READY_NODE_STABLE_MS = 750
1287
- export const WAIT_READY_PROGRESS_INTERVAL_MS = 2000
1288
- // how often the readiness probe re-runs. shared so `sootsim wait ready` and the
1289
- // flow runner's launchApp wait resolve a ready app with the same latency.
1290
- export const READY_PROBE_POLL_MS = 150
1291
-
1292
- export function readyProbeHasContent(
1293
- probe: Pick<ReadyProbe, 'nodes' | 'targets'> &
1294
- Partial<Pick<ReadyProbe, 'liveFrameActive'>>,
1295
- ) {
1296
- return (
1297
- probe.liveFrameActive || probe.targets > 0 || probe.nodes >= READY_CONTENT_NODE_FLOOR
1298
- )
1299
- }
1300
-
1301
- export function readyProbeHasTargetContent(
1302
- probe: Pick<ReadyProbe, 'targets'> & Partial<Pick<ReadyProbe, 'liveFrameActive'>>,
1303
- ) {
1304
- return probe.liveFrameActive || probe.targets > 0
1305
- }
1306
-
1307
999
  export interface WaitReadyStatus {
1308
1000
  ready: boolean
1309
1001
  elapsedMs: number
@@ -1369,126 +1061,57 @@ export function waitReadyReason(
1369
1061
  // the tenant tree, and the node count can then jump through placeholder
1370
1062
  // counts before the app has finished publishing its first real surface. require
1371
1063
  // visible inspectable content (or a larger non-placeholder tree) and a brief
1372
- // stable count. polling is caller-side so the timeout is decoupled from the
1373
- // bridge's per-command timeout (cold guest bundles routinely need 20-30s).
1064
+ // stable count. the engine owns the full polling loop, so every caller pays
1065
+ // exactly one bridge request even for a cold guest bundle.
1374
1066
  export async function inspectWaitReady(
1375
1067
  bridge: InspectBridge,
1376
1068
  timeoutMs = 20_000,
1377
1069
  options: WaitReadyOptions = {},
1378
1070
  ): Promise<WaitReadyStatus> {
1379
1071
  const start = Date.now()
1380
- const deadline = start + timeoutMs
1381
- const progressIntervalMs = options.progressIntervalMs ?? WAIT_READY_PROGRESS_INTERVAL_MS
1382
- let nextProgressAt = start + progressIntervalMs
1383
- let lastNodes = -1
1384
- let nodeStableSince = start
1385
- // the wait keeps polling through bridge errors — the target sim may still
1386
- // be launching (`sootsim open` running concurrently) — but the latest error
1387
- // is surfaced in progress lines and the timeout reason so a dead sim reads
1388
- // as "sim unreachable", not as misleading zero-node app state.
1389
- let bridgeError = ''
1390
- let last: ReadyProbe = {
1391
- flag: undefined,
1392
- at: 0,
1393
- nodes: 0,
1394
- targets: 0,
1395
- liveFrameActive: false,
1396
- liveFrameChannels: 0,
1397
- liveFramePublishes: 0,
1398
- errors: 0,
1399
- loadingText: '',
1400
- externalReady: null,
1401
- externalStatus: '',
1402
- externalError: '',
1403
- suppressedEntryError: '',
1404
- }
1405
- while (Date.now() < deadline) {
1406
- try {
1407
- last = (await bridge.send({ type: 'evaluate', code: WAIT_READY_PROBE })) ?? last
1408
- bridgeError = ''
1409
- } catch (error) {
1410
- bridgeError = error instanceof Error ? error.message : String(error)
1411
- // launching can make an unreachable sim transient, but target
1412
- // ambiguity cannot resolve until the caller chooses a sim.
1413
- if (
1414
- bridgeError.startsWith('multiple sims are connected:') ||
1415
- bridgeError.startsWith('saved sim ')
1416
- ) {
1417
- throw error
1418
- }
1419
- }
1420
- const now = Date.now()
1421
- const status: WaitReadyStatus = {
1422
- ready: false,
1423
- elapsedMs: now - start,
1424
- nodes: last.nodes,
1425
- targets: last.targets,
1426
- liveFrameActive: last.liveFrameActive,
1427
- liveFrameChannels: last.liveFrameChannels,
1428
- liveFramePublishes: last.liveFramePublishes,
1429
- flag: last.flag,
1430
- loadingText: last.loadingText,
1431
- externalReady: last.externalReady,
1432
- externalStatus: last.externalStatus,
1433
- externalError: last.externalError,
1434
- suppressedEntryError: last.suppressedEntryError,
1435
- errors: last.errors,
1436
- bridgeError,
1437
- }
1438
- if (last.nodes !== lastNodes) {
1439
- lastNodes = last.nodes
1440
- nodeStableSince = now
1441
- }
1442
- const hasReadySignal = last.flag === true && readyProbeHasContent(last)
1443
- const hasRenderedContentWithoutReadySignal =
1444
- last.flag !== true && readyProbeHasTargetContent(last)
1072
+ let result: SemanticWaitResult
1073
+ try {
1074
+ result = await bridge.send(
1075
+ {
1076
+ type: 'waitFor',
1077
+ waitForOptions: { condition: { type: 'ready' }, timeoutMs },
1078
+ },
1079
+ { timeoutMs: timeoutMs + 1_000 },
1080
+ )
1081
+ } catch (error) {
1082
+ const bridgeError = error instanceof Error ? error.message : String(error)
1445
1083
  if (
1446
- (hasReadySignal || hasRenderedContentWithoutReadySignal) &&
1447
- last.externalReady !== false &&
1448
- !last.externalError &&
1449
- !last.loadingText
1084
+ bridgeError.startsWith('multiple sims are connected:') ||
1085
+ bridgeError.startsWith('saved sim ')
1450
1086
  ) {
1451
- if (now - nodeStableSince >= READY_NODE_STABLE_MS) {
1452
- return {
1453
- ...status,
1454
- ready: true,
1455
- }
1456
- }
1087
+ throw error
1457
1088
  }
1458
- // a guest app that reported a load error is DONE — it paints a red box and
1459
- // stays there until something reloads it (the reload path clears `error`
1460
- // atomically). polling out the rest of the budget only delays the same
1461
- // answer, and it is why a boot failure used to cost 90s before the caller
1462
- // was told anything.
1463
- if (last.externalError) return status
1464
- if (options.onProgress && progressIntervalMs > 0 && now >= nextProgressAt) {
1465
- options.onProgress(status)
1466
- do {
1467
- nextProgressAt += progressIntervalMs
1468
- } while (now >= nextProgressAt)
1089
+ result = {
1090
+ matched: false,
1091
+ elapsedMs: Date.now() - start,
1092
+ polls: 0,
1093
+ error: bridgeError,
1469
1094
  }
1470
- await new Promise((r) => setTimeout(r, READY_PROBE_POLL_MS))
1471
1095
  }
1096
+ const probe = result.probe
1472
1097
  const status: WaitReadyStatus = {
1473
- ready: false,
1474
- elapsedMs: Date.now() - start,
1475
- nodes: last.nodes,
1476
- targets: last.targets,
1477
- liveFrameActive: last.liveFrameActive,
1478
- liveFrameChannels: last.liveFrameChannels,
1479
- liveFramePublishes: last.liveFramePublishes,
1480
- flag: last.flag,
1481
- loadingText: last.loadingText,
1482
- externalReady: last.externalReady,
1483
- externalStatus: last.externalStatus,
1484
- externalError: last.externalError,
1485
- suppressedEntryError: last.suppressedEntryError,
1486
- errors: last.errors,
1487
- bridgeError,
1488
- }
1489
- if (options.onProgress && progressIntervalMs > 0 && Date.now() >= nextProgressAt) {
1490
- options.onProgress(status)
1098
+ ready: result.matched,
1099
+ elapsedMs: result.elapsedMs,
1100
+ nodes: probe?.nodes ?? 0,
1101
+ targets: probe?.targets ?? 0,
1102
+ liveFrameActive: probe?.liveFrameActive ?? false,
1103
+ liveFrameChannels: probe?.liveFrameChannels ?? 0,
1104
+ liveFramePublishes: probe?.liveFramePublishes ?? 0,
1105
+ flag: probe?.flag,
1106
+ loadingText: probe?.loadingText ?? '',
1107
+ externalReady: probe?.externalReady ?? null,
1108
+ externalStatus: probe?.externalStatus ?? '',
1109
+ externalError: probe?.externalError ?? '',
1110
+ suppressedEntryError: probe?.suppressedEntryError ?? '',
1111
+ errors: probe?.errors ?? 0,
1112
+ bridgeError: result.error ?? '',
1491
1113
  }
1114
+ if (!status.ready && options.onProgress) options.onProgress(status)
1492
1115
  return status
1493
1116
  }
1494
1117
 
@@ -1499,72 +1122,26 @@ export async function inspectWaitSelector(
1499
1122
  testId: string,
1500
1123
  timeoutMs = 5000,
1501
1124
  opts: { gone?: boolean } = {},
1502
- ): Promise<{ found: boolean; node?: any; elapsed: number }> {
1503
- // gone-mode inverts the predicate: resolve (found:true) when the node is
1504
- // confirmed absent from the active visible inspect tree. this is the
1505
- // positive "the gate/screen left" signal — e.g. waiting for an access-gate
1506
- // button to disappear after a successful sign-in instead of re-tapping it.
1507
- // an indeterminate bridge (test hooks not ready yet) never counts as gone,
1508
- // and visible absence is debounced across two polls so a transient tree swap
1509
- // doesn't resolve early. `gone` only steers the predicate; the return shape
1510
- // is the same as presence-mode (callers/the handler don't need a gone flag).
1125
+ ): Promise<{ found: boolean; node?: SimSemanticNode; elapsed: number }> {
1511
1126
  const gone = opts.gone === true
1512
- const result = await bridge.send(
1513
- {
1514
- type: 'evaluate',
1515
- code: `(async () => {
1516
- const start = Date.now()
1517
- const deadline = start + ${timeoutMs}
1518
- const gone = ${gone}
1519
- const ID = ${JSON.stringify(testId)}
1520
- const present = (node) => !!(node && node.layout && node.layout.width > 0 && node.layout.height > 0)
1521
- const find = async () => {
1522
- const t = window.__sootsimTest
1523
- if (!t) return undefined // bridge not ready — indeterminate, not "gone"
1524
- try {
1525
- // gone-mode confirms a gate/screen LEFT — it only needs structural
1526
- // existence + layout, NOT the full-tree occlusion pass (pruneHidden).
1527
- // that pass (computeOccludedInspectNodeIds) walks the whole tree and
1528
- // does O(n²)-ish rect-occlusion testing EVERY poll; on a large
1529
- // post-login app tree it costs seconds per poll, so --gone can never
1530
- // get two clean "absent" polls inside budget and falsely reports the
1531
- // gate as still blocked — the root cause of the 3pc PR-preview outage.
1532
- // presence-mode keeps pruneHidden so "appeared" still means
1533
- // visibly-reachable. a departed gate is unmounted or zero-sized, so the
1534
- // cheap structural+layout check is correct here and ~O(n) cheap.
1535
- const matches = await t.queryAll?.(gone ? { hasId: ID } : { hasId: ID, pruneHidden: true })
1536
- if (!Array.isArray(matches)) return undefined
1537
- return matches.find(present) || null
1538
- } catch {
1539
- return undefined
1540
- }
1541
- }
1542
- let goneStreak = 0
1543
- while (Date.now() < deadline) {
1544
- const node = await find()
1545
- if (gone) {
1546
- if (node === null) {
1547
- goneStreak += 1
1548
- if (goneStreak >= 2) return { found: true, elapsed: Date.now() - start }
1549
- } else {
1550
- goneStreak = 0
1551
- }
1552
- } else if (present(node)) {
1553
- return { found: true, node, elapsed: Date.now() - start }
1554
- }
1555
- await new Promise((r) => setTimeout(r, 80))
1556
- }
1557
- return { found: false, elapsed: Date.now() - start }
1558
- })()`,
1559
- },
1127
+ const result: SemanticWaitResult = await bridge.send(
1560
1128
  {
1561
- timeoutMs: timeoutMs + 1_000,
1129
+ type: 'waitFor',
1130
+ waitForOptions: {
1131
+ condition: { type: gone ? 'absent' : 'present', testID: testId },
1132
+ timeoutMs,
1133
+ },
1562
1134
  },
1135
+ { timeoutMs: timeoutMs + 1_000 },
1563
1136
  )
1564
- return (result ?? { found: false, elapsed: timeoutMs }) as {
1565
- found: boolean
1566
- node?: any
1567
- elapsed: number
1137
+ if (result?.error) throw new Error(result.error)
1138
+ if (result?.node && !isSimSemanticTree([result.node])) {
1139
+ throw new Error('waitFor returned an invalid semantic node')
1140
+ }
1141
+ return {
1142
+ found: result?.matched === true,
1143
+ ...(result?.node ? { node: result.node } : {}),
1144
+ elapsed: result?.elapsedMs ?? timeoutMs,
1568
1145
  }
1569
1146
  }
1570
1147