peachsim 0.1.527

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