rnxsim 0.1.312 → 0.1.314

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/LICENSE +11 -10
  2. package/README.md +5 -0
  3. package/cli/app-config.ts +65 -0
  4. package/cli/app-fonts.ts +408 -0
  5. package/cli/app-project.ts +231 -0
  6. package/cli/app-splash.ts +185 -0
  7. package/cli/app-state-reset.ts +24 -0
  8. package/cli/auth.ts +155 -0
  9. package/cli/bin.ts +594 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2830 -0
  12. package/cli/browser-evals.ts +96 -0
  13. package/cli/commands/agent-wrapper.ts +986 -0
  14. package/cli/commands/agent.ts +423 -0
  15. package/cli/commands/app-fonts.ts +98 -0
  16. package/cli/commands/assert.ts +541 -0
  17. package/cli/commands/auth.ts +59 -0
  18. package/cli/commands/camera.ts +266 -0
  19. package/cli/commands/cleanup.ts +169 -0
  20. package/cli/commands/compat.ts +87 -0
  21. package/cli/commands/config.ts +32 -0
  22. package/cli/commands/control.ts +2142 -0
  23. package/cli/commands/cpu-profile.ts +269 -0
  24. package/cli/commands/daemon-mac-app.ts +169 -0
  25. package/cli/commands/daemon.ts +874 -0
  26. package/cli/commands/debug.ts +719 -0
  27. package/cli/commands/desktop.ts +39 -0
  28. package/cli/commands/detect.ts +197 -0
  29. package/cli/commands/detox.ts +385 -0
  30. package/cli/commands/device.ts +133 -0
  31. package/cli/commands/diagnose.ts +589 -0
  32. package/cli/commands/electron.ts +95 -0
  33. package/cli/commands/film.ts +379 -0
  34. package/cli/commands/flow.ts +1124 -0
  35. package/cli/commands/inspect/actions.ts +622 -0
  36. package/cli/commands/inspect/core.ts +2405 -0
  37. package/cli/commands/inspect/count.ts +17 -0
  38. package/cli/commands/inspect/describe.ts +192 -0
  39. package/cli/commands/inspect/env.ts +23 -0
  40. package/cli/commands/inspect/find.ts +171 -0
  41. package/cli/commands/inspect/get-layout.ts +39 -0
  42. package/cli/commands/inspect/keyboard.ts +52 -0
  43. package/cli/commands/inspect/list.ts +58 -0
  44. package/cli/commands/inspect/memory.ts +215 -0
  45. package/cli/commands/inspect/redaction.ts +39 -0
  46. package/cli/commands/inspect/resolve-target.ts +82 -0
  47. package/cli/commands/inspect/screens.ts +78 -0
  48. package/cli/commands/inspect/settle.ts +22 -0
  49. package/cli/commands/inspect/settling.ts +158 -0
  50. package/cli/commands/inspect/shared.ts +353 -0
  51. package/cli/commands/inspect/sleep.ts +14 -0
  52. package/cli/commands/inspect/tree.ts +32 -0
  53. package/cli/commands/inspect/url.ts +17 -0
  54. package/cli/commands/inspect/wait-event.ts +210 -0
  55. package/cli/commands/inspect/wait-idle.ts +24 -0
  56. package/cli/commands/inspect/wait-ready.ts +74 -0
  57. package/cli/commands/inspect/wait-selector.ts +54 -0
  58. package/cli/commands/inspect/wait.ts +31 -0
  59. package/cli/commands/inspect.ts +4519 -0
  60. package/cli/commands/install-desktop.ts +351 -0
  61. package/cli/commands/login.ts +331 -0
  62. package/cli/commands/logout.ts +31 -0
  63. package/cli/commands/maestro-generate.ts +361 -0
  64. package/cli/commands/maestro.ts +453 -0
  65. package/cli/commands/mode.ts +57 -0
  66. package/cli/commands/no-bridge-hint.ts +80 -0
  67. package/cli/commands/perf.ts +66 -0
  68. package/cli/commands/permissions.ts +203 -0
  69. package/cli/commands/profile.ts +108 -0
  70. package/cli/commands/react.ts +353 -0
  71. package/cli/commands/record.ts +1434 -0
  72. package/cli/commands/report-issue.ts +305 -0
  73. package/cli/commands/reset.ts +85 -0
  74. package/cli/commands/runtime.ts +351 -0
  75. package/cli/commands/screenshot-command.ts +106 -0
  76. package/cli/commands/screenshot-layers.ts +143 -0
  77. package/cli/commands/screenshot-mode.ts +37 -0
  78. package/cli/commands/screenshot.ts +488 -0
  79. package/cli/commands/screenshots-capture.ts +607 -0
  80. package/cli/commands/screenshots.ts +127 -0
  81. package/cli/commands/serve.ts +168 -0
  82. package/cli/commands/setup.ts +545 -0
  83. package/cli/commands/shell-boolean-mode.ts +81 -0
  84. package/cli/commands/skills.ts +467 -0
  85. package/cli/commands/slides.ts +361 -0
  86. package/cli/commands/state.ts +87 -0
  87. package/cli/commands/storage.ts +58 -0
  88. package/cli/commands/telemetry.ts +54 -0
  89. package/cli/commands/three-mode.ts +763 -0
  90. package/cli/commands/timeline.ts +122 -0
  91. package/cli/commands/upgrade.ts +208 -0
  92. package/cli/commands/upload.ts +1225 -0
  93. package/cli/commands/version.ts +54 -0
  94. package/cli/commands/what-happened.ts +327 -0
  95. package/cli/current-sim.ts +204 -0
  96. package/cli/desktop-companion.ts +300 -0
  97. package/cli/drivers/electron.ts +70 -0
  98. package/cli/drivers/index.ts +20 -0
  99. package/cli/drivers/playwright-provisioning.ts +180 -0
  100. package/cli/drivers/playwright.ts +698 -0
  101. package/cli/drivers/registry.ts +65 -0
  102. package/cli/drivers/types.ts +102 -0
  103. package/cli/flow-file.ts +142 -0
  104. package/cli/flow-live-status.ts +120 -0
  105. package/cli/flow-session.ts +187 -0
  106. package/cli/help.ts +80 -0
  107. package/cli/hidden-runtime-alias.ts +19 -0
  108. package/cli/hints.ts +216 -0
  109. package/cli/inspect-notice-state.ts +114 -0
  110. package/cli/maestro-js.ts +334 -0
  111. package/cli/open-url.ts +8 -0
  112. package/cli/parent-pid.ts +204 -0
  113. package/cli/parse-args.ts +211 -0
  114. package/cli/prompt.ts +51 -0
  115. package/cli/recording-access.ts +107 -0
  116. package/cli/registry.ts +1 -0
  117. package/cli/resolve-assets.ts +63 -0
  118. package/cli/run-registry.ts +226 -0
  119. package/cli/runtime-notes.ts +66 -0
  120. package/cli/runtime-summary.ts +25 -0
  121. package/cli/setup-repository.ts +187 -0
  122. package/cli/telemetry.ts +187 -0
  123. package/cli/ws-bridge.ts +798 -0
  124. package/dist-cli/bin.js +5 -5
  125. package/dist-cli/chunks/{agent-NXVDDCFG.js → agent-7YBDCYMA.js} +2 -2
  126. package/dist-cli/chunks/{agent-wrapper-DXJCFKL3.js → agent-wrapper-2GHFBHCR.js} +2 -2
  127. package/dist-cli/chunks/{app-fonts-W5AJZQJF.js → app-fonts-RSNVPQSU.js} +2 -2
  128. package/dist-cli/chunks/{assert-3LOJEUDT.js → assert-XCMX3XJX.js} +2 -2
  129. package/dist-cli/chunks/{auth-MFTYYPNX.js → auth-B442HRAX.js} +2 -2
  130. package/dist-cli/chunks/{beta-GT3NN67B.js → beta-XJ55JK3M.js} +2 -2
  131. package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
  132. package/dist-cli/chunks/{chunk-EUAVBTKU.js → chunk-277AEQZX.js} +2 -2
  133. package/dist-cli/chunks/{chunk-5OX7ANRA.js → chunk-2JNSK774.js} +2 -2
  134. package/dist-cli/chunks/{chunk-L24Z3CRS.js → chunk-32WOTSTR.js} +3 -3
  135. package/dist-cli/chunks/{chunk-BKNOLP3J.js → chunk-3S753SNQ.js} +2 -2
  136. package/dist-cli/chunks/{chunk-N6A3SXMR.js → chunk-46ZOLOYA.js} +2 -2
  137. package/dist-cli/chunks/{chunk-GZDHEONW.js → chunk-5L7ELDQL.js} +8 -9
  138. package/dist-cli/chunks/{chunk-YZRFF4YA.js → chunk-7OOPFSQS.js} +2 -2
  139. package/dist-cli/chunks/{chunk-TBMVBI26.js → chunk-7SV3RPRW.js} +2 -2
  140. package/dist-cli/chunks/{chunk-JG6S4RSH.js → chunk-7ZC35MOU.js} +1 -1
  141. package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
  142. package/dist-cli/chunks/{chunk-MENV7VLD.js → chunk-APWNH3A4.js} +1 -1
  143. package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
  144. package/dist-cli/chunks/{chunk-3D57IBPC.js → chunk-C2NL26TD.js} +1 -1
  145. package/dist-cli/chunks/chunk-D2FNUWAB.js +15 -0
  146. package/dist-cli/chunks/{chunk-4BXCZHAU.js → chunk-E5T4XSJ3.js} +2 -2
  147. package/dist-cli/chunks/{chunk-FTC4M26Q.js → chunk-EAC34EQS.js} +1 -1
  148. package/dist-cli/chunks/{chunk-NPVGMH2N.js → chunk-EG32ML36.js} +2 -2
  149. package/dist-cli/chunks/{chunk-DM6KUAOZ.js → chunk-FXUAC6D5.js} +1 -1
  150. package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
  151. package/dist-cli/chunks/{chunk-U4PN6JOT.js → chunk-HAXW27SS.js} +2 -2
  152. package/dist-cli/chunks/{chunk-ZMZTHU7V.js → chunk-IZAHPAN6.js} +1 -1
  153. package/dist-cli/chunks/{chunk-SI3BUTQR.js → chunk-J62KM5TB.js} +2 -2
  154. package/dist-cli/chunks/{chunk-Y2FSZSO7.js → chunk-JEMCD5E4.js} +1 -1
  155. package/dist-cli/chunks/{chunk-T4FT6CM7.js → chunk-JZS3Q37N.js} +2 -2
  156. package/dist-cli/chunks/{chunk-2SA2VTGP.js → chunk-KR4JON7D.js} +1 -1
  157. package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
  158. package/dist-cli/chunks/{chunk-HG7ORJMT.js → chunk-MCWPL644.js} +2 -2
  159. package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
  160. package/dist-cli/chunks/{chunk-YRV7HR3P.js → chunk-O6TRIZNS.js} +2 -2
  161. package/dist-cli/chunks/{chunk-WBT32R6N.js → chunk-P7XL2E73.js} +3 -3
  162. package/dist-cli/chunks/{chunk-MNGQ42QE.js → chunk-PFQTUKQ4.js} +62 -87
  163. package/dist-cli/chunks/{chunk-Q3RGLERJ.js → chunk-PG5RZCTN.js} +2 -2
  164. package/dist-cli/chunks/{chunk-OR3XTXEB.js → chunk-QGRI2Z4M.js} +2 -2
  165. package/dist-cli/chunks/chunk-QLXXE7GE.js +125 -0
  166. package/dist-cli/chunks/{chunk-KZW5WBGU.js → chunk-QOJJJWJE.js} +89 -133
  167. package/dist-cli/chunks/{chunk-BRIZOR4K.js → chunk-RWZY5427.js} +2 -2
  168. package/dist-cli/chunks/{chunk-TYE3WBSR.js → chunk-RZKU2K3J.js} +2 -2
  169. package/dist-cli/chunks/{chunk-7VWOSTD5.js → chunk-SBV4IK4H.js} +1 -1
  170. package/dist-cli/chunks/{chunk-FWYYCUAZ.js → chunk-SGMVFFMK.js} +1 -1
  171. package/dist-cli/chunks/{chunk-LDQ7HVCW.js → chunk-TAX4UT2N.js} +1 -1
  172. package/dist-cli/chunks/{chunk-G74YIEVS.js → chunk-TUOFAWXT.js} +1 -1
  173. package/dist-cli/chunks/{chunk-274UD5FD.js → chunk-UHZLOHGP.js} +1 -1
  174. package/dist-cli/chunks/{chunk-37I5AIS7.js → chunk-VC7V76U3.js} +1 -1
  175. package/dist-cli/chunks/{chunk-B7KMNHX7.js → chunk-VQVMLW4U.js} +1 -1
  176. package/dist-cli/chunks/{chunk-VZBLFMYC.js → chunk-VUKKYPZN.js} +2 -2
  177. package/dist-cli/chunks/{chunk-VUFRB347.js → chunk-WGGRJDRE.js} +1 -1
  178. package/dist-cli/chunks/chunk-X6H76EKP.js +15 -0
  179. package/dist-cli/chunks/chunk-XLZ5FNRT.js +27 -0
  180. package/dist-cli/chunks/{chunk-TCWQJILS.js → chunk-XULEACM4.js} +2 -2
  181. package/dist-cli/chunks/{chunk-XRGEEPJ5.js → chunk-YFSDM7AX.js} +4 -4
  182. package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
  183. package/dist-cli/chunks/{chunk-TGQNQ6XT.js → chunk-ZBSJO4NB.js} +10 -9
  184. package/dist-cli/chunks/{cleanup-H3GWQB63.js → cleanup-EYLGCA6Z.js} +2 -2
  185. package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
  186. package/dist-cli/chunks/{compat-5WG2XJV7.js → compat-TLJYHB4E.js} +2 -2
  187. package/dist-cli/chunks/{config-ANMBVVLL.js → config-4JWUOEXK.js} +2 -2
  188. package/dist-cli/chunks/{control-NSGNQFJG.js → control-HPAOYF4N.js} +2 -2
  189. package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
  190. package/dist-cli/chunks/{debug-CHV2O2KF.js → debug-4BKXF6KI.js} +5 -5
  191. package/dist-cli/chunks/{desktop-FIPIK4CH.js → desktop-ZQ6ZD2S6.js} +3 -3
  192. package/dist-cli/chunks/{detox-CVUJP7K6.js → detox-WNPASSS3.js} +2 -2
  193. package/dist-cli/chunks/{device-6WCUM4W6.js → device-BXLXVG3V.js} +2 -2
  194. package/dist-cli/chunks/{diagnose-I6EMWEII.js → diagnose-3QX7W5RW.js} +2 -2
  195. package/dist-cli/chunks/{disk-cleanup-P27CYFTN.js → disk-cleanup-KSWKI7WB.js} +2 -2
  196. package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
  197. package/dist-cli/chunks/{film-UOQFFYNF.js → film-GDMR33VO.js} +3 -3
  198. package/dist-cli/chunks/flow-NKMPBYCJ.js +4 -0
  199. package/dist-cli/chunks/help-OGCBHOKA.js +4 -0
  200. package/dist-cli/chunks/{hidden-runtime-alias-NJYFSJKZ.js → hidden-runtime-alias-S2GTDX3T.js} +2 -2
  201. package/dist-cli/chunks/home-paths-QRCDLTTV.js +4 -0
  202. package/dist-cli/chunks/inspect-FUYMOZPY.js +4 -0
  203. package/dist-cli/chunks/install-desktop-FR6YKW7Y.js +4 -0
  204. package/dist-cli/chunks/{login-BPFQENCX.js → login-IJAPUZHI.js} +4 -4
  205. package/dist-cli/chunks/{logout-5A45ZGY7.js → logout-QMXDFU2X.js} +2 -2
  206. package/dist-cli/chunks/{maestro-E4CSDMLM.js → maestro-ZXU3YCVX.js} +3 -3
  207. package/dist-cli/chunks/{maestro-generate-CKMOJQX2.js → maestro-generate-PYB5QY7K.js} +3 -3
  208. package/dist-cli/chunks/{mode-SZYNMIQF.js → mode-WGUCL5FZ.js} +2 -2
  209. package/dist-cli/chunks/{optional-demo-registry-FRH4SFVG.js → optional-demo-registry-WH2O6H36.js} +2 -2
  210. package/dist-cli/chunks/{perf-VXU2XJAX.js → perf-TOD3UFAH.js} +2 -2
  211. package/dist-cli/chunks/{permissions-IRIAOVND.js → permissions-I5BRJGTB.js} +2 -2
  212. package/dist-cli/chunks/{record-RY7FPLLH.js → record-ZYL2FYSK.js} +3 -3
  213. package/dist-cli/chunks/{report-issue-KNWXOE53.js → report-issue-TAI6DYZO.js} +2 -2
  214. package/dist-cli/chunks/reset-7YGYKQPQ.js +4 -0
  215. package/dist-cli/chunks/runtime-B4JO6QRI.js +4 -0
  216. package/dist-cli/chunks/{screenshot-command-QLALR6GY.js → screenshot-command-7ANLODZY.js} +7 -7
  217. package/dist-cli/chunks/{screenshot-layers-OQFMFVRL.js → screenshot-layers-A7FYXSVU.js} +3 -3
  218. package/dist-cli/chunks/{screenshots-capture-6TSRJFGB.js → screenshots-capture-WT2ZY6CB.js} +2 -2
  219. package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
  220. package/dist-cli/chunks/{setup-7V6UKX4U.js → setup-PCWLD22V.js} +2 -2
  221. package/dist-cli/chunks/{skills-QQBOSETH.js → skills-27ZHWCQS.js} +2 -2
  222. package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
  223. package/dist-cli/chunks/{storage-TAPMKK5O.js → storage-OYC57C4X.js} +6 -6
  224. package/dist-cli/chunks/store-32HSPZHI.js +4 -0
  225. package/dist-cli/chunks/telemetry-PBJR7XHR.js +4 -0
  226. package/dist-cli/chunks/{timeline-IAM564NA.js → timeline-RAJJFQT6.js} +2 -2
  227. package/dist-cli/chunks/{upgrade-CD55QXIX.js → upgrade-OEFNZRIP.js} +2 -2
  228. package/dist-cli/chunks/upload-LQJITLKK.js +4 -0
  229. package/dist-cli/chunks/version-NIXTY6PL.js +6 -0
  230. package/dist-cli/chunks/{web-6NW6YLPI.js → web-XBUTBVGR.js} +2 -2
  231. package/dist-cli/chunks/{what-happened-CZZDOOVG.js → what-happened-YNHRFUDX.js} +3 -3
  232. package/dist-lib/agent-daemon-client.cjs +1 -1
  233. package/dist-lib/agent-events.cjs +1 -1
  234. package/dist-lib/agent-identity.cjs +1 -1
  235. package/dist-lib/agent-sessions.cjs +1 -1
  236. package/dist-lib/attached-projects.cjs +1 -1
  237. package/dist-lib/auth/shared-session.cjs +1 -1
  238. package/dist-lib/backend-origin.cjs +1 -1
  239. package/dist-lib/beta.cjs +1 -1
  240. package/dist-lib/beta.mjs +1 -1
  241. package/dist-lib/bridge-constants.cjs +1 -1
  242. package/dist-lib/bridge-contract.cjs +20 -0
  243. package/dist-lib/cli-constants.cjs +1 -1
  244. package/dist-lib/config.cjs +1 -1
  245. package/dist-lib/detox/index.cjs +1 -1
  246. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  247. package/dist-lib/home-paths.cjs +67 -28
  248. package/dist-lib/host/bridge-host.cjs +140 -12
  249. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  250. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  251. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  252. package/dist-lib/host/websocket-proxy.cjs +1 -1
  253. package/dist-lib/index.cjs +2815 -40
  254. package/dist-lib/jump-to-source-babel.cjs +1 -1
  255. package/dist-lib/menu.cjs +1 -1
  256. package/dist-lib/menu.mjs +1 -1
  257. package/dist-lib/metro.cjs +1 -1
  258. package/dist-lib/profiles.cjs +1 -1
  259. package/dist-lib/public-brand.cjs +1 -1
  260. package/dist-lib/render-mode.cjs +1 -1
  261. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  262. package/dist-lib/sdk.cjs +2549 -2061
  263. package/dist-lib/sdk.mjs +2543 -2061
  264. package/dist-lib/skills.cjs +493 -282
  265. package/dist-lib/vite.cjs +1 -1
  266. package/package.json +8 -2
  267. package/src/bridge-constants.ts +3 -4
  268. package/src/bridge-contract.ts +251 -0
  269. package/src/connect.ts +83 -0
  270. package/src/disk-cleanup.ts +30 -0
  271. package/src/home-paths.ts +81 -38
  272. package/src/host/bridge-host.ts +134 -6
  273. package/src/index.ts +27 -1
  274. package/src/sdk.ts +8 -0
  275. package/src/sim-client.ts +660 -0
  276. package/dist-cli/chunks/camera-6EAK7CUL.js +0 -22
  277. package/dist-cli/chunks/chunk-33FKQH54.js +0 -5
  278. package/dist-cli/chunks/chunk-66NF2KNG.js +0 -9
  279. package/dist-cli/chunks/chunk-6UKJB2VC.js +0 -125
  280. package/dist-cli/chunks/chunk-7INBNA3U.js +0 -26
  281. package/dist-cli/chunks/chunk-AY47WKS5.js +0 -23
  282. package/dist-cli/chunks/chunk-HMDOLCKG.js +0 -15
  283. package/dist-cli/chunks/chunk-KR5BGMBZ.js +0 -4
  284. package/dist-cli/chunks/chunk-NLEMQ4PY.js +0 -6
  285. package/dist-cli/chunks/chunk-PEPUGJVI.js +0 -4
  286. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +0 -4
  287. package/dist-cli/chunks/daemon-JHF4BJ3U.js +0 -4
  288. package/dist-cli/chunks/drivers-HQ6AKZT6.js +0 -4
  289. package/dist-cli/chunks/flow-N27MRRNL.js +0 -4
  290. package/dist-cli/chunks/help-SF6FEGEE.js +0 -4
  291. package/dist-cli/chunks/home-paths-XOV44JEF.js +0 -4
  292. package/dist-cli/chunks/inspect-CWFRFQTY.js +0 -4
  293. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +0 -4
  294. package/dist-cli/chunks/runtime-AR3GJTGK.js +0 -4
  295. package/dist-cli/chunks/serve-F54J2NQO.js +0 -44
  296. package/dist-cli/chunks/store-7NX3S43H.js +0 -4
  297. package/dist-cli/chunks/telemetry-E7BUK6RA.js +0 -4
  298. package/dist-cli/chunks/upload-GZ6LIMYM.js +0 -4
  299. package/dist-cli/chunks/version-3DYJ5AS3.js +0 -6
