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
package/detox/index.ts ADDED
@@ -0,0 +1,2694 @@
1
+ // detox-compatible test driver for sootsim
2
+ // drop-in replacement for `import { by, device, element, expect, waitFor } from 'detox'`
3
+ // uses playwright to control a headless browser running sootsim
4
+
5
+ import * as fs from 'fs'
6
+ import * as path from 'path'
7
+ import { chromium } from 'playwright'
8
+ import { launchReapedChromeScoped } from '../../../scripts/lib/reap-browser'
9
+ import {
10
+ readBrowserCompositeCaptureRequest,
11
+ resolveBrowserCompositeCdpClip,
12
+ } from '../../sootsim-engine/src/capture/browser-composite'
13
+ import { pollUntil } from '../src/poll-until'
14
+ import {
15
+ createExpect,
16
+ createWaitFor,
17
+ describeNodeVisibility,
18
+ isNodeVisible,
19
+ } from './expectations'
20
+ import {
21
+ dispatchTwoFingerGesture,
22
+ dragScrollNode,
23
+ readSootsimInteractiveViewport,
24
+ readSootsimViewport,
25
+ resolveScrollStartPosition,
26
+ sootsimToPage,
27
+ waitForSootsimGestureHandler,
28
+ } from './gestures'
29
+ import { by, type Matcher } from './matchers'
30
+ import {
31
+ MOTION_CHANGE_DEFINITION,
32
+ measureMotionChange,
33
+ type MotionChangeDefinition,
34
+ type MotionChangeMeasurement,
35
+ } from './motion-change.cjs'
36
+ import { describeSootsimBridgeFailure, navigateSootsimPage } from './navigation-lifecycle'
37
+ import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs'
38
+ import type { SootSimElement } from './element-types'
39
+ import type { Browser, BrowserContext, Page } from 'playwright'
40
+
41
+ export { by }
42
+ export type { SootSimElement } from './element-types'
43
+
44
+ const BASE_URL = process.env.RNX_URL || 'http://localhost:5173'
45
+ const SCREENSHOT_DIR =
46
+ process.env.RNX_SCREENSHOT_DIR ||
47
+ path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
48
+ const DETOX_PLATFORM =
49
+ process.env.RNX_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
50
+
51
+ // shared state -- playwright page and browser
52
+ let _browser: Browser | null = null
53
+ let _reapBrowserNow: (() => void) | null = null
54
+ let _context: BrowserContext | null = null
55
+ let _page: Page | null = null
56
+ let _synchronizationEnabled = false
57
+ let _suspendedAppId: string | null = null
58
+ let _browserCompromised: string | null = null
59
+ let browserIdentity = 0
60
+ let contextIdentity = 0
61
+ let nextBrowserIdentity = 0
62
+ let nextContextIdentity = 0
63
+
64
+ // maximum duration for browser/context lifecycle operations (closeContext,
65
+ // newContext, newPage). playwright does not bound context.close() or context
66
+ // creation; this bound converts an opaque 180s Jest hook timeout into a
67
+ // named failure that identifies which lifecycle step stalled.
68
+ const BROWSER_LIFECYCLE_TIMEOUT_MS = 30_000
69
+
70
+ async function withLifecycleTimeout<T>(
71
+ actionName: string,
72
+ targetContextId: number,
73
+ fn: () => Promise<T>,
74
+ timeoutMs = BROWSER_LIFECYCLE_TIMEOUT_MS,
75
+ ): Promise<T> {
76
+ const start = Date.now()
77
+ let timer: ReturnType<typeof setTimeout> | undefined
78
+ try {
79
+ return await Promise.race([
80
+ fn(),
81
+ new Promise<never>((_, reject) => {
82
+ timer = setTimeout(() => {
83
+ const elapsed = Date.now() - start
84
+ reject(
85
+ new Error(
86
+ `sootsim browser lifecycle error: ${actionName} did not settle after ${elapsed}ms (context=${targetContextId})`,
87
+ ),
88
+ )
89
+ }, timeoutMs)
90
+ }),
91
+ ])
92
+ } finally {
93
+ if (timer) clearTimeout(timer)
94
+ }
95
+ }
96
+
97
+ const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
98
+
99
+ type StatusBarConfig = {
100
+ time?: string
101
+ dataNetwork?: string
102
+ wifiMode?: string
103
+ wifiBars?: string
104
+ cellularMode?: string
105
+ cellularBars?: string
106
+ operatorName?: string
107
+ batteryState?: string
108
+ batteryLevel?: string | number
109
+ }
110
+
111
+ function getPage(): Page {
112
+ if (_browserCompromised) {
113
+ throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
114
+ }
115
+ if (!_page)
116
+ throw new Error('sootsim driver not initialized -- call device.launchApp() first')
117
+ return _page
118
+ }
119
+
120
+ async function waitForSootsimTree(page: Page, timeout = 30000): Promise<void> {
121
+ try {
122
+ await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
123
+ timeout,
124
+ })
125
+ await page.evaluate(async (timeoutMs) => {
126
+ await Promise.race([
127
+ window.__sootsimTest!.waitForTree(),
128
+ new Promise((_, reject) => {
129
+ setTimeout(
130
+ () => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)),
131
+ timeoutMs,
132
+ )
133
+ }),
134
+ ])
135
+ }, timeout)
136
+ } catch (error) {
137
+ throw await describeSootsimBridgeFailure(page, error)
138
+ }
139
+ }
140
+
141
+ async function waitForSootsimSurfaceMetrics(page: Page, timeout = 30000): Promise<void> {
142
+ const deadline = Date.now() + timeout
143
+ let lastSnapshot: unknown = null
144
+ while (Date.now() < deadline) {
145
+ lastSnapshot = await page.evaluate(async () => {
146
+ const metricsWindow = (value: unknown): Record<string, unknown> | null => {
147
+ if (!value || typeof value !== 'object') return null
148
+ const windowValue = (value as Record<string, unknown>).window
149
+ if (!windowValue || typeof windowValue !== 'object') return null
150
+ return windowValue as Record<string, unknown>
151
+ }
152
+ const snapshot = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.()
153
+ const windowMetrics = metricsWindow(snapshot)
154
+ const width = windowMetrics?.width
155
+ const height = windowMetrics?.height
156
+ if (
157
+ typeof width === 'number' &&
158
+ typeof height === 'number' &&
159
+ width > 0 &&
160
+ height > 0
161
+ ) {
162
+ return { ready: true }
163
+ }
164
+ return { ready: false, snapshot }
165
+ })
166
+ if (
167
+ lastSnapshot &&
168
+ typeof lastSnapshot === 'object' &&
169
+ (lastSnapshot as { ready?: boolean }).ready
170
+ ) {
171
+ return
172
+ }
173
+ await page.waitForTimeout(50)
174
+ }
175
+ throw new Error(
176
+ `timed out waiting for sootsim surface metrics: ${JSON.stringify(lastSnapshot)}`,
177
+ )
178
+ }
179
+
180
+ async function waitForSootsimShellReady(page: Page, timeout = 30000): Promise<void> {
181
+ try {
182
+ await page.evaluate(async (timeoutMs) => {
183
+ type ShellHostReadyBridge = {
184
+ ready?: Promise<unknown>
185
+ firstContentPainted?: Promise<unknown>
186
+ }
187
+
188
+ const start = Date.now()
189
+ const deadline = start + timeoutMs
190
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
191
+ const remaining = () => Math.max(1, deadline - Date.now())
192
+ const withTimeout = async <T>(label: string, promise: Promise<T>) => {
193
+ let timer: ReturnType<typeof setTimeout> | null = null
194
+ try {
195
+ await Promise.race([
196
+ promise,
197
+ new Promise((_, reject) => {
198
+ timer = setTimeout(
199
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
200
+ remaining(),
201
+ )
202
+ }),
203
+ ])
204
+ } finally {
205
+ if (timer) clearTimeout(timer)
206
+ }
207
+ }
208
+
209
+ const isTenantOnly =
210
+ new URL(window.location.href).searchParams.get('renderMode') === 'tenant-only'
211
+ const currentUrl = new URL(window.location.href)
212
+ const expectsInitialAppPaint =
213
+ currentUrl.searchParams.has('test') || currentUrl.pathname.includes('/app/')
214
+ let shellHost: ShellHostReadyBridge | null = null
215
+ while (Date.now() < deadline) {
216
+ const candidate = (window as any).SootSim?.bridges?.shellHost
217
+ if (candidate || isTenantOnly) {
218
+ shellHost = candidate ?? null
219
+ break
220
+ }
221
+ await sleep(25)
222
+ }
223
+
224
+ if (!shellHost) {
225
+ if (isTenantOnly) return
226
+ throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`)
227
+ }
228
+
229
+ if (shellHost.ready && typeof shellHost.ready.then === 'function') {
230
+ await withTimeout('sootsim shell ready', shellHost.ready)
231
+ }
232
+ if (
233
+ !expectsInitialAppPaint &&
234
+ shellHost.firstContentPainted &&
235
+ typeof shellHost.firstContentPainted.then === 'function'
236
+ ) {
237
+ await withTimeout(
238
+ 'sootsim shell first content paint',
239
+ shellHost.firstContentPainted,
240
+ )
241
+ }
242
+
243
+ const waitForScreenTransitions = (window as any).__sootsimTest
244
+ ?.waitForScreenTransitions
245
+ if (typeof waitForScreenTransitions === 'function') {
246
+ const transitionResult = await waitForScreenTransitions({
247
+ timeoutMs: Math.min(remaining(), 3000),
248
+ settleMs: 64,
249
+ startWindowMs: 700,
250
+ })
251
+ if (transitionResult?.timedOut === true) {
252
+ throw new Error(
253
+ `sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`,
254
+ )
255
+ }
256
+ }
257
+ }, timeout)
258
+ } catch (error) {
259
+ throw await describeSootsimBridgeFailure(page, error)
260
+ }
261
+ }
262
+
263
+ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<void> {
264
+ const result = await page.evaluate(
265
+ async ({ timeout }) => {
266
+ const getPeerStats = (window as any).__sootsimTest?.getPeerStats
267
+ if (typeof getPeerStats !== 'function') {
268
+ return { settled: true, skipped: true }
269
+ }
270
+
271
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
272
+ const readStats = () =>
273
+ Promise.race([
274
+ getPeerStats(false),
275
+ new Promise((resolve) => setTimeout(() => resolve(null), 250)),
276
+ ])
277
+ const start = Date.now()
278
+ const deadline = start + timeout
279
+ const requiredStableMs = 120
280
+ let stableSince: number | null = null
281
+ let lastPending = 0
282
+ await sleep(0)
283
+
284
+ while (Date.now() < deadline) {
285
+ const stats = await readStats()
286
+ const pending =
287
+ stats &&
288
+ typeof stats === 'object' &&
289
+ typeof (stats as { pendingRunWorklets?: unknown }).pendingRunWorklets ===
290
+ 'number'
291
+ ? stats.pendingRunWorklets
292
+ : 0
293
+ lastPending = pending
294
+ if (pending === 0) {
295
+ stableSince ??= Date.now()
296
+ if (Date.now() - stableSince >= requiredStableMs) {
297
+ return { settled: true, elapsed: Date.now() - start, pending }
298
+ }
299
+ } else {
300
+ stableSince = null
301
+ }
302
+ await sleep(25)
303
+ }
304
+
305
+ return {
306
+ settled: false,
307
+ elapsed: Date.now() - start,
308
+ pending: lastPending,
309
+ }
310
+ },
311
+ { timeout },
312
+ )
313
+ if (!result?.settled) {
314
+ throw new Error(
315
+ `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${result?.pending ?? 'unknown'})`,
316
+ )
317
+ }
318
+ }
319
+
320
+ function reapOwnedBrowser(): void {
321
+ const reap = _reapBrowserNow
322
+ _reapBrowserNow = null
323
+ _browser = null
324
+ try {
325
+ reap?.()
326
+ } catch {}
327
+ }
328
+
329
+ async function closeContext() {
330
+ if (_browserCompromised) {
331
+ throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
332
+ }
333
+ if (!_context) return
334
+ const context = _context
335
+ const closingContextId = contextIdentity
336
+ try {
337
+ await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
338
+ _context = null
339
+ _page = null
340
+ contextIdentity = 0
341
+ _synchronizationEnabled = false
342
+ } catch (error) {
343
+ _browserCompromised = error instanceof Error ? error.message : String(error)
344
+ reapOwnedBrowser()
345
+ throw error
346
+ }
347
+ }
348
+
349
+ async function closeBrowser() {
350
+ if (!_browser) return
351
+ const browser = _browser
352
+ const reapBrowserNow = _reapBrowserNow
353
+ _browser = null
354
+ _reapBrowserNow = null
355
+ browserIdentity = 0
356
+
357
+ let closeError: Error | null = null
358
+ try {
359
+ await closeContext()
360
+ } catch (error) {
361
+ closeError = error instanceof Error ? error : new Error(String(error))
362
+ }
363
+
364
+ // reap Chrome directly without waiting on an unbounded browser.close()
365
+ try {
366
+ reapBrowserNow?.()
367
+ } catch {}
368
+
369
+ try {
370
+ await browser.close()
371
+ } catch {}
372
+
373
+ if (closeError) {
374
+ throw closeError
375
+ }
376
+ }
377
+
378
+ export async function waitForSootsimIdle(
379
+ page: Page,
380
+ maxMs = 3000,
381
+ strict = false,
382
+ ): Promise<void> {
383
+ const result = await page.evaluate(
384
+ async ({ maxMs, strict }) => {
385
+ const start = Date.now()
386
+ let transitionError: string | null = null
387
+ try {
388
+ const waitForScreenTransitions = (window as any).__sootsimTest
389
+ ?.waitForScreenTransitions
390
+ if (typeof waitForScreenTransitions === 'function') {
391
+ const transitionResult = await waitForScreenTransitions({
392
+ timeoutMs: Math.min(maxMs, 1800),
393
+ settleMs: 48,
394
+ startWindowMs: 600,
395
+ })
396
+ if (transitionResult?.timedOut) {
397
+ return {
398
+ settled: false,
399
+ elapsed: transitionResult.waitedMs,
400
+ reason: 'screen transition',
401
+ }
402
+ }
403
+ }
404
+ } catch (error) {
405
+ transitionError = error instanceof Error ? error.message : String(error)
406
+ }
407
+
408
+ const deadline = start + maxMs
409
+ const pollMs = 50
410
+ const requiredStablePolls = 3
411
+ const layoutTolerance = 1
412
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
413
+ const isLayoutStable = (a: number[] | null, b: number[]) => {
414
+ if (!a || a.length !== b.length) return false
415
+ for (let i = 0; i < b.length; i++) {
416
+ if (Math.abs(a[i] - b[i]) > layoutTolerance) return false
417
+ }
418
+ return true
419
+ }
420
+
421
+ const readSnapshot = async () => {
422
+ const root = (window as any).__sootsimRoot
423
+ const layout: number[] = []
424
+ let animating = false
425
+ let auxSurfaceDebug: unknown = null
426
+ let layoutDirty = false
427
+ let renderStatsAvailable = false
428
+ if (strict) {
429
+ try {
430
+ const renderHost: unknown = Reflect.get(window, '__sootsimRenderHost')
431
+ const queryStats =
432
+ typeof renderHost === 'object' && renderHost !== null
433
+ ? Reflect.get(renderHost, 'queryStats')
434
+ : null
435
+ const stats =
436
+ typeof queryStats === 'function' ? await queryStats.call(renderHost) : null
437
+ renderStatsAvailable = !!stats
438
+ auxSurfaceDebug = stats?.auxSurfaceDebug ?? null
439
+ animating =
440
+ stats?.hasActiveAnims === true ||
441
+ stats?.hasActiveNativeAnimations === true ||
442
+ stats?.hasPendingAnimationFrames === true
443
+ layoutDirty = stats?.layoutDirty === true && stats?.renderRequested === true
444
+ } catch {}
445
+ }
446
+ if (root) {
447
+ const walk = (node: any) => {
448
+ if (node.layout && node.layout.width > 0) {
449
+ layout.push(
450
+ Math.round(node.layout.x),
451
+ Math.round(node.layout.y),
452
+ Math.round(node.layout.width),
453
+ Math.round(node.layout.height),
454
+ )
455
+ }
456
+ for (const child of node.children || []) walk(child)
457
+ }
458
+ walk(root)
459
+ }
460
+ return {
461
+ animating,
462
+ auxSurfaceDebug,
463
+ layout,
464
+ layoutDirty,
465
+ renderStatsAvailable,
466
+ }
467
+ }
468
+
469
+ let stableLayout: number[] | null = null
470
+ let stable = 0
471
+ let lastSnapshot: Awaited<ReturnType<typeof readSnapshot>> | null = null
472
+ while (Date.now() < deadline) {
473
+ const snapshot = await readSnapshot()
474
+ lastSnapshot = snapshot
475
+ if (
476
+ (!strict || snapshot.renderStatsAvailable) &&
477
+ !snapshot.animating &&
478
+ !snapshot.layoutDirty &&
479
+ isLayoutStable(stableLayout, snapshot.layout)
480
+ ) {
481
+ stable++
482
+ if (stable >= requiredStablePolls) {
483
+ return { settled: true, elapsed: Date.now() - start }
484
+ }
485
+ } else {
486
+ stableLayout = snapshot.layout
487
+ stable = 0
488
+ }
489
+ await sleep(pollMs)
490
+ }
491
+ return {
492
+ settled: false,
493
+ elapsed: Date.now() - start,
494
+ reason: transitionError
495
+ ? `layout fallback after screen transition wait failed: ${transitionError}`
496
+ : strict
497
+ ? `strict idle state unavailable: renderStats=${lastSnapshot?.renderStatsAvailable === true} animating=${lastSnapshot?.animating === true} layoutDirty=${lastSnapshot?.layoutDirty === true} layoutNodes=${(lastSnapshot?.layout.length ?? 0) / 4} aux=${JSON.stringify(lastSnapshot?.auxSurfaceDebug)}`
498
+ : 'layout fallback',
499
+ }
500
+ },
501
+ { maxMs, strict },
502
+ )
503
+
504
+ if (!result?.settled) {
505
+ throw new Error(
506
+ `sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`,
507
+ )
508
+ }
509
+ }
510
+
511
+ // find a node in the sootsim tree matching a matcher descriptor
512
+ async function findNodeByMatcher(matcher: Matcher): Promise<any> {
513
+ const page = getPage()
514
+ if (matcher.index !== undefined) {
515
+ return page.evaluate(async (indexedMatcher: Matcher) => {
516
+ const filter =
517
+ indexedMatcher.type === 'id'
518
+ ? { hasId: indexedMatcher.value }
519
+ : indexedMatcher.type === 'text'
520
+ ? { hasText: indexedMatcher.value }
521
+ : indexedMatcher.type === 'label'
522
+ ? { hasLabel: indexedMatcher.value }
523
+ : indexedMatcher.type === 'role'
524
+ ? { hasRole: indexedMatcher.value }
525
+ : { type: indexedMatcher.value }
526
+ const results = await window.__sootsimTest!.queryAll(filter)
527
+ // detox indexes by.text over the text elements that read exactly that
528
+ // text. hasText also matches every ancestor, so index 0 would be the root.
529
+ const indexed =
530
+ indexedMatcher.type === 'text'
531
+ ? results.filter(
532
+ (node) => node.type === 'text' && node.text === indexedMatcher.value,
533
+ )
534
+ : results
535
+ return indexed[indexedMatcher.index ?? 0] ?? null
536
+ }, matcher)
537
+ }
538
+ if (matcher.type === 'id') {
539
+ return page.evaluate(
540
+ (id: string) => window.__sootsimTest!.findByTestId(id),
541
+ matcher.value,
542
+ )
543
+ } else if (matcher.type === 'text') {
544
+ return page.evaluate(
545
+ (text: string) => window.__sootsimTest!.findByText(text),
546
+ matcher.value,
547
+ )
548
+ } else if (matcher.type === 'label') {
549
+ return page.evaluate(
550
+ (label: string) => window.__sootsimTest!.findByLabel(label),
551
+ matcher.value,
552
+ )
553
+ } else if (matcher.type === 'role') {
554
+ return page.evaluate(
555
+ (role: string) => window.__sootsimTest!.findByRole(role),
556
+ matcher.value,
557
+ )
558
+ } else if (matcher.type === 'type') {
559
+ return page.evaluate(async (type: string) => {
560
+ const results = await window.__sootsimTest!.queryAll({ type })
561
+ return results[0] || null
562
+ }, matcher.value)
563
+ }
564
+ return null
565
+ }
566
+
567
+ // get the absolute center coordinates of a node in sootsim coordinate space
568
+ function getNodeCenter(nodeInfo: any): { x: number; y: number } {
569
+ return {
570
+ x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
571
+ y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2,
572
+ }
573
+ }
574
+
575
+ async function nodeOffsetToPage(
576
+ page: Page,
577
+ nodeInfo: any,
578
+ dx: number,
579
+ dy: number,
580
+ ): Promise<{ x: number; y: number }> {
581
+ const center = getNodeCenter(nodeInfo)
582
+ return sootsimToPage(page, center.x + dx, center.y + dy)
583
+ }
584
+
585
+ async function getDefaultTapPoint(
586
+ page: Page,
587
+ nodeInfo: any,
588
+ ): Promise<{ x: number; y: number }> {
589
+ const resolvedTarget = await page.evaluate(
590
+ (nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null,
591
+ nodeInfo.nodeId,
592
+ )
593
+ const center =
594
+ resolvedTarget &&
595
+ typeof resolvedTarget.cx === 'number' &&
596
+ typeof resolvedTarget.cy === 'number'
597
+ ? { x: resolvedTarget.cx, y: resolvedTarget.cy }
598
+ : getNodeCenter(nodeInfo)
599
+ const frame = nodeInfo.visibleFrame ?? {
600
+ x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
601
+ y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
602
+ width: nodeInfo.layout?.width ?? 0,
603
+ height: nodeInfo.layout?.height ?? 0,
604
+ }
605
+ const viewport = await readSootsimInteractiveViewport(page)
606
+ const fullFrameTolerance = 0.5
607
+ // the resolved center may belong to a descendant of the matched frame.
608
+ const canTapCenter =
609
+ typeof nodeInfo.absolutePosition?.x === 'number' &&
610
+ typeof nodeInfo.absolutePosition?.y === 'number' &&
611
+ typeof nodeInfo.layout?.width === 'number' &&
612
+ typeof nodeInfo.layout?.height === 'number' &&
613
+ Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance &&
614
+ Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance &&
615
+ frame.width >= nodeInfo.layout.width - fullFrameTolerance &&
616
+ frame.height >= nodeInfo.layout.height - fullFrameTolerance &&
617
+ center.x >= 0 &&
618
+ center.y >= 0 &&
619
+ center.x < viewport.width &&
620
+ center.y < viewport.height
621
+ if (canTapCenter) return center
622
+
623
+ const left = Math.max(0, frame.x)
624
+ const top = Math.max(0, frame.y)
625
+ const right = Math.min(viewport.width, frame.x + frame.width)
626
+ const bottom = Math.min(viewport.height, frame.y + frame.height)
627
+
628
+ if (right <= left || bottom <= top) {
629
+ return center
630
+ }
631
+
632
+ return {
633
+ x: left + (right - left) / 2,
634
+ y: top + (bottom - top) / 2,
635
+ }
636
+ }
637
+
638
+ async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) {
639
+ await page.evaluate(
640
+ async ({ x, y }) => {
641
+ const interactTap = window.SootSim?.bridges?.interact?.tap
642
+ if (typeof interactTap === 'function') {
643
+ const result = await interactTap(x, y)
644
+ if (result && (typeof result !== 'object' || result.hit !== false)) return
645
+ }
646
+ const tap = window.__sootsimTest?.tap
647
+ if (typeof tap !== 'function') {
648
+ throw new Error('sootsim tap bridge is not installed')
649
+ }
650
+ await tap(x, y)
651
+ },
652
+ { x, y },
653
+ )
654
+ }
655
+
656
+ async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
657
+ const focused = await page.evaluate(
658
+ () => window.__sootsimTest!.getFocusedNode?.() ?? null,
659
+ )
660
+ return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
661
+ }
662
+
663
+ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
664
+ const page = getPage()
665
+ const node = await findNodeByMatcher(el._matcher)
666
+ if (!node) {
667
+ throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`)
668
+ }
669
+ if (await isFocusedTextInputNode(page, node)) return
670
+ await el.tap()
671
+ await page.waitForTimeout(100)
672
+ }
673
+
674
+ function ensureScreenshotDir() {
675
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
676
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
677
+ }
678
+ }
679
+
680
+ function decodePngDataUrl(dataUrl: string): Buffer {
681
+ const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl)
682
+ if (!match) {
683
+ throw new Error(
684
+ `rnx screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`,
685
+ )
686
+ }
687
+ return Buffer.from(match[1], 'base64')
688
+ }
689
+
690
+ type FastScreenshotData = {
691
+ label: string
692
+ offsetMs: number
693
+ actualMs: number
694
+ captureRequestedAtMs?: number
695
+ dataUrl: string
696
+ frameIndex?: number
697
+ virtualTimestampMs?: number
698
+ }
699
+
700
+ function writeFastScreenshotFrames<T extends FastScreenshotData>(
701
+ namePrefix: string,
702
+ frames: T[],
703
+ ): Array<Omit<T, 'dataUrl'> & { path: string }> {
704
+ ensureScreenshotDir()
705
+ return frames.map((frame) => {
706
+ const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
707
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
708
+ fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl))
709
+ const { dataUrl: _dataUrl, ...metadata } = frame
710
+ return {
711
+ ...metadata,
712
+ path: filePath,
713
+ }
714
+ })
715
+ }
716
+
717
+ // single-shot canvas capture — no frame-stability poll. used for multi-stage
718
+ // animation captures where we explicitly want to sample mid-transition. the
719
+ // usual captureSootsimPng requires three consecutive idle frames to be byte-equal
720
+ // which is ideal for settled screenshots but blocks for the full timeout
721
+ // while an animation is running.
722
+ async function captureSootsimPngFast(opts?: {
723
+ crop?: { h: number; w: number; x: number; y: number }
724
+ }) {
725
+ const page = getPage()
726
+ const dataUrl = await page.evaluate(
727
+ async (captureOpts) => {
728
+ const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
729
+ .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
730
+ if (typeof screenshot !== 'function') {
731
+ throw new Error('rnx screenshot bridge is not installed')
732
+ }
733
+ return screenshot(captureOpts)
734
+ },
735
+ { crop: opts?.crop, format: 'png' },
736
+ )
737
+ if (!dataUrl) {
738
+ throw new Error('rnx screenshot bridge returned an empty image')
739
+ }
740
+ return decodePngDataUrl(dataUrl)
741
+ }
742
+
743
+ async function setSootsimCursorCaptureActive(page: Page, active: boolean) {
744
+ await page.evaluate(async (captureActive) => {
745
+ const bridge = window.__sootsimTest
746
+ if (typeof bridge?.setCursorCaptureActive !== 'function') {
747
+ throw new Error('sootsim cursor capture control is not installed')
748
+ }
749
+ await bridge.setCursorCaptureActive(captureActive)
750
+ }, active)
751
+ }
752
+
753
+ async function captureSootsimPng(opts?: {
754
+ crop?: { h: number; w: number; x: number; y: number }
755
+ }) {
756
+ const page = getPage()
757
+ // a focused TextInput caret changes on a timer. pin it visible for the
758
+ // capture transaction so forced frames represent one settled visual state.
759
+ await setSootsimCursorCaptureActive(page, true)
760
+ try {
761
+ const deadline = Date.now() + 8000
762
+ let previous: Buffer | null = null
763
+ let identicalFrames = 1
764
+ let attempts = 0
765
+ while (Date.now() < deadline) {
766
+ await waitForSootsimIdle(page, Math.max(1, deadline - Date.now()), true)
767
+ const current = await captureSootsimPngFast(opts)
768
+ attempts++
769
+ if (framesIdentical(previous, current)) {
770
+ identicalFrames++
771
+ if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) return current
772
+ } else {
773
+ identicalFrames = 1
774
+ }
775
+ previous = current
776
+ }
777
+ throw new Error(
778
+ `rnx screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames in ${attempts} captures`,
779
+ )
780
+ } finally {
781
+ await setSootsimCursorCaptureActive(page, false)
782
+ }
783
+ }
784
+
785
+ async function writeSootsimScreenshot(
786
+ name: string,
787
+ opts?: { crop?: { h: number; w: number; x: number; y: number } },
788
+ ) {
789
+ ensureScreenshotDir()
790
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
791
+ fs.writeFileSync(screenshotPath, await captureSootsimPng(opts))
792
+ return screenshotPath
793
+ }
794
+
795
+ async function dispatchStatusBarOverride(config: StatusBarConfig) {
796
+ const page = getPage()
797
+ const time =
798
+ typeof config.time === 'string' && config.time.length > 0 ? config.time : null
799
+ await page.evaluate(
800
+ ({ eventType, time }) => {
801
+ window.dispatchEvent(
802
+ new CustomEvent(eventType, {
803
+ detail: { time },
804
+ }),
805
+ )
806
+ },
807
+ { eventType: STATUS_BAR_OVERRIDE_EVENT, time },
808
+ )
809
+ await page.waitForTimeout(50)
810
+ }
811
+
812
+ const MAX_SETTLED_SPEED_PT_PER_SEC = 2.0
813
+
814
+ async function waitForFrameClock(page: Page, minMs = 25): Promise<void> {
815
+ const start = performance.now()
816
+ await page.evaluate(
817
+ () =>
818
+ new Promise<void>((resolve) => {
819
+ const timeoutId = setTimeout(() => resolve(), 50)
820
+ requestAnimationFrame(() => {
821
+ clearTimeout(timeoutId)
822
+ resolve()
823
+ })
824
+ }),
825
+ )
826
+ const elapsed = performance.now() - start
827
+ if (elapsed < minMs) {
828
+ await page.waitForTimeout(minMs - elapsed)
829
+ }
830
+ }
831
+
832
+ async function findStableNodeByMatcher(
833
+ matcher: Matcher,
834
+ action = 'tap',
835
+ maxWaitMs = 1500,
836
+ ): Promise<any> {
837
+ const page = getPage()
838
+ const start = performance.now()
839
+ let lastPos: { x: number; y: number } | null = null
840
+ let lastTime: number | null = null
841
+ let lastRate: { vx: number; vy: number; speed: number } | null = null
842
+ let sampleCount = 0
843
+ let stablePolls = 0
844
+ const requiredStable = 2
845
+ let hadNumericLayout = false
846
+ let disappearedMidWait = false
847
+
848
+ while (performance.now() - start < maxWaitMs) {
849
+ const node = await findNodeByMatcher(matcher)
850
+ if (!node) {
851
+ if (hadNumericLayout) {
852
+ disappearedMidWait = true
853
+ } else {
854
+ return null
855
+ }
856
+ } else {
857
+ disappearedMidWait = false
858
+ const pos = node.absolutePosition ?? node.layout
859
+ if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
860
+ hadNumericLayout = true
861
+ sampleCount++
862
+ const now = performance.now()
863
+ if (lastPos && lastTime !== null) {
864
+ const dtSec = (now - lastTime) / 1000
865
+ if (dtSec > 0.005) {
866
+ const vx = Math.abs(pos.x - lastPos.x) / dtSec
867
+ const vy = Math.abs(pos.y - lastPos.y) / dtSec
868
+ const speed = Math.sqrt(vx * vx + vy * vy)
869
+ lastRate = { vx, vy, speed }
870
+ if (speed < MAX_SETTLED_SPEED_PT_PER_SEC) {
871
+ stablePolls++
872
+ if (stablePolls >= requiredStable) {
873
+ return node
874
+ }
875
+ } else {
876
+ stablePolls = 0
877
+ }
878
+ }
879
+ }
880
+ lastPos = { x: pos.x, y: pos.y }
881
+ lastTime = now
882
+ }
883
+ }
884
+ await waitForFrameClock(page, 25)
885
+ }
886
+
887
+ const elapsed = Math.round(performance.now() - start)
888
+ if (disappearedMidWait) {
889
+ throw new Error(
890
+ `element disappeared while waiting to come to rest for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`,
891
+ )
892
+ }
893
+ if (!hadNumericLayout) {
894
+ throw new Error(
895
+ `element layout is not available for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`,
896
+ )
897
+ }
898
+ if (sampleCount < 2 || lastRate === null) {
899
+ throw new Error(
900
+ `element did not come to rest for ${action}: ${JSON.stringify(matcher)}; only one sample obtained after ${elapsed}ms`,
901
+ )
902
+ }
903
+ throw new Error(
904
+ `element did not come to rest for ${action}: ${JSON.stringify(matcher)}; still moving ${lastRate.vx.toFixed(1)} points/sec x / ${lastRate.vy.toFixed(1)} points/sec y after ${elapsed}ms`,
905
+ )
906
+ }
907
+
908
+ function createSootSimElement(matcher: Matcher): SootSimElement {
909
+ const el: SootSimElement = {
910
+ _matcher: matcher,
911
+
912
+ async tap(point) {
913
+ const page = getPage()
914
+ await waitForSootsimWorkletsIdle(page, 3000)
915
+ const node = await findStableNodeByMatcher(matcher, 'tap')
916
+ if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`)
917
+ if (!isNodeVisible(node)) {
918
+ throw new Error(
919
+ `Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
920
+ )
921
+ }
922
+ const defaultPoint = point ? null : await getDefaultTapPoint(page, node)
923
+ const targetSootSimX =
924
+ typeof point?.x === 'number'
925
+ ? node.absolutePosition.x + point.x
926
+ : (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
927
+ const targetSootSimY =
928
+ typeof point?.y === 'number'
929
+ ? node.absolutePosition.y + point.y
930
+ : (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
931
+ await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY)
932
+ if (_synchronizationEnabled) {
933
+ await waitForSootsimIdle(page)
934
+ }
935
+ },
936
+
937
+ async multiTap(times: number) {
938
+ // detox iOS multiTap performs N taps with the system's natural double-
939
+ // tap pause. UITapGestureRecognizer treats taps within ~250ms as a
940
+ // multi-tap. emit each tap as a discrete down/up at the same node
941
+ // center with a small inter-tap pause that stays inside RNGH's
942
+ // default maxDelay window of 200ms.
943
+ const page = getPage()
944
+ const node = await findStableNodeByMatcher(matcher, 'multiTap')
945
+ if (!node)
946
+ throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`)
947
+ if (!isNodeVisible(node)) {
948
+ throw new Error(
949
+ `Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
950
+ )
951
+ }
952
+ const center = getNodeCenter(node)
953
+ const pageCoords = await sootsimToPage(page, center.x, center.y)
954
+
955
+ for (let i = 0; i < times; i++) {
956
+ await page.mouse.move(pageCoords.x, pageCoords.y)
957
+ await page.mouse.down()
958
+ await page.waitForTimeout(50)
959
+ await page.mouse.up()
960
+ if (i < times - 1) {
961
+ await page.waitForTimeout(80)
962
+ }
963
+ }
964
+ await page.waitForTimeout(80)
965
+ },
966
+
967
+ // mirrors detox element.longPress(duration) and longPress(point, duration),
968
+ // where the point is relative to the element's origin
969
+ async longPress(
970
+ pointOrDuration?: { x: number; y: number } | number,
971
+ durationArg = 1000,
972
+ ) {
973
+ const page = getPage()
974
+ const node = await findStableNodeByMatcher(matcher, 'longPress')
975
+ if (!node)
976
+ throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`)
977
+ if (!isNodeVisible(node)) {
978
+ throw new Error(
979
+ `Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
980
+ )
981
+ }
982
+ const duration = typeof pointOrDuration === 'number' ? pointOrDuration : durationArg
983
+ const target =
984
+ typeof pointOrDuration === 'object'
985
+ ? {
986
+ x: node.absolutePosition.x + pointOrDuration.x,
987
+ y: node.absolutePosition.y + pointOrDuration.y,
988
+ }
989
+ : getNodeCenter(node)
990
+ await page.evaluate(
991
+ async ({ duration, x, y }) => {
992
+ const longPress = window.SootSim?.bridges?.interact?.longPress
993
+ if (typeof longPress !== 'function') {
994
+ throw new Error('sootsim longPress bridge is not installed')
995
+ }
996
+ const hit = await longPress(x, y, duration)
997
+ if (!hit) {
998
+ throw new Error(`sootsim longPress missed at ${x},${y}`)
999
+ }
1000
+ },
1001
+ { duration, x: target.x, y: target.y },
1002
+ )
1003
+ await page.waitForTimeout(50)
1004
+ },
1005
+
1006
+ async longPressAndDrag(
1007
+ duration: number,
1008
+ normalizedStartX: number,
1009
+ normalizedStartY: number,
1010
+ targetElement: SootSimElement,
1011
+ normalizedEndX: number,
1012
+ normalizedEndY: number,
1013
+ speed = 'fast',
1014
+ holdDuration = 0,
1015
+ ) {
1016
+ const page = getPage()
1017
+ // resolve source element
1018
+ const srcNode = await findStableNodeByMatcher(matcher, 'longPressAndDrag')
1019
+ if (!srcNode)
1020
+ throw new Error(`source element not found: ${JSON.stringify(matcher)}`)
1021
+
1022
+ // resolve target element
1023
+ const tgtNode = await findStableNodeByMatcher(
1024
+ targetElement._matcher,
1025
+ 'longPressAndDrag',
1026
+ )
1027
+ if (!tgtNode)
1028
+ throw new Error(
1029
+ `target element not found: ${JSON.stringify(targetElement._matcher)}`,
1030
+ )
1031
+
1032
+ // compute start and end in sootsim coords
1033
+ const startSootSimX =
1034
+ srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX
1035
+ const startSootSimY =
1036
+ srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY
1037
+ const endSootSimX =
1038
+ tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX
1039
+ const endSootSimY =
1040
+ tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY
1041
+
1042
+ const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
1043
+ const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
1044
+ const steps = speed === 'slow' ? 20 : 10
1045
+
1046
+ // press at start position
1047
+ await page.mouse.move(startPage.x, startPage.y)
1048
+ await page.mouse.down()
1049
+ await page.waitForTimeout(Math.max(duration, holdDuration))
1050
+
1051
+ // drag to end position
1052
+ const dx = (endPage.x - startPage.x) / steps
1053
+ const dy = (endPage.y - startPage.y) / steps
1054
+ for (let i = 1; i <= steps; i++) {
1055
+ await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
1056
+ await page.waitForTimeout(speed === 'slow' ? 30 : 10)
1057
+ }
1058
+
1059
+ await page.mouse.up()
1060
+ await page.waitForTimeout(50)
1061
+ },
1062
+
1063
+ async typeText(text: string) {
1064
+ const page = getPage()
1065
+ await focusElementForTextEntry(el)
1066
+ await page.keyboard.type(text)
1067
+ },
1068
+
1069
+ async replaceText(text: string) {
1070
+ const page = getPage()
1071
+ const node = await findNodeByMatcher(matcher)
1072
+ if (
1073
+ matcher.type === 'id' &&
1074
+ typeof node?.nodeId === 'number' &&
1075
+ node.nodeId >= 0x40000000
1076
+ ) {
1077
+ const replaced = await page.evaluate(
1078
+ async ({ id, value }) => {
1079
+ const mainShell = window.SootSim?.bridges?.mainShell
1080
+ if (typeof mainShell?.callTestBridge !== 'function') return false
1081
+ return mainShell.callTestBridge('replaceTextByTestId', id, value)
1082
+ },
1083
+ { id: matcher.value, value: text },
1084
+ )
1085
+ if (replaced === true) return
1086
+ }
1087
+ await focusElementForTextEntry(el)
1088
+ const value = typeof node?.text === 'string' ? node.text : ''
1089
+ for (let i = 0; i < value.length; i++) {
1090
+ await page.keyboard.press('Backspace')
1091
+ }
1092
+ await page.keyboard.type(text)
1093
+ },
1094
+
1095
+ async clearText() {
1096
+ const page = getPage()
1097
+ await focusElementForTextEntry(el)
1098
+ const node = await findNodeByMatcher(matcher)
1099
+ const value = typeof node?.text === 'string' ? node.text : ''
1100
+ for (let i = 0; i < value.length; i++) {
1101
+ await page.keyboard.press('Backspace')
1102
+ }
1103
+ },
1104
+
1105
+ async scroll(
1106
+ pixels: number,
1107
+ direction: 'up' | 'down' | 'left' | 'right',
1108
+ startPositionX?: number,
1109
+ startPositionY?: number,
1110
+ ) {
1111
+ const node = await findNodeByMatcher(matcher)
1112
+ if (!node)
1113
+ throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
1114
+ const page = getPage()
1115
+ await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY)
1116
+ },
1117
+
1118
+ async scrollTo(
1119
+ edge: 'top' | 'bottom' | 'left' | 'right',
1120
+ startPositionX?: number,
1121
+ startPositionY?: number,
1122
+ ) {
1123
+ const node = await findNodeByMatcher(matcher)
1124
+ if (!node)
1125
+ throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
1126
+
1127
+ const targetId =
1128
+ typeof node.testID === 'string' && node.testID
1129
+ ? node.testID
1130
+ : typeof node.id === 'string' && node.id
1131
+ ? node.id
1132
+ : null
1133
+ if (!targetId) {
1134
+ throw new Error(
1135
+ `scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`,
1136
+ )
1137
+ }
1138
+
1139
+ const normalizedStartX = resolveScrollStartPosition(
1140
+ startPositionX,
1141
+ 'scrollTo startPositionX',
1142
+ )
1143
+ const normalizedStartY = resolveScrollStartPosition(
1144
+ startPositionY,
1145
+ 'scrollTo startPositionY',
1146
+ )
1147
+ const hitX = node.absolutePosition.x + node.layout.width * normalizedStartX
1148
+ const hitY = node.absolutePosition.y + node.layout.height * normalizedStartY
1149
+ const page = getPage()
1150
+ const result = await page.evaluate(
1151
+ async ({ edge, hitX, hitY, targetId }) => {
1152
+ const bridge = window.__sootsimTest
1153
+ if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
1154
+ return { ok: false, reason: 'scroll bridge unavailable' }
1155
+ }
1156
+
1157
+ const state = await bridge.getScrollStateAt(hitX, hitY)
1158
+ if (!state) return { ok: false, reason: 'scroll state not found' }
1159
+
1160
+ const maxOffset =
1161
+ state.maxOffset && typeof state.maxOffset === 'object'
1162
+ ? (state.maxOffset as { x?: number; y?: number })
1163
+ : null
1164
+ const offset =
1165
+ state.offset && typeof state.offset === 'object'
1166
+ ? (state.offset as { x?: number; y?: number })
1167
+ : null
1168
+ const maxX =
1169
+ typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0)
1170
+ const maxY =
1171
+ typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0)
1172
+ const currentX =
1173
+ typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0)
1174
+ const currentY =
1175
+ typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0)
1176
+
1177
+ const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX
1178
+ const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY
1179
+ return bridge.scrollTo(targetId, x, y, false)
1180
+ },
1181
+ { edge, hitX, hitY, targetId },
1182
+ )
1183
+
1184
+ if (!result?.ok) {
1185
+ throw new Error(
1186
+ `scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`,
1187
+ )
1188
+ }
1189
+
1190
+ await page.evaluate(
1191
+ () =>
1192
+ new Promise<void>((resolve) => {
1193
+ requestAnimationFrame(() => resolve())
1194
+ }),
1195
+ )
1196
+ if (_synchronizationEnabled) {
1197
+ await waitForSootsimIdle(page)
1198
+ }
1199
+ },
1200
+
1201
+ async swipe(
1202
+ direction: 'up' | 'down' | 'left' | 'right',
1203
+ speed: 'fast' | 'slow' = 'fast',
1204
+ percentage = 0.75,
1205
+ normalizedStartingPointX = 0.5,
1206
+ normalizedStartingPointY = 0.5,
1207
+ ) {
1208
+ const node = await findStableNodeByMatcher(matcher, 'swipe')
1209
+ if (!node)
1210
+ throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
1211
+
1212
+ const page = getPage()
1213
+ const { absolutePosition, layout } = node
1214
+
1215
+ const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
1216
+ const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY
1217
+
1218
+ let endSootSimX = startSootSimX
1219
+ let endSootSimY = startSootSimY
1220
+ const viewport = await readSootsimViewport(page)
1221
+ const dist =
1222
+ direction === 'up' || direction === 'down'
1223
+ ? viewport.height * percentage
1224
+ : viewport.width * percentage
1225
+
1226
+ switch (direction) {
1227
+ case 'up':
1228
+ endSootSimY -= dist
1229
+ break
1230
+ case 'down':
1231
+ endSootSimY += dist
1232
+ break
1233
+ case 'left':
1234
+ endSootSimX -= dist
1235
+ break
1236
+ case 'right':
1237
+ endSootSimX += dist
1238
+ break
1239
+ }
1240
+
1241
+ // detox injects one point per display-link tick. its fast path contains
1242
+ // 21 points and its slow path 41, including the endpoints.
1243
+ const steps = speed === 'slow' ? 40 : 20
1244
+ const stepDelay = 1000 / 60
1245
+
1246
+ await page.evaluate(
1247
+ async ({ fromX, fromY, toX, toY, steps, stepMs }) => {
1248
+ // the interact bridge, not __sootsimTest.drag. the test bridge's drag
1249
+ // runs the whole down/move/up loop through the tenant's own
1250
+ // dispatchPointer and never fans to the shell, where gesture-handler's
1251
+ // recognizers live, so a swipe reported exact-zero activations for
1252
+ // every pan, fling, and manual gesture. taps were unaffected because
1253
+ // the responder path is tenant-local. the interact bridge resolves the
1254
+ // owner and feeds both, which is why the cases that call it directly
1255
+ // pass.
1256
+ const drag = window.__sootsimInteract?.drag
1257
+ if (typeof drag !== 'function') {
1258
+ throw new Error('sootsim interact bridge is not installed')
1259
+ }
1260
+ await drag(fromX, fromY, toX, toY, steps, stepMs)
1261
+ },
1262
+ {
1263
+ fromX: startSootSimX,
1264
+ fromY: startSootSimY,
1265
+ toX: endSootSimX,
1266
+ toY: endSootSimY,
1267
+ steps,
1268
+ stepMs: stepDelay,
1269
+ },
1270
+ )
1271
+ await page.waitForTimeout(100)
1272
+ },
1273
+
1274
+ async setColumnToValue(column: number, value: string) {
1275
+ if (!Number.isInteger(column) || column < 0) {
1276
+ throw new Error(`picker column must be a non-negative integer, got ${column}`)
1277
+ }
1278
+ if (typeof value !== 'string') {
1279
+ throw new Error(`picker value must be a string, got ${typeof value}`)
1280
+ }
1281
+
1282
+ const picker = await findStableNodeByMatcher(matcher, 'setColumnToValue')
1283
+ if (!picker) {
1284
+ throw new Error(
1285
+ `element not found for setColumnToValue: ${JSON.stringify(matcher)}`,
1286
+ )
1287
+ }
1288
+
1289
+ const page = getPage()
1290
+ const candidates = await page.evaluate(
1291
+ async ({ picker, column, value }) => {
1292
+ const result = await window.__sootsimTest?.queryTextCandidates?.({
1293
+ query: value,
1294
+ exact: true,
1295
+ })
1296
+ if (!result || !Array.isArray(result.candidates)) return []
1297
+ const left = picker.absolutePosition.x
1298
+ const top = picker.absolutePosition.y
1299
+ const right = left + picker.layout.width
1300
+ const bottom = top + picker.layout.height
1301
+ return result.candidates
1302
+ .filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column))
1303
+ .map((candidate) => candidate.info)
1304
+ .filter((candidate) => {
1305
+ const x = candidate.absolutePosition?.x
1306
+ const y = candidate.absolutePosition?.y
1307
+ const width = candidate.layout?.width
1308
+ const height = candidate.layout?.height
1309
+ if (
1310
+ typeof x !== 'number' ||
1311
+ typeof y !== 'number' ||
1312
+ typeof width !== 'number' ||
1313
+ typeof height !== 'number'
1314
+ ) {
1315
+ return false
1316
+ }
1317
+ const centerX = x + width / 2
1318
+ const centerY = y + height / 2
1319
+ return (
1320
+ centerX >= left && centerX <= right && centerY >= top && centerY <= bottom
1321
+ )
1322
+ })
1323
+ },
1324
+ { picker, column, value },
1325
+ )
1326
+
1327
+ if (candidates.length !== 1) {
1328
+ throw new Error(
1329
+ `setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`,
1330
+ )
1331
+ }
1332
+
1333
+ const target = candidates[0]
1334
+ // detox selects the picker row, whose hit rectangle stays flat. the
1335
+ // label is visually projected around the wheel and can overlap a later
1336
+ // label near the cylinder edge.
1337
+ const tapTarget = await page.evaluate(
1338
+ async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId),
1339
+ target.nodeId,
1340
+ )
1341
+ if (!tapTarget) {
1342
+ throw new Error(
1343
+ `setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`,
1344
+ )
1345
+ }
1346
+ await dispatchTapAtSootsimPoint(
1347
+ page,
1348
+ tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
1349
+ tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2,
1350
+ )
1351
+ },
1352
+
1353
+ async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) {
1354
+ // detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1
1355
+ // brings them together (zoom out). speed maps to step delay so RNGH's
1356
+ // velocity tracking sees a realistic delta. angle (radians) tilts the
1357
+ // pinch axis off horizontal — matches Detox's third arg.
1358
+ const page = getPage()
1359
+ const node = await findStableNodeByMatcher(matcher, 'pinch')
1360
+ if (!node)
1361
+ throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`)
1362
+ if (!node.nativePinchEnabled) {
1363
+ await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler')
1364
+ }
1365
+ const startSpread = Math.min(
1366
+ 60,
1367
+ Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
1368
+ )
1369
+ const endSpread = startSpread * Math.max(scale, 0.05)
1370
+ const cos = Math.cos(angle)
1371
+ const sin = Math.sin(angle)
1372
+ const offset = (d: number) => ({ dx: d * cos, dy: d * sin })
1373
+ const a0 = offset(-startSpread)
1374
+ const b0 = offset(startSpread)
1375
+ const a1 = offset(-endSpread)
1376
+ const b1 = offset(endSpread)
1377
+ await dispatchTwoFingerGesture(
1378
+ page,
1379
+ await nodeOffsetToPage(page, node, a0.dx, a0.dy),
1380
+ await nodeOffsetToPage(page, node, b0.dx, b0.dy),
1381
+ await nodeOffsetToPage(page, node, a1.dx, a1.dy),
1382
+ await nodeOffsetToPage(page, node, b1.dx, b1.dy),
1383
+ { steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 },
1384
+ )
1385
+ },
1386
+
1387
+ async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
1388
+ // two fingers held on a horizontal axis around the node center, then
1389
+ // both rotated to `radians` around the same center. positive radians
1390
+ // rotate counter-clockwise to match RNGH RotationGesture's reported
1391
+ // sign (its 'rotation' delta is positive for a counter-clockwise
1392
+ // motion of the rear finger relative to the front finger).
1393
+ const page = getPage()
1394
+ const node = await findStableNodeByMatcher(matcher, 'rotate')
1395
+ if (!node)
1396
+ throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`)
1397
+ await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler')
1398
+ const radius = Math.min(
1399
+ 60,
1400
+ Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
1401
+ )
1402
+ const startA = await nodeOffsetToPage(page, node, -radius, 0)
1403
+ const startB = await nodeOffsetToPage(page, node, radius, 0)
1404
+ const cos = Math.cos(radians)
1405
+ const sin = Math.sin(radians)
1406
+ const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin)
1407
+ const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin)
1408
+ await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
1409
+ steps: speed === 'fast' ? 10 : 20,
1410
+ stepDelayMs: speed === 'fast' ? 14 : 24,
1411
+ })
1412
+ },
1413
+
1414
+ async takeScreenshot(name: string): Promise<string> {
1415
+ const node = await findNodeByMatcher(matcher)
1416
+ if (!node)
1417
+ throw new Error(
1418
+ `element not found for takeScreenshot: ${JSON.stringify(matcher)}`,
1419
+ )
1420
+
1421
+ const box = node.frame ?? {
1422
+ x: node.absolutePosition.x,
1423
+ y: node.absolutePosition.y,
1424
+ width: node.layout.width,
1425
+ height: node.layout.height,
1426
+ }
1427
+ return writeSootsimScreenshot(name, {
1428
+ crop: {
1429
+ x: box.x,
1430
+ y: box.y,
1431
+ w: box.width,
1432
+ h: box.height,
1433
+ },
1434
+ })
1435
+ },
1436
+
1437
+ async getAttributes(): Promise<any> {
1438
+ const node = await findNodeByMatcher(matcher)
1439
+ if (!node)
1440
+ throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`)
1441
+ return {
1442
+ text: node.text || '',
1443
+ label: node.accessibilityLabel || node.text || '',
1444
+ identifier: node.testID || node.id || '',
1445
+ visible: node.layout.width > 0 && node.layout.height > 0,
1446
+ enabled: !node.accessibilityState?.disabled,
1447
+ ...node,
1448
+ }
1449
+ },
1450
+
1451
+ atIndex(index: number) {
1452
+ return createSootSimElement({ ...matcher, index })
1453
+ },
1454
+ }
1455
+
1456
+ return el
1457
+ }
1458
+
1459
+ // element() factory -- takes a matcher and returns an element interaction object
1460
+ export function element(matcher: Matcher): SootSimElement {
1461
+ return createSootSimElement(matcher)
1462
+ }
1463
+
1464
+ // device object -- app lifecycle
1465
+ export const device = {
1466
+ _currentUrl: '',
1467
+ _platform: DETOX_PLATFORM as 'ios' | 'android',
1468
+
1469
+ async launchApp(opts?: {
1470
+ delete?: boolean
1471
+ newInstance?: boolean
1472
+ url?: string
1473
+ launchArgs?: Record<string, any>
1474
+ // when set, the new BrowserContext records video at this dir. used by
1475
+ // captureProofMenuStages for the multi-stage animation diff path. must
1476
+ // be set at launchApp time because playwright recordVideo can't be
1477
+ // toggled on an existing context.
1478
+ recordVideoDir?: string
1479
+ }) {
1480
+ if (_browserCompromised) {
1481
+ throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
1482
+ }
1483
+ if (
1484
+ _page &&
1485
+ opts?.newInstance === false &&
1486
+ !opts.delete &&
1487
+ !opts.recordVideoDir &&
1488
+ _suspendedAppId
1489
+ ) {
1490
+ const page = _page
1491
+ const appId = _suspendedAppId
1492
+ await page.evaluate(
1493
+ (id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id),
1494
+ appId,
1495
+ )
1496
+ await pollUntil(
1497
+ () =>
1498
+ page.evaluate(async (id) => {
1499
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
1500
+ return (
1501
+ state?.state === 'app' &&
1502
+ state.activeApp === id &&
1503
+ state.switcherPhase === 'idle'
1504
+ )
1505
+ }, appId),
1506
+ Boolean,
1507
+ {
1508
+ timeoutMs: 10000,
1509
+ label: `SootSim app ${appId} to resume`,
1510
+ },
1511
+ )
1512
+ _suspendedAppId = null
1513
+ await waitForSootsimTree(page, 10000)
1514
+ return
1515
+ }
1516
+
1517
+ if (!_browser) {
1518
+ // Conformance renders CanvasKit and must use the real platform GPU. The
1519
+ // bundled chromium_headless_shell has no GPU and silently falls back to
1520
+ // SwiftShader, invalidating visual/perf results while pegging the box.
1521
+ // The canonical GPU args force Metal/Vulkan and stripping Playwright's
1522
+ // --enable-unsafe-swiftshader default makes GPU init fail loudly. The
1523
+ // scoped reaper owns the whole Chrome process tree if Detox is interrupted.
1524
+ const launched = await launchReapedChromeScoped(
1525
+ (options) => chromium.launch(options),
1526
+ { headless: true, ignoreDefaultArgs: ['--disable-popup-blocking'] },
1527
+ )
1528
+ _browser = launched.browser
1529
+ _reapBrowserNow = launched.reapNow
1530
+ browserIdentity = ++nextBrowserIdentity
1531
+ }
1532
+
1533
+ if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
1534
+ await closeContext()
1535
+ const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
1536
+ hasTouch: true,
1537
+ viewport: { width: 500, height: 900 },
1538
+ }
1539
+ if (opts?.recordVideoDir) {
1540
+ fs.mkdirSync(opts.recordVideoDir, { recursive: true })
1541
+ contextOpts.recordVideo = {
1542
+ dir: opts.recordVideoDir,
1543
+ size: { width: 500, height: 900 },
1544
+ }
1545
+ }
1546
+ const newContextIdentity = ++nextContextIdentity
1547
+ try {
1548
+ _context = await withLifecycleTimeout(
1549
+ '_browser.newContext()',
1550
+ newContextIdentity,
1551
+ () => _browser!.newContext(contextOpts),
1552
+ )
1553
+ } catch (error) {
1554
+ _browserCompromised = error instanceof Error ? error.message : String(error)
1555
+ reapOwnedBrowser()
1556
+ throw error
1557
+ }
1558
+ await _context.exposeBinding('__sootsimHostCapture', async (source, value) => {
1559
+ const request = readBrowserCompositeCaptureRequest(value)
1560
+ const session = await source.page.context().newCDPSession(source.page)
1561
+ try {
1562
+ const result = await session.send('Page.captureScreenshot', {
1563
+ format: 'png',
1564
+ fromSurface: true,
1565
+ captureBeyondViewport: true,
1566
+ clip: resolveBrowserCompositeCdpClip(
1567
+ request,
1568
+ await session.send('Page.getLayoutMetrics'),
1569
+ ),
1570
+ })
1571
+ return 'data:image/png;base64,' + result.data
1572
+ } finally {
1573
+ await session.detach()
1574
+ }
1575
+ })
1576
+ try {
1577
+ _page = await withLifecycleTimeout('_context.newPage()', newContextIdentity, () =>
1578
+ _context!.newPage(),
1579
+ )
1580
+ } catch (error) {
1581
+ _browserCompromised = error instanceof Error ? error.message : String(error)
1582
+ reapOwnedBrowser()
1583
+ throw error
1584
+ }
1585
+ contextIdentity = newContextIdentity
1586
+ }
1587
+
1588
+ const url = opts?.url || BASE_URL
1589
+ device._currentUrl = url
1590
+ await navigateSootsimPage(_page!, url, {
1591
+ browserId: browserIdentity,
1592
+ contextId: contextIdentity,
1593
+ operation: 'launchApp',
1594
+ })
1595
+
1596
+ await waitForSootsimTree(_page!, 30000)
1597
+ await waitForSootsimSurfaceMetrics(_page!, 30000)
1598
+ await waitForSootsimShellReady(_page!, 30000)
1599
+ await waitForSootsimWorkletsIdle(_page!, 3000)
1600
+ // record the wall-clock time the page is ready — captureProofMenuStages
1601
+ // uses this to compute trigger-in-video offsets
1602
+ device._lastReadyAtMs = Date.now()
1603
+ },
1604
+
1605
+ _lastReadyAtMs: 0,
1606
+
1607
+ async reloadReactNative() {
1608
+ const page = getPage()
1609
+ await page.reload({ waitUntil: 'load', timeout: 30000 })
1610
+ await waitForSootsimTree(page, 30000)
1611
+ await waitForSootsimSurfaceMetrics(page, 30000)
1612
+ await waitForSootsimShellReady(page, 30000)
1613
+ await waitForSootsimWorkletsIdle(page, 3000)
1614
+ },
1615
+
1616
+ async terminateApp() {
1617
+ await closeBrowser()
1618
+ },
1619
+
1620
+ async installApp() {
1621
+ // no-op for sootsim -- app runs in browser
1622
+ },
1623
+
1624
+ async uninstallApp() {
1625
+ // no-op
1626
+ },
1627
+
1628
+ async openURL(url: { url: string; sourceApp?: string }) {
1629
+ const page = getPage()
1630
+ await navigateSootsimPage(page, url.url, {
1631
+ browserId: browserIdentity,
1632
+ contextId: contextIdentity,
1633
+ operation: 'openURL',
1634
+ })
1635
+ await waitForSootsimTree(page, 10000)
1636
+ await waitForSootsimSurfaceMetrics(page, 10000)
1637
+ await waitForSootsimShellReady(page, 10000)
1638
+ await waitForSootsimWorkletsIdle(page, 3000)
1639
+ },
1640
+
1641
+ async takeScreenshot(name: string): Promise<string> {
1642
+ return writeSootsimScreenshot(name)
1643
+ },
1644
+
1645
+ // single-shot variant for multi-stage animation captures. skips the
1646
+ // frame-stability poll so callers can sample mid-transition.
1647
+ async takeScreenshotFast(name: string): Promise<string> {
1648
+ ensureScreenshotDir()
1649
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
1650
+ fs.writeFileSync(screenshotPath, await captureSootsimPngFast())
1651
+ return screenshotPath
1652
+ },
1653
+
1654
+ // record the SootSim canvas via the engine's headless recorder
1655
+ // (window.__sootsimRecorder — @rnx/plugin-recording). this is the
1656
+ // SAME code path `rnx record video` drives, NOT the rail-button
1657
+ // path (SootSim.bridges.startRecording wires the dialog state machine
1658
+ // and never stashes the resulting blob into __sootsimRecorder.lastBlob,
1659
+ // which is what getBlobBase64 streams from).
1660
+ //
1661
+ // unlike playwright's page-level recordVideo, the headless recorder
1662
+ // captures ONLY the device canvas (composited shell + tenant surfaces)
1663
+ // — no browser chrome, no menu bar, no device frame. uses the EXISTING
1664
+ // page/context so per-test state is preserved across capture.
1665
+ async startBridgeRecording(opts?: {
1666
+ durationMs?: number
1667
+ layers?: 'full' | 'tenant' | 'shell'
1668
+ fps?: number
1669
+ }): Promise<void> {
1670
+ const page = getPage()
1671
+ const startOpts = {
1672
+ format: 'webm' as const,
1673
+ fps: opts?.fps ?? 60,
1674
+ layers: opts?.layers ?? 'tenant',
1675
+ durationMs: opts?.durationMs ?? 5000,
1676
+ }
1677
+ const result = await page.evaluate(async (startOpts) => {
1678
+ const rec = (
1679
+ window as {
1680
+ __sootsimRecorder?: {
1681
+ start?: (
1682
+ o: unknown,
1683
+ ) => Promise<{ ok: boolean; error?: string; format?: string }>
1684
+ }
1685
+ }
1686
+ ).__sootsimRecorder
1687
+ const start = rec?.start
1688
+ if (typeof start !== 'function') {
1689
+ return { ok: false, error: '__sootsimRecorder.start not installed' }
1690
+ }
1691
+ return start(startOpts)
1692
+ }, startOpts)
1693
+ if (!result.ok) {
1694
+ throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`)
1695
+ }
1696
+ },
1697
+
1698
+ // stop the headless recorder, stream the resulting blob in chunks (same
1699
+ // protocol as `rnx record`), and write the webm to outPath.
1700
+ async stopAndSaveBridgeRecording(outPath: string): Promise<string> {
1701
+ const page = getPage()
1702
+ const stopResult = await page.evaluate(async () => {
1703
+ const rec = (
1704
+ window as {
1705
+ __sootsimRecorder?: {
1706
+ stop?: () => Promise<{
1707
+ ok: boolean
1708
+ error?: string
1709
+ size?: number
1710
+ mime?: string
1711
+ durationMs?: number
1712
+ }>
1713
+ }
1714
+ }
1715
+ ).__sootsimRecorder
1716
+ const stop = rec?.stop
1717
+ if (typeof stop !== 'function') {
1718
+ return { ok: false, error: '__sootsimRecorder.stop not installed' }
1719
+ }
1720
+ return stop()
1721
+ })
1722
+ if (!stopResult.ok) {
1723
+ throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`)
1724
+ }
1725
+ if (!stopResult.size) {
1726
+ throw new Error('stopBridgeRecording: recorder returned empty blob')
1727
+ }
1728
+ const chunks: Buffer[] = []
1729
+ let offset = 0
1730
+ const CHUNK = 2 * 1024 * 1024
1731
+ while (true) {
1732
+ const result: {
1733
+ data: string
1734
+ size: number
1735
+ offset: number
1736
+ done: boolean
1737
+ mime: string
1738
+ } | null = await page.evaluate(
1739
+ ({ offset, chunk }) => {
1740
+ const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } })
1741
+ .__sootsimRecorder
1742
+ const get = rec?.getBlobBase64 as
1743
+ | ((args: { offset: number; chunk: number }) => Promise<{
1744
+ data: string
1745
+ size: number
1746
+ offset: number
1747
+ done: boolean
1748
+ mime: string
1749
+ } | null>)
1750
+ | undefined
1751
+ if (typeof get !== 'function') return null
1752
+ return get({ offset, chunk })
1753
+ },
1754
+ { offset, chunk: CHUNK },
1755
+ )
1756
+ if (!result) throw new Error('rnx recorder produced no blob')
1757
+ chunks.push(Buffer.from(result.data, 'base64'))
1758
+ offset = result.offset
1759
+ if (result.done) break
1760
+ }
1761
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
1762
+ fs.writeFileSync(outPath, Buffer.concat(chunks))
1763
+ ;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath
1764
+ return outPath
1765
+ },
1766
+
1767
+ _lastRecordingPath: '' as string,
1768
+
1769
+ // park every render clock before the interaction, detect the first changed
1770
+ // stepped frame inside the named component, then advance exact 60fps frames
1771
+ // to each requested offset. native extraction also chooses the first frame
1772
+ // at or after an offset, so both sides use the same rounding convention.
1773
+ async takeScreenshotStagesFromVisibleChangeFast(
1774
+ namePrefix: string,
1775
+ stages: Array<{ label: string; offsetMs: number }>,
1776
+ options: {
1777
+ detectionTimeoutMs?: number
1778
+ frame?: { height: number; width: number; x: number; y: number } | null
1779
+ // a pointer the capture itself presses at the trigger point and releases
1780
+ // once durationMs of lockstep time has elapsed, so held states (a lifted
1781
+ // control thumb, a pressed row) are painted mid-hold. an external trigger
1782
+ // that holds and releases before returning never paints a frame in
1783
+ // between, because no lockstep frame steps while it runs. moves drag the
1784
+ // held pointer to each point once afterMs of lockstep time has elapsed
1785
+ // since the press, and the release lands at the last point reached.
1786
+ hold?: {
1787
+ durationMs: number
1788
+ moves?: Array<{ afterMs: number; x: number; y: number }>
1789
+ x: number
1790
+ y: number
1791
+ } | null
1792
+ rootFrame?: { height: number; width: number; x: number; y: number } | null
1793
+ settleMs?: number
1794
+ trigger?: () => unknown
1795
+ },
1796
+ ): Promise<{
1797
+ frames: Array<{
1798
+ label: string
1799
+ offsetMs: number
1800
+ actualMs: number
1801
+ path: string
1802
+ frameIndex: number
1803
+ virtualTimestampMs: number
1804
+ }>
1805
+ triggerCompletedMs: number
1806
+ triggerStartMs: number
1807
+ visibleChangeDetection: {
1808
+ actualMs: number
1809
+ changedPixelPercent: number
1810
+ changedPixels: number
1811
+ channelThreshold: number
1812
+ frame: { height: number; width: number; x: number; y: number } | null
1813
+ minimumChangePercent: number
1814
+ rootFrame: { height: number; width: number; x: number; y: number } | null
1815
+ totalPixels: number
1816
+ frameIndex: number
1817
+ virtualTimestampMs: number
1818
+ }
1819
+ }> {
1820
+ const page = getPage()
1821
+ const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`
1822
+ const settleMs = options.settleMs ?? 0
1823
+ if (!Number.isFinite(settleMs) || settleMs < 0) {
1824
+ throw new Error('rnx motion capture needs a non-negative settleMs')
1825
+ }
1826
+ await page.evaluate(measureMotionChange, {
1827
+ definition: MOTION_CHANGE_DEFINITION,
1828
+ installGlobal: true,
1829
+ })
1830
+ const capturePromise: Promise<{
1831
+ frames: Array<
1832
+ FastScreenshotData & {
1833
+ frameIndex: number
1834
+ virtualTimestampMs: number
1835
+ }
1836
+ >
1837
+ visibleChangeDetection: {
1838
+ actualMs: number
1839
+ changedPixelPercent: number
1840
+ changedPixels: number
1841
+ channelThreshold: number
1842
+ frameIndex: number
1843
+ minimumChangePercent: number
1844
+ totalPixels: number
1845
+ virtualTimestampMs: number
1846
+ }
1847
+ }> = page.evaluate(
1848
+ async (input) => {
1849
+ type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
1850
+ type CaptureState = {
1851
+ cancelled: boolean
1852
+ ready: boolean
1853
+ triggerCompleted: boolean
1854
+ }
1855
+ type CaptureWindow = Window & {
1856
+ __sootsimMeasureMotionChange?: (input: {
1857
+ baseline: Uint8ClampedArray
1858
+ candidate: Uint8ClampedArray
1859
+ definition: MotionChangeDefinition
1860
+ height: number
1861
+ label: string
1862
+ width: number
1863
+ }) => MotionChangeMeasurement
1864
+ __sootsimVisibleChangeCaptures?: Record<string, CaptureState>
1865
+ SootSim?: {
1866
+ bridges?: {
1867
+ interact?: {
1868
+ touchDown: (x: number, y: number) => Promise<boolean>
1869
+ touchMove: (x: number, y: number) => Promise<boolean>
1870
+ touchUp: (x: number, y: number) => Promise<boolean>
1871
+ }
1872
+ liveComposite?: {
1873
+ captureFresh: () => Promise<{
1874
+ canvas: HTMLCanvasElement | null
1875
+ frameToken: number
1876
+ }>
1877
+ }
1878
+ lockstepCapture?: {
1879
+ begin: (fps: number) => Promise<{
1880
+ stepFrame: () => Promise<FrameReceipt>
1881
+ stepToOffset: (
1882
+ anchor: FrameReceipt,
1883
+ offsetMs: number,
1884
+ ) => Promise<FrameReceipt>
1885
+ end: () => void
1886
+ }>
1887
+ }
1888
+ }
1889
+ }
1890
+ }
1891
+ type Snapshot = {
1892
+ actualMs: number
1893
+ bitmap: ImageBitmap
1894
+ label: string
1895
+ offsetMs: number
1896
+ frameIndex: number
1897
+ virtualTimestampMs: number
1898
+ }
1899
+
1900
+ const captureWindow = window as unknown as CaptureWindow
1901
+ const live = captureWindow.SootSim?.bridges?.liveComposite
1902
+ const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture
1903
+ if (!live || typeof live.captureFresh !== 'function') {
1904
+ throw new Error('rnx live composite bridge is not installed')
1905
+ }
1906
+ if (!lockstep || typeof lockstep.begin !== 'function') {
1907
+ throw new Error('SootSim lockstep capture bridge is not installed')
1908
+ }
1909
+ const registry =
1910
+ captureWindow.__sootsimVisibleChangeCaptures ??
1911
+ (captureWindow.__sootsimVisibleChangeCaptures = {})
1912
+ const state: CaptureState = {
1913
+ cancelled: false,
1914
+ ready: false,
1915
+ triggerCompleted: false,
1916
+ }
1917
+ registry[input.token] = state
1918
+ const measureChange = captureWindow.__sootsimMeasureMotionChange
1919
+ if (!measureChange) {
1920
+ throw new Error('rnx motion change definition is not installed')
1921
+ }
1922
+
1923
+ const captureBitmap = async () => {
1924
+ const { canvas } = await live.captureFresh()
1925
+ if (!canvas) throw new Error('rnx live composite produced no canvas')
1926
+ return createImageBitmap(canvas)
1927
+ }
1928
+ const boundsFor = (bitmap: ImageBitmap) => {
1929
+ const scale =
1930
+ input.rootFrame && input.rootFrame.width > 0
1931
+ ? bitmap.width / input.rootFrame.width
1932
+ : 1
1933
+ return {
1934
+ height: input.frame
1935
+ ? Math.max(
1936
+ 1,
1937
+ Math.min(
1938
+ bitmap.height,
1939
+ Math.ceil((input.frame.y + input.frame.height) * scale),
1940
+ ) - Math.max(0, Math.floor(input.frame.y * scale)),
1941
+ )
1942
+ : bitmap.height,
1943
+ left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
1944
+ top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
1945
+ width: input.frame
1946
+ ? Math.max(
1947
+ 1,
1948
+ Math.min(
1949
+ bitmap.width,
1950
+ Math.ceil((input.frame.x + input.frame.width) * scale),
1951
+ ) - Math.max(0, Math.floor(input.frame.x * scale)),
1952
+ )
1953
+ : bitmap.width,
1954
+ }
1955
+ }
1956
+ const regionPixels = (bitmap: ImageBitmap) => {
1957
+ const bounds = boundsFor(bitmap)
1958
+ const canvas = document.createElement('canvas')
1959
+ canvas.width = bounds.width
1960
+ canvas.height = bounds.height
1961
+ const context = canvas.getContext('2d', { willReadFrequently: true })
1962
+ if (!context) throw new Error('rnx motion detector needs a 2d canvas')
1963
+ context.drawImage(
1964
+ bitmap,
1965
+ bounds.left,
1966
+ bounds.top,
1967
+ bounds.width,
1968
+ bounds.height,
1969
+ 0,
1970
+ 0,
1971
+ bounds.width,
1972
+ bounds.height,
1973
+ )
1974
+ return context.getImageData(0, 0, bounds.width, bounds.height).data
1975
+ }
1976
+ const encode = (snapshot: Snapshot) => {
1977
+ const canvas = document.createElement('canvas')
1978
+ canvas.width = snapshot.bitmap.width
1979
+ canvas.height = snapshot.bitmap.height
1980
+ const context = canvas.getContext('2d')
1981
+ if (!context) throw new Error('rnx motion capture needs a 2d canvas')
1982
+ context.drawImage(snapshot.bitmap, 0, 0)
1983
+ snapshot.bitmap.close()
1984
+ return {
1985
+ actualMs: snapshot.actualMs,
1986
+ dataUrl: canvas.toDataURL('image/png'),
1987
+ label: snapshot.label,
1988
+ offsetMs: snapshot.offsetMs,
1989
+ frameIndex: snapshot.frameIndex,
1990
+ virtualTimestampMs: snapshot.virtualTimestampMs,
1991
+ }
1992
+ }
1993
+
1994
+ const interact = captureWindow.SootSim?.bridges?.interact
1995
+ if (input.hold && (!interact || typeof interact.touchDown !== 'function')) {
1996
+ throw new Error('rnx interact bridge is not installed')
1997
+ }
1998
+ const frameMs = 1000 / 60
1999
+ const controller = await lockstep.begin(60)
2000
+ // the hold's pointer goes down before the first stepped frame, so its
2001
+ // start is one frame before the first receipt; each move and the release
2002
+ // land on the first stepped frame at or past their time from there. the
2003
+ // deadlines are frame indexes: comparing summed virtual timestamps
2004
+ // against a duration lets rounding push an event past its frame.
2005
+ const moves = input.hold?.moves ?? []
2006
+ const holdState = {
2007
+ nextMove: 0,
2008
+ pressFrameIndex: null as number | null,
2009
+ released: false,
2010
+ x: input.hold?.x ?? 0,
2011
+ y: input.hold?.y ?? 0,
2012
+ }
2013
+ const holdEventFrameIndex = (afterMs: number) =>
2014
+ (holdState.pressFrameIndex ?? 0) + Math.ceil(afterMs / frameMs)
2015
+ const nextHoldEventFrameIndex = () => {
2016
+ if (!input.hold || holdState.released || holdState.pressFrameIndex === null) {
2017
+ return null
2018
+ }
2019
+ const move = moves[holdState.nextMove]
2020
+ return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
2021
+ }
2022
+ const advanceHold = async (receipt: FrameReceipt) => {
2023
+ if (!input.hold || !interact || holdState.released) return
2024
+ if (holdState.pressFrameIndex === null) {
2025
+ holdState.pressFrameIndex = receipt.frameIndex - 1
2026
+ }
2027
+ for (
2028
+ let move = moves[holdState.nextMove];
2029
+ move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs);
2030
+ move = moves[holdState.nextMove]
2031
+ ) {
2032
+ holdState.nextMove++
2033
+ holdState.x = move.x
2034
+ holdState.y = move.y
2035
+ await interact.touchMove(move.x, move.y)
2036
+ }
2037
+ if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
2038
+ holdState.released = true
2039
+ await interact.touchUp(holdState.x, holdState.y)
2040
+ }
2041
+ try {
2042
+ const baselineBitmap = await captureBitmap()
2043
+ const baseline = regionPixels(baselineBitmap)
2044
+ baselineBitmap.close()
2045
+ state.ready = true
2046
+ if (input.hold && interact) {
2047
+ const hit = await interact.touchDown(input.hold.x, input.hold.y)
2048
+ if (!hit) {
2049
+ throw new Error(`rnx motion hold missed at ${input.hold.x},${input.hold.y}`)
2050
+ }
2051
+ } else {
2052
+ while (!state.triggerCompleted) {
2053
+ if (state.cancelled) throw new Error('rnx motion capture was cancelled')
2054
+ await new Promise((resolve) => setTimeout(resolve, 0))
2055
+ }
2056
+ }
2057
+
2058
+ let visibleStart: FrameReceipt | null = null
2059
+ let detectedMeasurement: MotionChangeMeasurement | null = null
2060
+ const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
2061
+ for (let frame = 0; frame < detectionFrameLimit; frame++) {
2062
+ if (state.cancelled) throw new Error('rnx motion capture was cancelled')
2063
+ const receipt = await controller.stepFrame()
2064
+ await advanceHold(receipt)
2065
+ const candidateBitmap = await captureBitmap()
2066
+ const bounds = boundsFor(candidateBitmap)
2067
+ const measurement = measureChange({
2068
+ baseline,
2069
+ candidate: regionPixels(candidateBitmap),
2070
+ definition: input.motionChangeDefinition,
2071
+ height: bounds.height,
2072
+ label: 'SootSim visible-change detector',
2073
+ width: bounds.width,
2074
+ })
2075
+ candidateBitmap.close()
2076
+ if (measurement.passedMinimum) {
2077
+ visibleStart = receipt
2078
+ detectedMeasurement = measurement
2079
+ break
2080
+ }
2081
+ }
2082
+ if (!visibleStart || !detectedMeasurement) {
2083
+ throw new Error(
2084
+ `rnx motion had no visible change before ${input.detectionTimeoutMs}ms`,
2085
+ )
2086
+ }
2087
+
2088
+ const snapshots: Snapshot[] = []
2089
+ for (const stage of input.stages) {
2090
+ if (state.cancelled) throw new Error('rnx motion capture was cancelled')
2091
+ // step through every hold move and the release that fall before
2092
+ // this stage one frame at a time, so the stage paints the pointer
2093
+ // where it belongs rather than a hold the jump skipped past.
2094
+ const stageFrameIndex =
2095
+ visibleStart.frameIndex + Math.ceil(stage.offsetMs / frameMs)
2096
+ for (
2097
+ let eventFrameIndex = nextHoldEventFrameIndex();
2098
+ eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
2099
+ eventFrameIndex = nextHoldEventFrameIndex()
2100
+ ) {
2101
+ await advanceHold(await controller.stepFrame())
2102
+ }
2103
+ const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
2104
+ await advanceHold(receipt)
2105
+ snapshots.push({
2106
+ actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
2107
+ bitmap: await captureBitmap(),
2108
+ frameIndex: receipt.frameIndex,
2109
+ label: stage.label,
2110
+ offsetMs: stage.offsetMs,
2111
+ virtualTimestampMs: receipt.virtualTimestampMs,
2112
+ })
2113
+ }
2114
+ if (input.settleMs > 0) {
2115
+ const maximumOffsetMs = input.stages.reduce(
2116
+ (maximum, stage) => Math.max(maximum, stage.offsetMs),
2117
+ 0,
2118
+ )
2119
+ await controller.stepToOffset(visibleStart, maximumOffsetMs + input.settleMs)
2120
+ }
2121
+ return {
2122
+ frames: snapshots.map(encode),
2123
+ visibleChangeDetection: {
2124
+ actualMs: (visibleStart.frameIndex * 1000) / 60,
2125
+ changedPixelPercent: detectedMeasurement.changePercent,
2126
+ changedPixels: detectedMeasurement.changePixels,
2127
+ channelThreshold: input.motionChangeDefinition.channelThreshold,
2128
+ minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
2129
+ totalPixels: detectedMeasurement.totalPixels,
2130
+ frameIndex: visibleStart.frameIndex,
2131
+ virtualTimestampMs: visibleStart.virtualTimestampMs,
2132
+ },
2133
+ }
2134
+ } finally {
2135
+ controller.end()
2136
+ delete registry[input.token]
2137
+ }
2138
+ },
2139
+ {
2140
+ detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
2141
+ frame: options.frame ?? null,
2142
+ hold: options.hold ?? null,
2143
+ motionChangeDefinition: MOTION_CHANGE_DEFINITION,
2144
+ rootFrame: options.rootFrame ?? null,
2145
+ settleMs,
2146
+ stages,
2147
+ token,
2148
+ },
2149
+ )
2150
+
2151
+ await page.waitForFunction(
2152
+ (captureToken) => {
2153
+ const registry = (
2154
+ window as Window & {
2155
+ __sootsimVisibleChangeCaptures?: Record<
2156
+ string,
2157
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
2158
+ >
2159
+ }
2160
+ ).__sootsimVisibleChangeCaptures
2161
+ return registry?.[captureToken]?.ready === true
2162
+ },
2163
+ token,
2164
+ { timeout: 5000 },
2165
+ )
2166
+
2167
+ const triggerStartMs = Date.now()
2168
+ let triggerCompletedMs = 0
2169
+ try {
2170
+ if (!options.hold) {
2171
+ if (typeof options.trigger !== 'function') {
2172
+ throw new Error('rnx motion capture needs a trigger or a hold')
2173
+ }
2174
+ await Promise.resolve(options.trigger())
2175
+ }
2176
+ triggerCompletedMs = Date.now()
2177
+ await page.evaluate((captureToken) => {
2178
+ const registry = (
2179
+ window as Window & {
2180
+ __sootsimVisibleChangeCaptures?: Record<
2181
+ string,
2182
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
2183
+ >
2184
+ }
2185
+ ).__sootsimVisibleChangeCaptures
2186
+ const capture = registry?.[captureToken]
2187
+ if (!capture) throw new Error('rnx motion capture disarmed before trigger')
2188
+ capture.triggerCompleted = true
2189
+ }, token)
2190
+ const capture = await capturePromise
2191
+ return {
2192
+ frames: writeFastScreenshotFrames(namePrefix, capture.frames),
2193
+ triggerCompletedMs,
2194
+ triggerStartMs,
2195
+ visibleChangeDetection: {
2196
+ ...capture.visibleChangeDetection,
2197
+ frame: options.frame ?? null,
2198
+ rootFrame: options.rootFrame ?? null,
2199
+ },
2200
+ }
2201
+ } catch (error) {
2202
+ await page
2203
+ .evaluate((captureToken) => {
2204
+ const registry = (
2205
+ window as Window & {
2206
+ __sootsimVisibleChangeCaptures?: Record<
2207
+ string,
2208
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
2209
+ >
2210
+ }
2211
+ ).__sootsimVisibleChangeCaptures
2212
+ const capture = registry?.[captureToken]
2213
+ if (capture) capture.cancelled = true
2214
+ }, token)
2215
+ .catch(() => {})
2216
+ await capturePromise.catch(() => {})
2217
+ throw error
2218
+ }
2219
+ },
2220
+
2221
+ // FAST multi-stage capture for sub-second animation diffs. each stage
2222
+ // schedules a setTimeout at its requested offsetMs and at that moment
2223
+ // SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
2224
+ // (drawImage from the worker-transferred home + overlay canvases). PNG
2225
+ // encoding is queued after all snapshots land — encoding stalls don't
2226
+ // contaminate the animation window.
2227
+ //
2228
+ // unlike `takeScreenshotStages`, this does NOT go through screenshot()
2229
+ // (which calls forceRenderAll + composite + toDataURL serially at
2230
+ // 300-1300ms per call and blows the spring-open timing). the worker's
2231
+ // vsync-pumped rAF keeps the home canvas fresh; we just sample it.
2232
+ //
2233
+ // returns paths in stage order; actualMs reports when the snapshot
2234
+ // actually fired (typically within a few ms of offsetMs).
2235
+ async takeScreenshotStagesFast(
2236
+ namePrefix: string,
2237
+ stages: Array<{ label: string; offsetMs: number }>,
2238
+ options: {
2239
+ anchorAfterTrigger?: boolean
2240
+ skipWarmup?: boolean
2241
+ trigger: () => unknown
2242
+ },
2243
+ ): Promise<{
2244
+ captureAnchorMs: number
2245
+ frames: Array<{
2246
+ label: string
2247
+ offsetMs: number
2248
+ actualMs: number
2249
+ captureRequestedAtMs: number
2250
+ path: string
2251
+ }>
2252
+ triggerCompletedMs: number
2253
+ triggerStartMs: number
2254
+ }> {
2255
+ const page = getPage()
2256
+ const token = `timed-${process.pid}-${Date.now()}-${Math.random()}`
2257
+ const capturePromise: Promise<{
2258
+ captureAnchorMs: number
2259
+ frames: Array<{
2260
+ label: string
2261
+ offsetMs: number
2262
+ actualMs: number
2263
+ captureRequestedAtMs: number
2264
+ dataUrl: string
2265
+ }>
2266
+ }> = page.evaluate(
2267
+ async (input) => {
2268
+ type CaptureState = {
2269
+ anchorPerformanceMs: number | null
2270
+ anchorWallMs: number | null
2271
+ cancelled: boolean
2272
+ ready: boolean
2273
+ }
2274
+ type CaptureWindow = Window & {
2275
+ __sootsimTimedCaptures?: Record<string, CaptureState>
2276
+ SootSim?: {
2277
+ bridges?: {
2278
+ screenshot?: (opts?: unknown) => Promise<string>
2279
+ liveComposite?: {
2280
+ captureFresh: () => Promise<{
2281
+ canvas: HTMLCanvasElement | null
2282
+ frameToken: number
2283
+ }>
2284
+ }
2285
+ }
2286
+ }
2287
+ }
2288
+ const captureWindow = window as unknown as CaptureWindow
2289
+ const screenshot = captureWindow.SootSim?.bridges?.screenshot
2290
+ const live = captureWindow.SootSim?.bridges?.liveComposite
2291
+ if (typeof screenshot !== 'function') {
2292
+ throw new Error('rnx screenshot bridge is not installed')
2293
+ }
2294
+ const registry =
2295
+ captureWindow.__sootsimTimedCaptures ??
2296
+ (captureWindow.__sootsimTimedCaptures = {})
2297
+ const state: CaptureState = {
2298
+ anchorPerformanceMs: null,
2299
+ anchorWallMs: null,
2300
+ cancelled: false,
2301
+ ready: false,
2302
+ }
2303
+ registry[input.token] = state
2304
+ // initial warm-up: ensure the worker has produced at least one
2305
+ // frame after the menu's portal mount before we start sampling.
2306
+ // (a pump at higher cadence was tried and starved tap propagation —
2307
+ // the tenant React commit for setOpen never landed between
2308
+ // forceRender calls.)
2309
+ if (!input.skipWarmup) {
2310
+ await screenshot({ format: 'png' })
2311
+ }
2312
+ state.ready = true
2313
+ while (state.anchorPerformanceMs === null || state.anchorWallMs === null) {
2314
+ if (state.cancelled) throw new Error('rnx timed capture was cancelled')
2315
+ await new Promise((resolve) => setTimeout(resolve, 0))
2316
+ }
2317
+ const captureAnchorMs = state.anchorWallMs
2318
+ const captureAnchorPerformanceMs = state.anchorPerformanceMs
2319
+ type Snapshot = {
2320
+ label: string
2321
+ offsetMs: number
2322
+ actualMs: number
2323
+ captureRequestedAtMs: number
2324
+ dataUrl: string | null
2325
+ }
2326
+ // each stage fires at its requested offset and SYNCHRONOUSLY
2327
+ // snapshots the engine's host-side composite canvas (drawImage
2328
+ // from the worker-transferred home canvas + overlay). encoding
2329
+ // happens after — encoding stalls don't disturb the next stage's
2330
+ // capture moment.
2331
+ const snaps: Snapshot[] = await Promise.all(
2332
+ input.stages.map(
2333
+ (stage) =>
2334
+ new Promise<Snapshot>((resolve) => {
2335
+ const fire = async () => {
2336
+ const actualMs = performance.now() - captureAnchorPerformanceMs
2337
+ const captureRequestedAtMs = Date.now()
2338
+ try {
2339
+ let dataUrl: string | null = null
2340
+ if (live && typeof live.captureFresh === 'function') {
2341
+ // synchronous paint of current canvas state — no
2342
+ // worker round-trip. the background pump keeps the
2343
+ // canvas fresh.
2344
+ const { canvas } = await live.captureFresh()
2345
+ if (canvas) {
2346
+ const bmp = await createImageBitmap(canvas)
2347
+ const enc = document.createElement('canvas')
2348
+ enc.width = bmp.width
2349
+ enc.height = bmp.height
2350
+ const ctx = enc.getContext('2d')
2351
+ if (ctx) {
2352
+ ctx.drawImage(bmp, 0, 0)
2353
+ dataUrl = enc.toDataURL('image/png')
2354
+ }
2355
+ bmp.close()
2356
+ }
2357
+ }
2358
+ if (!dataUrl) {
2359
+ dataUrl = await screenshot({ format: 'png' })
2360
+ }
2361
+ resolve({
2362
+ label: stage.label,
2363
+ offsetMs: stage.offsetMs,
2364
+ actualMs,
2365
+ captureRequestedAtMs,
2366
+ dataUrl,
2367
+ })
2368
+ } catch {
2369
+ resolve({
2370
+ label: stage.label,
2371
+ offsetMs: stage.offsetMs,
2372
+ actualMs,
2373
+ captureRequestedAtMs,
2374
+ dataUrl: null,
2375
+ })
2376
+ }
2377
+ }
2378
+ setTimeout(
2379
+ () => {
2380
+ void fire()
2381
+ },
2382
+ Math.max(
2383
+ 0,
2384
+ captureAnchorPerformanceMs + stage.offsetMs - performance.now(),
2385
+ ),
2386
+ )
2387
+ }),
2388
+ ),
2389
+ )
2390
+ const out: Array<{
2391
+ label: string
2392
+ offsetMs: number
2393
+ actualMs: number
2394
+ captureRequestedAtMs: number
2395
+ dataUrl: string
2396
+ }> = []
2397
+ for (const snap of snaps) {
2398
+ if (!snap.dataUrl) continue
2399
+ out.push({
2400
+ label: snap.label,
2401
+ offsetMs: snap.offsetMs,
2402
+ actualMs: snap.actualMs,
2403
+ captureRequestedAtMs: snap.captureRequestedAtMs,
2404
+ dataUrl: snap.dataUrl,
2405
+ })
2406
+ }
2407
+ delete registry[input.token]
2408
+ return { captureAnchorMs, frames: out }
2409
+ },
2410
+ { stages, skipWarmup: options.skipWarmup === true, token },
2411
+ )
2412
+
2413
+ await page.waitForFunction(
2414
+ (captureToken) => {
2415
+ const registry = (
2416
+ window as Window & {
2417
+ __sootsimTimedCaptures?: Record<string, { ready: boolean }>
2418
+ }
2419
+ ).__sootsimTimedCaptures
2420
+ return registry?.[captureToken]?.ready === true
2421
+ },
2422
+ token,
2423
+ { timeout: 5000 },
2424
+ )
2425
+
2426
+ const armCapture = (anchorMs?: number) =>
2427
+ page.evaluate(
2428
+ ({ anchorMs, captureToken }) => {
2429
+ const registry = (
2430
+ window as Window & {
2431
+ __sootsimTimedCaptures?: Record<
2432
+ string,
2433
+ {
2434
+ anchorPerformanceMs: number | null
2435
+ anchorWallMs: number | null
2436
+ ready: boolean
2437
+ }
2438
+ >
2439
+ }
2440
+ ).__sootsimTimedCaptures
2441
+ const capture = registry?.[captureToken]
2442
+ if (!capture?.ready) throw new Error('rnx timed capture was not armed')
2443
+ const nowMs = Date.now()
2444
+ capture.anchorWallMs = anchorMs ?? nowMs
2445
+ capture.anchorPerformanceMs = performance.now() - (nowMs - capture.anchorWallMs)
2446
+ return capture.anchorWallMs
2447
+ },
2448
+ { anchorMs, captureToken: token },
2449
+ )
2450
+
2451
+ let triggerStartMs = 0
2452
+ let triggerCompletedMs = 0
2453
+ try {
2454
+ let captureAnchorMs: number
2455
+ if (options.anchorAfterTrigger === false) {
2456
+ captureAnchorMs = await armCapture()
2457
+ triggerStartMs = captureAnchorMs
2458
+ const triggerPromise = Promise.resolve()
2459
+ .then(options.trigger)
2460
+ .then(() => {
2461
+ triggerCompletedMs = Date.now()
2462
+ })
2463
+ const [capture] = await Promise.all([capturePromise, triggerPromise])
2464
+ return {
2465
+ captureAnchorMs,
2466
+ frames: writeFastScreenshotFrames(namePrefix, capture.frames),
2467
+ triggerCompletedMs,
2468
+ triggerStartMs,
2469
+ }
2470
+ }
2471
+
2472
+ triggerStartMs = Date.now()
2473
+ await Promise.resolve(options.trigger())
2474
+ triggerCompletedMs = Date.now()
2475
+ captureAnchorMs = await armCapture(triggerCompletedMs)
2476
+ const capture = await capturePromise
2477
+ return {
2478
+ captureAnchorMs,
2479
+ frames: writeFastScreenshotFrames(namePrefix, capture.frames),
2480
+ triggerCompletedMs,
2481
+ triggerStartMs,
2482
+ }
2483
+ } catch (error) {
2484
+ await page
2485
+ .evaluate((captureToken) => {
2486
+ const registry = (
2487
+ window as Window & {
2488
+ __sootsimTimedCaptures?: Record<string, { cancelled: boolean }>
2489
+ }
2490
+ ).__sootsimTimedCaptures
2491
+ const capture = registry?.[captureToken]
2492
+ if (capture) capture.cancelled = true
2493
+ }, token)
2494
+ .catch(() => {})
2495
+ await capturePromise.catch(() => {})
2496
+ throw error
2497
+ }
2498
+ },
2499
+
2500
+ // batched multi-stage capture for animation diffs. all N captures happen
2501
+ // inside one page.evaluate round trip so browser-side timing controls the
2502
+ // offsets — sidesteps the ~300ms per-call bridge overhead that blows
2503
+ // sub-second animation windows. each stage gets a screenshot at the
2504
+ // requested offsetMs (from when the evaluate starts, i.e. right after
2505
+ // trigger() returns). returns absolute paths in stage order.
2506
+ async takeScreenshotStages(
2507
+ namePrefix: string,
2508
+ stages: Array<{ label: string; offsetMs: number }>,
2509
+ ): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
2510
+ const page = getPage()
2511
+ const frames: Array<{
2512
+ label: string
2513
+ offsetMs: number
2514
+ actualMs: number
2515
+ dataUrl: string
2516
+ }> = await page.evaluate(
2517
+ async (input) => {
2518
+ const screenshot = (
2519
+ window as { SootSim?: { bridges?: { screenshot?: unknown } } }
2520
+ ).SootSim?.bridges?.screenshot as
2521
+ | ((opts?: unknown) => Promise<string>)
2522
+ | undefined
2523
+ if (typeof screenshot !== 'function') {
2524
+ throw new Error('rnx screenshot bridge is not installed')
2525
+ }
2526
+ const start = Date.now()
2527
+ const out: Array<{
2528
+ label: string
2529
+ offsetMs: number
2530
+ actualMs: number
2531
+ dataUrl: string
2532
+ }> = []
2533
+ for (const stage of input.stages) {
2534
+ const elapsed = Date.now() - start
2535
+ const wait = Math.max(0, stage.offsetMs - elapsed)
2536
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait))
2537
+ const actualMs = Date.now() - start
2538
+ const dataUrl = await screenshot({ format: 'png' })
2539
+ out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl })
2540
+ }
2541
+ return out
2542
+ },
2543
+ { stages },
2544
+ )
2545
+ return writeFastScreenshotFrames(namePrefix, frames)
2546
+ },
2547
+
2548
+ async shake() {
2549
+ // no-op for sootsim
2550
+ },
2551
+
2552
+ async setLocation(lat: number, lon: number) {
2553
+ // no-op
2554
+ },
2555
+
2556
+ async setStatusBar(config: StatusBarConfig) {
2557
+ await dispatchStatusBarOverride(config)
2558
+ },
2559
+
2560
+ async setURLBlacklist(urls: string[]) {
2561
+ // no-op
2562
+ },
2563
+
2564
+ async enableSynchronization() {
2565
+ _synchronizationEnabled = true
2566
+ },
2567
+
2568
+ async disableSynchronization() {
2569
+ _synchronizationEnabled = false
2570
+ },
2571
+
2572
+ getPlatform(): 'ios' | 'android' {
2573
+ return device._platform
2574
+ },
2575
+
2576
+ async pressBack() {
2577
+ if (device._platform !== 'android') return
2578
+ const page = getPage()
2579
+ const dispatched = await page.evaluate(async () => {
2580
+ const goBack = window.SootSim?.bridges?.mainShell?.goBack
2581
+ if (typeof goBack !== 'function') return false
2582
+ return (await goBack()) !== false
2583
+ })
2584
+ if (!dispatched) throw new Error('sootsim Android back bridge is not installed')
2585
+ if (_synchronizationEnabled) {
2586
+ await waitForSootsimIdle(page)
2587
+ }
2588
+ },
2589
+
2590
+ // a real browser key press. host.ts turns window keydown into the
2591
+ // `sootsim:hostKey` shell event that overlays listen to, so this drives the
2592
+ // same path a desktop user does for keys no device button covers (Escape).
2593
+ async pressKey(key: string) {
2594
+ const page = getPage()
2595
+ await page.keyboard.press(key)
2596
+ if (_synchronizationEnabled) {
2597
+ await waitForSootsimIdle(page)
2598
+ }
2599
+ },
2600
+
2601
+ // raw coordinate tap, bypassing the element-based visibility/hit-test path.
2602
+ // mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
2603
+ // target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
2604
+ // `element(...).tap({x,y})` would fail Detox's visibility threshold.
2605
+ async tap(point?: { x: number; y: number } | boolean) {
2606
+ if (point == null || typeof point === 'boolean') return
2607
+ const page = getPage()
2608
+ const { x, y } = point
2609
+ await page.evaluate(
2610
+ async ({ x: tx, y: ty }) => {
2611
+ const interactTap = window.SootSim?.bridges?.interact?.tap
2612
+ if (typeof interactTap === 'function') {
2613
+ const result = await interactTap(tx, ty)
2614
+ if (result && (typeof result !== 'object' || result.hit !== false)) return
2615
+ }
2616
+ const tap = window.__sootsimTest?.tap
2617
+ if (typeof tap !== 'function') {
2618
+ throw new Error('sootsim tap bridge is not installed')
2619
+ }
2620
+ await tap(tx, ty)
2621
+ },
2622
+ { x, y },
2623
+ )
2624
+ await page.waitForTimeout(50)
2625
+ if (_synchronizationEnabled) {
2626
+ await waitForSootsimIdle(page)
2627
+ }
2628
+ },
2629
+
2630
+ // window-point counterpart to element(...).longPress, for a target detox can
2631
+ // only address by coordinate (a native tab cell, a shell chrome control).
2632
+ async longPress(point: { x: number; y: number }, durationMs = 1000) {
2633
+ const page = getPage()
2634
+ const { x, y } = point
2635
+ await page.evaluate(
2636
+ async ({ x: px, y: py, ms }) => {
2637
+ const longPress = window.SootSim?.bridges?.interact?.longPress
2638
+ if (typeof longPress !== 'function') {
2639
+ throw new Error('sootsim longPress bridge is not installed')
2640
+ }
2641
+ const hit = await longPress(px, py, ms)
2642
+ if (!hit) {
2643
+ throw new Error(`sootsim longPress missed at ${px},${py}`)
2644
+ }
2645
+ },
2646
+ { x, y, ms: durationMs },
2647
+ )
2648
+ await page.waitForTimeout(50)
2649
+ if (_synchronizationEnabled) {
2650
+ await waitForSootsimIdle(page)
2651
+ }
2652
+ },
2653
+
2654
+ async sendToHome() {
2655
+ const page = getPage()
2656
+ _suspendedAppId = await page.evaluate(async () => {
2657
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
2658
+ const appId = state?.activeApp
2659
+ if (typeof appId !== 'string') {
2660
+ throw new Error(`cannot send an inactive SootSim app home`)
2661
+ }
2662
+ window.SootSim?.bridges?.mainShell?.goHome?.()
2663
+ return appId
2664
+ })
2665
+ await pollUntil(
2666
+ () =>
2667
+ page.evaluate(async () => {
2668
+ const state = await window.SootSim?.bridges?.mainShell?.getState?.()
2669
+ return state?.state === 'home' && state.switcherPhase === 'idle'
2670
+ }),
2671
+ Boolean,
2672
+ {
2673
+ timeoutMs: 10000,
2674
+ label: 'SootSim home transition',
2675
+ },
2676
+ )
2677
+ },
2678
+
2679
+ // get direct access to the playwright page for advanced use
2680
+ getPage(): Page {
2681
+ return getPage()
2682
+ },
2683
+ }
2684
+
2685
+ // exports matching detox API
2686
+ const sootsimExpect = createExpect(findNodeByMatcher, getPage)
2687
+ const sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage)
2688
+
2689
+ export { sootsimExpect as expect, sootsimWaitFor as waitFor }
2690
+
2691
+ // cleanup -- call this in afterAll
2692
+ export async function cleanup() {
2693
+ await closeBrowser()
2694
+ }