execbro 0.0.1-reserve → 1.10.13

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 (475) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +496 -0
  3. package/build/__tests__/helpers/fake-cdp-server.d.ts +56 -0
  4. package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -0
  5. package/build/__tests__/helpers/fake-cdp-server.js +120 -0
  6. package/build/__tests__/helpers/fake-cdp-server.js.map +1 -0
  7. package/build/__tests__/helpers/tap-test-helpers.d.ts +36 -0
  8. package/build/__tests__/helpers/tap-test-helpers.d.ts.map +1 -0
  9. package/build/__tests__/helpers/tap-test-helpers.js +130 -0
  10. package/build/__tests__/helpers/tap-test-helpers.js.map +1 -0
  11. package/build/__tests__/integration/auto-reconnect.test.d.ts +2 -0
  12. package/build/__tests__/integration/auto-reconnect.test.d.ts.map +1 -0
  13. package/build/__tests__/integration/auto-reconnect.test.js +118 -0
  14. package/build/__tests__/integration/auto-reconnect.test.js.map +1 -0
  15. package/build/__tests__/integration/connection-health.test.d.ts +2 -0
  16. package/build/__tests__/integration/connection-health.test.d.ts.map +1 -0
  17. package/build/__tests__/integration/connection-health.test.js +157 -0
  18. package/build/__tests__/integration/connection-health.test.js.map +1 -0
  19. package/build/__tests__/integration/execute-in-app.test.d.ts +2 -0
  20. package/build/__tests__/integration/execute-in-app.test.d.ts.map +1 -0
  21. package/build/__tests__/integration/execute-in-app.test.js +124 -0
  22. package/build/__tests__/integration/execute-in-app.test.js.map +1 -0
  23. package/build/__tests__/integration/per-call-timeout.test.d.ts +2 -0
  24. package/build/__tests__/integration/per-call-timeout.test.d.ts.map +1 -0
  25. package/build/__tests__/integration/per-call-timeout.test.js +103 -0
  26. package/build/__tests__/integration/per-call-timeout.test.js.map +1 -0
  27. package/build/__tests__/integration/tap-strategies.test.d.ts +2 -0
  28. package/build/__tests__/integration/tap-strategies.test.d.ts.map +1 -0
  29. package/build/__tests__/integration/tap-strategies.test.js +330 -0
  30. package/build/__tests__/integration/tap-strategies.test.js.map +1 -0
  31. package/build/__tests__/integration/tools.test.d.ts +2 -0
  32. package/build/__tests__/integration/tools.test.d.ts.map +1 -0
  33. package/build/__tests__/integration/tools.test.js +227 -0
  34. package/build/__tests__/integration/tools.test.js.map +1 -0
  35. package/build/__tests__/setup.d.ts +2 -0
  36. package/build/__tests__/setup.d.ts.map +1 -0
  37. package/build/__tests__/setup.js +15 -0
  38. package/build/__tests__/setup.js.map +1 -0
  39. package/build/__tests__/unit/androidEmulatorAvds.test.d.ts +2 -0
  40. package/build/__tests__/unit/androidEmulatorAvds.test.d.ts.map +1 -0
  41. package/build/__tests__/unit/androidEmulatorAvds.test.js +98 -0
  42. package/build/__tests__/unit/androidEmulatorAvds.test.js.map +1 -0
  43. package/build/__tests__/unit/buildToken.test.d.ts +2 -0
  44. package/build/__tests__/unit/buildToken.test.d.ts.map +1 -0
  45. package/build/__tests__/unit/buildToken.test.js +22 -0
  46. package/build/__tests__/unit/buildToken.test.js.map +1 -0
  47. package/build/__tests__/unit/bundle.test.d.ts +2 -0
  48. package/build/__tests__/unit/bundle.test.d.ts.map +1 -0
  49. package/build/__tests__/unit/bundle.test.js +53 -0
  50. package/build/__tests__/unit/bundle.test.js.map +1 -0
  51. package/build/__tests__/unit/burst.test.d.ts +2 -0
  52. package/build/__tests__/unit/burst.test.d.ts.map +1 -0
  53. package/build/__tests__/unit/burst.test.js +54 -0
  54. package/build/__tests__/unit/burst.test.js.map +1 -0
  55. package/build/__tests__/unit/connection-health.test.d.ts +2 -0
  56. package/build/__tests__/unit/connection-health.test.d.ts.map +1 -0
  57. package/build/__tests__/unit/connection-health.test.js +38 -0
  58. package/build/__tests__/unit/connection-health.test.js.map +1 -0
  59. package/build/__tests__/unit/debugGlobals.test.d.ts +2 -0
  60. package/build/__tests__/unit/debugGlobals.test.d.ts.map +1 -0
  61. package/build/__tests__/unit/debugGlobals.test.js +14 -0
  62. package/build/__tests__/unit/debugGlobals.test.js.map +1 -0
  63. package/build/__tests__/unit/deviceDiscovery.test.d.ts +2 -0
  64. package/build/__tests__/unit/deviceDiscovery.test.d.ts.map +1 -0
  65. package/build/__tests__/unit/deviceDiscovery.test.js +119 -0
  66. package/build/__tests__/unit/deviceDiscovery.test.js.map +1 -0
  67. package/build/__tests__/unit/deviceResolver.test.d.ts +2 -0
  68. package/build/__tests__/unit/deviceResolver.test.d.ts.map +1 -0
  69. package/build/__tests__/unit/deviceResolver.test.js +261 -0
  70. package/build/__tests__/unit/deviceResolver.test.js.map +1 -0
  71. package/build/__tests__/unit/escapeNonAscii.test.d.ts +2 -0
  72. package/build/__tests__/unit/escapeNonAscii.test.d.ts.map +1 -0
  73. package/build/__tests__/unit/escapeNonAscii.test.js +76 -0
  74. package/build/__tests__/unit/escapeNonAscii.test.js.map +1 -0
  75. package/build/__tests__/unit/exec.test.d.ts +2 -0
  76. package/build/__tests__/unit/exec.test.d.ts.map +1 -0
  77. package/build/__tests__/unit/exec.test.js +54 -0
  78. package/build/__tests__/unit/exec.test.js.map +1 -0
  79. package/build/__tests__/unit/executor.test.d.ts +2 -0
  80. package/build/__tests__/unit/executor.test.d.ts.map +1 -0
  81. package/build/__tests__/unit/executor.test.js +220 -0
  82. package/build/__tests__/unit/executor.test.js.map +1 -0
  83. package/build/__tests__/unit/failureArtifact.test.d.ts +2 -0
  84. package/build/__tests__/unit/failureArtifact.test.d.ts.map +1 -0
  85. package/build/__tests__/unit/failureArtifact.test.js +207 -0
  86. package/build/__tests__/unit/failureArtifact.test.js.map +1 -0
  87. package/build/__tests__/unit/fastRefreshRecorder.test.d.ts +2 -0
  88. package/build/__tests__/unit/fastRefreshRecorder.test.d.ts.map +1 -0
  89. package/build/__tests__/unit/fastRefreshRecorder.test.js +68 -0
  90. package/build/__tests__/unit/fastRefreshRecorder.test.js.map +1 -0
  91. package/build/__tests__/unit/fastRefreshTools.test.d.ts +2 -0
  92. package/build/__tests__/unit/fastRefreshTools.test.d.ts.map +1 -0
  93. package/build/__tests__/unit/fastRefreshTools.test.js +112 -0
  94. package/build/__tests__/unit/fastRefreshTools.test.js.map +1 -0
  95. package/build/__tests__/unit/fiberMatch.test.d.ts +2 -0
  96. package/build/__tests__/unit/fiberMatch.test.d.ts.map +1 -0
  97. package/build/__tests__/unit/fiberMatch.test.js +114 -0
  98. package/build/__tests__/unit/fiberMatch.test.js.map +1 -0
  99. package/build/__tests__/unit/fingerprint.test.d.ts +2 -0
  100. package/build/__tests__/unit/fingerprint.test.d.ts.map +1 -0
  101. package/build/__tests__/unit/fingerprint.test.js +99 -0
  102. package/build/__tests__/unit/fingerprint.test.js.map +1 -0
  103. package/build/__tests__/unit/focusedInput.test.d.ts +2 -0
  104. package/build/__tests__/unit/focusedInput.test.d.ts.map +1 -0
  105. package/build/__tests__/unit/focusedInput.test.js +58 -0
  106. package/build/__tests__/unit/focusedInput.test.js.map +1 -0
  107. package/build/__tests__/unit/focusedInputTools.test.d.ts +2 -0
  108. package/build/__tests__/unit/focusedInputTools.test.d.ts.map +1 -0
  109. package/build/__tests__/unit/focusedInputTools.test.js +79 -0
  110. package/build/__tests__/unit/focusedInputTools.test.js.map +1 -0
  111. package/build/__tests__/unit/iconSemantics.test.d.ts +2 -0
  112. package/build/__tests__/unit/iconSemantics.test.d.ts.map +1 -0
  113. package/build/__tests__/unit/iconSemantics.test.js +80 -0
  114. package/build/__tests__/unit/iconSemantics.test.js.map +1 -0
  115. package/build/__tests__/unit/imageBuffer.test.d.ts +2 -0
  116. package/build/__tests__/unit/imageBuffer.test.d.ts.map +1 -0
  117. package/build/__tests__/unit/imageBuffer.test.js +128 -0
  118. package/build/__tests__/unit/imageBuffer.test.js.map +1 -0
  119. package/build/__tests__/unit/logs.test.d.ts +2 -0
  120. package/build/__tests__/unit/logs.test.d.ts.map +1 -0
  121. package/build/__tests__/unit/logs.test.js +90 -0
  122. package/build/__tests__/unit/logs.test.js.map +1 -0
  123. package/build/__tests__/unit/measureComponent.test.d.ts +2 -0
  124. package/build/__tests__/unit/measureComponent.test.d.ts.map +1 -0
  125. package/build/__tests__/unit/measureComponent.test.js +66 -0
  126. package/build/__tests__/unit/measureComponent.test.js.map +1 -0
  127. package/build/__tests__/unit/measureComponentTools.test.d.ts +2 -0
  128. package/build/__tests__/unit/measureComponentTools.test.d.ts.map +1 -0
  129. package/build/__tests__/unit/measureComponentTools.test.js +94 -0
  130. package/build/__tests__/unit/measureComponentTools.test.js.map +1 -0
  131. package/build/__tests__/unit/metro.test.d.ts +2 -0
  132. package/build/__tests__/unit/metro.test.d.ts.map +1 -0
  133. package/build/__tests__/unit/metro.test.js +61 -0
  134. package/build/__tests__/unit/metro.test.js.map +1 -0
  135. package/build/__tests__/unit/network.test.d.ts +2 -0
  136. package/build/__tests__/unit/network.test.d.ts.map +1 -0
  137. package/build/__tests__/unit/network.test.js +102 -0
  138. package/build/__tests__/unit/network.test.js.map +1 -0
  139. package/build/__tests__/unit/networkInterceptor.test.d.ts +2 -0
  140. package/build/__tests__/unit/networkInterceptor.test.d.ts.map +1 -0
  141. package/build/__tests__/unit/networkInterceptor.test.js +104 -0
  142. package/build/__tests__/unit/networkInterceptor.test.js.map +1 -0
  143. package/build/__tests__/unit/probe.test.d.ts +2 -0
  144. package/build/__tests__/unit/probe.test.d.ts.map +1 -0
  145. package/build/__tests__/unit/probe.test.js +62 -0
  146. package/build/__tests__/unit/probe.test.js.map +1 -0
  147. package/build/__tests__/unit/rnGlobalsBootstrap.test.d.ts +2 -0
  148. package/build/__tests__/unit/rnGlobalsBootstrap.test.d.ts.map +1 -0
  149. package/build/__tests__/unit/rnGlobalsBootstrap.test.js +29 -0
  150. package/build/__tests__/unit/rnGlobalsBootstrap.test.js.map +1 -0
  151. package/build/__tests__/unit/screenState.test.d.ts +2 -0
  152. package/build/__tests__/unit/screenState.test.d.ts.map +1 -0
  153. package/build/__tests__/unit/screenState.test.js +384 -0
  154. package/build/__tests__/unit/screenState.test.js.map +1 -0
  155. package/build/__tests__/unit/screenshot-diff.test.d.ts +2 -0
  156. package/build/__tests__/unit/screenshot-diff.test.d.ts.map +1 -0
  157. package/build/__tests__/unit/screenshot-diff.test.js +97 -0
  158. package/build/__tests__/unit/screenshot-diff.test.js.map +1 -0
  159. package/build/__tests__/unit/tap-evidence.test.d.ts +2 -0
  160. package/build/__tests__/unit/tap-evidence.test.d.ts.map +1 -0
  161. package/build/__tests__/unit/tap-evidence.test.js +89 -0
  162. package/build/__tests__/unit/tap-evidence.test.js.map +1 -0
  163. package/build/__tests__/unit/tap.test.d.ts +2 -0
  164. package/build/__tests__/unit/tap.test.d.ts.map +1 -0
  165. package/build/__tests__/unit/tap.test.js +767 -0
  166. package/build/__tests__/unit/tap.test.js.map +1 -0
  167. package/build/__tests__/unit/timeoutClamp.test.d.ts +2 -0
  168. package/build/__tests__/unit/timeoutClamp.test.d.ts.map +1 -0
  169. package/build/__tests__/unit/timeoutClamp.test.js +17 -0
  170. package/build/__tests__/unit/timeoutClamp.test.js.map +1 -0
  171. package/build/__tests__/unit/toolDescriptions.test.d.ts +2 -0
  172. package/build/__tests__/unit/toolDescriptions.test.d.ts.map +1 -0
  173. package/build/__tests__/unit/toolDescriptions.test.js +145 -0
  174. package/build/__tests__/unit/toolDescriptions.test.js.map +1 -0
  175. package/build/__tests__/unit/transportClassify.test.d.ts +2 -0
  176. package/build/__tests__/unit/transportClassify.test.d.ts.map +1 -0
  177. package/build/__tests__/unit/transportClassify.test.js +60 -0
  178. package/build/__tests__/unit/transportClassify.test.js.map +1 -0
  179. package/build/__tests__/unit/verifyAction.test.d.ts +2 -0
  180. package/build/__tests__/unit/verifyAction.test.d.ts.map +1 -0
  181. package/build/__tests__/unit/verifyAction.test.js +26 -0
  182. package/build/__tests__/unit/verifyAction.test.js.map +1 -0
  183. package/build/core/android.d.ts +299 -0
  184. package/build/core/android.d.ts.map +1 -0
  185. package/build/core/android.js +1582 -0
  186. package/build/core/android.js.map +1 -0
  187. package/build/core/appDetection.d.ts +20 -0
  188. package/build/core/appDetection.d.ts.map +1 -0
  189. package/build/core/appDetection.js +167 -0
  190. package/build/core/appDetection.js.map +1 -0
  191. package/build/core/bundle.d.ts +49 -0
  192. package/build/core/bundle.d.ts.map +1 -0
  193. package/build/core/bundle.js +356 -0
  194. package/build/core/bundle.js.map +1 -0
  195. package/build/core/componentSearch.d.ts +41 -0
  196. package/build/core/componentSearch.d.ts.map +1 -0
  197. package/build/core/componentSearch.js +588 -0
  198. package/build/core/componentSearch.js.map +1 -0
  199. package/build/core/componentTree.d.ts +13 -0
  200. package/build/core/componentTree.d.ts.map +1 -0
  201. package/build/core/componentTree.js +275 -0
  202. package/build/core/componentTree.js.map +1 -0
  203. package/build/core/config.d.ts +9 -0
  204. package/build/core/config.d.ts.map +1 -0
  205. package/build/core/config.js +28 -0
  206. package/build/core/config.js.map +1 -0
  207. package/build/core/connection.d.ts +102 -0
  208. package/build/core/connection.d.ts.map +1 -0
  209. package/build/core/connection.js +1710 -0
  210. package/build/core/connection.js.map +1 -0
  211. package/build/core/connectionState.d.ts +122 -0
  212. package/build/core/connectionState.d.ts.map +1 -0
  213. package/build/core/connectionState.js +313 -0
  214. package/build/core/connectionState.js.map +1 -0
  215. package/build/core/debugGlobals.d.ts +6 -0
  216. package/build/core/debugGlobals.d.ts.map +1 -0
  217. package/build/core/debugGlobals.js +370 -0
  218. package/build/core/debugGlobals.js.map +1 -0
  219. package/build/core/deviceDiscovery.d.ts +52 -0
  220. package/build/core/deviceDiscovery.d.ts.map +1 -0
  221. package/build/core/deviceDiscovery.js +74 -0
  222. package/build/core/deviceDiscovery.js.map +1 -0
  223. package/build/core/deviceResolver.d.ts +44 -0
  224. package/build/core/deviceResolver.d.ts.map +1 -0
  225. package/build/core/deviceResolver.js +225 -0
  226. package/build/core/deviceResolver.js.map +1 -0
  227. package/build/core/errorScreenParser.d.ts +30 -0
  228. package/build/core/errorScreenParser.d.ts.map +1 -0
  229. package/build/core/errorScreenParser.js +198 -0
  230. package/build/core/errorScreenParser.js.map +1 -0
  231. package/build/core/errors.d.ts +14 -0
  232. package/build/core/errors.d.ts.map +1 -0
  233. package/build/core/errors.js +17 -0
  234. package/build/core/errors.js.map +1 -0
  235. package/build/core/escapeNonAscii.d.ts +18 -0
  236. package/build/core/escapeNonAscii.d.ts.map +1 -0
  237. package/build/core/escapeNonAscii.js +352 -0
  238. package/build/core/escapeNonAscii.js.map +1 -0
  239. package/build/core/exec.d.ts +22 -0
  240. package/build/core/exec.d.ts.map +1 -0
  241. package/build/core/exec.js +54 -0
  242. package/build/core/exec.js.map +1 -0
  243. package/build/core/executor.d.ts +9 -0
  244. package/build/core/executor.d.ts.map +1 -0
  245. package/build/core/executor.js +12 -0
  246. package/build/core/executor.js.map +1 -0
  247. package/build/core/failureArtifact.d.ts +120 -0
  248. package/build/core/failureArtifact.d.ts.map +1 -0
  249. package/build/core/failureArtifact.js +113 -0
  250. package/build/core/failureArtifact.js.map +1 -0
  251. package/build/core/fastRefreshRecorder.d.ts +23 -0
  252. package/build/core/fastRefreshRecorder.d.ts.map +1 -0
  253. package/build/core/fastRefreshRecorder.js +83 -0
  254. package/build/core/fastRefreshRecorder.js.map +1 -0
  255. package/build/core/fastRefreshTools.d.ts +19 -0
  256. package/build/core/fastRefreshTools.d.ts.map +1 -0
  257. package/build/core/fastRefreshTools.js +71 -0
  258. package/build/core/fastRefreshTools.js.map +1 -0
  259. package/build/core/feedback.d.ts +18 -0
  260. package/build/core/feedback.d.ts.map +1 -0
  261. package/build/core/feedback.js +73 -0
  262. package/build/core/feedback.js.map +1 -0
  263. package/build/core/fiberMatch.d.ts +25 -0
  264. package/build/core/fiberMatch.d.ts.map +1 -0
  265. package/build/core/fiberMatch.js +35 -0
  266. package/build/core/fiberMatch.js.map +1 -0
  267. package/build/core/fingerprint.d.ts +4 -0
  268. package/build/core/fingerprint.d.ts.map +1 -0
  269. package/build/core/fingerprint.js +47 -0
  270. package/build/core/fingerprint.js.map +1 -0
  271. package/build/core/focusedInput.d.ts +27 -0
  272. package/build/core/focusedInput.d.ts.map +1 -0
  273. package/build/core/focusedInput.js +110 -0
  274. package/build/core/focusedInput.js.map +1 -0
  275. package/build/core/focusedInputTools.d.ts +21 -0
  276. package/build/core/focusedInputTools.d.ts.map +1 -0
  277. package/build/core/focusedInputTools.js +51 -0
  278. package/build/core/focusedInputTools.js.map +1 -0
  279. package/build/core/format.d.ts +8 -0
  280. package/build/core/format.d.ts.map +1 -0
  281. package/build/core/format.js +34 -0
  282. package/build/core/format.js.map +1 -0
  283. package/build/core/guides.d.ts +21 -0
  284. package/build/core/guides.d.ts.map +1 -0
  285. package/build/core/guides.js +397 -0
  286. package/build/core/guides.js.map +1 -0
  287. package/build/core/iconSemantics.d.ts +18 -0
  288. package/build/core/iconSemantics.d.ts.map +1 -0
  289. package/build/core/iconSemantics.js +114 -0
  290. package/build/core/iconSemantics.js.map +1 -0
  291. package/build/core/imageBuffer.d.ts +37 -0
  292. package/build/core/imageBuffer.d.ts.map +1 -0
  293. package/build/core/imageBuffer.js +58 -0
  294. package/build/core/imageBuffer.js.map +1 -0
  295. package/build/core/index.d.ts +37 -0
  296. package/build/core/index.d.ts.map +1 -0
  297. package/build/core/index.js +66 -0
  298. package/build/core/index.js.map +1 -0
  299. package/build/core/inspector.d.ts +55 -0
  300. package/build/core/inspector.d.ts.map +1 -0
  301. package/build/core/inspector.js +603 -0
  302. package/build/core/inspector.js.map +1 -0
  303. package/build/core/ios.d.ts +263 -0
  304. package/build/core/ios.d.ts.map +1 -0
  305. package/build/core/ios.js +1705 -0
  306. package/build/core/ios.js.map +1 -0
  307. package/build/core/jsExecute.d.ts +62 -0
  308. package/build/core/jsExecute.d.ts.map +1 -0
  309. package/build/core/jsExecute.js +696 -0
  310. package/build/core/jsExecute.js.map +1 -0
  311. package/build/core/license.d.ts +48 -0
  312. package/build/core/license.d.ts.map +1 -0
  313. package/build/core/license.js +267 -0
  314. package/build/core/license.js.map +1 -0
  315. package/build/core/logbox.d.ts +58 -0
  316. package/build/core/logbox.d.ts.map +1 -0
  317. package/build/core/logbox.js +330 -0
  318. package/build/core/logbox.js.map +1 -0
  319. package/build/core/logs.d.ts +44 -0
  320. package/build/core/logs.d.ts.map +1 -0
  321. package/build/core/logs.js +149 -0
  322. package/build/core/logs.js.map +1 -0
  323. package/build/core/measureComponent.d.ts +19 -0
  324. package/build/core/measureComponent.d.ts.map +1 -0
  325. package/build/core/measureComponent.js +94 -0
  326. package/build/core/measureComponent.js.map +1 -0
  327. package/build/core/measureComponentTools.d.ts +6 -0
  328. package/build/core/measureComponentTools.d.ts.map +1 -0
  329. package/build/core/measureComponentTools.js +43 -0
  330. package/build/core/measureComponentTools.js.map +1 -0
  331. package/build/core/metro.d.ts +31 -0
  332. package/build/core/metro.d.ts.map +1 -0
  333. package/build/core/metro.js +124 -0
  334. package/build/core/metro.js.map +1 -0
  335. package/build/core/nativeOnlyHints.d.ts +14 -0
  336. package/build/core/nativeOnlyHints.d.ts.map +1 -0
  337. package/build/core/nativeOnlyHints.js +58 -0
  338. package/build/core/nativeOnlyHints.js.map +1 -0
  339. package/build/core/network.d.ts +43 -0
  340. package/build/core/network.d.ts.map +1 -0
  341. package/build/core/network.js +217 -0
  342. package/build/core/network.js.map +1 -0
  343. package/build/core/networkInterceptor.d.ts +30 -0
  344. package/build/core/networkInterceptor.d.ts.map +1 -0
  345. package/build/core/networkInterceptor.js +211 -0
  346. package/build/core/networkInterceptor.js.map +1 -0
  347. package/build/core/ocr.d.ts +87 -0
  348. package/build/core/ocr.d.ts.map +1 -0
  349. package/build/core/ocr.js +335 -0
  350. package/build/core/ocr.js.map +1 -0
  351. package/build/core/paths.d.ts +2 -0
  352. package/build/core/paths.d.ts.map +1 -0
  353. package/build/core/paths.js +28 -0
  354. package/build/core/paths.js.map +1 -0
  355. package/build/core/posthog.d.ts +5 -0
  356. package/build/core/posthog.d.ts.map +1 -0
  357. package/build/core/posthog.js +52 -0
  358. package/build/core/posthog.js.map +1 -0
  359. package/build/core/pressables.d.ts +46 -0
  360. package/build/core/pressables.d.ts.map +1 -0
  361. package/build/core/pressables.js +1719 -0
  362. package/build/core/pressables.js.map +1 -0
  363. package/build/core/probe.d.ts +12 -0
  364. package/build/core/probe.d.ts.map +1 -0
  365. package/build/core/probe.js +56 -0
  366. package/build/core/probe.js.map +1 -0
  367. package/build/core/redux.d.ts +22 -0
  368. package/build/core/redux.d.ts.map +1 -0
  369. package/build/core/redux.js +116 -0
  370. package/build/core/redux.js.map +1 -0
  371. package/build/core/register.d.ts +8 -0
  372. package/build/core/register.d.ts.map +1 -0
  373. package/build/core/register.js +202 -0
  374. package/build/core/register.js.map +1 -0
  375. package/build/core/rnGlobalsBootstrap.d.ts +16 -0
  376. package/build/core/rnGlobalsBootstrap.d.ts.map +1 -0
  377. package/build/core/rnGlobalsBootstrap.js +116 -0
  378. package/build/core/rnGlobalsBootstrap.js.map +1 -0
  379. package/build/core/screenLayout.d.ts +53 -0
  380. package/build/core/screenLayout.d.ts.map +1 -0
  381. package/build/core/screenLayout.js +878 -0
  382. package/build/core/screenLayout.js.map +1 -0
  383. package/build/core/screenState.d.ts +166 -0
  384. package/build/core/screenState.d.ts.map +1 -0
  385. package/build/core/screenState.js +1352 -0
  386. package/build/core/screenState.js.map +1 -0
  387. package/build/core/sdkBridge.d.ts +67 -0
  388. package/build/core/sdkBridge.d.ts.map +1 -0
  389. package/build/core/sdkBridge.js +148 -0
  390. package/build/core/sdkBridge.js.map +1 -0
  391. package/build/core/state.d.ts +37 -0
  392. package/build/core/state.d.ts.map +1 -0
  393. package/build/core/state.js +124 -0
  394. package/build/core/state.js.map +1 -0
  395. package/build/core/telemetry.d.ts +50 -0
  396. package/build/core/telemetry.d.ts.map +1 -0
  397. package/build/core/telemetry.js +653 -0
  398. package/build/core/telemetry.js.map +1 -0
  399. package/build/core/toolHelpers.d.ts +24 -0
  400. package/build/core/toolHelpers.d.ts.map +1 -0
  401. package/build/core/toolHelpers.js +118 -0
  402. package/build/core/toolHelpers.js.map +1 -0
  403. package/build/core/types.d.ts +162 -0
  404. package/build/core/types.d.ts.map +1 -0
  405. package/build/core/types.js +2 -0
  406. package/build/core/types.js.map +1 -0
  407. package/build/index.d.ts +4 -0
  408. package/build/index.d.ts.map +1 -0
  409. package/build/index.js +132 -0
  410. package/build/index.js.map +1 -0
  411. package/build/pro/screenshot-diff.d.ts +10 -0
  412. package/build/pro/screenshot-diff.d.ts.map +1 -0
  413. package/build/pro/screenshot-diff.js +38 -0
  414. package/build/pro/screenshot-diff.js.map +1 -0
  415. package/build/pro/tap.d.ts +291 -0
  416. package/build/pro/tap.d.ts.map +1 -0
  417. package/build/pro/tap.js +1839 -0
  418. package/build/pro/tap.js.map +1 -0
  419. package/build/pro/verifyAction.d.ts +50 -0
  420. package/build/pro/verifyAction.d.ts.map +1 -0
  421. package/build/pro/verifyAction.js +285 -0
  422. package/build/pro/verifyAction.js.map +1 -0
  423. package/build/tools/_deviceArg.d.ts +45 -0
  424. package/build/tools/_deviceArg.d.ts.map +1 -0
  425. package/build/tools/_deviceArg.js +58 -0
  426. package/build/tools/_deviceArg.js.map +1 -0
  427. package/build/tools/accountTools.d.ts +46 -0
  428. package/build/tools/accountTools.d.ts.map +1 -0
  429. package/build/tools/accountTools.js +223 -0
  430. package/build/tools/accountTools.js.map +1 -0
  431. package/build/tools/bundleTools.d.ts +3 -0
  432. package/build/tools/bundleTools.d.ts.map +1 -0
  433. package/build/tools/bundleTools.js +375 -0
  434. package/build/tools/bundleTools.js.map +1 -0
  435. package/build/tools/componentTools.d.ts +3 -0
  436. package/build/tools/componentTools.d.ts.map +1 -0
  437. package/build/tools/componentTools.js +746 -0
  438. package/build/tools/componentTools.js.map +1 -0
  439. package/build/tools/connectionTools.d.ts +3 -0
  440. package/build/tools/connectionTools.d.ts.map +1 -0
  441. package/build/tools/connectionTools.js +625 -0
  442. package/build/tools/connectionTools.js.map +1 -0
  443. package/build/tools/deviceTools.d.ts +3 -0
  444. package/build/tools/deviceTools.d.ts.map +1 -0
  445. package/build/tools/deviceTools.js +423 -0
  446. package/build/tools/deviceTools.js.map +1 -0
  447. package/build/tools/executionTools.d.ts +3 -0
  448. package/build/tools/executionTools.d.ts.map +1 -0
  449. package/build/tools/executionTools.js +243 -0
  450. package/build/tools/executionTools.js.map +1 -0
  451. package/build/tools/interactionTools.d.ts +3 -0
  452. package/build/tools/interactionTools.d.ts.map +1 -0
  453. package/build/tools/interactionTools.js +539 -0
  454. package/build/tools/interactionTools.js.map +1 -0
  455. package/build/tools/logTools.d.ts +3 -0
  456. package/build/tools/logTools.d.ts.map +1 -0
  457. package/build/tools/logTools.js +371 -0
  458. package/build/tools/logTools.js.map +1 -0
  459. package/build/tools/metaTools.d.ts +7 -0
  460. package/build/tools/metaTools.d.ts.map +1 -0
  461. package/build/tools/metaTools.js +186 -0
  462. package/build/tools/metaTools.js.map +1 -0
  463. package/build/tools/networkTools.d.ts +3 -0
  464. package/build/tools/networkTools.d.ts.map +1 -0
  465. package/build/tools/networkTools.js +478 -0
  466. package/build/tools/networkTools.js.map +1 -0
  467. package/build/tools/reduxTools.d.ts +3 -0
  468. package/build/tools/reduxTools.d.ts.map +1 -0
  469. package/build/tools/reduxTools.js +83 -0
  470. package/build/tools/reduxTools.js.map +1 -0
  471. package/build/tools/screenshotTools.d.ts +3 -0
  472. package/build/tools/screenshotTools.d.ts.map +1 -0
  473. package/build/tools/screenshotTools.js +703 -0
  474. package/build/tools/screenshotTools.js.map +1 -0
  475. package/package.json +63 -2