@@ -0,0 +1,2830 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import { devices, type DeviceModel } from 'sootsim-engine/settings'
4
+ import yaml from 'yaml'
5
+ import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
6
+ import { resetGuestAppStateEval } from './app-state-reset'
7
+ import {
8
+ READY_NODE_STABLE_MS,
9
+ READY_PROBE_POLL_MS,
10
+ WAIT_READY_PROBE,
11
+ readyProbeHasContent,
12
+ readyProbeHasTargetContent,
13
+ type ReadyProbe,
14
+ } from './commands/inspect/core'
15
+ import { waitForSootsimIdle } from './commands/inspect/settling'
16
+ import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
17
+ import { FlowLiveStatusReporter } from './flow-live-status'
18
+ import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
19
+ import { ensureCliRecordingEntitlement } from './recording-access'
20
+ import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract'
21
+ import type { WsBridge } from './ws-bridge'
22
+
23
+ const DEFAULT_TIMEOUT = 10000
24
+ // hosted-runner previews reach first-render much slower than local dev: the
25
+ // guest bundle (often 20+ MB) re-evals and re-hydrates on every launchApp hard
26
+ // reload, and it is served over a cloudflared tunnel. SOOTSIM_FLOW_TIMEOUT_SCALE
27
+ // multiplies every flow wait deadline so a slow-but-working app clears its own
28
+ // `.maestro` waits without editing per-app timeouts. off (1x) locally; run.sh
29
+ // sets it for the recording lane. clamped to 1..10.
30
+ const FLOW_TIMEOUT_SCALE = Math.min(
31
+ 10,
32
+ Math.max(1, Number(process.env.SOOTSIM_FLOW_TIMEOUT_SCALE) || 1),
33
+ )
34
+ const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
35
+ const SCREEN_W = 393
36
+ const SCREEN_H = 852
37
+ type FlowScreenshotPathMode = 'dir' | 'flow'
38
+ type ReplayTapTarget = {
39
+ id?: string | null
40
+ testID?: string | null
41
+ text?: string | null
42
+ type?: string | null
43
+ }
44
+ type FlowTargetSelector = {
45
+ id?: string
46
+ text?: string
47
+ index?: number
48
+ childOf?: { id?: string; text?: string }
49
+ }
50
+ type FlowPointTarget = FlowTargetSelector & {
51
+ point?: string
52
+ }
53
+ type FocusedTextInputInfo = {
54
+ nodeId?: number | null
55
+ id?: string | null
56
+ testID?: string | null
57
+ }
58
+ type ShellState = Record<string, unknown>
59
+
60
+ // shape of window.__sootsimShellPerf.stop() — the shell worker frame profile
61
+ // (the worker that paints app pixels) merged with the render-profile counters.
62
+ // see sootsim-engine shell-worker.ts stopShellFrameProfile.
63
+ export interface SootSimFlowProfileResult {
64
+ frames: number
65
+ skippedFrames: number
66
+ totalMs: number
67
+ avgMs: number
68
+ maxMs: number
69
+ p50: number
70
+ p95: number
71
+ p99: number
72
+ jankFrames: number
73
+ jankSampleFrames?: number
74
+ jankPct: number
75
+ avgOverlayMs: number
76
+ avgAuxMs: number
77
+ avgLayoutMs: number
78
+ auxSurfaces: Array<Record<string, unknown>>
79
+ worstFrames: Array<Record<string, unknown>>
80
+ frameSeries?: Array<Record<string, unknown>>
81
+ jank?: Record<string, unknown>
82
+ compositorCadence?: Record<string, unknown>
83
+ renderProfile?: Record<string, unknown>
84
+ }
85
+
86
+ export type SootSimFlowTraceStep = {
87
+ stepIndex: number
88
+ stepName: string
89
+ targetLabel?: string
90
+ startedAtMs: number
91
+ endedAtMs: number
92
+ durationMs: number
93
+ status: 'success' | 'skipped' | 'failure'
94
+ error?: string
95
+ screenshotPath?: string
96
+ }
97
+
98
+ export interface MaestroStep {
99
+ tapOn?:
100
+ | string
101
+ | {
102
+ id?: string
103
+ text?: string
104
+ index?: number
105
+ point?: string
106
+ childOf?: { id?: string; text?: string }
107
+ optional?: boolean
108
+ }
109
+ longPressOn?:
110
+ | string
111
+ | {
112
+ id?: string
113
+ text?: string
114
+ index?: number
115
+ point?: string
116
+ childOf?: { id?: string; text?: string }
117
+ optional?: boolean
118
+ }
119
+ tapAtCoords?: { x: number; y: number }
120
+ doubleTapAtCoords?: { x: number; y: number; gapMs?: number }
121
+ assertVisible?:
122
+ | string
123
+ | {
124
+ id?: string
125
+ text?: string
126
+ index?: number
127
+ childOf?: { id?: string; text?: string }
128
+ optional?: boolean
129
+ }
130
+ assertNotVisible?:
131
+ | string
132
+ | {
133
+ id?: string
134
+ text?: string
135
+ index?: number
136
+ childOf?: { id?: string; text?: string }
137
+ optional?: boolean
138
+ }
139
+ inputText?: string
140
+ pressKey?: string
141
+ dispatchKey?: string
142
+ swipe?: {
143
+ direction?: string
144
+ duration?: number
145
+ start?: string
146
+ end?: string
147
+ from?: { id?: string; text?: string }
148
+ optional?: boolean
149
+ }
150
+ scroll?: { direction?: string; optional?: boolean }
151
+ scrollTo?: { id?: string; nodeId?: number; x: number; y: number; optional?: boolean }
152
+ pinch?: {
153
+ from: [number, number, number, number]
154
+ to: [number, number, number, number]
155
+ steps?: number
156
+ stepMs?: number
157
+ optional?: boolean
158
+ }
159
+ takeScreenshot?:
160
+ | string
161
+ | {
162
+ path?: string
163
+ name?: string
164
+ withFrame?: boolean
165
+ // 'full' (default) captures everything; 'tenant' skips shell overlays
166
+ // (status bar, keyboard, toasts, notification center) for a clean
167
+ // app-only capture; 'shell' is the inverse.
168
+ layers?: 'full' | 'tenant' | 'shell'
169
+ }
170
+ waitFor?: { text?: string; id?: string; timeout?: number; optional?: boolean }
171
+ waitForAnimationToEnd?: boolean | number | { timeout?: number }
172
+ back?: boolean
173
+ hideKeyboard?: boolean
174
+ launchApp?: any
175
+ // maestro canonical form: object with `file` XOR inline `commands`, plus
176
+ // optional `when` gate and `env` vars scoped to the sub-flow. legacy
177
+ // sootsim also accepts a bare path string.
178
+ runFlow?:
179
+ | string
180
+ | {
181
+ file?: string
182
+ commands?: MaestroStep[]
183
+ env?: Record<string, string>
184
+ label?: string
185
+ when?: {
186
+ visible?: string | { id?: string; text?: string }
187
+ notVisible?: string | { id?: string; text?: string }
188
+ platform?: string
189
+ true?: string | boolean
190
+ }
191
+ }
192
+ // maestro runScript: execute a JS file in the flow's shared JS context
193
+ // (host-side — NOT the app page). env vars are scoped to the script.
194
+ runScript?:
195
+ | string
196
+ | {
197
+ file: string
198
+ env?: Record<string, string>
199
+ label?: string
200
+ when?: {
201
+ visible?: string | { id?: string; text?: string }
202
+ notVisible?: string | { id?: string; text?: string }
203
+ platform?: string
204
+ true?: string | boolean
205
+ }
206
+ }
207
+ repeat?: {
208
+ times?: number | string
209
+ when?: NonNullable<MaestroStep['when']>
210
+ condition?: NonNullable<MaestroStep['when']>
211
+ commands: MaestroStep[]
212
+ }
213
+ scrollUntilVisible?: {
214
+ element: string
215
+ centerElement?: boolean
216
+ direction?: string
217
+ timeout?: number
218
+ optional?: boolean
219
+ }
220
+ extendedWaitUntil?: {
221
+ visible?: string | { id?: string; text?: string }
222
+ notVisible?: string | { id?: string; text?: string }
223
+ timeout?: number
224
+ optional?: boolean
225
+ }
226
+ eraseText?: number
227
+ wait?: number
228
+ dumpTree?: number
229
+ assertTreeContains?: string
230
+ // maestro parity verbs
231
+ stopApp?: boolean | string | { appId?: string }
232
+ clearState?: boolean | { appId?: string }
233
+ clearKeychain?: boolean
234
+ copyTextFrom?: string | { id?: string; text?: string }
235
+ evalScript?: string
236
+ openLink?: string | { link: string; autoVerify?: boolean; browser?: boolean }
237
+ // maestro `when:` conditional runs the step only if the predicate holds.
238
+ // supports `visible` / `notVisible` with the same matcher shape used elsewhere.
239
+ when?: {
240
+ visible?: string | { id?: string; text?: string }
241
+ notVisible?: string | { id?: string; text?: string }
242
+ platform?: string
243
+ true?: string | boolean
244
+ }
245
+ // maestro lifecycle bookends — multi-doc flows may emit single-key steps
246
+ // with these keys carrying a sub-step array; handled in flattenSteps.
247
+ onFlowStart?: MaestroStep[]
248
+ onFlowComplete?: MaestroStep[]
249
+ }
250
+
251
+ function sleep(ms: number) {
252
+ return new Promise((resolve) => setTimeout(resolve, ms))
253
+ }
254
+
255
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
256
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
257
+ }
258
+
259
+ function readStringKey(value: unknown, key: string): string | null {
260
+ if (!isObjectRecord(value)) return null
261
+ const raw = value[key]
262
+ return typeof raw === 'string' && raw.length > 0 ? raw : null
263
+ }
264
+
265
+ function readBooleanKey(value: unknown, key: string): boolean {
266
+ if (!isObjectRecord(value)) return false
267
+ return value[key] === true
268
+ }
269
+
270
+ function readRecentShellAppId(state: ShellState | null): string | null {
271
+ if (!state) return null
272
+ const recentApps = state.recentApps
273
+ if (Array.isArray(recentApps)) {
274
+ for (const app of recentApps) {
275
+ const id = readStringKey(app, 'id')
276
+ if (id) return id
277
+ }
278
+ }
279
+ const bindings = state.surfaceBindings
280
+ return readStringKey(bindings, 'app:one') || readStringKey(bindings, 'app:two') || null
281
+ }
282
+
283
+ export function resolveFlowScreenshotPath(screenshotDir: string, name: string): string {
284
+ return resolveFlowScreenshotPathWithMode(screenshotDir, name, {
285
+ mode: 'dir',
286
+ })
287
+ }
288
+
289
+ function resolveProjectRelativeFlowBase(flowDir?: string): string {
290
+ if (!flowDir) return process.cwd()
291
+ return path.basename(flowDir) === '.maestro' ? path.dirname(flowDir) : flowDir
292
+ }
293
+
294
+ export function resolveFlowScreenshotPathWithMode(
295
+ screenshotDir: string,
296
+ name: string,
297
+ opts: {
298
+ mode: FlowScreenshotPathMode
299
+ flowDir?: string
300
+ },
301
+ ): string {
302
+ const normalizedName = name.endsWith('.png') ? name : `${name}.png`
303
+ if (path.isAbsolute(normalizedName)) return normalizedName
304
+ if (opts.mode === 'flow' && /[\\/]/.test(normalizedName)) {
305
+ const baseDir =
306
+ normalizedName.startsWith('./') || normalizedName.startsWith('../')
307
+ ? (opts.flowDir ?? process.cwd())
308
+ : resolveProjectRelativeFlowBase(opts.flowDir)
309
+ return path.resolve(baseDir, normalizedName)
310
+ }
311
+ return path.join(screenshotDir, normalizedName)
312
+ }
313
+
314
+ export function normalizeFlowScreenshotSpec(
315
+ step:
316
+ | string
317
+ | {
318
+ path?: string
319
+ name?: string
320
+ withFrame?: boolean
321
+ layers?: 'full' | 'tenant' | 'shell'
322
+ },
323
+ ): { path: string; withFrame: boolean; layers?: 'full' | 'tenant' | 'shell' } {
324
+ if (typeof step === 'string') {
325
+ return { path: step, withFrame: false }
326
+ }
327
+ const name = step.path?.trim() || step.name?.trim()
328
+ if (!name) {
329
+ throw new Error('takeScreenshot object form requires path or name')
330
+ }
331
+ return {
332
+ path: name,
333
+ withFrame: step.withFrame === true,
334
+ layers: step.layers,
335
+ }
336
+ }
337
+
338
+ function parsePoint(
339
+ value: string,
340
+ bounds?: { x: number; y: number; width: number; height: number },
341
+ ) {
342
+ const [rawX, rawY] = value.split(',').map((part) => part.trim())
343
+ const parseCoord = (coord: string, max: number) =>
344
+ coord.endsWith('%')
345
+ ? (Number.parseFloat(coord) / 100) * max
346
+ : Number.parseFloat(coord)
347
+ const originX = bounds?.x ?? 0
348
+ const originY = bounds?.y ?? 0
349
+ return {
350
+ x: originX + parseCoord(rawX, bounds?.width ?? SCREEN_W),
351
+ y: originY + parseCoord(rawY, bounds?.height ?? SCREEN_H),
352
+ }
353
+ }
354
+
355
+ // maestro accepts several commands as a bare YAML string (`- back`,
356
+ // `- waitForAnimationToEnd`, `- scrollUp`, …). map the string to the
357
+ // `{ verb: … }` object the dispatcher understands. directional `scroll*`
358
+ // shorthands map onto the `scroll` verb (its swipe inverts DOWN/UP).
359
+ function normalizeBareStringStep(verb: string): MaestroStep {
360
+ switch (verb) {
361
+ case 'back':
362
+ return { back: true }
363
+ case 'hideKeyboard':
364
+ return { hideKeyboard: true }
365
+ case 'waitForAnimationToEnd':
366
+ return { waitForAnimationToEnd: true }
367
+ case 'stopApp':
368
+ return { stopApp: true }
369
+ case 'clearState':
370
+ return { clearState: true }
371
+ case 'clearKeychain':
372
+ return { clearKeychain: true }
373
+ case 'eraseText':
374
+ return { eraseText: 50 }
375
+ case 'scroll':
376
+ case 'scrollDown':
377
+ return { scroll: { direction: 'DOWN' } }
378
+ case 'scrollUp':
379
+ return { scroll: { direction: 'UP' } }
380
+ default:
381
+ // unknown bare verb — surface it as the offending step (the dispatcher
382
+ // throws "unsupported flow step") rather than silently no-op.
383
+ return { [verb]: true } as MaestroStep
384
+ }
385
+ }
386
+
387
+ // parse the steps document of a flow file. `${...}` templates stay verbatim:
388
+ // interpolation is per-step at execution time (runStep), matching maestro —
389
+ // values produced mid-flow (runScript output.*, copyTextFrom) resolve
390
+ // correctly in later steps.
391
+ export function parseFlowSteps(content: string): MaestroStep[] {
392
+ const parts = content.split(/^---$/m)
393
+ const candidate = parts.length > 1 ? parts[parts.length - 1] : content
394
+ const parsed = yaml.parse(candidate)
395
+ return Array.isArray(parsed) ? parsed : []
396
+ }
397
+
398
+ export class SootSimBridgeFlowRunner {
399
+ stepDelay = 0
400
+
401
+ // maestro-compatible JS context for the whole flow run (shared with
402
+ // runFlow sub-flows): `${...}` templates, runScript, evalScript, output,
403
+ // maestro.copiedText. one per runner = one per flow file, like upstream.
404
+ readonly js = new MaestroJsContext({
405
+ platform: 'ios',
406
+ onLog: (msg) => console.log(`[flow] js: ${msg}`),
407
+ })
408
+
409
+ private firstLaunchDone = false
410
+ private profilingEnabled = false
411
+ private recordingEnabled = false
412
+ private recordingAccessChecked = false
413
+ private recordingStartedAtMs: number | null = null
414
+ private lastRecordingStartedAtMs: number | null = null
415
+ private lastRecordingDurationMs: number | null = null
416
+ private lastRecordingFrameStats: unknown = null
417
+ private flowTraceSteps: SootSimFlowTraceStep[] = []
418
+ private lastVisualSettledAtMs = 0
419
+ private simRouteHint: string | null = null
420
+ // set by runStep when a step throws — lets the outer flow command
421
+ // dump a rich per-step failure bundle (screenshot + describe.json +
422
+ // console + network) keyed by the failing step.
423
+ lastFailedStep: { index: number; kind: string; target: unknown } | null = null
424
+
425
+ constructor(
426
+ readonly bridge: WsBridge,
427
+ // simId rotates when the bridge picks a different open sim mid-flow;
428
+ // the rest of opts is stable.
429
+ private readonly opts: {
430
+ screenshotDir: string
431
+ flowDir: string
432
+ screenshotPathMode?: FlowScreenshotPathMode
433
+ // flow-wide default for screenshot layers — applied to every
434
+ // takeScreenshot step unless that step has its own `layers:`. set by
435
+ // `sootsim maestro test --no-shell` to produce clean tenant-only captures
436
+ // (useful when a stuck shell overlay would otherwise pollute the
437
+ // demo asset set).
438
+ screenshotLayers?: 'full' | 'tenant' | 'shell'
439
+ simId?: string
440
+ recordingOutputDir?: string
441
+ /** billing-origin override for the recording entitlement check. set
442
+ * by `maestro test --preview` so the entitlement is verified against the
443
+ * same origin that receives the upload (e.g. https://contrast.localhost:3000),
444
+ * not whatever origin the shared desktop sim happens to
445
+ * remember. */
446
+ billingOriginOverride?: string
447
+ /** pr preview uploads may authenticate with a github installation
448
+ * token instead of a paid desktop sim. upload/finalize already
449
+ * accepts that identity, so preview recording should use the same
450
+ * trust path. */
451
+ allowGitHubRecording?: boolean
452
+ /** local file capture is unmetered; preview/upload recording still
453
+ * verifies entitlement before producing a shareable cloud artifact. */
454
+ requireRecordingEntitlement?: boolean
455
+ recordingFormat?: 'webm' | 'mp4'
456
+ // fires after every page reload (launchApp clear-state) once the
457
+ // tree is back and simId has been rotated. lets preview mode
458
+ // re-start the event recorder, since `window.location.reload()`
459
+ // wipes any listeners the caller installed before the flow ran.
460
+ onAfterLaunch?: () => Promise<void> | void
461
+ },
462
+ ) {}
463
+
464
+ get simId(): string | undefined {
465
+ return this.opts.simId
466
+ }
467
+
468
+ setSimRouteHint(urlLike: string | null | undefined) {
469
+ this.simRouteHint = this.normalizeSimRoute(urlLike)
470
+ }
471
+
472
+ private normalizeSimRoute(urlLike: string | null | undefined): string | null {
473
+ if (!urlLike) return null
474
+ try {
475
+ const url = new URL(urlLike)
476
+ url.searchParams.delete('inspectOpen')
477
+ const search = url.searchParams.toString()
478
+ return `${url.origin}${url.pathname}${search ? `?${search}` : ''}`
479
+ } catch {
480
+ return urlLike
481
+ }
482
+ }
483
+
484
+ // sim ids rotate on `window.location.reload()` when the shell reconnects.
485
+ // after launchApp we re-resolve the primary sim so the rest of the flow
486
+ // doesn't send commands to a dead id.
487
+ private async refreshSimId(): Promise<void> {
488
+ if (!this.opts.simId) return
489
+ try {
490
+ const sims = await this.bridge.listSims()
491
+ const current = sims.find((b) => b.id === this.opts.simId)
492
+ if (current) {
493
+ this.simRouteHint = this.normalizeSimRoute(current.url || current.origin)
494
+ }
495
+ const stillThere = sims.find(
496
+ (b) => b.id === this.opts.simId && b.readyState === 'open',
497
+ )
498
+ if (stillThere) return
499
+ if (!this.simRouteHint) return
500
+ const hinted =
501
+ this.simRouteHint &&
502
+ sims.find(
503
+ (b) =>
504
+ b.readyState === 'open' &&
505
+ this.normalizeSimRoute(b.url || b.origin) === this.simRouteHint,
506
+ )
507
+ if (!hinted) return
508
+ const primary = sims.find((b) => b.isPrimary && b.readyState === 'open')
509
+ const firstOpen = sims.find((b) => b.readyState === 'open')
510
+ const next = hinted ?? primary ?? firstOpen
511
+ if (next) {
512
+ this.opts.simId = next.id
513
+ console.log(` [flow] sim rotated to ${next.id}`)
514
+ }
515
+ } catch {
516
+ // bridge unreachable — let the next command surface the real error
517
+ }
518
+ }
519
+
520
+ private async evaluate<T = unknown>(code: string, timeoutMs?: number): Promise<T> {
521
+ return this.bridge.send(
522
+ {
523
+ type: 'evaluate',
524
+ simId: this.opts.simId,
525
+ code,
526
+ },
527
+ timeoutMs ? { timeoutMs } : undefined,
528
+ ) as Promise<T>
529
+ }
530
+
531
+ private async callTest<T = unknown>(method: string, ...args: unknown[]): Promise<T> {
532
+ return this.bridge.send({
533
+ type: 'call',
534
+ simId: this.opts.simId,
535
+ path: `__sootsimTest.${method}`,
536
+ args,
537
+ }) as Promise<T>
538
+ }
539
+
540
+ // ordered input batch executed inside the page in one round-trip; the
541
+ // engine emits agent-cursor actions for every step natively.
542
+ private async perform(steps: PerformStep[]): Promise<PerformResult> {
543
+ return this.bridge.send({
544
+ type: 'perform',
545
+ simId: this.opts.simId,
546
+ steps,
547
+ })
548
+ }
549
+
550
+ async waitForTree(timeout: number = DEFAULT_TIMEOUT) {
551
+ const deadline = Date.now() + scaledTimeout(timeout)
552
+ const startedAt = Date.now()
553
+ let lastError: Error | null = null
554
+ // a not-ready app still answers `evaluate` (the probe body returns
555
+ // `{count, loading}`); a sim that can't even run the probe — bridge
556
+ // disconnect, command timeout — is a stale orphan or a dead tab. count
557
+ // those distinctly so we can fail fast instead of polling the full
558
+ // (up to 120s) timeout with no output, which reads as a dead hang (B1).
559
+ let consecutiveBridgeFailures = 0
560
+ let lastProgressAt = 0
561
+ // readiness mirrors `sootsim wait ready` (shared WAIT_READY_PROBE + the
562
+ // readyProbeHasContent decision). the previous inline probe used
563
+ // findByText('loading'/'capturing'/'opening app'), which substring-matched
564
+ // ordinary forum copy ("...still loading the card...") and reported a
565
+ // fully-painted screen as perpetually loading. WAIT_READY_PROBE only treats
566
+ // an exact, isolated loading label as "loading" and prefers the guest app's
567
+ // own ready signal, so a content-rich screen no longer false-positives.
568
+ let lastNodes = -1
569
+ let nodeStableSince = startedAt
570
+ while (Date.now() < deadline) {
571
+ try {
572
+ const probe = await this.evaluate<ReadyProbe | null>(
573
+ WAIT_READY_PROBE,
574
+ // the readiness probe is cheap — cap it well under the bridge-wide
575
+ // command timeout so a non-responsive sim is detected in seconds,
576
+ // not after a full 15s-per-poll stall.
577
+ 8000,
578
+ )
579
+ consecutiveBridgeFailures = 0
580
+ if (probe?.externalError) {
581
+ throw new Error(`app failed to load: ${probe.externalError}`)
582
+ }
583
+ if (probe) {
584
+ const now = Date.now()
585
+ if (probe.nodes !== lastNodes) {
586
+ lastNodes = probe.nodes
587
+ nodeStableSince = now
588
+ }
589
+ const hasReadySignal = probe.flag === true && readyProbeHasContent(probe)
590
+ const hasRenderedContentWithoutReadySignal =
591
+ probe.flag !== true && readyProbeHasTargetContent(probe)
592
+ const ready =
593
+ (hasReadySignal || hasRenderedContentWithoutReadySignal) &&
594
+ probe.externalReady !== false &&
595
+ !probe.externalError &&
596
+ !probe.loadingText &&
597
+ now - nodeStableSince >= READY_NODE_STABLE_MS
598
+ if (ready) return
599
+ }
600
+ } catch (err) {
601
+ lastError = err instanceof Error ? err : new Error(String(err))
602
+ // a real load error reported by the probe body — not a bridge
603
+ // failure. surface it immediately rather than retrying.
604
+ if (lastError.message.startsWith('app failed to load:')) {
605
+ throw lastError
606
+ }
607
+ consecutiveBridgeFailures++
608
+ if (consecutiveBridgeFailures >= 4) {
609
+ throw new Error(
610
+ `target sim is not responding to the bridge (${lastError.message}). ` +
611
+ `it is likely a stale orphan — run \`rnxsim list\`, then close it ` +
612
+ `with \`rnxsim close <id>\` or start a fresh one with \`rnxsim open --new <port>\`.`,
613
+ )
614
+ }
615
+ await this.refreshSimId()
616
+ }
617
+ // surface progress every ~5s so a slow-loading app or a sim mid-boot
618
+ // does not look like a dead hang while waitForTree polls (B1).
619
+ const elapsed = Date.now() - startedAt
620
+ if (elapsed - lastProgressAt >= 5000) {
621
+ lastProgressAt = elapsed
622
+ console.log(
623
+ ` [flow] waiting for app to be ready… (${Math.round(elapsed / 1000)}s)`,
624
+ )
625
+ }
626
+ // same cadence as inspectWaitReady, which runs this identical readiness
627
+ // decision for `sootsim wait ready`. polling slower here only delayed
628
+ // every launchApp by up to a poll interval.
629
+ await sleep(READY_PROBE_POLL_MS)
630
+ }
631
+ if (lastError?.message.startsWith('app failed to load:')) {
632
+ throw lastError
633
+ }
634
+ throw new Error(`app not ready after ${timeout}ms`)
635
+ }
636
+
637
+ private async ensureRecordingStarted() {
638
+ if (!this.opts.recordingOutputDir) {
639
+ throw new Error('recording output directory not configured')
640
+ }
641
+ if (!this.recordingAccessChecked) {
642
+ if (this.opts.requireRecordingEntitlement) {
643
+ await ensureCliRecordingEntitlement('flow --video', {
644
+ originOverride: this.opts.billingOriginOverride,
645
+ allowGitHubAuth: this.opts.allowGitHubRecording,
646
+ })
647
+ }
648
+ this.recordingAccessChecked = true
649
+ }
650
+
651
+ const startedAt = Date.now()
652
+ const result = await this.evaluate<{
653
+ ok: boolean
654
+ error?: string
655
+ format?: string
656
+ startedAtMs?: number
657
+ }>(`(() => {
658
+ const rec = window.__sootsimRecorder
659
+ if (!rec) return { ok: false, error: 'recorder unavailable on this page' }
660
+ const format = ${JSON.stringify(this.opts.recordingFormat ?? 'webm')}
661
+ if (rec.state() === 'recording') return { ok: true, format }
662
+ return rec.start({ format, fps: 24 })
663
+ })()`)
664
+
665
+ if (!result?.ok) {
666
+ throw new Error(result?.error || 'recording unavailable on this page')
667
+ }
668
+ if (!this.recordingStartedAtMs) {
669
+ this.recordingStartedAtMs =
670
+ typeof result.startedAtMs === 'number' && Number.isFinite(result.startedAtMs)
671
+ ? result.startedAtMs
672
+ : startedAt
673
+ }
674
+ }
675
+
676
+ async startRecording() {
677
+ this.recordingEnabled = true
678
+ await this.ensureRecordingStarted()
679
+ }
680
+
681
+ prepareRecording() {
682
+ this.recordingEnabled = true
683
+ }
684
+
685
+ getLastRecordingDurationMs(): number | null {
686
+ return this.lastRecordingDurationMs
687
+ }
688
+
689
+ getLastRecordingStartedAtMs(): number | null {
690
+ return this.lastRecordingStartedAtMs
691
+ }
692
+
693
+ getLastRecordingFrameStats(): unknown {
694
+ return this.lastRecordingFrameStats
695
+ }
696
+
697
+ getFlowTraceSteps(): SootSimFlowTraceStep[] {
698
+ return [...this.flowTraceSteps]
699
+ }
700
+
701
+ private flowStepTargetLabel(step: MaestroStep, stepName: string): string | undefined {
702
+ if (
703
+ stepName === 'inputText' ||
704
+ stepName === 'runScript' ||
705
+ stepName === 'evalScript'
706
+ ) {
707
+ return undefined
708
+ }
709
+ const value = (step as Record<string, unknown>)[stepName]
710
+ if (typeof value === 'string') return value.slice(0, 80)
711
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
712
+ if (!value || typeof value !== 'object') return undefined
713
+ const record = value as Record<string, unknown>
714
+ const direct = record.id ?? record.text ?? record.name ?? record.path
715
+ if (typeof direct === 'string' && direct) return direct.slice(0, 80)
716
+ // extendedWaitUntil keeps its target under visible/notVisible, so without
717
+ // this the step every flow waits on renders as a bare "Wait".
718
+ const awaited = record.visible ?? record.notVisible
719
+ if (typeof awaited === 'string' && awaited) return awaited.slice(0, 80)
720
+ if (awaited && typeof awaited === 'object') {
721
+ const target = awaited as Record<string, unknown>
722
+ const label = target.id ?? target.text
723
+ if (typeof label === 'string' && label) return label.slice(0, 80)
724
+ }
725
+ if (record.point && typeof record.point === 'string') return record.point.slice(0, 80)
726
+ if (typeof record.x === 'number' && typeof record.y === 'number') {
727
+ return `${Math.round(record.x)}, ${Math.round(record.y)}`
728
+ }
729
+ return undefined
730
+ }
731
+
732
+ private recordFlowTraceStep(args: {
733
+ stepIndex: number
734
+ stepName: string
735
+ targetLabel?: string
736
+ startedAtMs: number
737
+ status: SootSimFlowTraceStep['status']
738
+ error?: unknown
739
+ screenshotPath?: string
740
+ }) {
741
+ const endedAtMs = Date.now()
742
+ const entry: SootSimFlowTraceStep = {
743
+ stepIndex: args.stepIndex,
744
+ stepName: args.stepName,
745
+ ...(args.targetLabel ? { targetLabel: args.targetLabel } : {}),
746
+ startedAtMs: args.startedAtMs,
747
+ endedAtMs,
748
+ durationMs: Math.max(0, endedAtMs - args.startedAtMs),
749
+ status: args.status,
750
+ }
751
+ if (args.error != null) {
752
+ entry.error = args.error instanceof Error ? args.error.message : String(args.error)
753
+ }
754
+ if (args.screenshotPath) {
755
+ entry.screenshotPath = args.screenshotPath
756
+ }
757
+ this.flowTraceSteps.push(entry)
758
+ }
759
+
760
+ async stopRecording(): Promise<string | null> {
761
+ if (!this.recordingEnabled || !this.opts.recordingOutputDir) return null
762
+ this.recordingEnabled = false
763
+
764
+ const stoppedAt = Date.now()
765
+ const stopped = await this.evaluate<{
766
+ ok: boolean
767
+ error?: string
768
+ size?: number
769
+ mime?: string
770
+ frameStats?: unknown
771
+ }>(`window.__sootsimRecorder.stop()`)
772
+ if (!stopped?.ok) {
773
+ this.recordingStartedAtMs = null
774
+ this.lastRecordingFrameStats = null
775
+ throw new Error(stopped?.error || 'recording stop failed')
776
+ }
777
+ this.lastRecordingStartedAtMs = this.recordingStartedAtMs
778
+ this.lastRecordingDurationMs = this.recordingStartedAtMs
779
+ ? Math.max(1, stoppedAt - this.recordingStartedAtMs)
780
+ : null
781
+ this.lastRecordingFrameStats = stopped.frameStats ?? null
782
+ this.recordingStartedAtMs = null
783
+
784
+ // drain blob in chunks
785
+ const chunks: Buffer[] = []
786
+ let offset = 0
787
+ while (true) {
788
+ const part = await this.evaluate<{
789
+ data: string
790
+ size: number
791
+ offset: number
792
+ done: boolean
793
+ mime: string
794
+ } | null>(
795
+ `window.__sootsimRecorder.getBlobBase64({ offset: ${offset}, chunk: ${
796
+ 2 * 1024 * 1024
797
+ } })`,
798
+ )
799
+ if (!part) break
800
+ chunks.push(Buffer.from(part.data, 'base64'))
801
+ offset = part.offset
802
+ if (part.done) break
803
+ }
804
+
805
+ if (chunks.length === 0) {
806
+ throw new Error('recording requested but no video buffer was produced')
807
+ }
808
+
809
+ fs.mkdirSync(this.opts.recordingOutputDir, { recursive: true })
810
+ const videoMime = stopped.mime || this.opts.recordingFormat || 'video/webm'
811
+ const extension = videoMime.includes('mp4') ? 'mp4' : 'webm'
812
+ const outputPath = path.join(
813
+ this.opts.recordingOutputDir,
814
+ `sootsim-${Date.now()}.${extension}`,
815
+ )
816
+ fs.writeFileSync(outputPath, Buffer.concat(chunks))
817
+ return outputPath
818
+ }
819
+
820
+ async waitForRecordingTail(opts: { maxMs: number; smart?: boolean }) {
821
+ if (!this.recordingEnabled) return
822
+ const maxMs = Math.max(0, Math.round(opts.maxMs))
823
+ if (maxMs <= 0) return
824
+ if (!opts.smart) {
825
+ await sleep(maxMs)
826
+ return
827
+ }
828
+
829
+ const result = await waitForSootsimIdle({
830
+ bridge: this.bridge,
831
+ simId: this.opts.simId,
832
+ maxMs,
833
+ pollMs: 80,
834
+ stablePolls: 6,
835
+ strict: true,
836
+ })
837
+ await this.waitForVisualSettle()
838
+ console.log(
839
+ result.settled
840
+ ? ` [flow] recording tail settled in ${result.elapsed}ms`
841
+ : ` [flow] recording tail reached ${result.elapsed}ms budget`,
842
+ )
843
+ }
844
+
845
+ private async findElement(opts: {
846
+ text?: string
847
+ id?: string
848
+ index?: number
849
+ childOf?: { id?: string; text?: string }
850
+ }) {
851
+ // scoped matchers (maestro `index:` / `childOf:`) need the FULL candidate
852
+ // set, not the single best match findByText/findByTestId return. route them
853
+ // through queryAll: collect every match, optionally keep only those inside
854
+ // the childOf container's bounds (geometric containment mirrors "the X
855
+ // inside post Y" for vertically-stacked lists), then pick the nth.
856
+ if (opts.childOf != null || typeof opts.index === 'number') {
857
+ return this.findElementScoped(opts)
858
+ }
859
+ // maestro text selectors are REGEXPs (e.g. "users[,]? or feeds",
860
+ // ".*Reply 1.*"). only treat a selector as a pattern when it carries regex
861
+ // metacharacters AND the literal/a11y-label lookups miss, so ordinary copy
862
+ // ("Repost") never accidentally over-matches via regex.
863
+ const wantRegex = !!opts.text && /[.*+?^$()[\]{}|\\]/.test(opts.text)
864
+ return this.evaluate<any>(`(async () => {
865
+ const test = window.__sootsimTest
866
+ if (!test) return null
867
+ let node = null
868
+ if (${JSON.stringify(!!opts.text)}) {
869
+ node = await test.findByText(${JSON.stringify(opts.text || '')})
870
+ // maestro matches a text selector against the element's ACCESSIBILITY
871
+ // text too, not just rendered glyphs — icon-only buttons (e.g. bluesky's
872
+ // "Pin to Home" / "Mute accounts" pin/menu controls) carry the label on
873
+ // accessibilityLabel with no visible text node, so findByText misses
874
+ // them. fall back to an exact a11y-label match, mirroring the real
875
+ // driver. findByLabel resolves a node, but its info shape omits nodeId/
876
+ // isTextInput; re-resolve through findByTestId/findById when it exposes
877
+ // an id so tapOn still gets a resolveTapTarget-able nodeId.
878
+ if (!node) {
879
+ const labelled = await test.findByLabel?.(${JSON.stringify(opts.text || '')})
880
+ if (labelled) {
881
+ const reId = labelled.testID || labelled.id
882
+ node =
883
+ (reId &&
884
+ ((await test.findByTestId?.(reId)) || (await test.findById?.(reId)))) ||
885
+ labelled
886
+ }
887
+ }
888
+ if (!node && ${JSON.stringify(wantRegex)} && test.queryAll) {
889
+ // regex selector fallback: match the pattern against each visible
890
+ // node's text; prefer the narrowest matching node so a tight <text>
891
+ // wins over a wide container that merely contains it.
892
+ let re = null
893
+ try { re = new RegExp(${JSON.stringify(opts.text || '')}) } catch (e) { re = null }
894
+ if (re) {
895
+ const all = (await test.queryAll({ pruneHidden: true })) || []
896
+ const hits = all.filter(
897
+ (n) =>
898
+ n && n.absolutePosition && n.layout &&
899
+ typeof n.text === 'string' && re.test(n.text),
900
+ )
901
+ hits.sort((a, b) => (a.text || '').length - (b.text || '').length)
902
+ if (hits[0]) node = hits[0]
903
+ }
904
+ }
905
+ }
906
+ if (!node && ${JSON.stringify(!!opts.id)}) {
907
+ node = (await test.findByTestId(${JSON.stringify(opts.id || '')})) || (await test.findById(${JSON.stringify(opts.id || '')}))
908
+ }
909
+ if (!node || !node.absolutePosition || !node.layout) return null
910
+ return {
911
+ nodeId: typeof node.nodeId === 'number' ? node.nodeId : null,
912
+ id: node.id || null,
913
+ testID: node.testID || null,
914
+ text: node.text || null,
915
+ absolutePosition: node.absolutePosition,
916
+ layout: node.layout,
917
+ isTextInput: !!node.isTextInput,
918
+ }
919
+ })()`)
920
+ }
921
+
922
+ // maestro `index:` (nth match) and `childOf:` (match scoped to a container)
923
+ // resolver. queryAll returns every matching node in tree order; childOf keeps
924
+ // only candidates whose center sits inside the container's bounds.
925
+ private async findElementScoped(opts: {
926
+ text?: string
927
+ id?: string
928
+ index?: number
929
+ childOf?: { id?: string; text?: string }
930
+ }) {
931
+ const index = typeof opts.index === 'number' ? opts.index : 0
932
+ return this.evaluate<any>(`(async () => {
933
+ const test = window.__sootsimTest
934
+ if (!test || !test.queryAll) return null
935
+ // NOT pruneHidden: that over-filters (it dropped every postDropdownBtn in a
936
+ // feed and made index:0 resolve to nothing — worse than findByTestId). reach
937
+ // for the same set findByTestId sees, then PREFER on-screen ones below.
938
+ const filter = {}
939
+ if (${JSON.stringify(!!opts.id)}) filter.hasId = ${JSON.stringify(opts.id || '')}
940
+ if (${JSON.stringify(!!opts.text)}) filter.hasText = ${JSON.stringify(opts.text || '')}
941
+ let candidates = (await test.queryAll(filter)) || []
942
+ // a11y-label fallback (icon-only buttons) for text matchers, mirroring
943
+ // findElement's simple path.
944
+ if (${JSON.stringify(!!opts.text)} && candidates.length === 0) {
945
+ const all = (await test.queryAll({})) || []
946
+ candidates = all.filter((n) => (n.accessibilityLabel || '') === ${JSON.stringify(opts.text || '')})
947
+ }
948
+ candidates = candidates.filter((n) => n && n.absolutePosition && n.layout)
949
+ // prefer on-screen matches (findByTestId's visible-first behavior); only
950
+ // fall back to off-screen ones when nothing is on-screen, so index: still
951
+ // resolves a target that needs scrolling into view.
952
+ const onScreen = candidates.filter((n) => {
953
+ const cy = n.absolutePosition.y + n.layout.height / 2
954
+ return cy > 0 && cy < ${SCREEN_H}
955
+ })
956
+ if (onScreen.length > 0) candidates = onScreen
957
+ ${
958
+ opts.childOf
959
+ ? `
960
+ const container = ${
961
+ opts.childOf.id
962
+ ? `(await test.findByTestId(${JSON.stringify(opts.childOf.id)})) || (await test.findById(${JSON.stringify(opts.childOf.id)}))`
963
+ : `await test.findByText(${JSON.stringify(opts.childOf.text || '')})`
964
+ }
965
+ if (!container || !container.absolutePosition || !container.layout) return null
966
+ const x0 = container.absolutePosition.x
967
+ const y0 = container.absolutePosition.y
968
+ const x1 = x0 + container.layout.width
969
+ const y1 = y0 + container.layout.height
970
+ candidates = candidates.filter((n) => {
971
+ const cx = n.absolutePosition.x + n.layout.width / 2
972
+ const cy = n.absolutePosition.y + n.layout.height / 2
973
+ return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
974
+ })`
975
+ : ''
976
+ }
977
+ const node = candidates[${index}]
978
+ if (!node) return null
979
+ return {
980
+ nodeId: typeof node.nodeId === 'number' ? node.nodeId : null,
981
+ id: node.id || null,
982
+ testID: node.testID || null,
983
+ text: node.text || null,
984
+ absolutePosition: node.absolutePosition,
985
+ layout: node.layout,
986
+ isTextInput: !!node.isTextInput,
987
+ }
988
+ })()`)
989
+ }
990
+
991
+ private async resolveTapTarget(nodeId: number) {
992
+ return this.evaluate<any>(`(async () => {
993
+ const test = window.__sootsimTest
994
+ if (!test?.resolveTapTarget) return null
995
+ return await test.resolveTapTarget(${nodeId})
996
+ })()`)
997
+ }
998
+
999
+ private async activatePressTarget(opts: { text?: string; id?: string }) {
1000
+ const replayTarget: ReplayTapTarget = opts.id
1001
+ ? { id: opts.id, testID: opts.id, text: null }
1002
+ : { id: null, testID: null, text: opts.text ?? null }
1003
+ return this.evaluate<any>(`(async () => {
1004
+ const test = window.__sootsimTest
1005
+ if (!test) return null
1006
+ const node = ${
1007
+ opts.id
1008
+ ? `(await test.findByTestId?.(${JSON.stringify(opts.id)})) || (await test.findById?.(${JSON.stringify(opts.id)}))`
1009
+ : `await test.findByText?.(${JSON.stringify(opts.text || '')})`
1010
+ }
1011
+ let point = null
1012
+ if (node?.absolutePosition && node?.layout) {
1013
+ point = {
1014
+ x: node.absolutePosition.x + node.layout.width / 2,
1015
+ y: node.absolutePosition.y + node.layout.height / 2,
1016
+ }
1017
+ if (typeof node.nodeId === 'number' && test.resolveTapTarget) {
1018
+ const resolved = await test.resolveTapTarget(node.nodeId)
1019
+ if (
1020
+ resolved &&
1021
+ Number.isFinite(resolved.cx) &&
1022
+ Number.isFinite(resolved.cy)
1023
+ ) {
1024
+ point = { x: resolved.cx, y: resolved.cy }
1025
+ }
1026
+ }
1027
+ }
1028
+ ${
1029
+ opts.id
1030
+ ? `const direct = await test.activatePressById?.(${JSON.stringify(opts.id)})`
1031
+ : `const direct = await test.activatePressByText?.(${JSON.stringify(
1032
+ opts.text || '',
1033
+ )})`
1034
+ }
1035
+ if (direct?.ok && point) {
1036
+ window.dispatchEvent(
1037
+ new CustomEvent('sootsim:agentAction', {
1038
+ detail: { type: 'tap', x: point.x, y: point.y, target: ${JSON.stringify(
1039
+ replayTarget,
1040
+ )} },
1041
+ }),
1042
+ )
1043
+ }
1044
+ return direct || null
1045
+ })()`)
1046
+ }
1047
+
1048
+ private async activatePressAt(x: number, y: number) {
1049
+ return this.evaluate<any>(`(async () => {
1050
+ const test = window.__sootsimTest
1051
+ if (!test?.activatePressAt) return null
1052
+ const direct = await test.activatePressAt(${x}, ${y})
1053
+ if (direct?.ok) {
1054
+ window.dispatchEvent(
1055
+ new CustomEvent('sootsim:agentAction', {
1056
+ detail: { type: 'tap', x: ${x}, y: ${y} },
1057
+ }),
1058
+ )
1059
+ }
1060
+ return direct
1061
+ })()`)
1062
+ }
1063
+
1064
+ private async tap(
1065
+ x: number,
1066
+ y: number,
1067
+ target?: ReplayTapTarget,
1068
+ ): Promise<{ hit?: boolean } | null> {
1069
+ return (await this.bridge.send({
1070
+ type: 'tap',
1071
+ simId: this.opts.simId,
1072
+ x,
1073
+ y,
1074
+ target,
1075
+ })) as { hit?: boolean } | null
1076
+ }
1077
+
1078
+ private async longPress(
1079
+ x: number,
1080
+ y: number,
1081
+ durationMs: number,
1082
+ target?: ReplayTapTarget,
1083
+ ): Promise<{ ok?: boolean; value?: boolean } | null> {
1084
+ return (await this.bridge.send({
1085
+ type: 'longPress',
1086
+ simId: this.opts.simId,
1087
+ x,
1088
+ y,
1089
+ durationMs,
1090
+ target,
1091
+ })) as { ok?: boolean; value?: boolean } | null
1092
+ }
1093
+
1094
+ private async waitForElement(opts: FlowTargetSelector, timeoutMs = 5000) {
1095
+ const deadline = Date.now() + timeoutMs
1096
+ let element = await this.findElement(opts)
1097
+ while (!element && Date.now() < deadline) {
1098
+ await sleep(150)
1099
+ element = await this.findElement(opts)
1100
+ }
1101
+ if (!element) return element
1102
+ // a found element can still be mid-transition (modal slide-in, sheet
1103
+ // spring, list settle). resolving tap coordinates against a moving frame
1104
+ // taps whatever occupies that point after the animation lands — a 3pc
1105
+ // new-thread title tap opened the photo picker this way (2026-07-10).
1106
+ // require two consecutive samples at the same absolute position before
1107
+ // interacting, bounded so a perpetually-animating target still proceeds.
1108
+ const stabilityDeadline = Date.now() + 1200
1109
+ let prev = element
1110
+ while (Date.now() < stabilityDeadline) {
1111
+ await sleep(120)
1112
+ const next = await this.findElement(opts)
1113
+ if (!next) break
1114
+ const moved =
1115
+ Math.abs(next.absolutePosition.x - prev.absolutePosition.x) > 0.5 ||
1116
+ Math.abs(next.absolutePosition.y - prev.absolutePosition.y) > 0.5 ||
1117
+ Math.abs(next.layout.width - prev.layout.width) > 0.5 ||
1118
+ Math.abs(next.layout.height - prev.layout.height) > 0.5
1119
+ element = next
1120
+ if (!moved) return element
1121
+ prev = next
1122
+ }
1123
+ return element
1124
+ }
1125
+
1126
+ private replayTargetForElement(
1127
+ opts: FlowTargetSelector,
1128
+ element: any,
1129
+ tapTarget?: any,
1130
+ ): ReplayTapTarget {
1131
+ return {
1132
+ id: opts.id ?? tapTarget?.target?.id ?? element.id ?? null,
1133
+ testID: opts.id ?? tapTarget?.target?.testID ?? element.testID ?? null,
1134
+ text:
1135
+ opts.text ??
1136
+ tapTarget?.target?.text ??
1137
+ tapTarget?.target?.accessibilityLabel ??
1138
+ element.text ??
1139
+ element.accessibilityLabel ??
1140
+ null,
1141
+ type: tapTarget?.target?.type ?? element.type ?? null,
1142
+ }
1143
+ }
1144
+
1145
+ private async resolveInteractionPoint(opts: FlowPointTarget) {
1146
+ if (
1147
+ opts.point &&
1148
+ !opts.id &&
1149
+ !opts.text &&
1150
+ opts.index === undefined &&
1151
+ !opts.childOf
1152
+ ) {
1153
+ return {
1154
+ ...parsePoint(opts.point),
1155
+ element: null,
1156
+ target: undefined,
1157
+ }
1158
+ }
1159
+
1160
+ const element = await this.waitForElement(opts)
1161
+ if (!element) return null
1162
+ const tapTarget =
1163
+ typeof element.nodeId === 'number'
1164
+ ? await this.resolveTapTarget(element.nodeId)
1165
+ : null
1166
+ const target = this.replayTargetForElement(opts, element, tapTarget)
1167
+ if (opts.point) {
1168
+ const point = parsePoint(opts.point, {
1169
+ x: element.absolutePosition.x,
1170
+ y: element.absolutePosition.y,
1171
+ width: element.layout.width,
1172
+ height: element.layout.height,
1173
+ })
1174
+ return { ...point, element, target }
1175
+ }
1176
+ return {
1177
+ x: tapTarget?.cx ?? element.absolutePosition.x + element.layout.width / 2,
1178
+ y: tapTarget?.cy ?? element.absolutePosition.y + element.layout.height / 2,
1179
+ element,
1180
+ target,
1181
+ }
1182
+ }
1183
+
1184
+ async tapOn(target: string | FlowPointTarget) {
1185
+ const opts = typeof target === 'string' ? { text: target } : target
1186
+ if (opts.point) {
1187
+ const point = await this.resolveInteractionPoint(opts)
1188
+ if (!point) throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
1189
+ const direct = await this.activatePressAt(point.x, point.y)
1190
+ if (!direct?.ok) {
1191
+ await this.tap(point.x, point.y, point.target)
1192
+ }
1193
+ await sleep(300)
1194
+ return
1195
+ }
1196
+ // auto-wait like real maestro: if the target is not mounted yet, poll
1197
+ // before giving up. saves flows from adding an explicit wait before every
1198
+ // tapOn just to handle the post-nav frame or two before render.
1199
+ let element = await this.waitForElement(opts)
1200
+ // coord-based tap first — this is what fires the agent-cursor animation
1201
+ // (via the ws-bridge 'tap' case emitting sootsim:agentAction). the
1202
+ // activatePress fallback below bypasses that event, so we reserve it
1203
+ // for when coord-tap doesn't register a hit (rare: fully-virtualized or
1204
+ // off-screen targets).
1205
+ if (element) {
1206
+ // tapping a text input must actually focus it before the next step
1207
+ // (almost always an inputText) runs. focus propagates async across the
1208
+ // tenant→shell worker boundary, and right after a re-render storm (e.g.
1209
+ // bluesky's custom-server dialog dismiss re-mounting the login fields) a
1210
+ // single tap can land on a transitioning node and never grant focus —
1211
+ // the keys then go nowhere and the field stays empty, silently breaking
1212
+ // submit (the bluesky login-submit RCA, 2026-06-11). re-resolve + re-tap
1213
+ // until the input reports focused, the same gate sootsim's `type-into`
1214
+ // command uses. non-text-input taps keep the single-tap path.
1215
+ const tapInputAttempts = element.isTextInput ? 4 : 1
1216
+ for (let attempt = 0; attempt < tapInputAttempts; attempt++) {
1217
+ const current =
1218
+ attempt === 0
1219
+ ? element
1220
+ : await this.findElement({ text: opts.text, id: opts.id })
1221
+ if (!current) break
1222
+ const tapTarget =
1223
+ typeof current.nodeId === 'number'
1224
+ ? await this.resolveTapTarget(current.nodeId)
1225
+ : null
1226
+ const cx = tapTarget?.cx ?? current.absolutePosition.x + current.layout.width / 2
1227
+ const cy = tapTarget?.cy ?? current.absolutePosition.y + current.layout.height / 2
1228
+ const hit = await this.tap(
1229
+ cx,
1230
+ cy,
1231
+ this.replayTargetForElement(opts, current, tapTarget),
1232
+ )
1233
+ if (hit?.hit === true) {
1234
+ await sleep(300)
1235
+ if (!current.isTextInput) return
1236
+ // confirm focus landed on THIS text input; retry the tap if a
1237
+ // previous field still owns focus.
1238
+ if (await this.waitForFocusedTextInput(600, current)) return
1239
+ continue
1240
+ }
1241
+ break
1242
+ }
1243
+ }
1244
+ // fallback: bypass hit-testing by activating press handlers directly.
1245
+ // skips the cursor animation, but keeps the flow running on targets the
1246
+ // canvas hit-test missed (e.g. clipped or zero-area hit regions).
1247
+ const direct = await this.activatePressTarget(opts)
1248
+ if (direct?.ok) {
1249
+ await sleep(300)
1250
+ return
1251
+ }
1252
+ throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
1253
+ }
1254
+
1255
+ async longPressOn(target: string | FlowPointTarget) {
1256
+ const opts = typeof target === 'string' ? { text: target } : target
1257
+ const point = await this.resolveInteractionPoint(opts)
1258
+ if (!point) throw new Error(`longPressOn: element not found: ${JSON.stringify(opts)}`)
1259
+ const result = await this.longPress(point.x, point.y, 3000, point.target)
1260
+ if (result?.ok === false || result?.value === false) {
1261
+ throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
1262
+ }
1263
+ await sleep(300)
1264
+ }
1265
+
1266
+ async tapAtCoords(x: number, y: number) {
1267
+ await this.tap(x, y)
1268
+ await sleep(300)
1269
+ }
1270
+
1271
+ async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
1272
+ const result = await this.perform([
1273
+ { type: 'doubleTap', x, y, gapMs: Math.max(0, Math.round(gapMs)) },
1274
+ ])
1275
+ if (!result?.ok) {
1276
+ throw new Error(
1277
+ `doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
1278
+ )
1279
+ }
1280
+ await sleep(300)
1281
+ }
1282
+
1283
+ async assertVisible(target: string | { id?: string; text?: string }) {
1284
+ const opts = typeof target === 'string' ? { text: target } : target
1285
+ for (let attempt = 0; attempt < 15; attempt++) {
1286
+ if (await this.isElementVisible(opts)) {
1287
+ return
1288
+ }
1289
+ await sleep(200)
1290
+ }
1291
+ // when assertVisible misses, fall back to a "found but off-screen" hint
1292
+ // so a vertically-scrolled or modal-occluded element doesn't look the
1293
+ // same as a missing element. assertVisible failures in demo flows
1294
+ // burned time debugging which case it was; the diff is one bridge call.
1295
+ const found = await this.findElement(opts)
1296
+ const detail = found
1297
+ ? ` (matched node at y=${Math.round(found.absolutePosition.y)} h=${Math.round(found.layout.height)} — off-screen)`
1298
+ : ' (no matching node in tree)'
1299
+ throw new Error(`assertVisible: ${JSON.stringify(opts)} not visible${detail}`)
1300
+ }
1301
+
1302
+ async assertNotVisible(target: string | { id?: string; text?: string }) {
1303
+ const opts = typeof target === 'string' ? { text: target } : target
1304
+ if (await this.isElementVisible(opts)) {
1305
+ const found = await this.findElement(opts)
1306
+ const where = found
1307
+ ? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
1308
+ : ''
1309
+ throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
1310
+ }
1311
+ }
1312
+
1313
+ private async isElementVisible(opts: { id?: string; text?: string }) {
1314
+ const element = await this.findElement(opts)
1315
+ return !!(
1316
+ element &&
1317
+ element.absolutePosition.y + element.layout.height > 0 &&
1318
+ element.absolutePosition.y < SCREEN_H
1319
+ )
1320
+ }
1321
+
1322
+ async inputText(text: string) {
1323
+ // real maestro types into the currently-focused field, and its driver's
1324
+ // tap synchronously focuses it. in sootsim, focus propagates async across
1325
+ // the tenant→shell worker boundary (tap → focusTextInput → keyboard show),
1326
+ // so a preceding `tapOn` on a text input may not have landed focus by the
1327
+ // time we dispatch keystrokes — especially right after a re-render storm
1328
+ // (e.g. bluesky dismissing the custom-server dialog before tapping the
1329
+ // username field). without a focused input the keys go nowhere and the
1330
+ // field stays empty, silently breaking submit. wait for a focused text
1331
+ // input first — the same focus gate sootsim's own `type-into` uses (see
1332
+ // the 2026-04-17 bluesky tap-routing regression in commands/inspect.ts).
1333
+ if (!(await this.waitForFocusedTextInput())) {
1334
+ throw new Error(
1335
+ 'inputText: no focused TextInput; tap the input and wait for focus before typing',
1336
+ )
1337
+ }
1338
+ await this.bridge.send({
1339
+ type: 'keyboard',
1340
+ simId: this.opts.simId,
1341
+ action: 'type',
1342
+ text,
1343
+ })
1344
+ await sleep(200)
1345
+ }
1346
+
1347
+ // poll for a focused text input. returns true once getFocusedNode reports a
1348
+ // node (focus landed), false if the budget elapses. callers use it both as a
1349
+ // tap-focus confirmation (tapOn retry) and a pre-type barrier (inputText).
1350
+ private focusedTextInputMatches(
1351
+ focused: FocusedTextInputInfo,
1352
+ expected?: FocusedTextInputInfo | null,
1353
+ ): boolean {
1354
+ if (!expected) return true
1355
+ if (
1356
+ typeof expected.nodeId === 'number' &&
1357
+ typeof focused.nodeId === 'number' &&
1358
+ expected.nodeId === focused.nodeId
1359
+ ) {
1360
+ return true
1361
+ }
1362
+ const expectedTestID = expected.testID ?? null
1363
+ const focusedTestID = focused.testID ?? null
1364
+ if (expectedTestID && focusedTestID && expectedTestID === focusedTestID) {
1365
+ return true
1366
+ }
1367
+ const expectedId = expected.id ?? null
1368
+ const focusedId = focused.id ?? null
1369
+ if (expectedId && focusedId && expectedId === focusedId) {
1370
+ return true
1371
+ }
1372
+ return false
1373
+ }
1374
+
1375
+ private async waitForFocusedTextInput(
1376
+ timeoutMs = 1500,
1377
+ expected?: FocusedTextInputInfo | null,
1378
+ ): Promise<boolean> {
1379
+ const deadline = Date.now() + timeoutMs
1380
+ while (Date.now() < deadline) {
1381
+ try {
1382
+ const focused = await this.callTest<FocusedTextInputInfo | null>('getFocusedNode')
1383
+ if (focused && this.focusedTextInputMatches(focused, expected)) return true
1384
+ } catch {
1385
+ // bridge hiccup — keep polling within the budget
1386
+ }
1387
+ await sleep(50)
1388
+ }
1389
+ return false
1390
+ }
1391
+
1392
+ async pressKey(name: string) {
1393
+ await this.bridge.send({
1394
+ type: 'keyboard',
1395
+ simId: this.opts.simId,
1396
+ action: 'press',
1397
+ text: name,
1398
+ })
1399
+ await sleep(120)
1400
+ }
1401
+
1402
+ async dispatchKey(ch: string) {
1403
+ await this.bridge.send({
1404
+ type: 'keyboard',
1405
+ simId: this.opts.simId,
1406
+ action: 'dispatchKey',
1407
+ text: ch,
1408
+ })
1409
+ await sleep(120)
1410
+ }
1411
+
1412
+ async hideKeyboard() {
1413
+ // CLI flows need "dismiss keyboard" to mean "get the focused TextInput
1414
+ // out of the way before the next tap", not just "ask shell chrome to
1415
+ // animate down eventually". If the TextInput stays focused, the next tap
1416
+ // can still be swallowed by keyboard-dismiss policy on ScrollViews.
1417
+ try {
1418
+ await this.callTest<boolean>('blurFocusedTextInput')
1419
+ } catch {
1420
+ // fall through to the shell-level dismiss below; older bridges may not
1421
+ // expose the text-input helper yet.
1422
+ }
1423
+
1424
+ await this.bridge.send({
1425
+ type: 'keyboard',
1426
+ simId: this.opts.simId,
1427
+ action: 'dismiss',
1428
+ })
1429
+
1430
+ const deadline = Date.now() + 3000
1431
+ while (Date.now() < deadline) {
1432
+ const state = await this.evaluate<{ visible: boolean; focused: boolean }>(`(() => {
1433
+ const keyboard =
1434
+ window.__sootsimKeyboard ??
1435
+ window.SootSim?.bridges?.keyboard ??
1436
+ null
1437
+ const test = window.__sootsimTest
1438
+ return {
1439
+ visible: !!keyboard?.isVisible?.(),
1440
+ focused: !!test?.isTextInputFocused?.(),
1441
+ }
1442
+ })()`)
1443
+ if (!state?.visible && !state?.focused) {
1444
+ await sleep(80)
1445
+ return
1446
+ }
1447
+ await sleep(80)
1448
+ }
1449
+ }
1450
+
1451
+ async eraseText(count: number) {
1452
+ for (let i = 0; i < count; i++) {
1453
+ await this.bridge.send({
1454
+ type: 'keyboard',
1455
+ simId: this.opts.simId,
1456
+ action: 'dispatchKey',
1457
+ text: 'Backspace',
1458
+ })
1459
+ }
1460
+ await sleep(100)
1461
+ }
1462
+
1463
+ async waitFor(opts: { text?: string; id?: string; timeout?: number }) {
1464
+ const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
1465
+ while (Date.now() < deadline) {
1466
+ const element = await this.findElement(opts)
1467
+ if (element) return
1468
+ await sleep(200)
1469
+ }
1470
+ // surface whether the element existed but was off-screen vs never
1471
+ // showed up at all — the two failure modes need different debugging
1472
+ // (scroll-position bug vs missing wait-step / wrong screen).
1473
+ const final = await this.findElement(opts)
1474
+ const detail = final
1475
+ ? ` (matched node y=${Math.round(final.absolutePosition.y)} — still off-screen at deadline)`
1476
+ : ''
1477
+ throw new Error(
1478
+ `waitFor: ${JSON.stringify(opts)} not found after ${opts.timeout || DEFAULT_TIMEOUT}ms${detail}`,
1479
+ )
1480
+ }
1481
+
1482
+ async takeScreenshot(
1483
+ step:
1484
+ | string
1485
+ | {
1486
+ path?: string
1487
+ name?: string
1488
+ withFrame?: boolean
1489
+ layers?: 'full' | 'tenant' | 'shell'
1490
+ },
1491
+ ): Promise<string> {
1492
+ // give transitions + last-moment data paint a brief settle window before
1493
+ // freezing the frame. screenshot flows care more about visual completeness
1494
+ // than minimum latency. waitForVisualSettle drains in-flight animations
1495
+ // (it returns immediately if nothing is animating), then we hold for an
1496
+ // additional 250ms paint window for fade-ins / image decodes / etc.
1497
+ // before this change, a 250ms sleep was the *only* settle window — flows
1498
+ // that took a screenshot at the tail of a screen transition would catch
1499
+ // a half-finished frame on slow machines.
1500
+ if (!this.hasFreshVisualSettle()) {
1501
+ try {
1502
+ await this.waitForVisualSettle()
1503
+ await sleep(250)
1504
+ } catch {
1505
+ // settling helper can throw on disconnected sims; fall through so a
1506
+ // stale sim still produces *some* screenshot.
1507
+ }
1508
+ }
1509
+ // flow `takeScreenshot: <name>` may be a plain name ("hero") or a full
1510
+ // relative path with slashes ("apps/foo/screenshots/en/02-forum") —
1511
+ // maestro flows commonly use the latter. mkdir the parent of the
1512
+ // resolved output rather than just the base screenshotDir.
1513
+ const spec = normalizeFlowScreenshotSpec(step)
1514
+ const outputPath = resolveFlowScreenshotPathWithMode(
1515
+ this.opts.screenshotDir,
1516
+ spec.path,
1517
+ {
1518
+ mode: this.opts.screenshotPathMode ?? 'dir',
1519
+ flowDir: this.opts.flowDir,
1520
+ },
1521
+ )
1522
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
1523
+ // resolve layers: per-step `layers:` wins over the flow-wide opts default.
1524
+ const layers = spec.layers ?? this.opts.screenshotLayers
1525
+ const screenshotRequest: {
1526
+ type: 'screenshot'
1527
+ simId?: string
1528
+ layers?: 'full' | 'tenant' | 'shell'
1529
+ } = {
1530
+ type: 'screenshot',
1531
+ simId: this.opts.simId,
1532
+ }
1533
+ if (layers && layers !== 'full') screenshotRequest.layers = layers
1534
+ const dataUrl: string = await this.bridge.send(screenshotRequest)
1535
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1536
+ const rawBuffer = Buffer.from(base64, 'base64')
1537
+ if (spec.withFrame) {
1538
+ const model = await this.readCurrentDeviceModel()
1539
+ if (!model) {
1540
+ throw new Error('could not read current device model for framed screenshot')
1541
+ }
1542
+ const framed = await composeFramedScreenshot(rawBuffer, model)
1543
+ fs.writeFileSync(outputPath, framed)
1544
+ console.log(`[flow] screenshot: ${outputPath} (frame: ${model})`)
1545
+ return outputPath
1546
+ }
1547
+ fs.writeFileSync(outputPath, rawBuffer)
1548
+ console.log(`[flow] screenshot: ${outputPath}`)
1549
+ return outputPath
1550
+ }
1551
+
1552
+ private async readCurrentDeviceModel(): Promise<DeviceModel | null> {
1553
+ const settings = (await this.bridge.send({
1554
+ type: 'call',
1555
+ simId: this.opts.simId,
1556
+ path: 'SootSim.bridges.settings.get',
1557
+ args: [],
1558
+ })) as Record<string, unknown> | null
1559
+ const model =
1560
+ settings && typeof settings.deviceModel === 'string' ? settings.deviceModel : null
1561
+ if (!model || !(model in devices)) return null
1562
+ return model as DeviceModel
1563
+ }
1564
+
1565
+ async captureScreenshot(outputPath: string) {
1566
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
1567
+ const dataUrl: string = await this.bridge.send({
1568
+ type: 'screenshot',
1569
+ simId: this.opts.simId,
1570
+ })
1571
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1572
+ fs.writeFileSync(outputPath, Buffer.from(base64, 'base64'))
1573
+ }
1574
+
1575
+ // writes a per-failure debug bundle: screenshot + structured describe
1576
+ // + a11y tree + console errors/warnings + failed network requests +
1577
+ // shell state, all under one directory so post-mortem is one grep away.
1578
+ async captureFailureBundle(
1579
+ outputDir: string,
1580
+ context: {
1581
+ error: Error
1582
+ stepIndex?: number
1583
+ stepKind?: string
1584
+ stepTarget?: unknown
1585
+ },
1586
+ ): Promise<string> {
1587
+ fs.mkdirSync(outputDir, { recursive: true })
1588
+ const write = (name: string, content: string | Buffer) => {
1589
+ try {
1590
+ fs.writeFileSync(path.join(outputDir, name), content)
1591
+ } catch {
1592
+ // best-effort; don't let one failed write block the others
1593
+ }
1594
+ }
1595
+ const safeEval = async <T>(code: string): Promise<T | null> => {
1596
+ try {
1597
+ return (await this.bridge.send({
1598
+ type: 'evaluate',
1599
+ simId: this.opts.simId,
1600
+ code,
1601
+ })) as T
1602
+ } catch {
1603
+ return null
1604
+ }
1605
+ }
1606
+
1607
+ // 1. error context — also pull the sim's current URL so a bundle-load
1608
+ // failure (where the screenshot is blank and describe is empty) still
1609
+ // has a debuggable trail. `simId`, `simUrl` and the optional metro
1610
+ // manifest help triage "did the right bundle even get loaded?" — the
1611
+ // most common failure mode of headless playwright demo runs.
1612
+ const env = await safeEval<{
1613
+ simId?: string | null
1614
+ url?: string | null
1615
+ title?: string | null
1616
+ hidden?: boolean
1617
+ manifest?: unknown
1618
+ }>(`(async () => {
1619
+ const out = {
1620
+ simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null,
1621
+ url: location.href,
1622
+ title: document.title,
1623
+ hidden: document.hidden,
1624
+ }
1625
+ try {
1626
+ const params = new URL(location.href).searchParams
1627
+ const bundle = params.get('bundle')
1628
+ if (bundle) {
1629
+ const baseMatch = bundle.match(/^https?:\\/\\/[^/]+/)
1630
+ if (baseMatch) {
1631
+ const res = await fetch(baseMatch[0] + '/', {
1632
+ headers: { 'expo-platform': 'ios' },
1633
+ cache: 'no-store',
1634
+ })
1635
+ if (res.ok) {
1636
+ out.manifest = await res.json().catch(() => null)
1637
+ }
1638
+ }
1639
+ }
1640
+ } catch {}
1641
+ return out
1642
+ })()`)
1643
+ write(
1644
+ 'error.json',
1645
+ JSON.stringify(
1646
+ {
1647
+ message: context.error.message,
1648
+ stack: context.error.stack,
1649
+ stepIndex: context.stepIndex,
1650
+ stepKind: context.stepKind,
1651
+ stepTarget: context.stepTarget,
1652
+ capturedAt: new Date().toISOString(),
1653
+ sim: env ?? null,
1654
+ },
1655
+ null,
1656
+ 2,
1657
+ ),
1658
+ )
1659
+
1660
+ // 2. screenshot (png)
1661
+ try {
1662
+ const dataUrl: string = await this.bridge.send({
1663
+ type: 'screenshot',
1664
+ simId: this.opts.simId,
1665
+ })
1666
+ const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
1667
+ write('screenshot.png', Buffer.from(base64, 'base64'))
1668
+ } catch {}
1669
+
1670
+ // 3. high-resolution describe as json — every visible node with
1671
+ // full layout, style, a11y, transforms. the `--json` describe output
1672
+ // shape; inlined here so we don't depend on the cli reentering.
1673
+ const describeJson = await safeEval<any>(`(async () => {
1674
+ const t = window.__sootsimTest
1675
+ const mainShell = window.SootSim?.bridges?.mainShell
1676
+ if (!t) return { error: 'no test bridge' }
1677
+ let shell = null
1678
+ try {
1679
+ shell = typeof mainShell?.getState === 'function' ? await mainShell.getState() : null
1680
+ } catch {}
1681
+ const all = await t.queryAll({ pruneHidden: true })
1682
+ return { shell, nodes: all, url: location.href, title: document.title }
1683
+ })()`)
1684
+ if (describeJson) write('describe.json', JSON.stringify(describeJson, null, 2))
1685
+
1686
+ // 4. accessibility tree (flat text — what voiceover would read)
1687
+ const a11y = await safeEval<string>(
1688
+ `(async () => await window.__sootsimTest?.dumpAccessibilityTree?.(20))()`,
1689
+ )
1690
+ if (typeof a11y === 'string') write('a11y.txt', a11y)
1691
+
1692
+ // 5. tree dump (nested — useful for layout bugs)
1693
+ const tree = await safeEval<string>(
1694
+ `(async () => await window.__sootsimTest?.dumpTree?.(15))()`,
1695
+ )
1696
+ if (typeof tree === 'string') write('tree.txt', tree)
1697
+
1698
+ // 6. console buffer + failed requests (captured by sootsim's native-globals
1699
+ // hook / request recorder; same shape as `sootsim get errors` etc.).
1700
+ const consoleBuf = await safeEval<{
1701
+ errors: unknown[]
1702
+ warnings: unknown[]
1703
+ requests: unknown[]
1704
+ } | null>(`(() => {
1705
+ const c = window.__sootsimConsole
1706
+ return {
1707
+ errors: c?.getErrors?.() ?? [],
1708
+ warnings: c?.getWarnings?.() ?? [],
1709
+ requests: (window.__sootsimGetFailedRequests?.() ?? []),
1710
+ }
1711
+ })()`)
1712
+ if (consoleBuf) {
1713
+ write('console.json', JSON.stringify(consoleBuf, null, 2))
1714
+ }
1715
+
1716
+ return outputDir
1717
+ }
1718
+
1719
+ async swipe(direction: string = 'UP', duration: number = 300) {
1720
+ const centerX = SCREEN_W / 2
1721
+ const centerY = SCREEN_H / 2
1722
+ const distance = 200
1723
+ let fromX = centerX
1724
+ let fromY = centerY
1725
+ let toX = centerX
1726
+ let toY = centerY
1727
+
1728
+ switch (direction.toUpperCase()) {
1729
+ case 'UP':
1730
+ fromY += distance
1731
+ toY -= distance
1732
+ break
1733
+ case 'DOWN':
1734
+ fromY -= distance
1735
+ toY += distance
1736
+ break
1737
+ case 'LEFT':
1738
+ fromX += distance
1739
+ toX -= distance
1740
+ break
1741
+ case 'RIGHT':
1742
+ fromX -= distance
1743
+ toX += distance
1744
+ break
1745
+ }
1746
+
1747
+ const steps = Math.max(10, Math.round(duration / 16))
1748
+ await this.drag(fromX, fromY, toX, toY, steps, 16)
1749
+ }
1750
+
1751
+ async drag(
1752
+ fromX: number,
1753
+ fromY: number,
1754
+ toX: number,
1755
+ toY: number,
1756
+ steps = 12,
1757
+ stepMs = 16,
1758
+ ) {
1759
+ const result = await this.perform([
1760
+ {
1761
+ type: 'drag',
1762
+ fromX,
1763
+ fromY,
1764
+ toX,
1765
+ toY,
1766
+ steps: Math.max(1, Math.round(steps)),
1767
+ stepMs: Math.max(0, Math.round(stepMs)),
1768
+ },
1769
+ ])
1770
+ if (!result?.ok) {
1771
+ throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
1772
+ }
1773
+ await sleep(300)
1774
+ }
1775
+
1776
+ async swipeCoords(start: string, end: string, duration = 300) {
1777
+ const from = parsePoint(start)
1778
+ const to = parsePoint(end)
1779
+ const steps = Math.max(10, Math.round(duration / 16))
1780
+ await this.drag(from.x, from.y, to.x, to.y, steps, 16)
1781
+ }
1782
+
1783
+ async swipeFrom(
1784
+ from: { id?: string; text?: string },
1785
+ direction: string = 'UP',
1786
+ duration = 300,
1787
+ ) {
1788
+ const element = await this.findElement(from)
1789
+ if (!element) throw new Error(`swipeFrom: element not found: ${JSON.stringify(from)}`)
1790
+ const startX = element.absolutePosition.x + element.layout.width / 2
1791
+ const startY = element.absolutePosition.y + element.layout.height / 2
1792
+ const distance = 180
1793
+ let endX = startX
1794
+ let endY = startY
1795
+ switch (direction.toUpperCase()) {
1796
+ case 'UP':
1797
+ endY -= distance
1798
+ break
1799
+ case 'DOWN':
1800
+ endY += distance
1801
+ break
1802
+ case 'LEFT':
1803
+ endX -= distance
1804
+ break
1805
+ case 'RIGHT':
1806
+ endX += distance
1807
+ break
1808
+ }
1809
+ const steps = Math.max(10, Math.round(duration / 16))
1810
+ await this.drag(startX, startY, endX, endY, steps, 16)
1811
+ }
1812
+
1813
+ async scrollTo(target: string | { nodeId: number }, x: number, y: number) {
1814
+ const result = await this.callTest<{ ok: boolean; reason?: string }>(
1815
+ 'scrollTo',
1816
+ target,
1817
+ x,
1818
+ y,
1819
+ false,
1820
+ )
1821
+ if (!result?.ok) {
1822
+ throw new Error(`scrollTo failed: ${result?.reason || 'unknown error'}`)
1823
+ }
1824
+ await sleep(250)
1825
+ }
1826
+
1827
+ async pinch(opts: {
1828
+ from: [number, number, number, number]
1829
+ to: [number, number, number, number]
1830
+ steps?: number
1831
+ stepMs?: number
1832
+ }) {
1833
+ const result = await this.perform([
1834
+ {
1835
+ type: 'pinch',
1836
+ fromX1: opts.from[0],
1837
+ fromY1: opts.from[1],
1838
+ fromX2: opts.from[2],
1839
+ fromY2: opts.from[3],
1840
+ toX1: opts.to[0],
1841
+ toY1: opts.to[1],
1842
+ toX2: opts.to[2],
1843
+ toY2: opts.to[3],
1844
+ steps: opts.steps || 12,
1845
+ stepMs: opts.stepMs || 16,
1846
+ },
1847
+ ])
1848
+ if (!result?.ok) {
1849
+ throw new Error(`pinch failed: ${result?.error || 'unknown error'}`)
1850
+ }
1851
+ await sleep(250)
1852
+ }
1853
+
1854
+ async dumpTree(depth = 6) {
1855
+ const tree = await this.bridge.send({
1856
+ type: 'tree',
1857
+ simId: this.opts.simId,
1858
+ depth,
1859
+ })
1860
+ console.log('[flow] tree:')
1861
+ console.log(typeof tree === 'string' ? tree : JSON.stringify(tree, null, 2))
1862
+ return typeof tree === 'string' ? tree : JSON.stringify(tree)
1863
+ }
1864
+
1865
+ async assertTreeContains(value: string) {
1866
+ const tree = await this.dumpTree(8)
1867
+ if (!tree.includes(value)) {
1868
+ throw new Error(`assertTreeContains: "${value}" not in tree`)
1869
+ }
1870
+ }
1871
+
1872
+ async waitForAnimationToEnd(timeoutMs: number = 2000) {
1873
+ const maxMs = Math.max(0, Math.round(timeoutMs))
1874
+ if (maxMs <= 0) return
1875
+ const result = await waitForSootsimIdle({
1876
+ bridge: this.bridge,
1877
+ simId: this.opts.simId,
1878
+ maxMs,
1879
+ pollMs: 32,
1880
+ stablePolls: 2,
1881
+ strict: true,
1882
+ })
1883
+ if (result.settled) this.markVisualSettled()
1884
+ }
1885
+
1886
+ async back() {
1887
+ try {
1888
+ await this.tapOn('‹')
1889
+ } catch {
1890
+ await this.tapOn('<')
1891
+ }
1892
+ }
1893
+
1894
+ async scrollUntilVisible(opts: {
1895
+ element: string
1896
+ centerElement?: boolean
1897
+ direction?: string
1898
+ timeout?: number
1899
+ }) {
1900
+ const deadline = Date.now() + (opts.timeout || 15000)
1901
+ const direction = opts.direction?.toUpperCase() || 'DOWN'
1902
+ const swipeDirection =
1903
+ direction === 'DOWN' ? 'UP' : direction === 'UP' ? 'DOWN' : direction
1904
+
1905
+ while (Date.now() < deadline) {
1906
+ const element = await this.findElement({ text: opts.element })
1907
+ if (
1908
+ element &&
1909
+ element.absolutePosition.y >= 0 &&
1910
+ element.absolutePosition.y + element.layout.height > 0 &&
1911
+ element.absolutePosition.y < SCREEN_H - 50
1912
+ ) {
1913
+ if (opts.centerElement) {
1914
+ const targetY = SCREEN_H / 2
1915
+ const diff = element.absolutePosition.y - targetY
1916
+ if (Math.abs(diff) > 100) {
1917
+ const distance = Math.min(150, Math.abs(diff) * 0.5)
1918
+ const endY = diff > 0 ? targetY - distance : targetY + distance
1919
+ await this.drag(SCREEN_W / 2, targetY, SCREEN_W / 2, endY, 10, 16)
1920
+ }
1921
+ }
1922
+ return
1923
+ }
1924
+ await this.swipe(swipeDirection, 250)
1925
+ await sleep(500)
1926
+ }
1927
+
1928
+ throw new Error(`scrollUntilVisible: "${opts.element}" not found`)
1929
+ }
1930
+
1931
+ async extendedWaitUntil(opts: {
1932
+ visible?: string | { id?: string; text?: string }
1933
+ notVisible?: string | { id?: string; text?: string }
1934
+ timeout?: number
1935
+ }) {
1936
+ const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
1937
+ while (Date.now() < deadline) {
1938
+ const visibleOkay = opts.visible
1939
+ ? await this.isElementVisible(
1940
+ typeof opts.visible === 'string' ? { text: opts.visible } : opts.visible,
1941
+ )
1942
+ : true
1943
+ const notVisibleOkay = opts.notVisible
1944
+ ? !(await this.isElementVisible(
1945
+ typeof opts.notVisible === 'string'
1946
+ ? { text: opts.notVisible }
1947
+ : opts.notVisible,
1948
+ ))
1949
+ : true
1950
+ if (visibleOkay && notVisibleOkay) return
1951
+ await sleep(200)
1952
+ }
1953
+ throw new Error('extendedWaitUntil timed out')
1954
+ }
1955
+
1956
+ private async reloadGuestApp() {
1957
+ // hard page reload resets the tenant worker.
1958
+ await this.evaluate('window.location.reload()').catch(() => {})
1959
+ // the sim id rotates on a page reload; pick up the new primary before any
1960
+ // further command, or waitForTree loops against a dead id.
1961
+ await sleep(500)
1962
+ await this.refreshSimId()
1963
+ }
1964
+
1965
+ private async resetGuestAppData() {
1966
+ const reset: ResetResult = await this.bridge.send(
1967
+ {
1968
+ type: 'reset',
1969
+ simId: this.opts.simId,
1970
+ resetOptions: { strategy: 'data' },
1971
+ },
1972
+ { timeoutMs: 120000 },
1973
+ )
1974
+ if (!reset.ok) {
1975
+ throw new Error(reset.error ?? 'guest app data reset failed')
1976
+ }
1977
+ if (!reset.relaunched) {
1978
+ throw new Error('guest app data reset did not relaunch the app')
1979
+ }
1980
+ if (reset.workerReloaded !== true) {
1981
+ throw new Error('guest app data reset did not replace the tenant worker')
1982
+ }
1983
+ }
1984
+
1985
+ private async softReloadGuestApp(resetStorage: boolean) {
1986
+ const reloaded = await this.evaluate<boolean>(
1987
+ resetGuestAppStateEval(resetStorage),
1988
+ 120000,
1989
+ )
1990
+ if (!reloaded) {
1991
+ throw new Error('guest app soft reload bridge unavailable')
1992
+ }
1993
+ }
1994
+
1995
+ private async rearmCaptureAfterRuntimeReload() {
1996
+ if (!this.recordingEnabled && !this.profilingEnabled) return
1997
+ const deadline = Date.now() + 10000
1998
+ let lastError: unknown = null
1999
+ while (Date.now() < deadline) {
2000
+ await this.refreshSimId()
2001
+ try {
2002
+ if (this.recordingEnabled) await this.ensureRecordingStarted()
2003
+ if (this.profilingEnabled) await this.startProfile()
2004
+ return
2005
+ } catch (err) {
2006
+ lastError = err
2007
+ await sleep(250)
2008
+ }
2009
+ }
2010
+ throw new Error(
2011
+ `capture re-arm failed after app reload: ${
2012
+ lastError instanceof Error ? lastError.message : String(lastError)
2013
+ }`,
2014
+ )
2015
+ }
2016
+
2017
+ async launchApp(opts: any) {
2018
+ await this.refreshSimId()
2019
+ if (opts && typeof opts === 'object' && opts.arguments) {
2020
+ // real maestro passes process launch arguments to the native app.
2021
+ // sootsim has no native process — the bundle boots in-page — so
2022
+ // launch arguments have nothing to attach to. say so instead of
2023
+ // silently dropping them.
2024
+ console.log('[flow] launchApp.arguments ignored (rnx has no native process)')
2025
+ }
2026
+ const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
2027
+ const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
2028
+
2029
+ if (!this.firstLaunchDone && !wantsClear) {
2030
+ // First launch in the flow, no clear: if the shell is on home, mirror
2031
+ // Maestro's "launch the app" boundary through the shell. Otherwise the
2032
+ // guest app is already booting from the initial sim open, so just wait.
2033
+ this.firstLaunchDone = true
2034
+ if (wantsResetRuntime) {
2035
+ // SootSim benchmark flows need app auth/storage kept warm while the
2036
+ // guest React runtime starts fresh inside the timed/recorded boundary.
2037
+ // the reload re-runs the app entry inside the mounted app surface, so
2038
+ // it only does anything while the shell is showing the app. issued from
2039
+ // home it acks, blanks the surface, and never re-mounts a root
2040
+ // component, so launch first and let a flow that starts at home reset
2041
+ // the same runtime a flow that starts in-app does. a surface we just
2042
+ // launched still has to finish mounting before the reset, since
2043
+ // resetting mid-mount drops the module cache while that first load is
2044
+ // in flight; an already-foregrounded app is past that point and a
2045
+ // second readiness wait would only pad the measured reset.
2046
+ const launched = await this.launchShellAppFromHomeIfNeeded(opts)
2047
+ if (launched) await this.waitForTree(120000)
2048
+ await this.softReloadGuestApp(false)
2049
+ await this.waitForTree(120000)
2050
+ } else {
2051
+ await this.launchShellAppFromHomeIfNeeded(opts)
2052
+ }
2053
+ await this.waitForTree(30000)
2054
+ } else {
2055
+ this.firstLaunchDone = true
2056
+ if (wantsClear) {
2057
+ await this.resetGuestAppData()
2058
+ } else {
2059
+ await this.reloadGuestApp()
2060
+ }
2061
+ await this.waitForTree(120000)
2062
+ if (this.opts.onAfterLaunch) {
2063
+ try {
2064
+ await this.opts.onAfterLaunch()
2065
+ } catch (err) {
2066
+ // don't fail the whole flow if a post-launch hook throws —
2067
+ // surfaces as a warn at most, the run keeps going.
2068
+ console.warn(
2069
+ ` warn: onAfterLaunch hook threw: ${err instanceof Error ? err.message : err}`,
2070
+ )
2071
+ }
2072
+ }
2073
+ }
2074
+
2075
+ if (this.profilingEnabled) {
2076
+ await this.startProfile()
2077
+ }
2078
+ if (this.recordingEnabled) {
2079
+ await this.waitForVisualSettle()
2080
+ await this.ensureRecordingStarted()
2081
+ }
2082
+ }
2083
+
2084
+ private async waitForVisualSettle() {
2085
+ try {
2086
+ await this.evaluate(`new Promise((resolve) => {
2087
+ requestAnimationFrame(() => {
2088
+ requestAnimationFrame(() => {
2089
+ requestAnimationFrame(() => {
2090
+ setTimeout(resolve, 180)
2091
+ })
2092
+ })
2093
+ })
2094
+ })`)
2095
+ this.markVisualSettled()
2096
+ } catch {
2097
+ await sleep(180)
2098
+ this.markVisualSettled()
2099
+ }
2100
+ }
2101
+
2102
+ private markVisualSettled() {
2103
+ this.lastVisualSettledAtMs = Date.now()
2104
+ }
2105
+
2106
+ private hasFreshVisualSettle() {
2107
+ return Date.now() - this.lastVisualSettledAtMs < 750
2108
+ }
2109
+
2110
+ private resolveShellLaunchAppId(opts: unknown, state: ShellState | null): string {
2111
+ if (typeof opts === 'string' && opts.length > 0) return opts
2112
+ if (isObjectRecord(opts)) {
2113
+ const explicit = readStringKey(opts, 'appId') || readStringKey(opts, 'id')
2114
+ if (explicit) return explicit
2115
+ }
2116
+ return readRecentShellAppId(state) || 'connect'
2117
+ }
2118
+
2119
+ private async waitForShellAppLaunched(appId: string, timeoutMs: number) {
2120
+ const deadline = Date.now() + timeoutMs
2121
+ let lastState: ShellState | null = null
2122
+ while (Date.now() < deadline) {
2123
+ try {
2124
+ lastState = await getShellState(this.bridge)
2125
+ } catch {
2126
+ lastState = null
2127
+ }
2128
+ if (
2129
+ lastState?.state === 'app' &&
2130
+ lastState.activeApp === appId &&
2131
+ lastState.showSwitcher === false &&
2132
+ typeof lastState.launchProgress === 'number' &&
2133
+ lastState.launchProgress >= 0.98
2134
+ ) {
2135
+ return
2136
+ }
2137
+ await sleep(16)
2138
+ }
2139
+ }
2140
+
2141
+ private async launchShellAppFromHomeIfNeeded(opts: unknown): Promise<boolean> {
2142
+ let state: ShellState | null = null
2143
+ try {
2144
+ state = await getShellState(this.bridge)
2145
+ } catch {
2146
+ return false
2147
+ }
2148
+ if (state?.state !== 'home' || state.activeApp != null) return false
2149
+
2150
+ const appId = this.resolveShellLaunchAppId(opts, state)
2151
+ if (this.recordingEnabled) {
2152
+ await this.ensureRecordingStarted()
2153
+ }
2154
+ await callShellCommandWhenReady(this.bridge, 'launchApp', 1000, appId)
2155
+ await this.waitForShellAppLaunched(appId, 1000)
2156
+ return true
2157
+ }
2158
+
2159
+ async startProfile() {
2160
+ const result = await this.evaluate<any>(`(() => {
2161
+ if (!window.__sootsimShellPerf) {
2162
+ return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" }
2163
+ }
2164
+ window.__sootsimShellPerf.start()
2165
+ return { started: true }
2166
+ })()`)
2167
+ if (result?.error) {
2168
+ throw new Error(result.error)
2169
+ }
2170
+ this.profilingEnabled = true
2171
+ }
2172
+
2173
+ async stopProfile(): Promise<SootSimFlowProfileResult> {
2174
+ const result = await this.evaluate<any>(`(async () => {
2175
+ if (!window.__sootsimShellPerf) {
2176
+ return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" }
2177
+ }
2178
+ return await window.__sootsimShellPerf.stop()
2179
+ })()`)
2180
+
2181
+ this.profilingEnabled = false
2182
+
2183
+ if (result?.error) {
2184
+ throw new Error(result.error)
2185
+ }
2186
+
2187
+ return result as SootSimFlowProfileResult
2188
+ }
2189
+
2190
+ // maestro stopApp: tear down the current app runtime without clearing app
2191
+ // data. sootsim has no separate native process, so a hard page reload is the
2192
+ // nearest equivalent to killing and later re-launching the tenant runtime.
2193
+ // State clearing belongs to clearState / launchApp({ clearState: true }).
2194
+ async stopApp() {
2195
+ await this.reloadGuestApp()
2196
+ await this.rearmCaptureAfterRuntimeReload()
2197
+ }
2198
+
2199
+ // maestro clearState: wipe app-owned storage and relaunch fresh.
2200
+ async clearState() {
2201
+ await this.resetGuestAppData()
2202
+ await this.rearmCaptureAfterRuntimeReload()
2203
+ }
2204
+
2205
+ // maestro clearKeychain: there is no real keychain in sootsim's browser
2206
+ // sandbox. log loudly so the author notices, then continue — matching the
2207
+ // "loud warning, not silent skip" policy in the plan.
2208
+ clearKeychain() {
2209
+ console.warn('[flow] clearKeychain: rnx has no keychain surface — no-op (warning)')
2210
+ }
2211
+
2212
+ // maestro copyTextFrom: find an element and capture its text for use in
2213
+ // later steps via ${maestro.copiedText} (upstream form) or the legacy
2214
+ // ${maestroCopiedText}. the matcher shape matches tapOn.
2215
+ async copyTextFrom(target: string | { id?: string; text?: string }) {
2216
+ const opts = typeof target === 'string' ? { id: target } : target
2217
+ const node = await this.findElement(opts)
2218
+ if (!node) {
2219
+ throw new Error(`copyTextFrom: element not found: ${JSON.stringify(opts)}`)
2220
+ }
2221
+ const captured =
2222
+ (node as { text?: string | null }).text ??
2223
+ (node as { testID?: string | null }).testID ??
2224
+ ''
2225
+ this.js.setCopiedText(captured)
2226
+ this.js.putEnv('maestroCopiedText', captured)
2227
+ console.log(`[flow] copied text: ${JSON.stringify(captured)}`)
2228
+ }
2229
+
2230
+ // maestro evalScript: run JS in the flow's shared HOST-side context (the
2231
+ // same engine that owns `output` and env vars) — never in the app page.
2232
+ // the canonical maestro form is `evalScript: ${output.x = 1}`, which is
2233
+ // pure template evaluation. a script with no `${}` at all would be a
2234
+ // silent no-op upstream; we charitably evaluate it as raw JS in the same
2235
+ // context so plain-JS evalScript blocks fail or work loudly.
2236
+ evalScript(code: string) {
2237
+ const hasTemplate = /(?<!\\)\$\{[^$]*\}/.test(code)
2238
+ const result = hasTemplate
2239
+ ? this.js.evaluateStringTemplate(code)
2240
+ : this.js.evaluate(code)
2241
+ if (result !== undefined && result !== '') {
2242
+ console.log(`[flow] evalScript ->`, result)
2243
+ }
2244
+ }
2245
+
2246
+ // maestro runScript: execute a JS file in the shared flow context. env
2247
+ // vars are scoped to the script (upstream runInSubScope=true). paths
2248
+ // resolve relative to the flow file, like runFlow.
2249
+ runScript(target: NonNullable<MaestroStep['runScript']>) {
2250
+ const spec = typeof target === 'string' ? { file: target } : target
2251
+ if (!spec.file) throw new Error('runScript requires a file')
2252
+ const scriptPath = path.resolve(this.opts.flowDir, spec.file)
2253
+ const source = fs.readFileSync(scriptPath, 'utf8')
2254
+ this.js.evaluate(source, { env: spec.env })
2255
+ console.log(`[flow] runScript: ${spec.file} done`)
2256
+ }
2257
+
2258
+ // maestro openLink: on-device maestro asks the OS to open the link, and
2259
+ // iOS routes an app-claimed URL into the running app as a Linking 'url'
2260
+ // event. sootsim mirrors that: dispatch through the engine's Linking
2261
+ // emulation so react-navigation useLinking / addEventListener('url')
2262
+ // handlers fire exactly like on a real device. an app that doesn't
2263
+ // handle the link simply ignores it (same as iOS routing to Safari —
2264
+ // the app under test sees nothing).
2265
+ async openLink(target: string | { link: string }) {
2266
+ const link = typeof target === 'string' ? target : target.link
2267
+ if (!link) throw new Error('openLink: missing link')
2268
+ const result = await this.callTest<{ ok: boolean; error?: string }>(
2269
+ 'openDeepLink',
2270
+ link,
2271
+ )
2272
+ if (!result?.ok) {
2273
+ throw new Error(`openLink failed: ${result?.error || 'no test bridge'}`)
2274
+ }
2275
+ await sleep(300)
2276
+ }
2277
+
2278
+ // evaluate a `when:` predicate. returns true if the step should run.
2279
+ private async evaluateWhen(when: NonNullable<MaestroStep['when']>): Promise<boolean> {
2280
+ if (when.visible !== undefined) {
2281
+ const opts =
2282
+ typeof when.visible === 'string' ? { text: when.visible } : when.visible
2283
+ const el = await this.findElement(opts)
2284
+ return !!el
2285
+ }
2286
+ if (when.notVisible !== undefined) {
2287
+ const opts =
2288
+ typeof when.notVisible === 'string' ? { text: when.notVisible } : when.notVisible
2289
+ const el = await this.findElement(opts)
2290
+ return !el
2291
+ }
2292
+ if (when.platform !== undefined) {
2293
+ // sootsim emulates iOS — match against the context's platform the way
2294
+ // upstream compares against cachedDeviceInfo.platform. keeps
2295
+ // cross-platform maestro flows running without stripping guards.
2296
+ return when.platform.toLowerCase() === this.js.maestro.platform.toLowerCase()
2297
+ }
2298
+ if (when.true !== undefined) {
2299
+ // the `true:` script string was template-evaluated with the step;
2300
+ // apply upstream's falsy rules (blank/false/undefined/null/0).
2301
+ return scriptConditionIsTruthy(when.true)
2302
+ }
2303
+ return true
2304
+ }
2305
+
2306
+ private isOptional(step: MaestroStep): boolean {
2307
+ // maestro supports `optional: true` either on the matcher sub-object or
2308
+ // (in some dialects) as a top-level sibling of the verb. accept both.
2309
+ const topLevelOptional = (step as Record<string, unknown>).optional === true
2310
+ if (topLevelOptional) return true
2311
+ const entry = Object.entries(step).find(([k]) => k !== 'when' && k !== 'optional')
2312
+ const value = entry ? entry[1] : undefined
2313
+ return !!(
2314
+ value &&
2315
+ typeof value === 'object' &&
2316
+ 'optional' in (value as object) &&
2317
+ (value as { optional?: boolean }).optional
2318
+ )
2319
+ }
2320
+
2321
+ async runStep(rawStepInput: MaestroStep | string, stepIndex: number) {
2322
+ // maestro allows several commands in BARE-STRING form (`- back`,
2323
+ // `- hideKeyboard`, `- waitForAnimationToEnd`, `- scrollUp`, …). yaml parses
2324
+ // those to a plain string, so normalize to the `{ verb: true }` object form
2325
+ // the dispatcher expects. without this, `Object.keys("back")` is `['0',…]`
2326
+ // and the step falls through to "unsupported flow step".
2327
+ const rawStep: MaestroStep =
2328
+ typeof rawStepInput === 'string'
2329
+ ? normalizeBareStringStep(rawStepInput)
2330
+ : rawStepInput
2331
+ const stepName =
2332
+ Object.keys(rawStep).find((k) => k !== 'when') || Object.keys(rawStep)[0]
2333
+ console.log(`[flow] step ${stepIndex + 1}: ${stepName}`)
2334
+ const startedAtMs = Date.now()
2335
+ let targetLabel = this.flowStepTargetLabel(rawStep, stepName)
2336
+
2337
+ let step: MaestroStep
2338
+ try {
2339
+ // `${...}` templates resolve here, at execution time (like maestro's
2340
+ // per-command evaluateScripts) — values produced by earlier steps
2341
+ // (runScript output.*, copyTextFrom) are visible. nested command
2342
+ // lists are left verbatim; they interpolate when they run.
2343
+ step = this.js.interpolateStep(rawStep)
2344
+ targetLabel = this.flowStepTargetLabel(step, stepName) ?? targetLabel
2345
+ } catch (error) {
2346
+ if (this.isOptional(rawStep)) {
2347
+ console.log(
2348
+ `[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`,
2349
+ )
2350
+ this.recordFlowTraceStep({
2351
+ stepIndex,
2352
+ stepName,
2353
+ targetLabel,
2354
+ startedAtMs,
2355
+ status: 'skipped',
2356
+ error,
2357
+ })
2358
+ return
2359
+ }
2360
+ this.lastFailedStep = {
2361
+ index: stepIndex,
2362
+ kind: stepName,
2363
+ target: (rawStep as Record<string, unknown>)[stepName],
2364
+ }
2365
+ this.recordFlowTraceStep({
2366
+ stepIndex,
2367
+ stepName,
2368
+ targetLabel,
2369
+ startedAtMs,
2370
+ status: 'failure',
2371
+ error,
2372
+ })
2373
+ throw error
2374
+ }
2375
+
2376
+ // maestro `when:` gate — evaluate before running anything. if the
2377
+ // predicate fails, log and continue to the next step.
2378
+ if (step.when) {
2379
+ const shouldRun = await this.evaluateWhen(step.when)
2380
+ if (!shouldRun) {
2381
+ console.log(`[flow] (when: predicate false, skipped)`)
2382
+ this.recordFlowTraceStep({
2383
+ stepIndex,
2384
+ stepName,
2385
+ targetLabel,
2386
+ startedAtMs,
2387
+ status: 'skipped',
2388
+ })
2389
+ return
2390
+ }
2391
+ }
2392
+
2393
+ try {
2394
+ // per-step watchdog: abort if nothing forward-progresses for 10s.
2395
+ // catches dead bundles / frozen bridges / hung ws calls. explicit
2396
+ // wait steps (extendedWaitUntil, waitFor) opt out and keep their
2397
+ // own timeout — the user asked for that time; don't override.
2398
+ const hasExplicitWait = !!(
2399
+ step.extendedWaitUntil ||
2400
+ step.waitFor ||
2401
+ step.scrollUntilVisible ||
2402
+ step.launchApp ||
2403
+ step.runFlow ||
2404
+ // runScript http calls are synchronous with a 5-minute upstream
2405
+ // timeout — the 10s watchdog would kill legitimate setup scripts.
2406
+ step.runScript
2407
+ )
2408
+ const STEP_WATCHDOG_MS = 10_000
2409
+ const body = this.runStepInner(step)
2410
+ let screenshotPath: string | undefined
2411
+ if (hasExplicitWait) {
2412
+ const result = await body
2413
+ if (typeof result === 'string') screenshotPath = result
2414
+ } else {
2415
+ let timer: ReturnType<typeof setTimeout> | null = null
2416
+ const watchdog = new Promise<never>((_, reject) => {
2417
+ timer = setTimeout(() => {
2418
+ reject(
2419
+ new Error(
2420
+ `step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`,
2421
+ ),
2422
+ )
2423
+ }, STEP_WATCHDOG_MS)
2424
+ })
2425
+ try {
2426
+ const result = await Promise.race([body, watchdog])
2427
+ if (typeof result === 'string') screenshotPath = result
2428
+ } finally {
2429
+ if (timer) clearTimeout(timer)
2430
+ }
2431
+ }
2432
+ const isAssertion = !!(
2433
+ step.assertVisible ||
2434
+ step.assertNotVisible ||
2435
+ step.extendedWaitUntil ||
2436
+ step.waitFor
2437
+ )
2438
+ if (this.stepDelay > 0 && !isAssertion) {
2439
+ await sleep(this.stepDelay)
2440
+ }
2441
+ this.recordFlowTraceStep({
2442
+ stepIndex,
2443
+ stepName,
2444
+ targetLabel,
2445
+ startedAtMs,
2446
+ status: 'success',
2447
+ screenshotPath,
2448
+ })
2449
+ } catch (error) {
2450
+ if (this.isOptional(step)) {
2451
+ console.log(
2452
+ `[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`,
2453
+ )
2454
+ this.recordFlowTraceStep({
2455
+ stepIndex,
2456
+ stepName,
2457
+ targetLabel,
2458
+ startedAtMs,
2459
+ status: 'skipped',
2460
+ error,
2461
+ })
2462
+ return
2463
+ }
2464
+ this.lastFailedStep = {
2465
+ index: stepIndex,
2466
+ kind: stepName,
2467
+ target: (step as Record<string, unknown>)[stepName],
2468
+ }
2469
+ this.recordFlowTraceStep({
2470
+ stepIndex,
2471
+ stepName,
2472
+ targetLabel,
2473
+ startedAtMs,
2474
+ status: 'failure',
2475
+ error,
2476
+ })
2477
+ throw error
2478
+ }
2479
+ }
2480
+
2481
+ private async runStepInner(step: MaestroStep) {
2482
+ if (step.tapOn) await this.tapOn(step.tapOn)
2483
+ else if (step.longPressOn) await this.longPressOn(step.longPressOn)
2484
+ else if (step.scrollUntilVisible)
2485
+ await this.scrollUntilVisible(step.scrollUntilVisible)
2486
+ else if (step.extendedWaitUntil) await this.extendedWaitUntil(step.extendedWaitUntil)
2487
+ else if (step.assertVisible) await this.assertVisible(step.assertVisible)
2488
+ else if (step.assertNotVisible) await this.assertNotVisible(step.assertNotVisible)
2489
+ else if (step.inputText) await this.inputText(step.inputText)
2490
+ else if (step.pressKey) await this.pressKey(step.pressKey)
2491
+ else if (step.dispatchKey) await this.dispatchKey(step.dispatchKey)
2492
+ else if (step.waitFor) await this.waitFor(step.waitFor)
2493
+ else if (step.takeScreenshot) return this.takeScreenshot(step.takeScreenshot)
2494
+ else if (step.swipe) {
2495
+ if (step.swipe.start && step.swipe.end) {
2496
+ await this.swipeCoords(step.swipe.start, step.swipe.end, step.swipe.duration)
2497
+ } else if (step.swipe.from) {
2498
+ await this.swipeFrom(step.swipe.from, step.swipe.direction, step.swipe.duration)
2499
+ } else {
2500
+ await this.swipe(step.swipe.direction, step.swipe.duration)
2501
+ }
2502
+ } else if (step.scroll) {
2503
+ await this.swipe(step.scroll.direction === 'DOWN' ? 'UP' : 'DOWN')
2504
+ } else if (step.scrollTo) {
2505
+ const target =
2506
+ typeof step.scrollTo.nodeId === 'number'
2507
+ ? { nodeId: step.scrollTo.nodeId }
2508
+ : step.scrollTo.id
2509
+ if (!target) throw new Error('scrollTo requires id or nodeId')
2510
+ await this.scrollTo(target, step.scrollTo.x, step.scrollTo.y)
2511
+ } else if (step.pinch) {
2512
+ await this.pinch(step.pinch)
2513
+ } else if (step.waitForAnimationToEnd) {
2514
+ const waitSpec = step.waitForAnimationToEnd
2515
+ await this.waitForAnimationToEnd(
2516
+ typeof waitSpec === 'number'
2517
+ ? waitSpec
2518
+ : typeof waitSpec === 'object' && typeof waitSpec.timeout === 'number'
2519
+ ? waitSpec.timeout
2520
+ : 2000,
2521
+ )
2522
+ } else if (step.back) {
2523
+ await this.back()
2524
+ } else if (step.hideKeyboard) {
2525
+ await this.hideKeyboard()
2526
+ } else if (step.launchApp) {
2527
+ await this.launchApp(step.launchApp)
2528
+ } else if (typeof step.wait === 'number') {
2529
+ await sleep(step.wait)
2530
+ } else if (step.dumpTree) {
2531
+ await this.dumpTree(step.dumpTree)
2532
+ } else if (step.tapAtCoords) {
2533
+ await this.tapAtCoords(step.tapAtCoords.x, step.tapAtCoords.y)
2534
+ } else if (step.doubleTapAtCoords) {
2535
+ await this.doubleTapAtCoords(
2536
+ step.doubleTapAtCoords.x,
2537
+ step.doubleTapAtCoords.y,
2538
+ step.doubleTapAtCoords.gapMs,
2539
+ )
2540
+ } else if (step.assertTreeContains) {
2541
+ await this.assertTreeContains(step.assertTreeContains)
2542
+ } else if (typeof step.eraseText === 'number') {
2543
+ await this.eraseText(step.eraseText)
2544
+ } else if (step.repeat && Array.isArray(step.repeat.commands)) {
2545
+ // align with upstream Orchestra.repeatCommand: `times` is OPTIONAL (when
2546
+ // absent → Int.MAX_VALUE, i.e. bounded only by the condition), and a
2547
+ // `when`/`condition` predicate runs the body `while (cond && i < maxRuns)`.
2548
+ // `times` may arrive as a `${...}`-interpolated string. the `commands`
2549
+ // guard distinguishes this command-repeat from `tapOn: { repeat: N }`,
2550
+ // which is a tap-count and is handled inside tapOn, not here.
2551
+ const rawTimes = step.repeat.times
2552
+ const maxRuns =
2553
+ rawTimes === undefined || rawTimes === null || rawTimes === ''
2554
+ ? Number.MAX_SAFE_INTEGER
2555
+ : Number(rawTimes)
2556
+ if (!Number.isFinite(maxRuns)) {
2557
+ throw new Error(`repeat.times is not a number: ${rawTimes}`)
2558
+ }
2559
+ const condition = step.repeat.when ?? step.repeat.condition
2560
+ const checkCondition = async (): Promise<boolean> => {
2561
+ if (!condition) return true
2562
+ return this.evaluateWhen(condition)
2563
+ }
2564
+ // upstream guards an unbounded `repeat` (no times, no condition that ever
2565
+ // goes false) only via the flow timeout; cap a condition-less infinite
2566
+ // repeat so a malformed flow can't hang the runner forever.
2567
+ const hardCap = condition ? maxRuns : Math.min(maxRuns, 1000)
2568
+ let i = 0
2569
+ while (i < hardCap && (await checkCondition())) {
2570
+ for (let j = 0; j < step.repeat.commands.length; j++) {
2571
+ // re-interpolate per iteration (upstream resetCommand + evaluateScripts)
2572
+ await this.runStep(step.repeat.commands[j], j)
2573
+ }
2574
+ i++
2575
+ }
2576
+ } else if (step.stopApp !== undefined) {
2577
+ await this.stopApp()
2578
+ } else if (step.clearState !== undefined) {
2579
+ await this.clearState()
2580
+ } else if (step.clearKeychain !== undefined) {
2581
+ this.clearKeychain()
2582
+ } else if (step.copyTextFrom !== undefined) {
2583
+ await this.copyTextFrom(step.copyTextFrom)
2584
+ } else if (typeof step.evalScript === 'string') {
2585
+ this.evalScript(step.evalScript)
2586
+ } else if (step.runScript) {
2587
+ const rs = step.runScript
2588
+ const innerWhen = typeof rs === 'string' ? undefined : rs.when
2589
+ if (innerWhen && !(await this.evaluateWhen(innerWhen))) {
2590
+ console.log(`[flow] runScript skipped (when predicate false)`)
2591
+ return
2592
+ }
2593
+ this.runScript(rs)
2594
+ } else if (step.openLink !== undefined) {
2595
+ await this.openLink(step.openLink)
2596
+ } else if (step.runFlow) {
2597
+ const rf = step.runFlow
2598
+ const file = typeof rf === 'string' ? rf : rf.file
2599
+ const commands = typeof rf === 'string' ? undefined : rf.commands
2600
+ const env = typeof rf === 'string' ? undefined : rf.env
2601
+ const innerWhen = typeof rf === 'string' ? undefined : rf.when
2602
+ if (innerWhen) {
2603
+ const shouldRun = await this.evaluateWhen(innerWhen)
2604
+ if (!shouldRun) {
2605
+ console.log(
2606
+ `[flow] runFlow skipped (when predicate false): ${file ?? 'inline commands'}`,
2607
+ )
2608
+ return
2609
+ }
2610
+ }
2611
+ if (!file && !commands) {
2612
+ throw new Error('runFlow requires either file or commands')
2613
+ }
2614
+ // env vars (and any putEnv inside) are scoped to the sub-flow, like
2615
+ // upstream's enterEnvScope/leaveEnvScope around runFlowCommand.
2616
+ this.js.enterEnvScope()
2617
+ try {
2618
+ if (env) {
2619
+ for (const [k, v] of Object.entries(env)) this.js.putEnv(k, v)
2620
+ }
2621
+ if (file) {
2622
+ const flowPath = path.resolve(this.opts.flowDir, file)
2623
+ const flowContent = fs.readFileSync(flowPath, 'utf8')
2624
+ const previousDir = this.opts.flowDir
2625
+ ;(this.opts as { flowDir: string }).flowDir = path.dirname(flowPath)
2626
+ try {
2627
+ await this.runFlow(parseFlowSteps(flowContent))
2628
+ } finally {
2629
+ ;(this.opts as { flowDir: string }).flowDir = previousDir
2630
+ }
2631
+ } else if (commands) {
2632
+ for (let i = 0; i < commands.length; i++) {
2633
+ await this.runStep(commands[i], i)
2634
+ }
2635
+ }
2636
+ } finally {
2637
+ this.js.leaveEnvScope()
2638
+ }
2639
+ } else {
2640
+ throw new Error(`unsupported flow step: ${JSON.stringify(step)}`)
2641
+ }
2642
+ }
2643
+
2644
+ // live progress reporter (shell devtools "test" tab). attached by
2645
+ // runFlowPlayback; absent for programmatic callers.
2646
+ liveStatus: FlowLiveStatusReporter | null = null
2647
+
2648
+ async runFlow(steps: MaestroStep[]) {
2649
+ // maestro `onFlowStart` / `onFlowComplete` lifecycle hooks: if any step
2650
+ // in the array is a sole-key entry whose key is one of these names and
2651
+ // whose value is a list of sub-steps, pull them out and run them as
2652
+ // bookend arrays. this matches how maestro flattens multi-doc YAML.
2653
+ const onStart: MaestroStep[] = []
2654
+ const onComplete: MaestroStep[] = []
2655
+ const body: MaestroStep[] = []
2656
+ for (const step of steps) {
2657
+ const keys = Object.keys(step)
2658
+ if (
2659
+ keys.length === 1 &&
2660
+ keys[0] === 'onFlowStart' &&
2661
+ Array.isArray(step.onFlowStart)
2662
+ ) {
2663
+ onStart.push(...step.onFlowStart)
2664
+ continue
2665
+ }
2666
+ if (
2667
+ keys.length === 1 &&
2668
+ keys[0] === 'onFlowComplete' &&
2669
+ Array.isArray(step.onFlowComplete)
2670
+ ) {
2671
+ onComplete.push(...step.onFlowComplete)
2672
+ continue
2673
+ }
2674
+ body.push(step)
2675
+ }
2676
+
2677
+ if (this.liveStatus) {
2678
+ await this.liveStatus.plan(
2679
+ body.map((rawStep, index) => {
2680
+ // yaml parses bare-string steps (`- back`) as strings; mirror
2681
+ // runStep's normalization so the rail shows the verb, not chars.
2682
+ const step =
2683
+ typeof rawStep === 'string' ? normalizeBareStringStep(rawStep) : rawStep
2684
+ const name = Object.keys(step).find((k) => k !== 'when') ?? Object.keys(step)[0]
2685
+ return { index, name, target: this.flowStepTargetLabel(step, name) }
2686
+ }),
2687
+ )
2688
+ }
2689
+
2690
+ if (onStart.length > 0) {
2691
+ console.log(`[flow] onFlowStart (${onStart.length} steps)`)
2692
+ for (let i = 0; i < onStart.length; i++) {
2693
+ await this.runStep(onStart[i], i)
2694
+ }
2695
+ }
2696
+
2697
+ let caughtError: Error | null = null
2698
+ try {
2699
+ await this.runFlowBody(body)
2700
+ } catch (err) {
2701
+ caughtError = err instanceof Error ? err : new Error(String(err))
2702
+ }
2703
+
2704
+ if (onComplete.length > 0) {
2705
+ console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
2706
+ for (let i = 0; i < onComplete.length; i++) {
2707
+ try {
2708
+ await this.runStep(onComplete[i], i)
2709
+ } catch (hookErr) {
2710
+ console.warn(
2711
+ `[flow] onFlowComplete step ${i + 1} failed: ${
2712
+ hookErr instanceof Error ? hookErr.message : hookErr
2713
+ }`,
2714
+ )
2715
+ }
2716
+ }
2717
+ }
2718
+
2719
+ if (caughtError) throw caughtError
2720
+ }
2721
+
2722
+ private async runFlowBody(steps: MaestroStep[]) {
2723
+ for (let i = 0; i < steps.length; i++) {
2724
+ const step = steps[i]
2725
+ // the ceiling must never fire before the step's own deadline. waitFor and
2726
+ // extendedWaitUntil both scale their declared timeout by
2727
+ // SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too — and it has to
2728
+ // count extendedWaitUntil at all. Missing that, a flow declaring
2729
+ // `extendedWaitUntil: {timeout: 90000}` was killed by the race at 60s while
2730
+ // runStep was still waiting, so runStep never reached its own catch and the
2731
+ // step was never recorded: a failed run whose every recorded step is green.
2732
+ const explicitWait = typeof step.wait === 'number' ? step.wait : 0
2733
+ const waitForTimeout =
2734
+ typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
2735
+ const extendedWaitTimeout =
2736
+ typeof step.extendedWaitUntil?.timeout === 'number'
2737
+ ? step.extendedWaitUntil.timeout
2738
+ : 0
2739
+ const stepCeiling = Math.max(
2740
+ 60_000,
2741
+ explicitWait + 15_000,
2742
+ scaledTimeout(waitForTimeout) + 15_000,
2743
+ scaledTimeout(extendedWaitTimeout) + 15_000,
2744
+ )
2745
+
2746
+ // pause gate + live rail: checked between top-level steps only, so a
2747
+ // devtools pause never interrupts a step mid-gesture.
2748
+ if (this.liveStatus) {
2749
+ await this.liveStatus.waitWhilePaused()
2750
+ await this.liveStatus.step(i, 'running')
2751
+ }
2752
+ const liveStartedAtMs = Date.now()
2753
+ const reportOutcome = async (failed: boolean, error?: unknown) => {
2754
+ if (!this.liveStatus) return
2755
+ // the last trace entry after runStep settles is the top-level step's
2756
+ // own record (nested runFlow/repeat sub-steps record before it), so
2757
+ // its status carries the skipped/success distinction for free.
2758
+ const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
2759
+ const traced = last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs
2760
+ await this.liveStatus.step(
2761
+ i,
2762
+ failed
2763
+ ? 'failure'
2764
+ : traced && last.status === 'skipped'
2765
+ ? 'skipped'
2766
+ : 'success',
2767
+ {
2768
+ durationMs: Date.now() - liveStartedAtMs,
2769
+ error: failed
2770
+ ? error instanceof Error
2771
+ ? error.message.slice(0, 300)
2772
+ : String(error).slice(0, 300)
2773
+ : (traced && last.error) || undefined,
2774
+ },
2775
+ )
2776
+ }
2777
+
2778
+ try {
2779
+ // runFlow runs its own sub-steps (each separately deadlined) and
2780
+ // launchApp self-governs with an internal waitForTree (120s on a
2781
+ // clearState reload) — a cache-cleared reload of a heavy bundle (bluesky)
2782
+ // re-fetches every chunk from metro and routinely needs >60s, so the
2783
+ // outer per-step race would kill a launch that's legitimately still
2784
+ // booting. let both govern their own timing.
2785
+ if (step.runFlow || step.launchApp) {
2786
+ await this.runStep(step, i)
2787
+ } else {
2788
+ await Promise.race([
2789
+ this.runStep(step, i),
2790
+ new Promise<never>((_, reject) =>
2791
+ setTimeout(
2792
+ () =>
2793
+ reject(
2794
+ new Error(
2795
+ `step ${i + 1} (${Object.keys(step)[0]}) exceeded ${stepCeiling}ms deadline`,
2796
+ ),
2797
+ ),
2798
+ stepCeiling,
2799
+ ),
2800
+ ),
2801
+ ])
2802
+ }
2803
+ } catch (error) {
2804
+ // a ceiling hit rejects the race while runStep is still awaiting, so
2805
+ // runStep never records the step itself. record it here so the trace
2806
+ // always names the step that failed.
2807
+ const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
2808
+ if (!(last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs)) {
2809
+ const stepName = Object.keys(step)[0]
2810
+ this.lastFailedStep = {
2811
+ index: i,
2812
+ kind: stepName,
2813
+ target: (step as Record<string, unknown>)[stepName],
2814
+ }
2815
+ this.recordFlowTraceStep({
2816
+ stepIndex: i,
2817
+ stepName,
2818
+ targetLabel: this.flowStepTargetLabel(step, stepName),
2819
+ startedAtMs: liveStartedAtMs,
2820
+ status: 'failure',
2821
+ error,
2822
+ })
2823
+ }
2824
+ await reportOutcome(true, error)
2825
+ throw error
2826
+ }
2827
+ await reportOutcome(false)
2828
+ }
2829
+ }
2830
+ }