@@ -0,0 +1,1839 @@
1
+ import { connectedApps } from "../core/state.js";
2
+ import { pressElement } from "../core/executor.js";
3
+ import { iosTap, iosFindElement, iosScreenshot, isUiDriverAvailable, getUiDriverInstallHint, getIOSSafeAreaTop } from "../core/ios.js";
4
+ import { androidTap, androidFindElement } from "../core/android.js";
5
+ import { compareScreenshots } from "./screenshot-diff.js";
6
+ import { scanMetroPorts, fetchDevices, selectMainDevice } from "../core/metro.js";
7
+ import { connectToDevice, clearReconnectionSuppression, getConnectedAppByDevice } from "../core/connection.js";
8
+ import { resolveDeviceTarget, formatResolverError } from "../core/deviceResolver.js";
9
+ import { notifyDriverMissing } from "../core/logbox.js";
10
+ import { captureFailureArtifact } from "../core/failureArtifact.js";
11
+ import { captureScreenshot, verifyAndCapture, burstCaptureAndVerify, } from "./verifyAction.js";
12
+ export function buildVerificationExplanation(v) {
13
+ const pct = (rate) => (rate * 100).toFixed(1) + "%";
14
+ const action = v.action ?? "tap";
15
+ const Action = action[0].toUpperCase() + action.slice(1);
16
+ const target = action === "swipe" ? "scroll surface" : "element";
17
+ // Burst path with typed verdict
18
+ if (v.kind === "settled_elsewhere") {
19
+ return `${Action} caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
20
+ }
21
+ if (v.kind === "snap_back") {
22
+ if (action === "swipe") {
23
+ return (`Snap-back detected: content moved during the drag (frame ${v.peakFrame} peak ${pct(v.peakChangeRate || 0)} diff) ` +
24
+ `but returned to the starting position. Classic 'content fits inside the viewport' pattern — ` +
25
+ `check contentSize vs layoutSize on the ScrollView, not gesture handling.`);
26
+ }
27
+ return (`Transient visual feedback detected (frame ${v.peakFrame} peak ${pct(v.peakChangeRate || 0)} diff) ` +
28
+ `but no persistent change. ${Action} triggered a momentary animation that settled back.`);
29
+ }
30
+ if (v.kind === "missed") {
31
+ return (`No visual change detected — neither persistent nor transient across burst frames. ` +
32
+ `The ${target} may not respond visually or the ${action} may have missed its target.`);
33
+ }
34
+ // Legacy non-burst path
35
+ if (v.meaningful) {
36
+ return `${Action} caused a visible UI change (${pct(v.changeRate)} pixel diff). The screen updated as expected.`;
37
+ }
38
+ return (`No visual change detected between before and after screenshots. ` +
39
+ `The ${target} may not respond visually or the ${action} may have missed.`);
40
+ }
41
+ const BURST_CHANGE_THRESHOLD = 0.005;
42
+ export async function analyzeBurstFrames(frames, options) {
43
+ if (frames.length < 2) {
44
+ return {
45
+ meaningful: false,
46
+ persistentChangeRate: 0,
47
+ transientChangeDetected: false,
48
+ peakChangeRate: 0,
49
+ peakFrame: 0,
50
+ framesWithChange: [],
51
+ kind: "missed"
52
+ };
53
+ }
54
+ let peakChangeRate = 0;
55
+ let peakFrame = 0;
56
+ const framesWithChange = [];
57
+ for (let i = 1; i < frames.length; i++) {
58
+ const diff = await compareScreenshots(frames[i - 1], frames[i], options);
59
+ if (diff.changeRate > BURST_CHANGE_THRESHOLD) {
60
+ framesWithChange.push(i);
61
+ }
62
+ if (diff.changeRate > peakChangeRate) {
63
+ peakChangeRate = diff.changeRate;
64
+ peakFrame = i;
65
+ }
66
+ }
67
+ const persistentDiff = await compareScreenshots(frames[0], frames[frames.length - 1], options);
68
+ const persistentChangeRate = persistentDiff.changeRate;
69
+ const transientChangeDetected = !persistentDiff.changed && framesWithChange.length > 0;
70
+ // Snap-back (transient motion that reverts) is NOT a successful gesture —
71
+ // the content moved during the drag but returned to its starting position.
72
+ // Callers diagnosing scroll failures need this to read as "didn't work".
73
+ const meaningful = persistentDiff.changed;
74
+ let kind;
75
+ if (persistentDiff.changed) {
76
+ kind = "settled_elsewhere";
77
+ }
78
+ else if (transientChangeDetected) {
79
+ kind = "snap_back";
80
+ }
81
+ else {
82
+ kind = "missed";
83
+ }
84
+ return {
85
+ meaningful,
86
+ persistentChangeRate,
87
+ transientChangeDetected,
88
+ peakChangeRate,
89
+ peakFrame,
90
+ framesWithChange,
91
+ kind
92
+ };
93
+ }
94
+ // --- Helpers ---
95
+ export function buildQuery(options) {
96
+ const query = {};
97
+ if (options.text !== undefined)
98
+ query.text = options.text;
99
+ if (options.testID !== undefined)
100
+ query.testID = options.testID;
101
+ if (options.component !== undefined)
102
+ query.component = options.component;
103
+ if (options.x !== undefined)
104
+ query.x = options.x;
105
+ if (options.y !== undefined)
106
+ query.y = options.y;
107
+ return query;
108
+ }
109
+ /**
110
+ * Check if text contains characters that break Hermes Runtime.evaluate.
111
+ * Standard accented Latin characters (Polish, Vietnamese, French, German, etc.)
112
+ * and Cyrillic work fine in Hermes. Only emoji and special Unicode ranges cause issues.
113
+ */
114
+ export function hasProblematicUnicode(text) {
115
+ const emojiPattern = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{1F900}-\u{1F9FF}]|[\u{200D}]|[\u{20E3}]|[\u{E0020}-\u{E007F}]/u;
116
+ return emojiPattern.test(text);
117
+ }
118
+ function normalizeForMatch(text) {
119
+ return text.trim().replace(/\s+/g, " ").toLowerCase();
120
+ }
121
+ export function reconstructLinesFromWords(words) {
122
+ if (!words.length)
123
+ return [];
124
+ const sorted = [...words].sort((a, b) => a.bbox.y0 - b.bbox.y0);
125
+ const groups = [];
126
+ let current = [];
127
+ let runningMidY = -Infinity;
128
+ let runningHeight = 0;
129
+ for (const w of sorted) {
130
+ const wMid = (w.bbox.y0 + w.bbox.y1) / 2;
131
+ const wHeight = Math.max(1, w.bbox.y1 - w.bbox.y0);
132
+ const tolerance = Math.min(wHeight, runningHeight || wHeight) * 0.5;
133
+ if (current.length === 0 || Math.abs(wMid - runningMidY) <= tolerance) {
134
+ current.push(w);
135
+ const n = current.length;
136
+ runningMidY = runningMidY === -Infinity ? wMid : (runningMidY * (n - 1) + wMid) / n;
137
+ runningHeight = (runningHeight * (n - 1) + wHeight) / n;
138
+ }
139
+ else {
140
+ groups.push(current);
141
+ current = [w];
142
+ runningMidY = wMid;
143
+ runningHeight = wHeight;
144
+ }
145
+ }
146
+ if (current.length > 0)
147
+ groups.push(current);
148
+ return groups.map(group => {
149
+ const sortedWords = [...group].sort((a, b) => a.bbox.x0 - b.bbox.x0);
150
+ const text = sortedWords.map(w => w.text).join(" ");
151
+ const x0 = Math.min(...sortedWords.map(w => w.bbox.x0));
152
+ const y0 = Math.min(...sortedWords.map(w => w.bbox.y0));
153
+ const x1 = Math.max(...sortedWords.map(w => w.bbox.x1));
154
+ const y1 = Math.max(...sortedWords.map(w => w.bbox.y1));
155
+ const tapX = sortedWords.reduce((s, w) => s + w.tapCenter.x, 0) / sortedWords.length;
156
+ const tapY = sortedWords.reduce((s, w) => s + w.tapCenter.y, 0) / sortedWords.length;
157
+ return { text, bbox: { x0, y0, x1, y1 }, tapCenter: { x: tapX, y: tapY }, words: sortedWords };
158
+ });
159
+ }
160
+ /**
161
+ * Narrow the tap point to just the words that produced the substring match.
162
+ * Without this, a query for "Remotely" against a reconstructed line "Yes Remotely No"
163
+ * would tap at the line's centroid (middle of "Remotely" in this case, but easily wrong
164
+ * for asymmetric layouts). Tracks character offsets through the joined text so we know
165
+ * which constituent words contributed to the matched range.
166
+ */
167
+ function refineTapCenterToMatchingWords(line, normalizedNeedle) {
168
+ const normalizedFull = normalizeForMatch(line.text);
169
+ const startChar = normalizedFull.indexOf(normalizedNeedle);
170
+ if (startChar < 0)
171
+ return line.tapCenter;
172
+ const endChar = startChar + normalizedNeedle.length;
173
+ let offset = 0;
174
+ const covered = [];
175
+ for (const w of line.words) {
176
+ const wNorm = normalizeForMatch(w.text);
177
+ const wordStart = offset;
178
+ const wordEnd = offset + wNorm.length;
179
+ // Word overlaps the matched range
180
+ if (wordEnd > startChar && wordStart < endChar) {
181
+ covered.push(w);
182
+ }
183
+ offset = wordEnd + 1; // +1 for the joining space
184
+ }
185
+ if (covered.length === 0)
186
+ return line.tapCenter;
187
+ const tapX = covered.reduce((s, w) => s + w.tapCenter.x, 0) / covered.length;
188
+ const tapY = covered.reduce((s, w) => s + w.tapCenter.y, 0) / covered.length;
189
+ return { x: tapX, y: tapY };
190
+ }
191
+ export function findOcrMatch(ocrResult, query) {
192
+ const needle = normalizeForMatch(query);
193
+ if (!needle)
194
+ return null;
195
+ const words = ocrResult.words ?? [];
196
+ const lines = ocrResult.lines ?? [];
197
+ const exactWord = words.find((w) => normalizeForMatch(w.text) === needle);
198
+ if (exactWord)
199
+ return { text: exactWord.text, tapCenter: exactWord.tapCenter };
200
+ const exactLine = lines.find((l) => normalizeForMatch(l.text) === needle);
201
+ if (exactLine)
202
+ return { text: exactLine.text, tapCenter: exactLine.tapCenter };
203
+ const substringLine = lines.find((l) => normalizeForMatch(l.text).includes(needle));
204
+ if (substringLine)
205
+ return { text: substringLine.text, tapCenter: substringLine.tapCenter };
206
+ const substringWord = words.find((w) => normalizeForMatch(w.text).includes(needle));
207
+ if (substringWord)
208
+ return { text: substringWord.text, tapCenter: substringWord.tapCenter };
209
+ // Fall through to phrases reconstructed from word detections. Tap point is
210
+ // refined to only the words covering the matched substring, so a query for
211
+ // "Continue with Google" inside "[icon] Continue with Google" lands on the
212
+ // text run instead of the line centroid (and won't drift onto the icon).
213
+ const reconstructed = reconstructLinesFromWords(words);
214
+ const exactRecon = reconstructed.find((l) => normalizeForMatch(l.text) === needle);
215
+ if (exactRecon) {
216
+ return { text: exactRecon.text, tapCenter: refineTapCenterToMatchingWords(exactRecon, needle) };
217
+ }
218
+ const substringRecon = reconstructed.find((l) => normalizeForMatch(l.text).includes(needle));
219
+ if (substringRecon) {
220
+ return { text: substringRecon.text, tapCenter: refineTapCenterToMatchingWords(substringRecon, needle) };
221
+ }
222
+ return null;
223
+ }
224
+ export function getAvailableStrategies(query, strategy) {
225
+ if (query.x !== undefined && query.y !== undefined) {
226
+ return ["coordinate"];
227
+ }
228
+ if (strategy !== "auto") {
229
+ // Always fallback to OCR for text queries — explicit strategy may miss visible text
230
+ if (query.text && strategy !== "ocr" && strategy !== "coordinate") {
231
+ return [strategy, "ocr"];
232
+ }
233
+ return [strategy];
234
+ }
235
+ if (query.component && !query.text && !query.testID) {
236
+ return ["fiber"];
237
+ }
238
+ if (query.testID && !query.text) {
239
+ return ["accessibility", "fiber"];
240
+ }
241
+ if (query.text) {
242
+ const strategies = [];
243
+ strategies.push("accessibility");
244
+ if (!hasProblematicUnicode(query.text)) {
245
+ strategies.push("fiber");
246
+ }
247
+ strategies.push("ocr");
248
+ return strategies;
249
+ }
250
+ return ["fiber", "accessibility", "ocr"];
251
+ }
252
+ /**
253
+ * Convert screenshot image coordinates to platform-native tap coordinates.
254
+ *
255
+ * For iOS: screenshot pixels → device pixels (undo downscale) → points (÷ DPR)
256
+ * For Android: screenshot pixels → device pixels (undo downscale)
257
+ *
258
+ * IMPORTANT: Only use this for EXTERNAL coordinates from screenshots.
259
+ * Internal strategies (OCR, accessibility, fiber) produce tap-ready coordinates
260
+ * and call iosTap/androidTap directly — they must NOT go through this function.
261
+ */
262
+ export function convertScreenshotToTapCoords(pixelX, pixelY, platform, devicePixelRatio, scaleFactor = 1) {
263
+ const deviceX = pixelX * scaleFactor;
264
+ const deviceY = pixelY * scaleFactor;
265
+ if (platform === "android") {
266
+ return { x: Math.round(deviceX), y: Math.round(deviceY) };
267
+ }
268
+ return {
269
+ x: Math.round(deviceX / devicePixelRatio),
270
+ y: Math.round(deviceY / devicePixelRatio)
271
+ };
272
+ }
273
+ /** @deprecated Use convertScreenshotToTapCoords instead */
274
+ export const convertPixelsToPoints = convertScreenshotToTapCoords;
275
+ export function formatTapSuccess(data) {
276
+ const { screenshot, verification, ...rest } = data;
277
+ // I3 (2026-05-16): when the tap landed on a TextInput-like element and the diff
278
+ // reports no visual change, that does NOT mean the focus failed. iOS simulators with
279
+ // the hardware keyboard connected (Cmd+K) suppress the on-screen keyboard, so the
280
+ // input is focused but the screen looks identical. Surface this so the agent doesn't
281
+ // assume the tap missed and retry the wrong thing.
282
+ const isTextInputComponent = (() => {
283
+ const name = data.component ?? data.pressed ?? "";
284
+ return /textfield|textinput|edittext|searchfield/i.test(name);
285
+ })();
286
+ const note = verification && !verification.skipped && verification.meaningful === false && isTextInputComponent
287
+ ? "TextInput focused but no visual change detected. If the simulator has a hardware keyboard connected (Cmd+K), the software keyboard is suppressed even though the input is focused — proceed with text entry via ios_input_text / android_input_text."
288
+ : undefined;
289
+ return {
290
+ success: true,
291
+ ...rest,
292
+ ...(verification && { verification }),
293
+ ...(screenshot && { screenshot }),
294
+ ...(note && { warning: note })
295
+ };
296
+ }
297
+ export function formatTapFailure(data) {
298
+ const errorMsg = data.error || buildErrorMessage(data.query);
299
+ const warning = data.verification && !data.verification.skipped && data.verification.meaningful === false
300
+ ? "Tap executed but no visual change detected. The element may not exist at these coordinates. Examine the screenshot to verify and retry with adjusted coordinates."
301
+ : undefined;
302
+ const lastStrategy = data.attempted.length > 0 ? data.attempted[data.attempted.length - 1].strategy : undefined;
303
+ return {
304
+ success: false,
305
+ method: lastStrategy,
306
+ query: data.query,
307
+ error: errorMsg,
308
+ attempted: data.attempted,
309
+ suggestion: data.suggestion,
310
+ matches: data.matches,
311
+ ...(data.ambiguous && { ambiguous: true }),
312
+ ...(data.device && { device: data.device }),
313
+ ...(data.verification && { verification: data.verification }),
314
+ ...(data.screenshot && { screenshot: data.screenshot }),
315
+ ...(warning && { warning })
316
+ };
317
+ }
318
+ function buildErrorMessage(query) {
319
+ const parts = [];
320
+ if (query.text)
321
+ parts.push(`text="${query.text}"`);
322
+ if (query.testID)
323
+ parts.push(`testID="${query.testID}"`);
324
+ if (query.component)
325
+ parts.push(`component="${query.component}"`);
326
+ return `No element found matching ${parts.join(", ")}`;
327
+ }
328
+ // I2 (2026-05-16): RN component composition routinely produces N matches at the same
329
+ // coordinates for the same testID/text (e.g. ThemedButton → TouchableOpacity → TouchableOpacity).
330
+ // The C1 ambiguity guard refuses all such taps and demands `index=`. That's correct in the
331
+ // spatial-ambiguity case (two distinct buttons share a label) but wrong in the
332
+ // wrapper-ambiguity case — there's only one logical button, just stacked components.
333
+ //
334
+ // Collapse matches that share the same (testID, text) and overlap geometrically
335
+ // (centers within TOLERANCE_PX). Keeps the first occurrence — fiber/a11y walks parent-first,
336
+ // so the outermost wrapper survives (its onPress almost always proxies to inner handlers).
337
+ // Spatial ambiguity (different x/y) is preserved and continues to surface the C1 refusal.
338
+ function collapseGeometricallyEquivalentMatches(matches, getCenter, getKey) {
339
+ if (matches.length <= 1)
340
+ return matches;
341
+ const TOLERANCE_PX = 2;
342
+ const out = [];
343
+ for (const m of matches) {
344
+ const mc = getCenter(m);
345
+ const mk = getKey(m);
346
+ const isDuplicate = out.some(o => {
347
+ if (getKey(o) !== mk)
348
+ return false;
349
+ const oc = getCenter(o);
350
+ return Math.abs((mc.x ?? 0) - (oc.x ?? 0)) <= TOLERANCE_PX &&
351
+ Math.abs((mc.y ?? 0) - (oc.y ?? 0)) <= TOLERANCE_PX;
352
+ });
353
+ if (!isDuplicate)
354
+ out.push(m);
355
+ }
356
+ return out;
357
+ }
358
+ export function makeEmptyEvidenceSink() {
359
+ return {
360
+ fiber: { ran: false, durationMs: 0, metroConnected: false, pressables: [] },
361
+ accessibility: { ran: false, durationMs: 0, elements: [] },
362
+ ocr: { ran: false, durationMs: 0, detections: [], closestMatch: null, bestCandidate: null }
363
+ };
364
+ }
365
+ function ocrSimilarity(a, b) {
366
+ if (!a || !b)
367
+ return 0;
368
+ if (a === b)
369
+ return 1;
370
+ // Dice coefficient over character bigrams (case/space-insensitive)
371
+ const bigrams = (s) => {
372
+ const m = new Map();
373
+ if (s.length < 2) {
374
+ if (s)
375
+ m.set(s, 1);
376
+ return m;
377
+ }
378
+ for (let i = 0; i < s.length - 1; i++) {
379
+ const bg = s.slice(i, i + 2);
380
+ m.set(bg, (m.get(bg) ?? 0) + 1);
381
+ }
382
+ return m;
383
+ };
384
+ const ag = bigrams(a);
385
+ const bg = bigrams(b);
386
+ let intersection = 0;
387
+ for (const [k, va] of ag) {
388
+ const vb = bg.get(k);
389
+ if (vb)
390
+ intersection += Math.min(va, vb);
391
+ }
392
+ const total = Array.from(ag.values()).reduce((s, v) => s + v, 0)
393
+ + Array.from(bg.values()).reduce((s, v) => s + v, 0);
394
+ if (!total)
395
+ return 0;
396
+ return (2 * intersection) / total;
397
+ }
398
+ export function findClosestOcrText(ocrResult, query) {
399
+ if (!query)
400
+ return null;
401
+ const needle = normalizeForMatch(query);
402
+ if (!needle)
403
+ return null;
404
+ const candidates = [];
405
+ if (ocrResult?.words?.length)
406
+ candidates.push(...ocrResult.words.map(w => ({ text: w.text })));
407
+ if (ocrResult?.lines?.length)
408
+ candidates.push(...ocrResult.lines.map(l => ({ text: l.text })));
409
+ // Include reconstructed phrases so the diagnostic surfaces "Continue with Google@1.00"
410
+ // instead of "Continue@0.54" when the engine fragmented a multi-word phrase into
411
+ // separate word detections.
412
+ if (ocrResult?.words?.length) {
413
+ candidates.push(...reconstructLinesFromWords(ocrResult.words).map(l => ({ text: l.text })));
414
+ }
415
+ if (!candidates.length)
416
+ return null;
417
+ let best = null;
418
+ for (const c of candidates) {
419
+ const norm = normalizeForMatch(c.text);
420
+ if (!norm)
421
+ continue;
422
+ const score = ocrSimilarity(needle, norm);
423
+ if (!best || score > best.score)
424
+ best = { text: c.text, score };
425
+ }
426
+ return best;
427
+ }
428
+ // --- Strategy Functions ---
429
+ async function tryFiberStrategy(query, index, maxTraversalDepth, sink, device) {
430
+ if (sink) {
431
+ sink.fiber.ran = true;
432
+ sink.fiber.metroConnected = connectedApps.size > 0;
433
+ }
434
+ const startedAt = Date.now();
435
+ try {
436
+ // Retry with increasing depth if the initial traversal finds nothing
437
+ const baseDepth = maxTraversalDepth ?? 15;
438
+ const depthAttempts = [baseDepth];
439
+ // Only add deeper retries if user didn't explicitly set a high depth
440
+ if (baseDepth <= 15) {
441
+ depthAttempts.push(30, 45);
442
+ }
443
+ else if (baseDepth <= 30) {
444
+ depthAttempts.push(baseDepth * 2);
445
+ }
446
+ let lastResult = null;
447
+ for (const depth of depthAttempts) {
448
+ const result = await tryFiberAtDepth(query, index, depth, device);
449
+ if (sink && result.matches?.length) {
450
+ sink.fiber.pressables = result.matches.slice(0, 50).map(m => ({
451
+ label: m.text || undefined,
452
+ testID: m.testID ?? undefined,
453
+ componentName: m.component,
454
+ bounds: (m.x !== undefined && m.y !== undefined)
455
+ ? { x: m.x, y: m.y, width: 0, height: 0 }
456
+ : undefined
457
+ }));
458
+ }
459
+ if (result.success || result.matches) {
460
+ return result;
461
+ }
462
+ lastResult = result;
463
+ }
464
+ return lastResult;
465
+ }
466
+ finally {
467
+ if (sink)
468
+ sink.fiber.durationMs = Date.now() - startedAt;
469
+ }
470
+ }
471
+ async function tryFiberAtDepth(query, index, maxTraversalDepth, device) {
472
+ try {
473
+ const result = await pressElement({
474
+ text: query.text,
475
+ testID: query.testID,
476
+ component: query.component,
477
+ index,
478
+ maxTraversalDepth,
479
+ device
480
+ });
481
+ if (!result.success) {
482
+ return { success: false, reason: result.error || "pressElement failed" };
483
+ }
484
+ if (!result.result) {
485
+ return { success: false, reason: "No result from pressElement" };
486
+ }
487
+ const parsed = JSON.parse(result.result);
488
+ if (parsed.error) {
489
+ const strategyResult = {
490
+ success: false,
491
+ reason: parsed.error
492
+ };
493
+ if (parsed.matches) {
494
+ strategyResult.matches = parsed.matches;
495
+ }
496
+ return strategyResult;
497
+ }
498
+ // Fiber finds the element by text/testID/component, then measures its
499
+ // host component's screen position for a native tap. This ensures the tap
500
+ // goes through React's event pipeline, executing any onPress wrappers
501
+ // (analytics, debouncing, state tracking) inside the component.
502
+ if (parsed.needsNativeTap) {
503
+ // Ambiguity guard: if multiple elements match and the caller didn't
504
+ // specify an explicit index, refuse to tap and surface the full list
505
+ // so the agent can pick the right one.
506
+ if ((parsed.totalMatches ?? 1) > 1 && index === undefined) {
507
+ // I2: collapse geometrically-identical wrapper matches before refusing.
508
+ const collapsed = collapseGeometricallyEquivalentMatches(parsed.allMatches ?? [], (m) => ({ x: m.x, y: m.y }), (m) => `${m.testID ?? ""}::${m.text ?? ""}`);
509
+ if (collapsed.length > 1) {
510
+ return {
511
+ success: false,
512
+ reason: `Ambiguous: ${collapsed.length} elements match this query — use index= to pick one`,
513
+ matches: collapsed,
514
+ ambiguous: true
515
+ };
516
+ }
517
+ // Collapsed to one logical element — fall through and tap. The JS-side
518
+ // search already picked allMatches[0] (the outermost wrapper) for
519
+ // parsed.pressed/parsed.nativeTapTarget.
520
+ }
521
+ const elementType = parsed.isInput ? "input element" : "pressable element";
522
+ if (parsed.nativeTapTarget && parsed.nativeTapTarget.x && parsed.nativeTapTarget.y) {
523
+ return {
524
+ success: false,
525
+ reason: `Found ${parsed.pressed} (${elementType}) — measured coordinates for native tap`,
526
+ pressed: parsed.pressed,
527
+ text: parsed.text,
528
+ path: parsed.path || null,
529
+ component: parsed.pressed || null,
530
+ convertedTo: {
531
+ x: parsed.nativeTapTarget.x,
532
+ y: parsed.nativeTapTarget.y,
533
+ unit: parsed.nativeTapTarget.unit || "points"
534
+ }
535
+ };
536
+ }
537
+ return {
538
+ success: false,
539
+ reason: `Found ${parsed.pressed} (${elementType}) but could not measure coordinates — falling through to next strategy`
540
+ };
541
+ }
542
+ // All elements now use needsNativeTap — this shouldn't be reached
543
+ return {
544
+ success: false,
545
+ reason: "Unexpected: element did not request native tap"
546
+ };
547
+ }
548
+ catch (err) {
549
+ return {
550
+ success: false,
551
+ reason: `Fiber strategy error: ${err instanceof Error ? err.message : String(err)}`
552
+ };
553
+ }
554
+ }
555
+ async function tryAccessibilityStrategy(query, index, platform, udid, sink, signal) {
556
+ if (sink)
557
+ sink.accessibility.ran = true;
558
+ const startedAt = Date.now();
559
+ try {
560
+ const hasTestID = !!query.testID;
561
+ const hasText = !!query.text;
562
+ if (!hasTestID && !hasText) {
563
+ return {
564
+ success: false,
565
+ reason: "No text or testID for accessibility search"
566
+ };
567
+ }
568
+ if (platform === "ios") {
569
+ // iOS: testID maps to accessibilityIdentifier — search by identifier first,
570
+ // then fall back to labelContains for text-based searches
571
+ let result;
572
+ if (hasTestID && !hasText) {
573
+ // Try exact identifier match first (testID → accessibilityIdentifier)
574
+ result = await iosFindElement({
575
+ identifier: query.testID,
576
+ index
577
+ }, udid);
578
+ // Fall back to identifierContains if exact match fails
579
+ if (!result.success || !result.allMatches || result.allMatches.length === 0) {
580
+ result = await iosFindElement({
581
+ identifierContains: query.testID,
582
+ index
583
+ }, udid);
584
+ }
585
+ // Last resort: try labelContains in case testID is reflected in label
586
+ if (!result.success || !result.allMatches || result.allMatches.length === 0) {
587
+ result = await iosFindElement({
588
+ labelContains: query.testID,
589
+ index
590
+ }, udid);
591
+ }
592
+ }
593
+ else {
594
+ const searchText = query.text;
595
+ // Exact label match first to avoid matching dialog titles like "Clear All"
596
+ // when the query is a single word that happens to be a substring ("Clear").
597
+ result = await iosFindElement({
598
+ label: searchText,
599
+ index
600
+ }, udid);
601
+ if (!result.success || !result.allMatches || result.allMatches.length === 0) {
602
+ result = await iosFindElement({
603
+ labelContains: searchText,
604
+ index
605
+ }, udid);
606
+ }
607
+ }
608
+ if (sink && result.allMatches?.length) {
609
+ sink.accessibility.elements = result.allMatches.slice(0, 50).map(el => ({
610
+ label: el.label || undefined,
611
+ testID: el.identifier || undefined,
612
+ frame: el.frame
613
+ ? { x: el.frame.x, y: el.frame.y, width: el.frame.width, height: el.frame.height }
614
+ : undefined
615
+ }));
616
+ }
617
+ if (!result.success || !result.allMatches || result.allMatches.length === 0) {
618
+ return {
619
+ success: false,
620
+ reason: result.error ?? "No iOS accessibility match"
621
+ };
622
+ }
623
+ // Ambiguity guard: when the predicate matches multiple a11y elements
624
+ // (e.g. text="My Events" hits both the header title AND the bottom tab),
625
+ // refuse to tap without an explicit index — picking allMatches[0] silently
626
+ // landed on non-interactive header elements in production telemetry.
627
+ // Mirrors the fiber-strategy guard further up the file.
628
+ // I2: collapse geometrically-identical wrapper matches before refusing.
629
+ const iosEffectiveMatches = result.allMatches.length > 1
630
+ ? collapseGeometricallyEquivalentMatches(result.allMatches, (m) => ({ x: m.center?.x, y: m.center?.y }), (m) => `${m.identifier ?? ""}::${m.label ?? ""}`)
631
+ : result.allMatches;
632
+ if (iosEffectiveMatches.length > 1 && index === undefined) {
633
+ return {
634
+ success: false,
635
+ reason: `Ambiguous: ${iosEffectiveMatches.length} elements match this query — use index= to pick one`,
636
+ matches: iosEffectiveMatches.map((m, i) => ({
637
+ index: i,
638
+ component: m.type ?? "",
639
+ text: m.label ?? "",
640
+ testID: m.identifier ?? null,
641
+ x: m.center?.x,
642
+ y: m.center?.y
643
+ })),
644
+ ambiguous: true
645
+ };
646
+ }
647
+ const match = iosEffectiveMatches[index ?? 0];
648
+ if (!match) {
649
+ return {
650
+ success: false,
651
+ reason: `Index ${index} out of bounds (${iosEffectiveMatches.length} matches)`
652
+ };
653
+ }
654
+ await iosTap(match.center.x, match.center.y, { udid });
655
+ return {
656
+ success: true,
657
+ reason: "Tapped via iOS accessibility",
658
+ pressed: match.label || match.type,
659
+ text: match.label || undefined,
660
+ component: match.type || null,
661
+ convertedTo: { x: match.center.x, y: match.center.y, unit: "points" }
662
+ };
663
+ }
664
+ else {
665
+ // Android: testID maps to resource-id, text maps to text content
666
+ const searchOptions = { index };
667
+ if (hasTestID && !hasText) {
668
+ searchOptions.resourceId = query.testID;
669
+ }
670
+ else if (hasText) {
671
+ searchOptions.textContains = query.text;
672
+ }
673
+ let result = await androidFindElement(searchOptions, undefined, signal);
674
+ // If testID search via resourceId failed, try contentDescContains
675
+ // (older RN versions map testID to content-description)
676
+ if (hasTestID && !hasText && (!result.success || !result.allMatches || result.allMatches.length === 0)) {
677
+ result = await androidFindElement({
678
+ contentDescContains: query.testID,
679
+ index
680
+ }, undefined, signal);
681
+ }
682
+ if (sink && result.allMatches?.length) {
683
+ sink.accessibility.elements = result.allMatches.slice(0, 50).map(el => ({
684
+ label: el.text || el.contentDesc || undefined,
685
+ testID: el.resourceId || undefined,
686
+ frame: el.bounds
687
+ ? { x: el.bounds.left, y: el.bounds.top, width: el.bounds.width, height: el.bounds.height }
688
+ : undefined
689
+ }));
690
+ }
691
+ if (!result.success || !result.allMatches || result.allMatches.length === 0) {
692
+ return {
693
+ success: false,
694
+ reason: result.error ?? "No Android accessibility match"
695
+ };
696
+ }
697
+ // Ambiguity guard — see iOS branch above for rationale.
698
+ // I2: collapse geometrically-identical wrapper matches before refusing.
699
+ const androidEffectiveMatches = result.allMatches.length > 1
700
+ ? collapseGeometricallyEquivalentMatches(result.allMatches, (m) => ({ x: m.center?.x, y: m.center?.y }), (m) => `${m.resourceId ?? ""}::${m.text ?? m.contentDesc ?? ""}`)
701
+ : result.allMatches;
702
+ if (androidEffectiveMatches.length > 1 && index === undefined) {
703
+ return {
704
+ success: false,
705
+ reason: `Ambiguous: ${androidEffectiveMatches.length} elements match this query — use index= to pick one`,
706
+ matches: androidEffectiveMatches.map((m, i) => ({
707
+ index: i,
708
+ component: m.className ?? "",
709
+ text: m.text ?? m.contentDesc ?? "",
710
+ testID: m.resourceId ?? null,
711
+ x: m.center?.x,
712
+ y: m.center?.y
713
+ })),
714
+ ambiguous: true
715
+ };
716
+ }
717
+ const match = androidEffectiveMatches[index ?? 0];
718
+ if (!match) {
719
+ return {
720
+ success: false,
721
+ reason: `Index ${index} out of bounds (${androidEffectiveMatches.length} matches)`
722
+ };
723
+ }
724
+ await androidTap(match.center.x, match.center.y);
725
+ return {
726
+ success: true,
727
+ reason: "Tapped via Android accessibility",
728
+ pressed: match.text || match.className || undefined,
729
+ text: match.text || undefined,
730
+ component: match.className || undefined,
731
+ convertedTo: { x: match.center.x, y: match.center.y, unit: "pixels" }
732
+ };
733
+ }
734
+ }
735
+ catch (err) {
736
+ return {
737
+ success: false,
738
+ reason: `Accessibility strategy error: ${err instanceof Error ? err.message : String(err)}`
739
+ };
740
+ }
741
+ finally {
742
+ if (sink)
743
+ sink.accessibility.durationMs = Date.now() - startedAt;
744
+ }
745
+ }
746
+ /**
747
+ * Run OCR sense (capture + recognize + match) and tap the match. Also records
748
+ * `sink.ocr.bestCandidate` (matched text + tap coords) into the evidence sink,
749
+ * which is serialized into the R2 failure bundle for diagnostics.
750
+ */
751
+ async function tryOcrStrategy(query, platform, udid, sink, signal) {
752
+ if (sink)
753
+ sink.ocr.ran = true;
754
+ const ocrStartedAt = Date.now();
755
+ try {
756
+ const searchText = query.text;
757
+ if (!searchText) {
758
+ return { success: false, reason: "OCR strategy requires text query" };
759
+ }
760
+ let imageBuffer;
761
+ let scaleFactor = 1;
762
+ if (platform === "ios") {
763
+ const screenshot = await iosScreenshot(undefined, udid);
764
+ if (!screenshot.success || !screenshot.data) {
765
+ return {
766
+ success: false,
767
+ reason: "Failed to capture iOS screenshot for OCR"
768
+ };
769
+ }
770
+ imageBuffer = screenshot.data;
771
+ scaleFactor = screenshot.scaleFactor ?? 1;
772
+ }
773
+ else {
774
+ const { androidScreenshot } = await import("../core/android.js");
775
+ const screenshot = await androidScreenshot(undefined, undefined, signal);
776
+ if (!screenshot.success || !screenshot.data) {
777
+ return {
778
+ success: false,
779
+ reason: "Failed to capture Android screenshot for OCR"
780
+ };
781
+ }
782
+ imageBuffer = screenshot.data;
783
+ scaleFactor = screenshot.scaleFactor ?? 1;
784
+ }
785
+ const { recognizeText } = await import("../core/ocr.js");
786
+ const ocrResult = await recognizeText(imageBuffer, {
787
+ scaleFactor,
788
+ platform,
789
+ signal
790
+ });
791
+ if (sink && ocrResult) {
792
+ const allDetections = [
793
+ ...(ocrResult.words ?? []),
794
+ ...(ocrResult.lines ?? [])
795
+ ];
796
+ sink.ocr.detections = allDetections.slice(0, 100).map(r => ({
797
+ text: r.text,
798
+ bbox: [r.bbox.x0, r.bbox.y0, r.bbox.x1 - r.bbox.x0, r.bbox.y1 - r.bbox.y0],
799
+ conf: r.confidence ?? 0
800
+ }));
801
+ sink.ocr.closestMatch = findClosestOcrText(ocrResult, searchText);
802
+ }
803
+ const match = findOcrMatch(ocrResult, searchText);
804
+ // Record the best candidate (matched text + tap coords) into the
805
+ // evidence sink. This is serialized into the R2 failure bundle and is
806
+ // useful when diagnosing OCR taps (e.g. OCR found the match at these
807
+ // coords but the tap didn't register a visual change).
808
+ if (sink && match) {
809
+ const closest = sink.ocr.closestMatch;
810
+ sink.ocr.bestCandidate = {
811
+ text: match.text,
812
+ score: closest && closest.text === match.text ? closest.score : 1,
813
+ tapCenter: { x: match.tapCenter.x, y: match.tapCenter.y },
814
+ scaleFactor
815
+ };
816
+ }
817
+ if (!match) {
818
+ return {
819
+ success: false,
820
+ reason: `OCR did not find text "${searchText}" on screen`
821
+ };
822
+ }
823
+ if (platform === "ios") {
824
+ // tapCenter is in image-pixel space (downscaled) — convert to points
825
+ const { getDevicePixelRatio } = await import("../core/ios.js");
826
+ const dpr = await getDevicePixelRatio(udid);
827
+ const tapResult = await iosTap(Math.round((match.tapCenter.x * scaleFactor) / dpr), Math.round((match.tapCenter.y * scaleFactor) / dpr), { udid });
828
+ if (!tapResult.success) {
829
+ return {
830
+ success: false,
831
+ reason: `OCR found "${match.text}" but tap failed: ${tapResult.error}`
832
+ };
833
+ }
834
+ }
835
+ else {
836
+ // Android: image-pixel → device-pixel (undo downscale), ADB accepts pixels
837
+ await androidTap(Math.round(match.tapCenter.x * scaleFactor), Math.round(match.tapCenter.y * scaleFactor));
838
+ }
839
+ return {
840
+ success: true,
841
+ reason: "Tapped via OCR text recognition",
842
+ text: match.text,
843
+ convertedTo: {
844
+ x: match.tapCenter.x,
845
+ y: match.tapCenter.y,
846
+ unit: "pixels"
847
+ }
848
+ };
849
+ }
850
+ catch (err) {
851
+ return {
852
+ success: false,
853
+ reason: `OCR strategy error: ${err instanceof Error ? err.message : String(err)}`
854
+ };
855
+ }
856
+ finally {
857
+ if (sink)
858
+ sink.ocr.durationMs = Date.now() - ocrStartedAt;
859
+ }
860
+ }
861
+ async function tryCoordinateStrategy(pixelX, pixelY, platform, lastScreenshot, udid) {
862
+ try {
863
+ if (platform === "ios") {
864
+ const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
865
+ const { getDevicePixelRatio } = await import("../core/ios.js");
866
+ const devicePixelRatio = await getDevicePixelRatio(udid);
867
+ const converted = convertScreenshotToTapCoords(pixelX, pixelY, "ios", devicePixelRatio, scaleFactor);
868
+ const tapResult = await iosTap(converted.x, converted.y, { udid });
869
+ if (!tapResult.success) {
870
+ return {
871
+ success: false,
872
+ reason: `Coordinate tap failed: ${tapResult.error}`
873
+ };
874
+ }
875
+ return {
876
+ success: true,
877
+ reason: "Tapped at coordinates (iOS)",
878
+ convertedTo: { x: converted.x, y: converted.y, unit: "points" }
879
+ };
880
+ }
881
+ else {
882
+ const scaleFactor = lastScreenshot?.scaleFactor ?? 1;
883
+ const converted = convertScreenshotToTapCoords(pixelX, pixelY, "android", 1, scaleFactor);
884
+ await androidTap(converted.x, converted.y);
885
+ return {
886
+ success: true,
887
+ reason: "Tapped at coordinates (Android)",
888
+ convertedTo: { x: converted.x, y: converted.y, unit: "pixels" }
889
+ };
890
+ }
891
+ }
892
+ catch (err) {
893
+ return {
894
+ success: false,
895
+ reason: `Coordinate strategy error: ${err instanceof Error ? err.message : String(err)}`
896
+ };
897
+ }
898
+ }
899
+ const TAP_TIMEOUT_MS = 25000;
900
+ const MIN_STRATEGY_BUDGET_MS = 500;
901
+ // Per-strategy budget. OCR cap on Android is bumped via maxStrategyMs() because
902
+ // the ADB screencap+pull leg has ~2s variance on real devices; iOS stays at 5s
903
+ // where xcrun simctl screenshot is consistent.
904
+ // Heavy strategies (fiber on deep trees with multi-depth retries, axe accessibility
905
+ // dumps on dense iOS screens) need more headroom — previous caps produced spurious
906
+ // timeouts that the agent read as "element missing" when the strategy simply didn't
907
+ // get to finish. The overall TAP_TIMEOUT_MS budget still bounds the worst case.
908
+ // Coordinate looks "light" (one tap subprocess) but on iOS it pairs an axe/idb
909
+ // invocation with a CDP getDevicePixelRatio + best-effort fiber inspection;
910
+ // the 3000ms cap surfaced as spurious "coordinate timed out" failures while
911
+ // 20+ seconds of the overall budget were still unused. Align with the other
912
+ // strategies — the global TAP_TIMEOUT_MS still bounds the worst case.
913
+ const MAX_STRATEGY_MS = {
914
+ fiber: 8000,
915
+ accessibility: 6000,
916
+ ocr: 6000,
917
+ coordinate: 8000
918
+ };
919
+ function maxStrategyMs(strategy, platform) {
920
+ if (strategy === "ocr" && platform === "android")
921
+ return 9000;
922
+ return MAX_STRATEGY_MS[strategy] ?? 5000;
923
+ }
924
+ // Matches only the outer withTimeout wrapper message for a tap strategy.
925
+ // Nested sub-operation errors inside a strategy (e.g. "CDP getProperties timed out after 150ms")
926
+ // must NOT be classified as a tap-level timeout.
927
+ const STRATEGY_TIMEOUT_RE = /^(fiber|accessibility|ocr|coordinate) timed out after \d+ms$/;
928
+ export function isTapTimeout(attempted) {
929
+ return attempted.some((a) => a.outcome === "timeout" || a.outcome === "skipped" || STRATEGY_TIMEOUT_RE.test(a.reason) || a.reason.startsWith("Skipped —"));
930
+ }
931
+ function withTimeout(promise, ms, label) {
932
+ return new Promise((resolve, reject) => {
933
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
934
+ promise.then((val) => {
935
+ clearTimeout(timer);
936
+ resolve(val);
937
+ }, (err) => {
938
+ clearTimeout(timer);
939
+ reject(err);
940
+ });
941
+ });
942
+ }
943
+ /**
944
+ * Like withTimeout, but creates an AbortController whose signal is handed to
945
+ * the inner factory. On timeout the signal aborts so subprocess work
946
+ * (uiautomator dump, OCR fetch, screencap) can be killed instead of running
947
+ * past the strategy cap and bleeding into total tap duration.
948
+ */
949
+ function withCancelableTimeout(make, ms, label) {
950
+ const ctrl = new AbortController();
951
+ const timer = setTimeout(() => ctrl.abort(), ms);
952
+ return make(ctrl.signal).then((val) => { clearTimeout(timer); return val; }, (err) => {
953
+ clearTimeout(timer);
954
+ if (ctrl.signal.aborted)
955
+ throw new Error(`${label} timed out after ${ms}ms`);
956
+ throw err;
957
+ });
958
+ }
959
+ /**
960
+ * Compute marker coordinates in screenshot-pixel space (what the returned PNG uses).
961
+ * Unit rules by strategy, assuming screenshotScale = downscale factor:
962
+ * coordinate : input is already screenshot pixels → pass through
963
+ * ocr : match.tapCenter is image-pixel (= screenshot-pixel) → pass through
964
+ * iOS points : point * DPR / screenshotScale
965
+ * android dp : dp * densityScale / screenshotScale
966
+ * android devicePx : devicePx / screenshotScale
967
+ */
968
+ function computeMarkerPx(args) {
969
+ const { strategy, input, convertedTo, platform, screenshotScale, devicePixelRatio, androidDensityScale } = args;
970
+ const scale = screenshotScale || 1;
971
+ if (strategy === "coordinate" || strategy === "native-coordinate") {
972
+ return input ? { x: input.x, y: input.y } : undefined;
973
+ }
974
+ if (!convertedTo)
975
+ return undefined;
976
+ if (strategy === "ocr") {
977
+ return { x: Math.round(convertedTo.x), y: Math.round(convertedTo.y) };
978
+ }
979
+ if (platform === "ios") {
980
+ // fiber/accessibility on iOS return points
981
+ const dpr = devicePixelRatio || 3;
982
+ return {
983
+ x: Math.round((convertedTo.x * dpr) / scale),
984
+ y: Math.round((convertedTo.y * dpr) / scale)
985
+ };
986
+ }
987
+ // Android
988
+ if (strategy === "fiber+native" && androidDensityScale) {
989
+ // Fabric fiber returns dp; fiber+native path scales to device pixels before tap
990
+ return {
991
+ x: Math.round((convertedTo.x * androidDensityScale) / scale),
992
+ y: Math.round((convertedTo.y * androidDensityScale) / scale)
993
+ };
994
+ }
995
+ // accessibility android: convertedTo is device pixels
996
+ return {
997
+ x: Math.round(convertedTo.x / scale),
998
+ y: Math.round(convertedTo.y / scale)
999
+ };
1000
+ }
1001
+ // D2 (Step 6): in-memory ring buffer of recent coord-strategy artifact keys
1002
+ // `${round(x/20)}:${round(y/20)}`. Entries expire after 60s. The first
1003
+ // failed coord tap at a grid cell uploads its artifact; retries within the
1004
+ // window get suppressed.
1005
+ const recentCoordArtifacts = new Map();
1006
+ const COORD_DEDUP_WINDOW_MS = 60_000;
1007
+ const COORD_DEDUP_BUCKET = 20;
1008
+ function shouldDedupArtifact(ctx) {
1009
+ // Only applies to coordinate-strategy artifacts where we have x,y coords.
1010
+ const winningStrat = ctx.attempted.find(a => a.reason === "success")?.strategy
1011
+ ?? ctx.attempted[ctx.attempted.length - 1]?.strategy;
1012
+ if (winningStrat !== "coordinate")
1013
+ return false;
1014
+ const x = ctx.chosenTapPoint?.x ?? ctx.query.x;
1015
+ const y = ctx.chosenTapPoint?.y ?? ctx.query.y;
1016
+ if (typeof x !== "number" || typeof y !== "number")
1017
+ return false;
1018
+ const key = `${Math.round(x / COORD_DEDUP_BUCKET)}:${Math.round(y / COORD_DEDUP_BUCKET)}`;
1019
+ const now = Date.now();
1020
+ // Sweep stale entries opportunistically — keeps the map from growing.
1021
+ for (const [k, ts] of recentCoordArtifacts) {
1022
+ if (now - ts > COORD_DEDUP_WINDOW_MS)
1023
+ recentCoordArtifacts.delete(k);
1024
+ }
1025
+ const last = recentCoordArtifacts.get(key);
1026
+ if (last !== undefined && (now - last) < COORD_DEDUP_WINDOW_MS)
1027
+ return true;
1028
+ recentCoordArtifacts.set(key, now);
1029
+ return false;
1030
+ }
1031
+ /** Test-only: drop the dedup state so a fresh tap always uploads. */
1032
+ export function resetCoordArtifactDedup() {
1033
+ recentCoordArtifacts.clear();
1034
+ }
1035
+ async function captureTapArtifact(ctx) {
1036
+ try {
1037
+ const { getServerVersion, categorizeError } = await import("../core/telemetry.js");
1038
+ const strategyChain = ctx.attempted.map(a => `${a.strategy}:${a.reason.slice(0, 40)}`).join("|");
1039
+ // Resolve error category up-front so we can short-circuit driver-missing failures.
1040
+ // These aren't tap-tool bugs — they're host setup problems (no idb/axe/adb on PATH).
1041
+ // Skipping the artifact upload saves R2 storage and stops these from polluting the
1042
+ // failure dashboard, which is the signal we use to triage genuine tap regressions.
1043
+ const errorCategory = ctx.errorCategory
1044
+ ?? categorizeError(ctx.errorMessage ?? "", strategyChain);
1045
+ if (errorCategory === "driver_missing") {
1046
+ return undefined;
1047
+ }
1048
+ // D1 (Step 6): no-metro unmeaningful taps have no diagnostic signal
1049
+ // beyond a screenshot diff that's already below threshold by definition.
1050
+ // ~50% of recent failure-artifact rows were these — they crowd out real
1051
+ // failures in the dashboard. Telemetry row still flows; just no R2 bundle.
1052
+ const noMetro = ctx.evidence ? !ctx.evidence.fiber.metroConnected : connectedApps.size === 0;
1053
+ if (ctx.outcome === "unmeaningful" && noMetro) {
1054
+ return undefined;
1055
+ }
1056
+ // D2 (Step 6): retry deduplication — coord-strategy taps at the same
1057
+ // grid cell within 60s upload the same near-identical artifact. The
1058
+ // first capture is enough; subsequent ones are noise.
1059
+ if (shouldDedupArtifact(ctx)) {
1060
+ return undefined;
1061
+ }
1062
+ const result = await captureFailureArtifact({
1063
+ outcome: ctx.outcome,
1064
+ predicate: ctx.query,
1065
+ errorMessage: ctx.errorMessage,
1066
+ errorCategory,
1067
+ strategyChain,
1068
+ sessionId: "",
1069
+ version: getServerVersion(),
1070
+ changeRate: ctx.verification?.changeRate,
1071
+ meaningful: ctx.verification?.meaningful,
1072
+ senses: ctx.evidence
1073
+ ? {
1074
+ ocr: {
1075
+ ran: ctx.evidence.ocr.ran,
1076
+ durationMs: ctx.evidence.ocr.durationMs,
1077
+ detections: ctx.evidence.ocr.detections,
1078
+ closestMatch: ctx.evidence.ocr.closestMatch
1079
+ },
1080
+ fiber: {
1081
+ ran: ctx.evidence.fiber.ran,
1082
+ durationMs: ctx.evidence.fiber.durationMs,
1083
+ metroConnected: ctx.evidence.fiber.metroConnected,
1084
+ pressables: ctx.evidence.fiber.pressables.map(p => ({
1085
+ label: p.label,
1086
+ testID: p.testID,
1087
+ componentName: p.componentName,
1088
+ bounds: p.bounds
1089
+ ? [p.bounds.x, p.bounds.y, p.bounds.width, p.bounds.height]
1090
+ : undefined
1091
+ }))
1092
+ },
1093
+ accessibility: {
1094
+ ran: ctx.evidence.accessibility.ran,
1095
+ durationMs: ctx.evidence.accessibility.durationMs,
1096
+ elements: ctx.evidence.accessibility.elements.map(el => ({
1097
+ label: el.label,
1098
+ testID: el.testID,
1099
+ frame: el.frame
1100
+ ? [el.frame.x, el.frame.y, el.frame.width, el.frame.height]
1101
+ : undefined
1102
+ }))
1103
+ }
1104
+ }
1105
+ : {
1106
+ ocr: { ran: false, durationMs: 0, detections: [], closestMatch: null },
1107
+ fiber: {
1108
+ ran: ctx.attempted.some(a => a.strategy === "fiber"),
1109
+ durationMs: 0,
1110
+ metroConnected: connectedApps.size > 0,
1111
+ pressables: (ctx.fiberMatches || []).slice(0, 10).map(m => ({
1112
+ label: m.text || undefined,
1113
+ testID: m.testID || undefined,
1114
+ componentName: m.component
1115
+ }))
1116
+ },
1117
+ accessibility: {
1118
+ ran: ctx.attempted.some(a => a.strategy === "accessibility"),
1119
+ durationMs: 0,
1120
+ elements: []
1121
+ }
1122
+ },
1123
+ chosenTapPoint: ctx.chosenTapPoint ?? null,
1124
+ chosenElement: null,
1125
+ screenshots: {
1126
+ before: ctx.screenshotBuffer ?? null,
1127
+ afterWithMarker: ctx.afterWithMarker ?? null
1128
+ },
1129
+ deviceMeta: {
1130
+ platform: ctx.platform,
1131
+ driver: ctx.iosDriver,
1132
+ screenSize: { w: ctx.screenshotMeta?.width || 0, h: ctx.screenshotMeta?.height || 0 }
1133
+ }
1134
+ });
1135
+ return result.signals;
1136
+ }
1137
+ catch {
1138
+ return undefined;
1139
+ }
1140
+ }
1141
+ function attachArtifactSignals(result, signals) {
1142
+ if (!signals)
1143
+ return result;
1144
+ if (signals.artifactKey)
1145
+ result.artifactKey = signals.artifactKey;
1146
+ if (signals.ocrClosestMatch)
1147
+ result.ocrClosestMatch = signals.ocrClosestMatch;
1148
+ if (signals.fiberPressableCount)
1149
+ result.fiberPressableCount = signals.fiberPressableCount;
1150
+ if (signals.accessibilityMatchCount)
1151
+ result.accessibilityMatchCount = signals.accessibilityMatchCount;
1152
+ if (signals.appRoute)
1153
+ result.appRoute = signals.appRoute;
1154
+ // Append agent-facing hints to error message
1155
+ if (result.error) {
1156
+ if (signals.ocrClosestMatch) {
1157
+ result.error = `${result.error}\nClosest OCR match: ${signals.ocrClosestMatch}`;
1158
+ }
1159
+ if (signals.nearbyPressables.length > 0) {
1160
+ const labels = signals.nearbyPressables
1161
+ .map(p => p.testID || p.label)
1162
+ .filter(Boolean)
1163
+ .slice(0, 3)
1164
+ .join(", ");
1165
+ if (labels)
1166
+ result.error = `${result.error}\nNearby pressables: ${labels}`;
1167
+ }
1168
+ }
1169
+ return result;
1170
+ }
1171
+ // --- Orchestrator ---
1172
+ export async function tap(options) {
1173
+ const query = buildQuery(options);
1174
+ const strategy = options.strategy || "auto";
1175
+ const index = options.index;
1176
+ const maxTraversalDepth = options.maxTraversalDepth;
1177
+ const deadline = Date.now() + TAP_TIMEOUT_MS;
1178
+ const remainingMs = () => Math.max(0, deadline - Date.now());
1179
+ // Validate inputs
1180
+ const hasSearchParam = query.text || query.testID || query.component;
1181
+ const hasCoordinates = query.x !== undefined || query.y !== undefined;
1182
+ if (!hasSearchParam && !hasCoordinates) {
1183
+ return {
1184
+ success: false,
1185
+ query,
1186
+ error: "Must provide at least one of: text, testID, component, or x/y coordinates"
1187
+ };
1188
+ }
1189
+ if (hasCoordinates && (query.x === undefined || query.y === undefined)) {
1190
+ return {
1191
+ success: false,
1192
+ query,
1193
+ error: "Both x and y coordinates must be provided"
1194
+ };
1195
+ }
1196
+ // Native mode: bypass React Native connection, tap directly via ADB/simctl
1197
+ if (options.native && hasCoordinates) {
1198
+ const nativeResolved = await resolveDeviceTarget(options.device);
1199
+ if (!nativeResolved.ok) {
1200
+ return {
1201
+ success: false,
1202
+ query,
1203
+ error: formatResolverError(nativeResolved.error)
1204
+ };
1205
+ }
1206
+ const platform = nativeResolved.target.platform;
1207
+ const nativeUdid = nativeResolved.target.iosUdid;
1208
+ const nativeShouldScreenshot = options.screenshot !== false;
1209
+ // Decoupled (I5, 2026-05-16): verify runs even when image bytes aren't returned.
1210
+ const nativeShouldVerify = options.verify !== false;
1211
+ let nativeBeforeBuffer = null;
1212
+ let nativeScreenshotMeta;
1213
+ if (nativeShouldVerify) {
1214
+ const before = await captureScreenshot(platform, nativeUdid);
1215
+ nativeBeforeBuffer = before?.buffer || null;
1216
+ if (before) {
1217
+ nativeScreenshotMeta = {
1218
+ originalWidth: before.width,
1219
+ originalHeight: before.height,
1220
+ scaleFactor: before.scaleFactor
1221
+ };
1222
+ }
1223
+ }
1224
+ // If no screenshot was taken for verification, take one just for scaleFactor
1225
+ if (!nativeScreenshotMeta) {
1226
+ const ref = await captureScreenshot(platform, nativeUdid);
1227
+ if (ref) {
1228
+ nativeScreenshotMeta = {
1229
+ originalWidth: ref.width,
1230
+ originalHeight: ref.height,
1231
+ scaleFactor: ref.scaleFactor
1232
+ };
1233
+ // Also use it for verification if buffer is needed
1234
+ if (!nativeBeforeBuffer) {
1235
+ nativeBeforeBuffer = ref.buffer;
1236
+ }
1237
+ }
1238
+ }
1239
+ let result;
1240
+ try {
1241
+ result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, nativeScreenshotMeta, nativeUdid), remainingMs(), "native-coordinate");
1242
+ }
1243
+ catch (err) {
1244
+ return formatTapFailure({
1245
+ query,
1246
+ attempted: [
1247
+ {
1248
+ strategy: "native-coordinate",
1249
+ reason: err instanceof Error ? err.message : String(err)
1250
+ }
1251
+ ],
1252
+ suggestion: `Tap timed out. Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) and retry with coordinates.`
1253
+ });
1254
+ }
1255
+ if (result.success) {
1256
+ let screenshot;
1257
+ let verification;
1258
+ const nativeMarker = computeMarkerPx({
1259
+ strategy: "native-coordinate",
1260
+ input: { x: query.x, y: query.y },
1261
+ platform,
1262
+ screenshotScale: nativeScreenshotMeta?.scaleFactor || 1
1263
+ });
1264
+ if (options.burst && nativeShouldVerify && nativeBeforeBuffer) {
1265
+ ({ screenshot, verification } = await burstCaptureAndVerify({
1266
+ platform,
1267
+ beforeBuffer: nativeBeforeBuffer,
1268
+ udid: nativeUdid,
1269
+ beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
1270
+ markerPx: nativeMarker
1271
+ }));
1272
+ if (!nativeShouldScreenshot)
1273
+ screenshot = undefined;
1274
+ }
1275
+ else {
1276
+ ({ screenshot, verification } = await verifyAndCapture({
1277
+ platform,
1278
+ shouldVerify: nativeShouldVerify,
1279
+ shouldScreenshot: nativeShouldScreenshot,
1280
+ beforeBuffer: nativeBeforeBuffer,
1281
+ udid: nativeUdid,
1282
+ beforeScaleFactor: nativeScreenshotMeta?.scaleFactor,
1283
+ markerPx: nativeMarker
1284
+ }));
1285
+ }
1286
+ return formatTapSuccess({
1287
+ method: "native-coordinate",
1288
+ query,
1289
+ pressed: result.pressed,
1290
+ convertedTo: result.convertedTo,
1291
+ platform,
1292
+ screenshot,
1293
+ verification
1294
+ });
1295
+ }
1296
+ return formatTapFailure({
1297
+ query,
1298
+ attempted: [{ strategy: "native-coordinate", reason: result.reason }],
1299
+ suggestion: `Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) to verify coordinates.`
1300
+ });
1301
+ }
1302
+ // Resolve device target via the unified resolver. Replaces the prior chain
1303
+ // of (udid → connectedApp → findSimulatorByName) lookups and the standalone
1304
+ // "no platform → probe OS for devices" branch. Returns a structured error
1305
+ // when ambiguous; surface that to the caller so they can disambiguate.
1306
+ const resolved = await resolveDeviceTarget(options.device);
1307
+ if (!resolved.ok) {
1308
+ return {
1309
+ success: false,
1310
+ query,
1311
+ error: formatResolverError(resolved.error)
1312
+ };
1313
+ }
1314
+ const platform = resolved.target.platform;
1315
+ let targetUdid = resolved.target.iosUdid;
1316
+ // Pick the connected app to bias strategy selection. Prefer the registry
1317
+ // entry whose identifier matches the resolved target; fall back to a
1318
+ // device-name match (handy when registry entries lack identifiers yet);
1319
+ // last resort is the first connected app on the matching platform.
1320
+ const allApps = Array.from(connectedApps.values());
1321
+ let hasMetro = allApps.length > 0;
1322
+ let app;
1323
+ if (resolved.target.iosUdid) {
1324
+ app = allApps.find((a) => a.platform === "ios" && a.simulatorUdid === resolved.target.iosUdid);
1325
+ }
1326
+ else if (resolved.target.androidSerial) {
1327
+ app = allApps.find((a) => a.platform === "android" && a.adbSerial === resolved.target.androidSerial);
1328
+ }
1329
+ if (!app && options.device) {
1330
+ try {
1331
+ app = getConnectedAppByDevice(options.device) ?? undefined;
1332
+ }
1333
+ catch {
1334
+ // getConnectedAppByDevice throws on ambiguous matches; the resolver
1335
+ // would have already returned that error, so reaching here means
1336
+ // the resolved target's identifier just doesn't appear in the
1337
+ // registry yet — fall through to the platform-first pick below.
1338
+ }
1339
+ }
1340
+ if (!app) {
1341
+ app = allApps.find((a) => a.platform === platform) ?? allApps[0];
1342
+ }
1343
+ // Try to auto-connect to Metro (for fiber strategy), but don't fail if it doesn't work.
1344
+ if (!hasMetro) {
1345
+ try {
1346
+ await withTimeout((async () => {
1347
+ clearReconnectionSuppression();
1348
+ const openPorts = await scanMetroPorts();
1349
+ for (const port of openPorts) {
1350
+ const devices = await fetchDevices(port);
1351
+ const mainDevice = selectMainDevice(devices);
1352
+ if (mainDevice) {
1353
+ await connectToDevice(mainDevice, port);
1354
+ break;
1355
+ }
1356
+ }
1357
+ })(), Math.min(remainingMs(), 3000), "auto-connect");
1358
+ const apps = Array.from(connectedApps.values());
1359
+ hasMetro = apps.length > 0;
1360
+ app = apps.find((a) => a.platform === platform) ?? apps[0];
1361
+ if (platform === "ios" && !targetUdid) {
1362
+ targetUdid = app?.simulatorUdid ?? targetUdid;
1363
+ }
1364
+ }
1365
+ catch {
1366
+ // Auto-connect failed — Metro-dependent strategies will be skipped
1367
+ }
1368
+ }
1369
+ // The response's `device` label must reflect the RESOLVED target, not the
1370
+ // registry-app fallback. When the registry lookup couldn't find a matching
1371
+ // app (e.g. simulatorUdid hadn't been backfilled yet), `app` lands on the
1372
+ // first iOS entry — which on a multi-sim setup is the WRONG device. The
1373
+ // resolver already knows the right deviceName; trust it. Bug #5 (2026-05-20).
1374
+ const deviceName = resolved.target.deviceName || app?.deviceInfo?.deviceName;
1375
+ // Determine strategies
1376
+ const strategies = getAvailableStrategies(query, strategy);
1377
+ const attempted = [];
1378
+ const evidence = makeEmptyEvidenceSink();
1379
+ // Early UI driver check for iOS — fail fast instead of falling through every strategy
1380
+ const UI_DRIVER_REQUIRED_STRATEGIES = ["accessibility", "ocr", "coordinate"];
1381
+ let uiDriverMissing = false;
1382
+ if (platform === "ios") {
1383
+ uiDriverMissing = !(await isUiDriverAvailable());
1384
+ }
1385
+ // Filter strategies by available capabilities
1386
+ const filteredStrategies = strategies.filter((strat) => {
1387
+ if (strat === "fiber" && !hasMetro) {
1388
+ attempted.push({
1389
+ strategy: "fiber",
1390
+ reason: "Skipped — no Metro connection (required for fiber)",
1391
+ outcome: "skipped"
1392
+ });
1393
+ return false;
1394
+ }
1395
+ if (uiDriverMissing && UI_DRIVER_REQUIRED_STRATEGIES.includes(strat)) {
1396
+ attempted.push({
1397
+ strategy: strat,
1398
+ reason: "Skipped — iOS UI driver is not installed (required for iOS tap/accessibility/OCR)",
1399
+ outcome: "skipped"
1400
+ });
1401
+ return false;
1402
+ }
1403
+ return true;
1404
+ });
1405
+ if (filteredStrategies.length === 0) {
1406
+ if (uiDriverMissing) {
1407
+ notifyDriverMissing("ios");
1408
+ }
1409
+ const errorMessage = uiDriverMissing
1410
+ ? `Cannot tap on iOS Simulator — ${getUiDriverInstallHint()}\n\nThe iOS UI driver is required for tapping, swiping, text input, and accessibility queries on iOS Simulators.\n\nAfter installing, retry the tap.`
1411
+ : "All strategies require Metro connection, which is unavailable.\n\nTo fix:\n1. Make sure your React Native app is running\n2. Run scan_metro to connect\n3. Or use tap(x, y, native=true) for coordinate-based taps";
1412
+ return {
1413
+ success: false,
1414
+ query,
1415
+ attempted,
1416
+ error: errorMessage
1417
+ };
1418
+ }
1419
+ // Determine screenshot and verification behavior.
1420
+ // Decoupled (I5, 2026-05-16): verify can run without returning image bytes —
1421
+ // capture cost is paid either way; bandwidth cost is what `screenshot` toggles.
1422
+ const shouldScreenshot = options.screenshot !== false;
1423
+ const canVerify = options.verify !== false;
1424
+ // Capture "before" screenshot. Always attempted (independent of canVerify)
1425
+ // so failure artifacts always carry a before.png — the diagnostic value of
1426
+ // the before frame doesn't depend on whether we run the post-tap diff.
1427
+ let beforeBuffer = null;
1428
+ let beforeScaleFactor;
1429
+ {
1430
+ const before = await captureScreenshot(platform, targetUdid);
1431
+ beforeBuffer = before?.buffer || null;
1432
+ beforeScaleFactor = before?.scaleFactor;
1433
+ }
1434
+ // OCR runs lazily when the loop reaches it (see the `case "ocr"` branch).
1435
+ // A concurrent pre-warm probe used to fire here to shave ~5s off OCR-win
1436
+ // rows, but it dispatched a paid Google Vision request at t=0 on every
1437
+ // text-predicate tap — and since cloud OCR (~200ms) finishes well before
1438
+ // the higher-priority strategy that usually wins, the post-win abort came
1439
+ // too late to cancel the billed request. OCR wins only ~1.9% of taps, so
1440
+ // the pre-warm paid for cloud Vision on ~42% of eligible taps to help a
1441
+ // tiny minority. Removed 2026-06-02; the timeout-recovery path below still
1442
+ // salvages the perfect-match-past-cap case.
1443
+ // Execute strategies in order with per-strategy caps and overall budget
1444
+ for (const strat of filteredStrategies) {
1445
+ const remaining = remainingMs();
1446
+ if (remaining < MIN_STRATEGY_BUDGET_MS) {
1447
+ attempted.push({
1448
+ strategy: strat,
1449
+ reason: `Skipped — only ${remaining}ms remaining (budget ${TAP_TIMEOUT_MS}ms)`,
1450
+ outcome: "skipped"
1451
+ });
1452
+ continue;
1453
+ }
1454
+ const cap = maxStrategyMs(strat, platform);
1455
+ const budget = Math.min(cap, remaining);
1456
+ let result;
1457
+ try {
1458
+ switch (strat) {
1459
+ case "fiber":
1460
+ // Fiber is JS-only against a CDP target — no subprocess to cancel,
1461
+ // so the cheaper non-cancellable wrapper is fine.
1462
+ result = await withTimeout(tryFiberStrategy(query, index, maxTraversalDepth, evidence, deviceName), budget, `fiber`);
1463
+ break;
1464
+ case "accessibility":
1465
+ result = await withCancelableTimeout((signal) => tryAccessibilityStrategy(query, index, platform, targetUdid, evidence, signal), budget, `accessibility`);
1466
+ break;
1467
+ case "ocr":
1468
+ result = await withCancelableTimeout((signal) => tryOcrStrategy(query, platform, targetUdid, evidence, signal), budget, `ocr`);
1469
+ break;
1470
+ case "coordinate":
1471
+ // Prefer `beforeScaleFactor` (captured against `targetUdid` this turn)
1472
+ // over `app.lastScreenshot.scaleFactor` (stale and may belong to a
1473
+ // different device on a multi-sim setup). Bug #5 (2026-05-20).
1474
+ result = await withTimeout(tryCoordinateStrategy(query.x, query.y, platform, beforeScaleFactor != null
1475
+ ? { originalWidth: 0, originalHeight: 0, scaleFactor: beforeScaleFactor }
1476
+ : app?.lastScreenshot, targetUdid), budget, `coordinate`);
1477
+ break;
1478
+ default:
1479
+ result = { success: false, reason: `Unknown strategy: ${strat}` };
1480
+ }
1481
+ }
1482
+ catch (err) {
1483
+ const reason = err instanceof Error ? err.message : String(err);
1484
+ // Classify the throw: a strategy-level timeout means "result UNKNOWN"
1485
+ // (the strategy didn't finish), distinct from "ran clean and found
1486
+ // nothing". Agents must not infer absence from timeouts. The
1487
+ // `outcome` field carries that distinction; `reason` stays the raw
1488
+ // message so existing log-matchers / classifiers keep working.
1489
+ const outcome = STRATEGY_TIMEOUT_RE.test(reason) ? "timeout" : "error";
1490
+ attempted.push({ strategy: strat, reason, outcome });
1491
+ continue;
1492
+ }
1493
+ if (result.success) {
1494
+ let screenshot;
1495
+ let verification;
1496
+ let afterWithMarkerBuffer;
1497
+ let dprForMarker;
1498
+ if (platform === "ios") {
1499
+ try {
1500
+ const { getDevicePixelRatio } = await import("../core/ios.js");
1501
+ dprForMarker = await getDevicePixelRatio(targetUdid);
1502
+ }
1503
+ catch {
1504
+ dprForMarker = 3;
1505
+ }
1506
+ }
1507
+ const strategyMarker = computeMarkerPx({
1508
+ strategy: strat,
1509
+ input: strat === "coordinate" ? { x: query.x, y: query.y } : undefined,
1510
+ convertedTo: result.convertedTo,
1511
+ platform,
1512
+ screenshotScale: beforeScaleFactor || app?.lastScreenshot?.scaleFactor || 1,
1513
+ devicePixelRatio: dprForMarker
1514
+ });
1515
+ if (options.burst && canVerify && beforeBuffer) {
1516
+ ({ screenshot, verification, afterWithMarkerBuffer } = await burstCaptureAndVerify({
1517
+ platform,
1518
+ beforeBuffer,
1519
+ udid: targetUdid,
1520
+ beforeScaleFactor,
1521
+ markerPx: strategyMarker
1522
+ }));
1523
+ // Burst always produces a screenshot for diff visualization. Drop the
1524
+ // bytes when the caller didn't ask for them AND the tap was meaningful
1525
+ // — keeping the screenshot on unmeaningful taps saves the agent a
1526
+ // round-trip when it needs to diagnose. Frames remain in imageBuffer
1527
+ // either way (accessible via get_images(groupId=verification.burstGroupId)).
1528
+ if (!shouldScreenshot && verification?.meaningful !== false)
1529
+ screenshot = undefined;
1530
+ }
1531
+ else {
1532
+ ({ screenshot, verification, afterWithMarkerBuffer } = await verifyAndCapture({
1533
+ platform,
1534
+ shouldVerify: canVerify,
1535
+ shouldScreenshot: true,
1536
+ beforeBuffer,
1537
+ udid: targetUdid,
1538
+ beforeScaleFactor,
1539
+ markerPx: strategyMarker
1540
+ }));
1541
+ // Mirror the burst-path gate: drop the screenshot only when the
1542
+ // caller didn't ask for it AND the tap was meaningful.
1543
+ if (!shouldScreenshot && verification?.meaningful !== false)
1544
+ screenshot = undefined;
1545
+ }
1546
+ if (screenshot && app) {
1547
+ app.lastScreenshot = {
1548
+ originalWidth: screenshot.width,
1549
+ originalHeight: screenshot.height,
1550
+ scaleFactor: screenshot.scaleFactor
1551
+ };
1552
+ }
1553
+ const successResult = formatTapSuccess({
1554
+ method: strat,
1555
+ query,
1556
+ pressed: result.pressed,
1557
+ text: result.text,
1558
+ path: result.path,
1559
+ component: result.component,
1560
+ convertedTo: result.convertedTo,
1561
+ platform,
1562
+ device: deviceName,
1563
+ screenshot,
1564
+ verification
1565
+ });
1566
+ // Capture an artifact for "successful but unmeaningful" taps so we can
1567
+ // diagnose taps that landed wrong or hit non-responsive elements.
1568
+ // Uses the same `meaningful` flag the agent sees so dashboard outcomes
1569
+ // stay consistent with what the caller observed.
1570
+ if (verification && !verification.skipped && verification.meaningful === false) {
1571
+ const unmeaningfulSignals = await captureTapArtifact({
1572
+ query,
1573
+ outcome: "unmeaningful",
1574
+ attempted: [...attempted, { strategy: strat, reason: "success" }],
1575
+ platform,
1576
+ iosDriver: platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "axe") : undefined,
1577
+ deviceName,
1578
+ screenshotMeta: screenshot ? { width: screenshot.width, height: screenshot.height } : undefined,
1579
+ screenshotBuffer: beforeBuffer,
1580
+ afterWithMarker: afterWithMarkerBuffer ?? null,
1581
+ chosenTapPoint: strategyMarker ? { x: strategyMarker.x, y: strategyMarker.y } : null,
1582
+ verification,
1583
+ evidence
1584
+ });
1585
+ attachArtifactSignals(successResult, unmeaningfulSignals);
1586
+ }
1587
+ return successResult;
1588
+ }
1589
+ // Classify the strategy's non-success result. The reason string carries
1590
+ // the diagnostic from the strategy; outcome lets agents branch on category
1591
+ // without parsing prose. "invisible" / "ambiguous" are pinned by substring
1592
+ // matches the fiber strategy emits via pressables.ts.
1593
+ const reasonStr = result.reason || "";
1594
+ let attemptOutcome = "not-found";
1595
+ if (result.ambiguous)
1596
+ attemptOutcome = "ambiguous";
1597
+ else if (reasonStr.indexOf("but none are visible") !== -1)
1598
+ attemptOutcome = "invisible";
1599
+ else if (STRATEGY_TIMEOUT_RE.test(reasonStr))
1600
+ attemptOutcome = "timeout";
1601
+ attempted.push({ strategy: strat, reason: reasonStr, outcome: attemptOutcome });
1602
+ // If fiber found an element with measured coordinates, do a native tap directly
1603
+ if (strat === "fiber" && result.convertedTo && result.pressed) {
1604
+ try {
1605
+ const coords = result.convertedTo;
1606
+ if (platform === "ios") {
1607
+ // react-native-screens modal/sheet presentations cause measureInWindow to
1608
+ // return y relative to the screen's content origin, not the window. If the
1609
+ // measured y falls inside the safe-area band, shift it down by the inset.
1610
+ const safeAreaTop = await getIOSSafeAreaTop(targetUdid);
1611
+ const tapY = (safeAreaTop > 0 && coords.y < safeAreaTop) ? coords.y + safeAreaTop : coords.y;
1612
+ await iosTap(coords.x, tapY, { udid: targetUdid });
1613
+ coords.y = tapY;
1614
+ }
1615
+ else {
1616
+ // Fabric returns dp — androidTap expects pixels
1617
+ // Convert dp to pixels using device density
1618
+ const { androidGetDensity } = await import("../core/android.js");
1619
+ const densityResult = await androidGetDensity();
1620
+ const densityScale = (densityResult.density || 420) / 160;
1621
+ const pxX = Math.round(coords.x * densityScale);
1622
+ const pxY = Math.round(coords.y * densityScale);
1623
+ await androidTap(pxX, pxY);
1624
+ // Report the actual tap coords (pixels) the caller can pass straight
1625
+ // to coordinate tools / verification — not the raw dp from fiber.
1626
+ // Matches every other Android coord report in the tool (OB1, 2026-05-20).
1627
+ coords.x = pxX;
1628
+ coords.y = pxY;
1629
+ coords.unit = "pixels";
1630
+ }
1631
+ // fiber+native uses native tap — always verify
1632
+ let screenshot;
1633
+ let verification;
1634
+ let fnDpr;
1635
+ let fnDensity;
1636
+ if (platform === "ios") {
1637
+ try {
1638
+ const { getDevicePixelRatio } = await import("../core/ios.js");
1639
+ fnDpr = await getDevicePixelRatio(targetUdid);
1640
+ }
1641
+ catch {
1642
+ fnDpr = 3;
1643
+ }
1644
+ }
1645
+ else {
1646
+ try {
1647
+ const { androidGetDensity } = await import("../core/android.js");
1648
+ const d = await androidGetDensity();
1649
+ fnDensity = (d.density || 420) / 160;
1650
+ }
1651
+ catch {
1652
+ fnDensity = undefined;
1653
+ }
1654
+ }
1655
+ const fiberMarker = computeMarkerPx({
1656
+ strategy: "fiber+native",
1657
+ convertedTo: coords,
1658
+ platform,
1659
+ screenshotScale: beforeScaleFactor || app?.lastScreenshot?.scaleFactor || 1,
1660
+ devicePixelRatio: fnDpr,
1661
+ androidDensityScale: fnDensity
1662
+ });
1663
+ if (options.burst && canVerify && beforeBuffer) {
1664
+ ({ screenshot, verification } = await burstCaptureAndVerify({
1665
+ platform,
1666
+ beforeBuffer,
1667
+ udid: targetUdid,
1668
+ beforeScaleFactor,
1669
+ markerPx: fiberMarker
1670
+ }));
1671
+ }
1672
+ else {
1673
+ ({ screenshot, verification } = await verifyAndCapture({
1674
+ platform,
1675
+ shouldVerify: canVerify,
1676
+ shouldScreenshot: true,
1677
+ beforeBuffer,
1678
+ udid: targetUdid,
1679
+ beforeScaleFactor,
1680
+ markerPx: fiberMarker
1681
+ }));
1682
+ }
1683
+ if (!shouldScreenshot && verification?.meaningful !== false)
1684
+ screenshot = undefined;
1685
+ if (screenshot && app) {
1686
+ app.lastScreenshot = {
1687
+ originalWidth: screenshot.width,
1688
+ originalHeight: screenshot.height,
1689
+ scaleFactor: screenshot.scaleFactor
1690
+ };
1691
+ }
1692
+ return formatTapSuccess({
1693
+ method: "fiber+native",
1694
+ query,
1695
+ pressed: result.pressed,
1696
+ text: result.text,
1697
+ path: result.path,
1698
+ component: result.component,
1699
+ convertedTo: coords,
1700
+ platform,
1701
+ device: deviceName,
1702
+ screenshot,
1703
+ verification
1704
+ });
1705
+ }
1706
+ catch {
1707
+ // Native tap at fiber coordinates failed — continue to next strategy
1708
+ }
1709
+ }
1710
+ // Ambiguous fiber result — multiple elements matched, no index given.
1711
+ // Return immediately with the full list so the agent can decide.
1712
+ // Do NOT fall through to other strategies (they can't resolve ambiguity).
1713
+ if (result.matches && result.ambiguous) {
1714
+ const { screenshot: matchScreenshot } = shouldScreenshot
1715
+ ? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
1716
+ : { screenshot: undefined };
1717
+ if (matchScreenshot && app) {
1718
+ app.lastScreenshot = {
1719
+ originalWidth: matchScreenshot.width,
1720
+ originalHeight: matchScreenshot.height,
1721
+ scaleFactor: matchScreenshot.scaleFactor
1722
+ };
1723
+ }
1724
+ return formatTapFailure({
1725
+ query,
1726
+ attempted,
1727
+ error: `Ambiguous: ${result.matches.length} elements match this query. Tap did not execute.`,
1728
+ suggestion: `Specify index= (0–${result.matches.length - 1}) or add text= to narrow down. See matches[] for position and text of each element.`,
1729
+ device: deviceName,
1730
+ matches: result.matches,
1731
+ ambiguous: true,
1732
+ screenshot: matchScreenshot
1733
+ });
1734
+ }
1735
+ }
1736
+ // All strategies failed — check if the tap budget was the cause.
1737
+ // Only outer withTimeout wrapper messages or Skipped entries count; nested
1738
+ // sub-op errors that happen to contain "timed out" do not imply the tap
1739
+ // itself ran out of time.
1740
+ const hitTimeout = isTapTimeout(attempted);
1741
+ const elapsed = TAP_TIMEOUT_MS - remainingMs();
1742
+ // If fiber located the element but visibility filtered it out, prepend a
1743
+ // scroll-or-dismiss hint so the agent doesn't waste a turn assuming the
1744
+ // testID/component is missing.
1745
+ const fiberSawInvisible = attempted.some(a => a.strategy === "fiber" && a.outcome === "invisible");
1746
+ const allTimedOut = attempted.length > 0 && attempted.every(a => a.outcome === "timeout" || a.outcome === "skipped");
1747
+ let suggestion = buildSuggestion(query, strategies, platform);
1748
+ if (fiberSawInvisible) {
1749
+ suggestion = `Element exists in the React tree but is not on screen. Try scrolling it into view with swipe(), dismiss any overlay covering it, or wait for layout to settle. ` + suggestion;
1750
+ }
1751
+ else if (allTimedOut) {
1752
+ // No strategy got to a definitive answer — don't let the agent conclude
1753
+ // the element is missing. Steer toward retry or a different strategy.
1754
+ suggestion = `All strategies timed out — the element's presence is UNKNOWN. Retry the tap (transient slowness is common on dense screens), or try a different strategy explicitly (e.g. strategy='fiber' if accessibility timed out). ` + suggestion;
1755
+ }
1756
+ const { screenshot: failScreenshot } = shouldScreenshot
1757
+ ? await verifyAndCapture({ platform, shouldVerify: false, shouldScreenshot: true, beforeBuffer: null, udid: targetUdid })
1758
+ : { screenshot: undefined };
1759
+ if (failScreenshot && app) {
1760
+ app.lastScreenshot = {
1761
+ originalWidth: failScreenshot.width,
1762
+ originalHeight: failScreenshot.height,
1763
+ scaleFactor: failScreenshot.scaleFactor
1764
+ };
1765
+ }
1766
+ // Pick the right error framing: a definitive "not found" is different from
1767
+ // "all strategies timed out — we don't know whether it exists".
1768
+ let errorOverride;
1769
+ if (hitTimeout) {
1770
+ errorOverride = `Tap timed out after ${elapsed}ms (budget ${TAP_TIMEOUT_MS}ms)`;
1771
+ }
1772
+ else if (allTimedOut) {
1773
+ errorOverride = `All tap strategies timed out before completing — element presence is UNKNOWN`;
1774
+ }
1775
+ else if (fiberSawInvisible) {
1776
+ errorOverride = `Element matches the query but is not visible on screen`;
1777
+ }
1778
+ const failureResult = formatTapFailure({
1779
+ query,
1780
+ attempted,
1781
+ error: errorOverride,
1782
+ suggestion,
1783
+ device: deviceName,
1784
+ screenshot: failScreenshot
1785
+ });
1786
+ const failSignals = await captureTapArtifact({
1787
+ query,
1788
+ outcome: "failure",
1789
+ errorMessage: failureResult.error,
1790
+ attempted,
1791
+ platform,
1792
+ iosDriver: platform === "ios" ? (process.env.IOS_DRIVER?.toLowerCase() || "axe") : undefined,
1793
+ deviceName,
1794
+ screenshotMeta: failScreenshot ? { width: failScreenshot.width, height: failScreenshot.height } : undefined,
1795
+ screenshotBuffer: beforeBuffer,
1796
+ afterWithMarker: null,
1797
+ chosenTapPoint: null,
1798
+ evidence
1799
+ });
1800
+ return attachArtifactSignals(failureResult, failSignals);
1801
+ }
1802
+ // Matches testIDs typical of virtualized list items: trailing `-<digits>` (e.g. `store-item-0`,
1803
+ // `route-item-6`) or containing a UUID v4 fragment (e.g. `group-card-e4566bc6-9164-4fd0-...`).
1804
+ // Used by buildSuggestion() to tell the agent to scroll first when a list-item testID
1805
+ // can't be found in the visible fiber tree.
1806
+ const VIRTUALIZED_TESTID_RE = /(-\d+$)|([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
1807
+ function looksLikeVirtualizedListItem(testID) {
1808
+ return VIRTUALIZED_TESTID_RE.test(testID);
1809
+ }
1810
+ function buildSuggestion(query, triedStrategies, platform) {
1811
+ const suggestions = [];
1812
+ if (!triedStrategies.includes("ocr") && query.text) {
1813
+ suggestions.push("Try strategy='ocr' to find text visually on screen");
1814
+ }
1815
+ if (query.text && query.text.length <= 2) {
1816
+ suggestions.push("Very short text is unreliable for OCR — use testID or coordinates instead");
1817
+ }
1818
+ if (query.text && hasProblematicUnicode(query.text)) {
1819
+ suggestions.push("Emoji text cannot use fiber strategy — use testID or coordinates instead");
1820
+ }
1821
+ if (query.component && triedStrategies.includes("fiber")) {
1822
+ suggestions.push("Component not found or has no onPress handler — use find_components to discover exact component names, or use text/coordinates instead");
1823
+ }
1824
+ if (query.testID && !triedStrategies.includes("ocr")) {
1825
+ suggestions.push("testID not found in fiber/accessibility tree — verify the element is on the current screen with a screenshot");
1826
+ }
1827
+ // List-item testIDs (suffix `-N` or containing a UUID) are typically rendered inside a
1828
+ // virtualized list — items beyond the viewport are unmounted, so the fiber tree genuinely
1829
+ // doesn't contain them. Adding the hint as a separate suggestion (rather than rewriting
1830
+ // the error) keeps the existing message stable for tooling that parses it.
1831
+ if (query.testID && looksLikeVirtualizedListItem(query.testID)) {
1832
+ suggestions.push("testID looks like a virtualized list item — scroll it on-screen first " +
1833
+ `(swipe()${platform === "ios" ? " or ios_button arrow keys" : ""}) before tapping`);
1834
+ }
1835
+ suggestions.push(`Take a screenshot (${platform === "ios" ? "ios_screenshot" : "android_screenshot"}) ` +
1836
+ "to verify the element is visible, then use x/y coordinates");
1837
+ return suggestions.join(". ");
1838
+ }
1839
+ //# sourceMappingURL=tap.js.map