rnxsim 0.0.0 → 0.1.312

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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-NXVDDCFG.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-DXJCFKL3.js +17 -0
  25. package/dist-cli/chunks/app-fonts-W5AJZQJF.js +4 -0
  26. package/dist-cli/chunks/assert-3LOJEUDT.js +49 -0
  27. package/dist-cli/chunks/auth-MFTYYPNX.js +12 -0
  28. package/dist-cli/chunks/beta-GT3NN67B.js +4 -0
  29. package/dist-cli/chunks/camera-6EAK7CUL.js +22 -0
  30. package/dist-cli/chunks/chunk-274UD5FD.js +4 -0
  31. package/dist-cli/chunks/chunk-2SA2VTGP.js +5 -0
  32. package/dist-cli/chunks/chunk-33FKQH54.js +5 -0
  33. package/dist-cli/chunks/chunk-37I5AIS7.js +3 -0
  34. package/dist-cli/chunks/chunk-3D57IBPC.js +4 -0
  35. package/dist-cli/chunks/chunk-4BXCZHAU.js +4 -0
  36. package/dist-cli/chunks/chunk-5OX7ANRA.js +5 -0
  37. package/dist-cli/chunks/chunk-66NF2KNG.js +9 -0
  38. package/dist-cli/chunks/chunk-6UKJB2VC.js +125 -0
  39. package/dist-cli/chunks/chunk-7INBNA3U.js +26 -0
  40. package/dist-cli/chunks/chunk-7VWOSTD5.js +5 -0
  41. package/dist-cli/chunks/chunk-AY47WKS5.js +23 -0
  42. package/dist-cli/chunks/chunk-B7KMNHX7.js +4 -0
  43. package/dist-cli/chunks/chunk-BKNOLP3J.js +4 -0
  44. package/dist-cli/chunks/chunk-BRIZOR4K.js +28 -0
  45. package/dist-cli/chunks/chunk-DM6KUAOZ.js +4 -0
  46. package/dist-cli/chunks/chunk-EUAVBTKU.js +4 -0
  47. package/dist-cli/chunks/chunk-FTC4M26Q.js +4 -0
  48. package/dist-cli/chunks/chunk-FWYYCUAZ.js +4 -0
  49. package/dist-cli/chunks/chunk-G74YIEVS.js +7 -0
  50. package/dist-cli/chunks/chunk-GZDHEONW.js +507 -0
  51. package/dist-cli/chunks/chunk-HG7ORJMT.js +5 -0
  52. package/dist-cli/chunks/chunk-HMDOLCKG.js +15 -0
  53. package/dist-cli/chunks/chunk-JG6S4RSH.js +4 -0
  54. package/dist-cli/chunks/chunk-KR5BGMBZ.js +4 -0
  55. package/dist-cli/chunks/chunk-KZW5WBGU.js +983 -0
  56. package/dist-cli/chunks/chunk-L24Z3CRS.js +30 -0
  57. package/dist-cli/chunks/chunk-LDQ7HVCW.js +4 -0
  58. package/dist-cli/chunks/chunk-MENV7VLD.js +4 -0
  59. package/dist-cli/chunks/chunk-MNGQ42QE.js +568 -0
  60. package/dist-cli/chunks/chunk-N6A3SXMR.js +4 -0
  61. package/dist-cli/chunks/chunk-NLEMQ4PY.js +6 -0
  62. package/dist-cli/chunks/chunk-NPVGMH2N.js +101 -0
  63. package/dist-cli/chunks/chunk-OR3XTXEB.js +107 -0
  64. package/dist-cli/chunks/chunk-PEPUGJVI.js +4 -0
  65. package/dist-cli/chunks/chunk-Q3RGLERJ.js +142 -0
  66. package/dist-cli/chunks/chunk-SI3BUTQR.js +4 -0
  67. package/dist-cli/chunks/chunk-T4FT6CM7.js +4 -0
  68. package/dist-cli/chunks/chunk-TBMVBI26.js +7 -0
  69. package/dist-cli/chunks/chunk-TCWQJILS.js +5 -0
  70. package/dist-cli/chunks/chunk-TGQNQ6XT.js +766 -0
  71. package/dist-cli/chunks/chunk-TYE3WBSR.js +5 -0
  72. package/dist-cli/chunks/chunk-U4PN6JOT.js +6 -0
  73. package/dist-cli/chunks/chunk-VUFRB347.js +4 -0
  74. package/dist-cli/chunks/chunk-VZBLFMYC.js +10 -0
  75. package/dist-cli/chunks/chunk-WBT32R6N.js +25 -0
  76. package/dist-cli/chunks/chunk-XRGEEPJ5.js +38 -0
  77. package/dist-cli/chunks/chunk-Y2FSZSO7.js +71 -0
  78. package/dist-cli/chunks/chunk-YRV7HR3P.js +74 -0
  79. package/dist-cli/chunks/chunk-YZRFF4YA.js +14 -0
  80. package/dist-cli/chunks/chunk-ZMZTHU7V.js +4 -0
  81. package/dist-cli/chunks/cleanup-H3GWQB63.js +25 -0
  82. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +4 -0
  83. package/dist-cli/chunks/compat-5WG2XJV7.js +20 -0
  84. package/dist-cli/chunks/config-ANMBVVLL.js +13 -0
  85. package/dist-cli/chunks/control-NSGNQFJG.js +4 -0
  86. package/dist-cli/chunks/daemon-JHF4BJ3U.js +4 -0
  87. package/dist-cli/chunks/debug-CHV2O2KF.js +200 -0
  88. package/dist-cli/chunks/desktop-FIPIK4CH.js +31 -0
  89. package/dist-cli/chunks/detox-CVUJP7K6.js +52 -0
  90. package/dist-cli/chunks/device-6WCUM4W6.js +18 -0
  91. package/dist-cli/chunks/diagnose-I6EMWEII.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-P27CYFTN.js +4 -0
  93. package/dist-cli/chunks/drivers-HQ6AKZT6.js +4 -0
  94. package/dist-cli/chunks/film-UOQFFYNF.js +74 -0
  95. package/dist-cli/chunks/flow-N27MRRNL.js +4 -0
  96. package/dist-cli/chunks/help-SF6FEGEE.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-NJYFSJKZ.js +4 -0
  98. package/dist-cli/chunks/home-paths-XOV44JEF.js +4 -0
  99. package/dist-cli/chunks/inspect-CWFRFQTY.js +4 -0
  100. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +4 -0
  101. package/dist-cli/chunks/login-BPFQENCX.js +28 -0
  102. package/dist-cli/chunks/logout-5A45ZGY7.js +4 -0
  103. package/dist-cli/chunks/maestro-E4CSDMLM.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-CKMOJQX2.js +36 -0
  105. package/dist-cli/chunks/mode-SZYNMIQF.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-FRH4SFVG.js +4 -0
  107. package/dist-cli/chunks/perf-VXU2XJAX.js +48 -0
  108. package/dist-cli/chunks/permissions-IRIAOVND.js +13 -0
  109. package/dist-cli/chunks/record-RY7FPLLH.js +72 -0
  110. package/dist-cli/chunks/report-issue-KNWXOE53.js +29 -0
  111. package/dist-cli/chunks/runtime-AR3GJTGK.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-QLALR6GY.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-OQFMFVRL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-6TSRJFGB.js +42 -0
  115. package/dist-cli/chunks/serve-F54J2NQO.js +44 -0
  116. package/dist-cli/chunks/setup-7V6UKX4U.js +32 -0
  117. package/dist-cli/chunks/skills-QQBOSETH.js +50 -0
  118. package/dist-cli/chunks/storage-TAPMKK5O.js +37 -0
  119. package/dist-cli/chunks/store-7NX3S43H.js +4 -0
  120. package/dist-cli/chunks/telemetry-E7BUK6RA.js +4 -0
  121. package/dist-cli/chunks/timeline-IAM564NA.js +24 -0
  122. package/dist-cli/chunks/upgrade-CD55QXIX.js +7 -0
  123. package/dist-cli/chunks/upload-GZ6LIMYM.js +4 -0
  124. package/dist-cli/chunks/version-3DYJ5AS3.js +6 -0
  125. package/dist-cli/chunks/web-6NW6YLPI.js +4 -0
  126. package/dist-cli/chunks/what-happened-CZZDOOVG.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23911 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
package/detox/index.ts ADDED
@@ -0,0 +1,2118 @@
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 { createExpect, createWaitFor } from './expectations'
10
+ import {
11
+ dispatchTwoFingerGesture,
12
+ dragScrollNode,
13
+ readSootsimInteractiveViewport,
14
+ readSootsimViewport,
15
+ sootsimToPage,
16
+ waitForSootsimGestureHandler,
17
+ } from './gestures'
18
+ import { by, type Matcher } from './matchers'
19
+ import {
20
+ MOTION_CHANGE_DEFINITION,
21
+ measureMotionChange,
22
+ type MotionChangeDefinition,
23
+ type MotionChangeMeasurement,
24
+ } from './motion-change.cjs'
25
+ import type { SootSimElement } from './element-types'
26
+ import type { Browser, BrowserContext, Page } from 'playwright'
27
+ import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
28
+
29
+ export { by }
30
+ export type { SootSimElement } from './element-types'
31
+
32
+ const BASE_URL = process.env.SOOTSIM_URL || 'http://localhost:5173'
33
+ const SCREENSHOT_DIR =
34
+ process.env.SOOTSIM_SCREENSHOT_DIR ||
35
+ path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
36
+ const DETOX_PLATFORM =
37
+ process.env.SOOTSIM_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
38
+
39
+ // shared state -- playwright page and browser
40
+ let _browser: Browser | null = null
41
+ let _reapBrowserNow: (() => void) | null = null
42
+ let _context: BrowserContext | null = null
43
+ let _page: Page | null = null
44
+ let _synchronizationEnabled = false
45
+ const pageDiagnostics = new WeakMap<Page, string[]>()
46
+
47
+ const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
48
+
49
+ type StatusBarConfig = {
50
+ time?: string
51
+ dataNetwork?: string
52
+ wifiMode?: string
53
+ wifiBars?: string
54
+ cellularMode?: string
55
+ cellularBars?: string
56
+ operatorName?: string
57
+ batteryState?: string
58
+ batteryLevel?: string | number
59
+ }
60
+
61
+ function getPage(): Page {
62
+ if (!_page)
63
+ throw new Error('sootsim driver not initialized -- call device.launchApp() first')
64
+ return _page
65
+ }
66
+
67
+ function recordPageDiagnostic(page: Page, message: string) {
68
+ const entries = pageDiagnostics.get(page)
69
+ if (!entries) return
70
+ entries.push(message)
71
+ if (entries.length > 40) entries.shift()
72
+ }
73
+
74
+ function attachPageDiagnostics(page: Page) {
75
+ if (pageDiagnostics.has(page)) return
76
+ pageDiagnostics.set(page, [])
77
+ page.on('console', (message) => {
78
+ recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`)
79
+ })
80
+ page.on('pageerror', (error) => {
81
+ recordPageDiagnostic(page, `[pageerror] ${error.message}`)
82
+ })
83
+ page.on('requestfailed', (request) => {
84
+ const failure = request.failure()
85
+ recordPageDiagnostic(
86
+ page,
87
+ `[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ''}`,
88
+ )
89
+ })
90
+ }
91
+
92
+ async function describeSootsimBridgeFailure(page: Page, cause: unknown) {
93
+ let pageState: unknown = null
94
+ try {
95
+ pageState = await page.evaluate(() => ({
96
+ bodyText: document.body?.innerText?.slice(0, 500) ?? '',
97
+ globals: Object.keys(window)
98
+ .filter((key) => key.startsWith('__sootsim') || key === 'SootSim')
99
+ .sort(),
100
+ readyState: document.readyState,
101
+ title: document.title,
102
+ url: window.location.href,
103
+ }))
104
+ } catch (error) {
105
+ pageState = {
106
+ evaluateError: error instanceof Error ? error.message : String(error),
107
+ }
108
+ }
109
+
110
+ return new Error(
111
+ [
112
+ `timed out waiting for sootsim test bridge`,
113
+ `cause: ${cause instanceof Error ? cause.message : String(cause)}`,
114
+ `page: ${JSON.stringify(pageState)}`,
115
+ `recent page diagnostics:\n${(pageDiagnostics.get(page) ?? []).join('\n') || '(none)'}`,
116
+ ].join('\n'),
117
+ )
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
+ async function closeContext() {
321
+ const context = _context
322
+ _context = null
323
+ _page = null
324
+ _synchronizationEnabled = false
325
+ if (context) await context.close()
326
+ }
327
+
328
+ async function closeBrowser() {
329
+ if (!_browser) return
330
+ const browser = _browser
331
+ const reapBrowserNow = _reapBrowserNow
332
+ _browser = null
333
+ _reapBrowserNow = null
334
+ await closeContext()
335
+ try {
336
+ await browser.close()
337
+ } finally {
338
+ reapBrowserNow?.()
339
+ }
340
+ }
341
+
342
+ export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void> {
343
+ const result = await page.evaluate(
344
+ async ({ maxMs }) => {
345
+ let transitionError: string | null = null
346
+ try {
347
+ const waitForScreenTransitions = (window as any).__sootsimTest
348
+ ?.waitForScreenTransitions
349
+ if (typeof waitForScreenTransitions === 'function') {
350
+ const transitionResult = await waitForScreenTransitions({
351
+ timeoutMs: Math.min(maxMs, 1800),
352
+ settleMs: 48,
353
+ startWindowMs: 600,
354
+ })
355
+ if (
356
+ transitionResult?.started === true &&
357
+ transitionResult.settled === true &&
358
+ transitionResult.timedOut !== true
359
+ ) {
360
+ return { settled: true, elapsed: transitionResult.waitedMs ?? 0 }
361
+ }
362
+ if (transitionResult?.timedOut) {
363
+ return {
364
+ settled: false,
365
+ elapsed: transitionResult.waitedMs,
366
+ reason: 'screen transition',
367
+ }
368
+ }
369
+ }
370
+ } catch (error) {
371
+ transitionError = error instanceof Error ? error.message : String(error)
372
+ }
373
+
374
+ const start = Date.now()
375
+ const deadline = start + maxMs
376
+ const pollMs = 50
377
+ const requiredStablePolls = 3
378
+ const layoutTolerance = 1
379
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
380
+ const isLayoutStable = (a: number[] | null, b: number[]) => {
381
+ if (!a || a.length !== b.length) return false
382
+ for (let i = 0; i < b.length; i++) {
383
+ if (Math.abs(a[i] - b[i]) > layoutTolerance) return false
384
+ }
385
+ return true
386
+ }
387
+
388
+ const readSnapshot = async () => {
389
+ const root = (window as any).__sootsimRoot
390
+ const layout: number[] = []
391
+ if (root) {
392
+ const walk = (node: any) => {
393
+ if (node.layout && node.layout.width > 0) {
394
+ layout.push(
395
+ Math.round(node.layout.x),
396
+ Math.round(node.layout.y),
397
+ Math.round(node.layout.width),
398
+ Math.round(node.layout.height),
399
+ )
400
+ }
401
+ for (const child of node.children || []) walk(child)
402
+ }
403
+ walk(root)
404
+ }
405
+ return layout
406
+ }
407
+
408
+ let stableLayout: number[] | null = null
409
+ let stable = 0
410
+ while (Date.now() < deadline) {
411
+ const layout = await readSnapshot()
412
+ if (isLayoutStable(stableLayout, layout)) {
413
+ stable++
414
+ if (stable >= requiredStablePolls) {
415
+ return { settled: true, elapsed: Date.now() - start }
416
+ }
417
+ } else {
418
+ stableLayout = layout
419
+ stable = 0
420
+ }
421
+ await sleep(pollMs)
422
+ }
423
+ return {
424
+ settled: false,
425
+ elapsed: Date.now() - start,
426
+ reason: transitionError
427
+ ? `layout fallback after screen transition wait failed: ${transitionError}`
428
+ : 'layout fallback',
429
+ }
430
+ },
431
+ { maxMs },
432
+ )
433
+
434
+ if (!result?.settled) {
435
+ throw new Error(
436
+ `sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`,
437
+ )
438
+ }
439
+ }
440
+
441
+ // find a node in the sootsim tree matching a matcher descriptor
442
+ async function findNodeByMatcher(matcher: Matcher): Promise<any> {
443
+ const page = getPage()
444
+ if (matcher.index !== undefined) {
445
+ return page.evaluate(async (indexedMatcher: Matcher) => {
446
+ const filter =
447
+ indexedMatcher.type === 'id'
448
+ ? { hasId: indexedMatcher.value }
449
+ : indexedMatcher.type === 'text'
450
+ ? { hasText: indexedMatcher.value }
451
+ : indexedMatcher.type === 'label'
452
+ ? { hasLabel: indexedMatcher.value }
453
+ : indexedMatcher.type === 'role'
454
+ ? { hasRole: indexedMatcher.value }
455
+ : { type: indexedMatcher.value }
456
+ const results = await window.__sootsimTest!.queryAll(filter)
457
+ return results[indexedMatcher.index ?? 0] ?? null
458
+ }, matcher)
459
+ }
460
+ if (matcher.type === 'id') {
461
+ return page.evaluate(
462
+ (id: string) => window.__sootsimTest!.findByTestId(id),
463
+ matcher.value,
464
+ )
465
+ } else if (matcher.type === 'text') {
466
+ return page.evaluate(
467
+ (text: string) => window.__sootsimTest!.findByText(text),
468
+ matcher.value,
469
+ )
470
+ } else if (matcher.type === 'label') {
471
+ return page.evaluate(
472
+ (label: string) => window.__sootsimTest!.findByLabel(label),
473
+ matcher.value,
474
+ )
475
+ } else if (matcher.type === 'role') {
476
+ return page.evaluate(
477
+ (role: string) => window.__sootsimTest!.findByRole(role),
478
+ matcher.value,
479
+ )
480
+ } else if (matcher.type === 'type') {
481
+ return page.evaluate(async (type: string) => {
482
+ const results = await window.__sootsimTest!.queryAll({ type })
483
+ return results[0] || null
484
+ }, matcher.value)
485
+ }
486
+ return null
487
+ }
488
+
489
+ // get the absolute center coordinates of a node in sootsim coordinate space
490
+ function getNodeCenter(nodeInfo: any): { x: number; y: number } {
491
+ return {
492
+ x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
493
+ y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2,
494
+ }
495
+ }
496
+
497
+ async function nodeOffsetToPage(
498
+ page: Page,
499
+ nodeInfo: any,
500
+ dx: number,
501
+ dy: number,
502
+ ): Promise<{ x: number; y: number }> {
503
+ const center = getNodeCenter(nodeInfo)
504
+ return sootsimToPage(page, center.x + dx, center.y + dy)
505
+ }
506
+
507
+ async function getDefaultTapPoint(
508
+ page: Page,
509
+ nodeInfo: any,
510
+ ): Promise<{ x: number; y: number }> {
511
+ const resolvedTarget = await page.evaluate(
512
+ (nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null,
513
+ nodeInfo.nodeId,
514
+ )
515
+ const center =
516
+ resolvedTarget &&
517
+ typeof resolvedTarget.cx === 'number' &&
518
+ typeof resolvedTarget.cy === 'number'
519
+ ? { x: resolvedTarget.cx, y: resolvedTarget.cy }
520
+ : getNodeCenter(nodeInfo)
521
+ const frame = nodeInfo.visibleFrame ?? {
522
+ x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
523
+ y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
524
+ width: nodeInfo.layout?.width ?? 0,
525
+ height: nodeInfo.layout?.height ?? 0,
526
+ }
527
+ const fullFrameTolerance = 0.5
528
+ const fullyVisible =
529
+ typeof nodeInfo.absolutePosition?.x === 'number' &&
530
+ typeof nodeInfo.absolutePosition?.y === 'number' &&
531
+ typeof nodeInfo.layout?.width === 'number' &&
532
+ typeof nodeInfo.layout?.height === 'number' &&
533
+ Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance &&
534
+ Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance &&
535
+ frame.width >= nodeInfo.layout.width - fullFrameTolerance &&
536
+ frame.height >= nodeInfo.layout.height - fullFrameTolerance
537
+ if (fullyVisible) return center
538
+
539
+ const viewport = await readSootsimInteractiveViewport(page)
540
+ const left = Math.max(0, frame.x)
541
+ const top = Math.max(0, frame.y)
542
+ const right = Math.min(viewport.width, frame.x + frame.width)
543
+ const bottom = Math.min(viewport.height, frame.y + frame.height)
544
+
545
+ if (right <= left || bottom <= top) {
546
+ return center
547
+ }
548
+
549
+ return {
550
+ x: left + (right - left) / 2,
551
+ y: top + (bottom - top) / 2,
552
+ }
553
+ }
554
+
555
+ async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) {
556
+ await page.evaluate(
557
+ async ({ x, y }) => {
558
+ const interactTap = window.SootSim?.bridges?.interact?.tap
559
+ if (typeof interactTap === 'function') {
560
+ const result = await interactTap(x, y)
561
+ if (result && (typeof result !== 'object' || result.hit !== false)) return
562
+ }
563
+ const tap = window.__sootsimTest?.tap
564
+ if (typeof tap !== 'function') {
565
+ throw new Error('sootsim tap bridge is not installed')
566
+ }
567
+ await tap(x, y)
568
+ },
569
+ { x, y },
570
+ )
571
+ }
572
+
573
+ async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
574
+ const focused = await page.evaluate(
575
+ () => window.__sootsimTest!.getFocusedNode?.() ?? null,
576
+ )
577
+ return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
578
+ }
579
+
580
+ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
581
+ const page = getPage()
582
+ const node = await findNodeByMatcher(el._matcher)
583
+ if (!node) {
584
+ throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`)
585
+ }
586
+ if (await isFocusedTextInputNode(page, node)) return
587
+ await el.tap()
588
+ await page.waitForTimeout(100)
589
+ }
590
+
591
+ function ensureScreenshotDir() {
592
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
593
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
594
+ }
595
+ }
596
+
597
+ function decodePngDataUrl(dataUrl: string): Buffer {
598
+ const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl)
599
+ if (!match) {
600
+ throw new Error(
601
+ `sootsim screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`,
602
+ )
603
+ }
604
+ return Buffer.from(match[1], 'base64')
605
+ }
606
+
607
+ type FastScreenshotData = {
608
+ label: string
609
+ offsetMs: number
610
+ actualMs: number
611
+ dataUrl: string
612
+ frameIndex?: number
613
+ virtualTimestampMs?: number
614
+ }
615
+
616
+ function writeFastScreenshotFrames<T extends FastScreenshotData>(
617
+ namePrefix: string,
618
+ frames: T[],
619
+ ): Array<Omit<T, 'dataUrl'> & { path: string }> {
620
+ ensureScreenshotDir()
621
+ return frames.map((frame) => {
622
+ const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
623
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
624
+ fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl))
625
+ const { dataUrl: _dataUrl, ...metadata } = frame
626
+ return {
627
+ ...metadata,
628
+ path: filePath,
629
+ }
630
+ })
631
+ }
632
+
633
+ // single-shot canvas capture — no frame-stability poll. used for multi-stage
634
+ // animation captures where we explicitly want to sample mid-transition. the
635
+ // usual captureSootsimPng polls until two consecutive frames are byte-equal
636
+ // which is ideal for settled screenshots but blocks for the full timeout
637
+ // while an animation is running.
638
+ async function captureSootsimPngFast(opts?: {
639
+ crop?: { h: number; w: number; x: number; y: number }
640
+ }) {
641
+ const page = getPage()
642
+ const dataUrl = await page.evaluate(
643
+ async (captureOpts) => {
644
+ const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
645
+ .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
646
+ if (typeof screenshot !== 'function') {
647
+ throw new Error('sootsim screenshot bridge is not installed')
648
+ }
649
+ return screenshot(captureOpts)
650
+ },
651
+ { crop: opts?.crop, format: 'png' },
652
+ )
653
+ if (!dataUrl) {
654
+ throw new Error('sootsim screenshot bridge returned an empty image')
655
+ }
656
+ return decodePngDataUrl(dataUrl)
657
+ }
658
+
659
+ async function captureSootsimPng(opts?: {
660
+ crop?: { h: number; w: number; x: number; y: number }
661
+ }) {
662
+ const page = getPage()
663
+ // frame-stability poll: capture twice with a gap > one tenant live-blur
664
+ // push interval (~67ms) and accept only when both PNGs are byte-equal.
665
+ // PNGs from canvas.toDataURL are deterministic for stable RGBA, so byte-
666
+ // equal = pixel-equal. without this, the keyboard's blur stream and the
667
+ // text-input cursor blink produce 1-12pp frame-to-frame variance in
668
+ // captures fired blindly after a settle, which then drifts the published
669
+ // proof manifest. matches the same poll in scripts/conformance-fast-iter.ts
670
+ // so cron and publish lock onto the same stable frame.
671
+ const result: {
672
+ dataUrl: string
673
+ stable: boolean
674
+ attempts: number
675
+ elapsedMs: number
676
+ } = await page.evaluate(
677
+ async (captureOpts) => {
678
+ const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
679
+ .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
680
+ if (typeof screenshot !== 'function') {
681
+ throw new Error('sootsim screenshot bridge is not installed')
682
+ }
683
+ const gapMs = 100
684
+ const maxWaitMs = 8000
685
+ const minAttempts = 3
686
+ const t0 = Date.now()
687
+ let prev = await screenshot(captureOpts)
688
+ let attempts = 1
689
+ while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
690
+ await new Promise((r) => setTimeout(r, gapMs))
691
+ const next = await screenshot(captureOpts)
692
+ attempts++
693
+ if (next === prev) {
694
+ return {
695
+ dataUrl: next,
696
+ stable: true,
697
+ attempts,
698
+ elapsedMs: Date.now() - t0,
699
+ }
700
+ }
701
+ prev = next
702
+ }
703
+ return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 }
704
+ },
705
+ {
706
+ crop: opts?.crop,
707
+ format: 'png',
708
+ },
709
+ )
710
+ if (!result.dataUrl) {
711
+ throw new Error('sootsim screenshot bridge returned an empty image')
712
+ }
713
+ if (!result.stable) {
714
+ console.warn(
715
+ `[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`,
716
+ )
717
+ }
718
+ return decodePngDataUrl(result.dataUrl)
719
+ }
720
+
721
+ async function writeSootsimScreenshot(
722
+ name: string,
723
+ opts?: { crop?: { h: number; w: number; x: number; y: number } },
724
+ ) {
725
+ ensureScreenshotDir()
726
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
727
+ fs.writeFileSync(screenshotPath, await captureSootsimPng(opts))
728
+ return screenshotPath
729
+ }
730
+
731
+ async function dispatchStatusBarOverride(config: StatusBarConfig) {
732
+ const page = getPage()
733
+ const time =
734
+ typeof config.time === 'string' && config.time.length > 0 ? config.time : null
735
+ await page.evaluate(
736
+ ({ eventType, time }) => {
737
+ window.dispatchEvent(
738
+ new CustomEvent(eventType, {
739
+ detail: { time },
740
+ }),
741
+ )
742
+ },
743
+ { eventType: STATUS_BAR_OVERRIDE_EVENT, time },
744
+ )
745
+ await page.waitForTimeout(50)
746
+ }
747
+
748
+ async function dispatchSootsimEventInPage<K extends SootsimEventName>(
749
+ type: K,
750
+ detail: SootsimEventMap[K],
751
+ ) {
752
+ const page = getPage()
753
+ await page.evaluate(
754
+ ({ detail, type }) => {
755
+ window.dispatchEvent(new CustomEvent(type, { detail }))
756
+ },
757
+ { detail, type },
758
+ )
759
+ }
760
+
761
+ function createSootSimElement(matcher: Matcher): SootSimElement {
762
+ const el: SootSimElement = {
763
+ _matcher: matcher,
764
+
765
+ async tap(point) {
766
+ const page = getPage()
767
+ await waitForSootsimWorkletsIdle(page, 3000)
768
+ const node = await findNodeByMatcher(matcher)
769
+ if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`)
770
+ const defaultPoint = point ? null : await getDefaultTapPoint(page, node)
771
+ const targetSootSimX =
772
+ typeof point?.x === 'number'
773
+ ? node.absolutePosition.x + point.x
774
+ : (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
775
+ const targetSootSimY =
776
+ typeof point?.y === 'number'
777
+ ? node.absolutePosition.y + point.y
778
+ : (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
779
+ await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY)
780
+ if (_synchronizationEnabled) {
781
+ await waitForSootsimIdle(page)
782
+ }
783
+ },
784
+
785
+ async multiTap(times: number) {
786
+ // detox iOS multiTap performs N taps with the system's natural double-
787
+ // tap pause. UITapGestureRecognizer treats taps within ~250ms as a
788
+ // multi-tap. emit each tap as a discrete down/up at the same node
789
+ // center with a small inter-tap pause that stays inside RNGH's
790
+ // default maxDelay window of 200ms.
791
+ const page = getPage()
792
+ const node = await findNodeByMatcher(matcher)
793
+ if (!node)
794
+ throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`)
795
+ const center = getNodeCenter(node)
796
+ const pageCoords = await sootsimToPage(page, center.x, center.y)
797
+
798
+ for (let i = 0; i < times; i++) {
799
+ await page.mouse.move(pageCoords.x, pageCoords.y)
800
+ await page.mouse.down()
801
+ await page.waitForTimeout(50)
802
+ await page.mouse.up()
803
+ if (i < times - 1) {
804
+ await page.waitForTimeout(80)
805
+ }
806
+ }
807
+ await page.waitForTimeout(80)
808
+ },
809
+
810
+ async longPress(duration = 1000) {
811
+ const page = getPage()
812
+ const node = await findNodeByMatcher(matcher)
813
+ if (!node)
814
+ throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`)
815
+ const center = getNodeCenter(node)
816
+ await page.evaluate(
817
+ async ({ duration, x, y }) => {
818
+ const longPress = window.SootSim?.bridges?.interact?.longPress
819
+ if (typeof longPress !== 'function') {
820
+ throw new Error('sootsim longPress bridge is not installed')
821
+ }
822
+ const hit = await longPress(x, y, duration)
823
+ if (!hit) {
824
+ throw new Error(`sootsim longPress missed at ${x},${y}`)
825
+ }
826
+ },
827
+ { duration, x: center.x, y: center.y },
828
+ )
829
+ await page.waitForTimeout(50)
830
+ },
831
+
832
+ async longPressAndDrag(
833
+ duration: number,
834
+ normalizedStartX: number,
835
+ normalizedStartY: number,
836
+ targetElement: SootSimElement,
837
+ normalizedEndX: number,
838
+ normalizedEndY: number,
839
+ speed = 'fast',
840
+ holdDuration = 0,
841
+ ) {
842
+ const page = getPage()
843
+ // resolve source element
844
+ const srcNode = await findNodeByMatcher(matcher)
845
+ if (!srcNode)
846
+ throw new Error(`source element not found: ${JSON.stringify(matcher)}`)
847
+
848
+ // resolve target element
849
+ const tgtNode = await findNodeByMatcher(targetElement._matcher)
850
+ if (!tgtNode)
851
+ throw new Error(
852
+ `target element not found: ${JSON.stringify(targetElement._matcher)}`,
853
+ )
854
+
855
+ // compute start and end in sootsim coords
856
+ const startSootSimX =
857
+ srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX
858
+ const startSootSimY =
859
+ srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY
860
+ const endSootSimX =
861
+ tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX
862
+ const endSootSimY =
863
+ tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY
864
+
865
+ const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
866
+ const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
867
+ const steps = speed === 'slow' ? 20 : 10
868
+
869
+ // press at start position
870
+ await page.mouse.move(startPage.x, startPage.y)
871
+ await page.mouse.down()
872
+ await page.waitForTimeout(Math.max(duration, holdDuration))
873
+
874
+ // drag to end position
875
+ const dx = (endPage.x - startPage.x) / steps
876
+ const dy = (endPage.y - startPage.y) / steps
877
+ for (let i = 1; i <= steps; i++) {
878
+ await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
879
+ await page.waitForTimeout(speed === 'slow' ? 30 : 10)
880
+ }
881
+
882
+ await page.mouse.up()
883
+ await page.waitForTimeout(50)
884
+ },
885
+
886
+ async typeText(text: string) {
887
+ const page = getPage()
888
+ await focusElementForTextEntry(el)
889
+ await page.keyboard.type(text)
890
+ },
891
+
892
+ async replaceText(text: string) {
893
+ const page = getPage()
894
+ await focusElementForTextEntry(el)
895
+ const node = await findNodeByMatcher(matcher)
896
+ const value = typeof node?.text === 'string' ? node.text : ''
897
+ for (let i = 0; i < value.length; i++) {
898
+ await page.keyboard.press('Backspace')
899
+ }
900
+ await page.keyboard.type(text)
901
+ },
902
+
903
+ async clearText() {
904
+ const page = getPage()
905
+ await focusElementForTextEntry(el)
906
+ const node = await findNodeByMatcher(matcher)
907
+ const value = typeof node?.text === 'string' ? node.text : ''
908
+ for (let i = 0; i < value.length; i++) {
909
+ await page.keyboard.press('Backspace')
910
+ }
911
+ },
912
+
913
+ async scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right') {
914
+ const node = await findNodeByMatcher(matcher)
915
+ if (!node)
916
+ throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
917
+ const page = getPage()
918
+ await dragScrollNode(page, node, pixels, direction)
919
+ },
920
+
921
+ async scrollTo(edge: 'top' | 'bottom' | 'left' | 'right') {
922
+ const node = await findNodeByMatcher(matcher)
923
+ if (!node)
924
+ throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
925
+
926
+ const targetId =
927
+ typeof node.testID === 'string' && node.testID
928
+ ? node.testID
929
+ : typeof node.id === 'string' && node.id
930
+ ? node.id
931
+ : null
932
+ if (!targetId) {
933
+ throw new Error(
934
+ `scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`,
935
+ )
936
+ }
937
+
938
+ const hitX = node.absolutePosition.x + node.layout.width / 2
939
+ const hitY = node.absolutePosition.y + node.layout.height / 2
940
+ const page = getPage()
941
+ const result = await page.evaluate(
942
+ async ({ edge, hitX, hitY, targetId }) => {
943
+ const bridge = window.__sootsimTest
944
+ if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
945
+ return { ok: false, reason: 'scroll bridge unavailable' }
946
+ }
947
+
948
+ const state = await bridge.getScrollStateAt(hitX, hitY)
949
+ if (!state) return { ok: false, reason: 'scroll state not found' }
950
+
951
+ const maxOffset =
952
+ state.maxOffset && typeof state.maxOffset === 'object'
953
+ ? (state.maxOffset as { x?: number; y?: number })
954
+ : null
955
+ const offset =
956
+ state.offset && typeof state.offset === 'object'
957
+ ? (state.offset as { x?: number; y?: number })
958
+ : null
959
+ const maxX =
960
+ typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0)
961
+ const maxY =
962
+ typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0)
963
+ const currentX =
964
+ typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0)
965
+ const currentY =
966
+ typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0)
967
+
968
+ const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX
969
+ const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY
970
+ return bridge.scrollTo(targetId, x, y, false)
971
+ },
972
+ { edge, hitX, hitY, targetId },
973
+ )
974
+
975
+ if (!result?.ok) {
976
+ throw new Error(
977
+ `scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`,
978
+ )
979
+ }
980
+
981
+ await page.evaluate(
982
+ () =>
983
+ new Promise<void>((resolve) => {
984
+ requestAnimationFrame(() => resolve())
985
+ }),
986
+ )
987
+ if (_synchronizationEnabled) {
988
+ await waitForSootsimIdle(page)
989
+ }
990
+ },
991
+
992
+ async swipe(
993
+ direction: 'up' | 'down' | 'left' | 'right',
994
+ speed: 'fast' | 'slow' = 'fast',
995
+ percentage = 0.75,
996
+ normalizedStartingPointX = 0.5,
997
+ normalizedStartingPointY = 0.5,
998
+ ) {
999
+ const node = await findNodeByMatcher(matcher)
1000
+ if (!node)
1001
+ throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
1002
+
1003
+ const page = getPage()
1004
+ const { absolutePosition, layout } = node
1005
+
1006
+ const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
1007
+ const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY
1008
+
1009
+ let endSootSimX = startSootSimX
1010
+ let endSootSimY = startSootSimY
1011
+ const viewport = await readSootsimViewport(page)
1012
+ const dist =
1013
+ direction === 'up' || direction === 'down'
1014
+ ? viewport.height * percentage
1015
+ : viewport.width * percentage
1016
+
1017
+ switch (direction) {
1018
+ case 'up':
1019
+ endSootSimY -= dist
1020
+ break
1021
+ case 'down':
1022
+ endSootSimY += dist
1023
+ break
1024
+ case 'left':
1025
+ endSootSimX -= dist
1026
+ break
1027
+ case 'right':
1028
+ endSootSimX += dist
1029
+ break
1030
+ }
1031
+
1032
+ // detox injects one point per display-link tick. its fast path contains
1033
+ // 21 points and its slow path 41, including the endpoints.
1034
+ const steps = speed === 'slow' ? 40 : 20
1035
+ const stepDelay = 1000 / 60
1036
+
1037
+ await page.evaluate(
1038
+ async ({ fromX, fromY, toX, toY, steps, stepMs }) => {
1039
+ // the interact bridge, not __sootsimTest.drag. the test bridge's drag
1040
+ // runs the whole down/move/up loop through the tenant's own
1041
+ // dispatchPointer and never fans to the shell, where gesture-handler's
1042
+ // recognizers live, so a swipe reported exact-zero activations for
1043
+ // every pan, fling, and manual gesture. taps were unaffected because
1044
+ // the responder path is tenant-local. the interact bridge resolves the
1045
+ // owner and feeds both, which is why the cases that call it directly
1046
+ // pass.
1047
+ const drag = window.__sootsimInteract?.drag
1048
+ if (typeof drag !== 'function') {
1049
+ throw new Error('sootsim interact bridge is not installed')
1050
+ }
1051
+ await drag(fromX, fromY, toX, toY, steps, stepMs)
1052
+ },
1053
+ {
1054
+ fromX: startSootSimX,
1055
+ fromY: startSootSimY,
1056
+ toX: endSootSimX,
1057
+ toY: endSootSimY,
1058
+ steps,
1059
+ stepMs: stepDelay,
1060
+ },
1061
+ )
1062
+ await page.waitForTimeout(100)
1063
+ },
1064
+
1065
+ async setColumnToValue(column: number, value: string) {
1066
+ if (!Number.isInteger(column) || column < 0) {
1067
+ throw new Error(`picker column must be a non-negative integer, got ${column}`)
1068
+ }
1069
+ if (typeof value !== 'string') {
1070
+ throw new Error(`picker value must be a string, got ${typeof value}`)
1071
+ }
1072
+
1073
+ const picker = await findNodeByMatcher(matcher)
1074
+ if (!picker) {
1075
+ throw new Error(
1076
+ `element not found for setColumnToValue: ${JSON.stringify(matcher)}`,
1077
+ )
1078
+ }
1079
+
1080
+ const page = getPage()
1081
+ const candidates = await page.evaluate(
1082
+ async ({ picker, column, value }) => {
1083
+ const result = await window.__sootsimTest?.queryTextCandidates?.({
1084
+ query: value,
1085
+ exact: true,
1086
+ })
1087
+ if (!result || !Array.isArray(result.candidates)) return []
1088
+ const left = picker.absolutePosition.x
1089
+ const top = picker.absolutePosition.y
1090
+ const right = left + picker.layout.width
1091
+ const bottom = top + picker.layout.height
1092
+ return result.candidates
1093
+ .filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column))
1094
+ .map((candidate) => candidate.info)
1095
+ .filter((candidate) => {
1096
+ const x = candidate.absolutePosition?.x
1097
+ const y = candidate.absolutePosition?.y
1098
+ const width = candidate.layout?.width
1099
+ const height = candidate.layout?.height
1100
+ if (
1101
+ typeof x !== 'number' ||
1102
+ typeof y !== 'number' ||
1103
+ typeof width !== 'number' ||
1104
+ typeof height !== 'number'
1105
+ ) {
1106
+ return false
1107
+ }
1108
+ const centerX = x + width / 2
1109
+ const centerY = y + height / 2
1110
+ return (
1111
+ centerX >= left && centerX <= right && centerY >= top && centerY <= bottom
1112
+ )
1113
+ })
1114
+ },
1115
+ { picker, column, value },
1116
+ )
1117
+
1118
+ if (candidates.length !== 1) {
1119
+ throw new Error(
1120
+ `setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`,
1121
+ )
1122
+ }
1123
+
1124
+ const target = candidates[0]
1125
+ // detox selects the picker row, whose hit rectangle stays flat. the
1126
+ // label is visually projected around the wheel and can overlap a later
1127
+ // label near the cylinder edge.
1128
+ const tapTarget = await page.evaluate(
1129
+ async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId),
1130
+ target.nodeId,
1131
+ )
1132
+ if (!tapTarget) {
1133
+ throw new Error(
1134
+ `setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`,
1135
+ )
1136
+ }
1137
+ await dispatchTapAtSootsimPoint(
1138
+ page,
1139
+ tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
1140
+ tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2,
1141
+ )
1142
+ },
1143
+
1144
+ async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) {
1145
+ // detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1
1146
+ // brings them together (zoom out). speed maps to step delay so RNGH's
1147
+ // velocity tracking sees a realistic delta. angle (radians) tilts the
1148
+ // pinch axis off horizontal — matches Detox's third arg.
1149
+ const page = getPage()
1150
+ const node = await findNodeByMatcher(matcher)
1151
+ if (!node)
1152
+ throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`)
1153
+ await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler')
1154
+ const startSpread = Math.min(
1155
+ 60,
1156
+ Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
1157
+ )
1158
+ const endSpread = startSpread * Math.max(scale, 0.05)
1159
+ const cos = Math.cos(angle)
1160
+ const sin = Math.sin(angle)
1161
+ const offset = (d: number) => ({ dx: d * cos, dy: d * sin })
1162
+ const a0 = offset(-startSpread)
1163
+ const b0 = offset(startSpread)
1164
+ const a1 = offset(-endSpread)
1165
+ const b1 = offset(endSpread)
1166
+ await dispatchTwoFingerGesture(
1167
+ page,
1168
+ await nodeOffsetToPage(page, node, a0.dx, a0.dy),
1169
+ await nodeOffsetToPage(page, node, b0.dx, b0.dy),
1170
+ await nodeOffsetToPage(page, node, a1.dx, a1.dy),
1171
+ await nodeOffsetToPage(page, node, b1.dx, b1.dy),
1172
+ { steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 },
1173
+ )
1174
+ },
1175
+
1176
+ async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
1177
+ // two fingers held on a horizontal axis around the node center, then
1178
+ // both rotated to `radians` around the same center. positive radians
1179
+ // rotate counter-clockwise to match RNGH RotationGesture's reported
1180
+ // sign (its 'rotation' delta is positive for a counter-clockwise
1181
+ // motion of the rear finger relative to the front finger).
1182
+ const page = getPage()
1183
+ const node = await findNodeByMatcher(matcher)
1184
+ if (!node)
1185
+ throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`)
1186
+ await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler')
1187
+ const radius = Math.min(
1188
+ 60,
1189
+ Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
1190
+ )
1191
+ const startA = await nodeOffsetToPage(page, node, -radius, 0)
1192
+ const startB = await nodeOffsetToPage(page, node, radius, 0)
1193
+ const cos = Math.cos(radians)
1194
+ const sin = Math.sin(radians)
1195
+ const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin)
1196
+ const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin)
1197
+ await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
1198
+ steps: speed === 'fast' ? 10 : 20,
1199
+ stepDelayMs: speed === 'fast' ? 14 : 24,
1200
+ })
1201
+ },
1202
+
1203
+ async takeScreenshot(name: string): Promise<string> {
1204
+ const node = await findNodeByMatcher(matcher)
1205
+ if (!node)
1206
+ throw new Error(
1207
+ `element not found for takeScreenshot: ${JSON.stringify(matcher)}`,
1208
+ )
1209
+
1210
+ const box = node.frame ?? {
1211
+ x: node.absolutePosition.x,
1212
+ y: node.absolutePosition.y,
1213
+ width: node.layout.width,
1214
+ height: node.layout.height,
1215
+ }
1216
+ return writeSootsimScreenshot(name, {
1217
+ crop: {
1218
+ x: box.x,
1219
+ y: box.y,
1220
+ w: box.width,
1221
+ h: box.height,
1222
+ },
1223
+ })
1224
+ },
1225
+
1226
+ async getAttributes(): Promise<any> {
1227
+ const node = await findNodeByMatcher(matcher)
1228
+ if (!node)
1229
+ throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`)
1230
+ return {
1231
+ text: node.text || '',
1232
+ label: node.accessibilityLabel || node.text || '',
1233
+ identifier: node.testID || node.id || '',
1234
+ visible: node.layout.width > 0 && node.layout.height > 0,
1235
+ enabled: !node.accessibilityState?.disabled,
1236
+ ...node,
1237
+ }
1238
+ },
1239
+
1240
+ atIndex(index: number) {
1241
+ return createSootSimElement({ ...matcher, index })
1242
+ },
1243
+ }
1244
+
1245
+ return el
1246
+ }
1247
+
1248
+ // element() factory -- takes a matcher and returns an element interaction object
1249
+ export function element(matcher: Matcher): SootSimElement {
1250
+ return createSootSimElement(matcher)
1251
+ }
1252
+
1253
+ // device object -- app lifecycle
1254
+ export const device = {
1255
+ _currentUrl: '',
1256
+ _platform: DETOX_PLATFORM as 'ios' | 'android',
1257
+
1258
+ async launchApp(opts?: {
1259
+ delete?: boolean
1260
+ newInstance?: boolean
1261
+ url?: string
1262
+ launchArgs?: Record<string, any>
1263
+ // when set, the new BrowserContext records video at this dir. used by
1264
+ // captureProofMenuStages for the multi-stage animation diff path. must
1265
+ // be set at launchApp time because playwright recordVideo can't be
1266
+ // toggled on an existing context.
1267
+ recordVideoDir?: string
1268
+ }) {
1269
+ if (!_browser) {
1270
+ // Conformance renders CanvasKit and must use the real platform GPU. The
1271
+ // bundled chromium_headless_shell has no GPU and silently falls back to
1272
+ // SwiftShader, invalidating visual/perf results while pegging the box.
1273
+ // The canonical GPU args force Metal/Vulkan and stripping Playwright's
1274
+ // --enable-unsafe-swiftshader default makes GPU init fail loudly. The
1275
+ // scoped reaper owns the whole Chrome process tree if Detox is interrupted.
1276
+ const launched = await launchReapedChromeScoped(
1277
+ (options) => chromium.launch(options),
1278
+ { headless: true },
1279
+ )
1280
+ _browser = launched.browser
1281
+ _reapBrowserNow = launched.reapNow
1282
+ }
1283
+
1284
+ if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
1285
+ await closeContext()
1286
+ const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
1287
+ hasTouch: true,
1288
+ viewport: { width: 500, height: 900 },
1289
+ }
1290
+ if (opts?.recordVideoDir) {
1291
+ fs.mkdirSync(opts.recordVideoDir, { recursive: true })
1292
+ contextOpts.recordVideo = {
1293
+ dir: opts.recordVideoDir,
1294
+ size: { width: 500, height: 900 },
1295
+ }
1296
+ }
1297
+ _context = await _browser.newContext(contextOpts)
1298
+ _page = await _context.newPage()
1299
+ attachPageDiagnostics(_page)
1300
+ }
1301
+
1302
+ const url = opts?.url || BASE_URL
1303
+ device._currentUrl = url
1304
+ await _page!.goto(url, { waitUntil: 'load', timeout: 30000 })
1305
+
1306
+ await waitForSootsimTree(_page!, 30000)
1307
+ await waitForSootsimSurfaceMetrics(_page!, 30000)
1308
+ await waitForSootsimShellReady(_page!, 30000)
1309
+ await waitForSootsimWorkletsIdle(_page!, 3000)
1310
+ // record the wall-clock time the page is ready — captureProofMenuStages
1311
+ // uses this to compute trigger-in-video offsets
1312
+ device._lastReadyAtMs = Date.now()
1313
+ },
1314
+
1315
+ _lastReadyAtMs: 0,
1316
+
1317
+ async reloadReactNative() {
1318
+ const page = getPage()
1319
+ await page.reload({ waitUntil: 'load', timeout: 30000 })
1320
+ await waitForSootsimTree(page, 30000)
1321
+ await waitForSootsimSurfaceMetrics(page, 30000)
1322
+ await waitForSootsimShellReady(page, 30000)
1323
+ await waitForSootsimWorkletsIdle(page, 3000)
1324
+ },
1325
+
1326
+ async terminateApp() {
1327
+ await closeBrowser()
1328
+ },
1329
+
1330
+ async installApp() {
1331
+ // no-op for sootsim -- app runs in browser
1332
+ },
1333
+
1334
+ async uninstallApp() {
1335
+ // no-op
1336
+ },
1337
+
1338
+ async openURL(url: { url: string; sourceApp?: string }) {
1339
+ const page = getPage()
1340
+ await page.goto(url.url, { waitUntil: 'load', timeout: 30000 })
1341
+ await waitForSootsimTree(page, 10000)
1342
+ await waitForSootsimSurfaceMetrics(page, 10000)
1343
+ await waitForSootsimShellReady(page, 10000)
1344
+ await waitForSootsimWorkletsIdle(page, 3000)
1345
+ },
1346
+
1347
+ async takeScreenshot(name: string): Promise<string> {
1348
+ return writeSootsimScreenshot(name)
1349
+ },
1350
+
1351
+ // single-shot variant for multi-stage animation captures. skips the
1352
+ // frame-stability poll so callers can sample mid-transition.
1353
+ async takeScreenshotFast(name: string): Promise<string> {
1354
+ ensureScreenshotDir()
1355
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
1356
+ fs.writeFileSync(screenshotPath, await captureSootsimPngFast())
1357
+ return screenshotPath
1358
+ },
1359
+
1360
+ // record the SootSim canvas via the engine's headless recorder
1361
+ // (window.__sootsimRecorder — @sootsim/plugin-recording). this is the
1362
+ // SAME code path `sootsim record video` drives, NOT the rail-button
1363
+ // path (SootSim.bridges.startRecording wires the dialog state machine
1364
+ // and never stashes the resulting blob into __sootsimRecorder.lastBlob,
1365
+ // which is what getBlobBase64 streams from).
1366
+ //
1367
+ // unlike playwright's page-level recordVideo, the headless recorder
1368
+ // captures ONLY the device canvas (composited shell + tenant surfaces)
1369
+ // — no browser chrome, no menu bar, no device frame. uses the EXISTING
1370
+ // page/context so per-test state is preserved across capture.
1371
+ async startBridgeRecording(opts?: {
1372
+ durationMs?: number
1373
+ layers?: 'full' | 'tenant' | 'shell'
1374
+ fps?: number
1375
+ }): Promise<void> {
1376
+ const page = getPage()
1377
+ const startOpts = {
1378
+ format: 'webm' as const,
1379
+ fps: opts?.fps ?? 60,
1380
+ layers: opts?.layers ?? 'tenant',
1381
+ durationMs: opts?.durationMs ?? 5000,
1382
+ }
1383
+ const result = await page.evaluate(async (startOpts) => {
1384
+ const rec = (
1385
+ window as {
1386
+ __sootsimRecorder?: {
1387
+ start?: (
1388
+ o: unknown,
1389
+ ) => Promise<{ ok: boolean; error?: string; format?: string }>
1390
+ }
1391
+ }
1392
+ ).__sootsimRecorder
1393
+ const start = rec?.start
1394
+ if (typeof start !== 'function') {
1395
+ return { ok: false, error: '__sootsimRecorder.start not installed' }
1396
+ }
1397
+ return start(startOpts)
1398
+ }, startOpts)
1399
+ if (!result.ok) {
1400
+ throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`)
1401
+ }
1402
+ },
1403
+
1404
+ // stop the headless recorder, stream the resulting blob in chunks (same
1405
+ // protocol as `sootsim record`), and write the webm to outPath.
1406
+ async stopAndSaveBridgeRecording(outPath: string): Promise<string> {
1407
+ const page = getPage()
1408
+ const stopResult = await page.evaluate(async () => {
1409
+ const rec = (
1410
+ window as {
1411
+ __sootsimRecorder?: {
1412
+ stop?: () => Promise<{
1413
+ ok: boolean
1414
+ error?: string
1415
+ size?: number
1416
+ mime?: string
1417
+ durationMs?: number
1418
+ }>
1419
+ }
1420
+ }
1421
+ ).__sootsimRecorder
1422
+ const stop = rec?.stop
1423
+ if (typeof stop !== 'function') {
1424
+ return { ok: false, error: '__sootsimRecorder.stop not installed' }
1425
+ }
1426
+ return stop()
1427
+ })
1428
+ if (!stopResult.ok) {
1429
+ throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`)
1430
+ }
1431
+ if (!stopResult.size) {
1432
+ throw new Error('stopBridgeRecording: recorder returned empty blob')
1433
+ }
1434
+ const chunks: Buffer[] = []
1435
+ let offset = 0
1436
+ const CHUNK = 2 * 1024 * 1024
1437
+ while (true) {
1438
+ const result: {
1439
+ data: string
1440
+ size: number
1441
+ offset: number
1442
+ done: boolean
1443
+ mime: string
1444
+ } | null = await page.evaluate(
1445
+ ({ offset, chunk }) => {
1446
+ const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } })
1447
+ .__sootsimRecorder
1448
+ const get = rec?.getBlobBase64 as
1449
+ | ((args: { offset: number; chunk: number }) => Promise<{
1450
+ data: string
1451
+ size: number
1452
+ offset: number
1453
+ done: boolean
1454
+ mime: string
1455
+ } | null>)
1456
+ | undefined
1457
+ if (typeof get !== 'function') return null
1458
+ return get({ offset, chunk })
1459
+ },
1460
+ { offset, chunk: CHUNK },
1461
+ )
1462
+ if (!result) throw new Error('rnx recorder produced no blob')
1463
+ chunks.push(Buffer.from(result.data, 'base64'))
1464
+ offset = result.offset
1465
+ if (result.done) break
1466
+ }
1467
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
1468
+ fs.writeFileSync(outPath, Buffer.concat(chunks))
1469
+ ;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath
1470
+ return outPath
1471
+ },
1472
+
1473
+ _lastRecordingPath: '' as string,
1474
+
1475
+ // set the engine's ContextMenu animation-progress override. visual-proof
1476
+ // harnesses use this to drive each capture stage to a known animProgress
1477
+ // (matched against the iOS oracle's curve) instead of fighting capture
1478
+ // latency vs. sub-second spring timing. pass null to clear.
1479
+ //
1480
+ // dispatches a host CustomEvent that the engine forwards to the tenant
1481
+ // worker (see shell-event-registry.ts + input-bridge.ts handleShellEvent).
1482
+ // the worker writes globalThis.__sootsimMenuAnimOverride which the engine
1483
+ // ContextMenu reads each rAF tick.
1484
+ async setMenuAnimOverride(progress: number | null): Promise<void> {
1485
+ // dispatched from the playwright host; the typed `dispatchSootsimEvent`
1486
+ // helper lives in the engine bundle and isn't reachable from outside
1487
+ // the page. keep this host-side wrapper typed against SootsimEventMap so
1488
+ // the event names and payload shapes still come from the central registry.
1489
+ await dispatchSootsimEventInPage('sootsim:menuAnimOverride', { value: progress })
1490
+ },
1491
+
1492
+ // park every render clock before the interaction, detect the first changed
1493
+ // stepped frame inside the named component, then advance exact 60fps frames
1494
+ // to each requested offset. native extraction also chooses the first frame
1495
+ // at or after an offset, so both sides use the same rounding convention.
1496
+ async takeScreenshotStagesFromVisibleChangeFast(
1497
+ namePrefix: string,
1498
+ stages: Array<{ label: string; offsetMs: number }>,
1499
+ options: {
1500
+ detectionTimeoutMs?: number
1501
+ frame?: { height: number; width: number; x: number; y: number } | null
1502
+ rootFrame?: { height: number; width: number; x: number; y: number } | null
1503
+ trigger: () => unknown
1504
+ },
1505
+ ): Promise<{
1506
+ frames: Array<{
1507
+ label: string
1508
+ offsetMs: number
1509
+ actualMs: number
1510
+ path: string
1511
+ frameIndex: number
1512
+ virtualTimestampMs: number
1513
+ }>
1514
+ triggerCompletedMs: number
1515
+ triggerStartMs: number
1516
+ visibleChangeDetection: {
1517
+ actualMs: number
1518
+ changedPixelPercent: number
1519
+ changedPixels: number
1520
+ channelThreshold: number
1521
+ frame: { height: number; width: number; x: number; y: number } | null
1522
+ minimumChangePercent: number
1523
+ rootFrame: { height: number; width: number; x: number; y: number } | null
1524
+ totalPixels: number
1525
+ frameIndex: number
1526
+ virtualTimestampMs: number
1527
+ }
1528
+ }> {
1529
+ const page = getPage()
1530
+ const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`
1531
+ await page.evaluate(measureMotionChange, {
1532
+ definition: MOTION_CHANGE_DEFINITION,
1533
+ installGlobal: true,
1534
+ })
1535
+ const capturePromise: Promise<{
1536
+ frames: Array<
1537
+ FastScreenshotData & {
1538
+ frameIndex: number
1539
+ virtualTimestampMs: number
1540
+ }
1541
+ >
1542
+ visibleChangeDetection: {
1543
+ actualMs: number
1544
+ changedPixelPercent: number
1545
+ changedPixels: number
1546
+ channelThreshold: number
1547
+ frameIndex: number
1548
+ minimumChangePercent: number
1549
+ totalPixels: number
1550
+ virtualTimestampMs: number
1551
+ }
1552
+ }> = page.evaluate(
1553
+ async (input) => {
1554
+ type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
1555
+ type CaptureState = {
1556
+ cancelled: boolean
1557
+ ready: boolean
1558
+ triggerCompleted: boolean
1559
+ }
1560
+ type CaptureWindow = Window & {
1561
+ __sootsimMeasureMotionChange?: (input: {
1562
+ baseline: Uint8ClampedArray
1563
+ candidate: Uint8ClampedArray
1564
+ definition: MotionChangeDefinition
1565
+ height: number
1566
+ label: string
1567
+ width: number
1568
+ }) => MotionChangeMeasurement
1569
+ __sootsimVisibleChangeCaptures?: Record<string, CaptureState>
1570
+ SootSim?: {
1571
+ bridges?: {
1572
+ liveComposite?: {
1573
+ captureFresh: () => Promise<{
1574
+ canvas: HTMLCanvasElement | null
1575
+ frameToken: number
1576
+ }>
1577
+ }
1578
+ lockstepCapture?: {
1579
+ begin: (fps: number) => Promise<{
1580
+ stepFrame: () => Promise<FrameReceipt>
1581
+ stepToOffset: (
1582
+ anchor: FrameReceipt,
1583
+ offsetMs: number,
1584
+ ) => Promise<FrameReceipt>
1585
+ end: () => void
1586
+ }>
1587
+ }
1588
+ }
1589
+ }
1590
+ }
1591
+ type Snapshot = {
1592
+ actualMs: number
1593
+ bitmap: ImageBitmap
1594
+ label: string
1595
+ offsetMs: number
1596
+ frameIndex: number
1597
+ virtualTimestampMs: number
1598
+ }
1599
+
1600
+ const captureWindow = window as unknown as CaptureWindow
1601
+ const live = captureWindow.SootSim?.bridges?.liveComposite
1602
+ const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture
1603
+ if (!live || typeof live.captureFresh !== 'function') {
1604
+ throw new Error('rnx live composite bridge is not installed')
1605
+ }
1606
+ if (!lockstep || typeof lockstep.begin !== 'function') {
1607
+ throw new Error('SootSim lockstep capture bridge is not installed')
1608
+ }
1609
+ const registry =
1610
+ captureWindow.__sootsimVisibleChangeCaptures ??
1611
+ (captureWindow.__sootsimVisibleChangeCaptures = {})
1612
+ const state: CaptureState = {
1613
+ cancelled: false,
1614
+ ready: false,
1615
+ triggerCompleted: false,
1616
+ }
1617
+ registry[input.token] = state
1618
+ const measureChange = captureWindow.__sootsimMeasureMotionChange
1619
+ if (!measureChange) {
1620
+ throw new Error('rnx motion change definition is not installed')
1621
+ }
1622
+
1623
+ const captureBitmap = async () => {
1624
+ const { canvas } = await live.captureFresh()
1625
+ if (!canvas) throw new Error('rnx live composite produced no canvas')
1626
+ return createImageBitmap(canvas)
1627
+ }
1628
+ const boundsFor = (bitmap: ImageBitmap) => {
1629
+ const scale =
1630
+ input.rootFrame && input.rootFrame.width > 0
1631
+ ? bitmap.width / input.rootFrame.width
1632
+ : 1
1633
+ return {
1634
+ height: input.frame
1635
+ ? Math.max(
1636
+ 1,
1637
+ Math.min(
1638
+ bitmap.height,
1639
+ Math.ceil((input.frame.y + input.frame.height) * scale),
1640
+ ) - Math.max(0, Math.floor(input.frame.y * scale)),
1641
+ )
1642
+ : bitmap.height,
1643
+ left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
1644
+ top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
1645
+ width: input.frame
1646
+ ? Math.max(
1647
+ 1,
1648
+ Math.min(
1649
+ bitmap.width,
1650
+ Math.ceil((input.frame.x + input.frame.width) * scale),
1651
+ ) - Math.max(0, Math.floor(input.frame.x * scale)),
1652
+ )
1653
+ : bitmap.width,
1654
+ }
1655
+ }
1656
+ const regionPixels = (bitmap: ImageBitmap) => {
1657
+ const bounds = boundsFor(bitmap)
1658
+ const canvas = document.createElement('canvas')
1659
+ canvas.width = bounds.width
1660
+ canvas.height = bounds.height
1661
+ const context = canvas.getContext('2d', { willReadFrequently: true })
1662
+ if (!context) throw new Error('rnx motion detector needs a 2d canvas')
1663
+ context.drawImage(
1664
+ bitmap,
1665
+ bounds.left,
1666
+ bounds.top,
1667
+ bounds.width,
1668
+ bounds.height,
1669
+ 0,
1670
+ 0,
1671
+ bounds.width,
1672
+ bounds.height,
1673
+ )
1674
+ return context.getImageData(0, 0, bounds.width, bounds.height).data
1675
+ }
1676
+ const encode = (snapshot: Snapshot) => {
1677
+ const canvas = document.createElement('canvas')
1678
+ canvas.width = snapshot.bitmap.width
1679
+ canvas.height = snapshot.bitmap.height
1680
+ const context = canvas.getContext('2d')
1681
+ if (!context) throw new Error('rnx motion capture needs a 2d canvas')
1682
+ context.drawImage(snapshot.bitmap, 0, 0)
1683
+ snapshot.bitmap.close()
1684
+ return {
1685
+ actualMs: snapshot.actualMs,
1686
+ dataUrl: canvas.toDataURL('image/png'),
1687
+ label: snapshot.label,
1688
+ offsetMs: snapshot.offsetMs,
1689
+ frameIndex: snapshot.frameIndex,
1690
+ virtualTimestampMs: snapshot.virtualTimestampMs,
1691
+ }
1692
+ }
1693
+
1694
+ const controller = await lockstep.begin(60)
1695
+ try {
1696
+ const baselineBitmap = await captureBitmap()
1697
+ const baseline = regionPixels(baselineBitmap)
1698
+ baselineBitmap.close()
1699
+ state.ready = true
1700
+ while (!state.triggerCompleted) {
1701
+ if (state.cancelled) throw new Error('rnx motion capture was cancelled')
1702
+ await new Promise((resolve) => setTimeout(resolve, 0))
1703
+ }
1704
+
1705
+ let visibleStart: FrameReceipt | null = null
1706
+ let detectedMeasurement: MotionChangeMeasurement | null = null
1707
+ const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
1708
+ for (let frame = 0; frame < detectionFrameLimit; frame++) {
1709
+ if (state.cancelled) throw new Error('rnx motion capture was cancelled')
1710
+ const receipt = await controller.stepFrame()
1711
+ const candidateBitmap = await captureBitmap()
1712
+ const bounds = boundsFor(candidateBitmap)
1713
+ const measurement = measureChange({
1714
+ baseline,
1715
+ candidate: regionPixels(candidateBitmap),
1716
+ definition: input.motionChangeDefinition,
1717
+ height: bounds.height,
1718
+ label: 'SootSim visible-change detector',
1719
+ width: bounds.width,
1720
+ })
1721
+ candidateBitmap.close()
1722
+ if (measurement.passedMinimum) {
1723
+ visibleStart = receipt
1724
+ detectedMeasurement = measurement
1725
+ break
1726
+ }
1727
+ }
1728
+ if (!visibleStart || !detectedMeasurement) {
1729
+ throw new Error(
1730
+ `rnx motion had no visible change before ${input.detectionTimeoutMs}ms`,
1731
+ )
1732
+ }
1733
+
1734
+ const snapshots: Snapshot[] = []
1735
+ for (const stage of input.stages) {
1736
+ if (state.cancelled) throw new Error('rnx motion capture was cancelled')
1737
+ const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
1738
+ snapshots.push({
1739
+ actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
1740
+ bitmap: await captureBitmap(),
1741
+ frameIndex: receipt.frameIndex,
1742
+ label: stage.label,
1743
+ offsetMs: stage.offsetMs,
1744
+ virtualTimestampMs: receipt.virtualTimestampMs,
1745
+ })
1746
+ }
1747
+ return {
1748
+ frames: snapshots.map(encode),
1749
+ visibleChangeDetection: {
1750
+ actualMs: (visibleStart.frameIndex * 1000) / 60,
1751
+ changedPixelPercent: detectedMeasurement.changePercent,
1752
+ changedPixels: detectedMeasurement.changePixels,
1753
+ channelThreshold: input.motionChangeDefinition.channelThreshold,
1754
+ minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
1755
+ totalPixels: detectedMeasurement.totalPixels,
1756
+ frameIndex: visibleStart.frameIndex,
1757
+ virtualTimestampMs: visibleStart.virtualTimestampMs,
1758
+ },
1759
+ }
1760
+ } finally {
1761
+ controller.end()
1762
+ delete registry[input.token]
1763
+ }
1764
+ },
1765
+ {
1766
+ detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
1767
+ frame: options.frame ?? null,
1768
+ motionChangeDefinition: MOTION_CHANGE_DEFINITION,
1769
+ rootFrame: options.rootFrame ?? null,
1770
+ stages,
1771
+ token,
1772
+ },
1773
+ )
1774
+
1775
+ await page.waitForFunction(
1776
+ (captureToken) => {
1777
+ const registry = (
1778
+ window as Window & {
1779
+ __sootsimVisibleChangeCaptures?: Record<
1780
+ string,
1781
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
1782
+ >
1783
+ }
1784
+ ).__sootsimVisibleChangeCaptures
1785
+ return registry?.[captureToken]?.ready === true
1786
+ },
1787
+ token,
1788
+ { timeout: 5000 },
1789
+ )
1790
+
1791
+ const triggerStartMs = Date.now()
1792
+ let triggerCompletedMs = 0
1793
+ try {
1794
+ await Promise.resolve(options.trigger())
1795
+ triggerCompletedMs = Date.now()
1796
+ await page.evaluate((captureToken) => {
1797
+ const registry = (
1798
+ window as Window & {
1799
+ __sootsimVisibleChangeCaptures?: Record<
1800
+ string,
1801
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
1802
+ >
1803
+ }
1804
+ ).__sootsimVisibleChangeCaptures
1805
+ const capture = registry?.[captureToken]
1806
+ if (!capture) throw new Error('rnx motion capture disarmed before trigger')
1807
+ capture.triggerCompleted = true
1808
+ }, token)
1809
+ const capture = await capturePromise
1810
+ return {
1811
+ frames: writeFastScreenshotFrames(namePrefix, capture.frames),
1812
+ triggerCompletedMs,
1813
+ triggerStartMs,
1814
+ visibleChangeDetection: {
1815
+ ...capture.visibleChangeDetection,
1816
+ frame: options.frame ?? null,
1817
+ rootFrame: options.rootFrame ?? null,
1818
+ },
1819
+ }
1820
+ } catch (error) {
1821
+ await page
1822
+ .evaluate((captureToken) => {
1823
+ const registry = (
1824
+ window as Window & {
1825
+ __sootsimVisibleChangeCaptures?: Record<
1826
+ string,
1827
+ { cancelled: boolean; ready: boolean; triggerCompleted: boolean }
1828
+ >
1829
+ }
1830
+ ).__sootsimVisibleChangeCaptures
1831
+ const capture = registry?.[captureToken]
1832
+ if (capture) capture.cancelled = true
1833
+ }, token)
1834
+ .catch(() => {})
1835
+ await capturePromise.catch(() => {})
1836
+ throw error
1837
+ }
1838
+ },
1839
+
1840
+ // FAST multi-stage capture for sub-second animation diffs. each stage
1841
+ // schedules a setTimeout at its requested offsetMs and at that moment
1842
+ // SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
1843
+ // (drawImage from the worker-transferred home + overlay canvases). PNG
1844
+ // encoding is queued after all snapshots land — encoding stalls don't
1845
+ // contaminate the animation window.
1846
+ //
1847
+ // unlike `takeScreenshotStages`, this does NOT go through screenshot()
1848
+ // (which calls forceRenderAll + composite + toDataURL serially at
1849
+ // 300-1300ms per call and blows the spring-open timing). the worker's
1850
+ // vsync-pumped rAF keeps the home canvas fresh; we just sample it.
1851
+ //
1852
+ // returns paths in stage order; actualMs reports when the snapshot
1853
+ // actually fired (typically within a few ms of offsetMs).
1854
+ async takeScreenshotStagesFast(
1855
+ namePrefix: string,
1856
+ stages: Array<{ label: string; offsetMs: number }>,
1857
+ options?: { skipWarmup?: boolean },
1858
+ ): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
1859
+ const page = getPage()
1860
+ const frames: Array<{
1861
+ label: string
1862
+ offsetMs: number
1863
+ actualMs: number
1864
+ dataUrl: string
1865
+ }> = await page.evaluate(
1866
+ async (input) => {
1867
+ const sim = window as {
1868
+ SootSim?: {
1869
+ bridges?: {
1870
+ screenshot?: (opts?: unknown) => Promise<string>
1871
+ liveComposite?: {
1872
+ captureFresh: () => Promise<{
1873
+ canvas: HTMLCanvasElement | null
1874
+ frameToken: number
1875
+ }>
1876
+ }
1877
+ }
1878
+ }
1879
+ }
1880
+ const screenshot = sim.SootSim?.bridges?.screenshot
1881
+ const live = sim.SootSim?.bridges?.liveComposite
1882
+ if (typeof screenshot !== 'function') {
1883
+ throw new Error('rnx screenshot bridge is not installed')
1884
+ }
1885
+ // initial warm-up: ensure the worker has produced at least one
1886
+ // frame after the menu's portal mount before we start sampling.
1887
+ // (a pump at higher cadence was tried and starved tap propagation —
1888
+ // the tenant React commit for setOpen never landed between
1889
+ // forceRender calls.)
1890
+ if (!input.skipWarmup) {
1891
+ await screenshot({ format: 'png' })
1892
+ }
1893
+ const start = performance.now()
1894
+ type Snapshot = {
1895
+ label: string
1896
+ offsetMs: number
1897
+ actualMs: number
1898
+ dataUrl: string | null
1899
+ }
1900
+ // each stage fires at its requested offset and SYNCHRONOUSLY
1901
+ // snapshots the engine's host-side composite canvas (drawImage
1902
+ // from the worker-transferred home canvas + overlay). encoding
1903
+ // happens after — encoding stalls don't disturb the next stage's
1904
+ // capture moment.
1905
+ const snaps: Snapshot[] = await Promise.all(
1906
+ input.stages.map(
1907
+ (stage) =>
1908
+ new Promise<Snapshot>((resolve) => {
1909
+ const fire = async () => {
1910
+ const actualMs = performance.now() - start
1911
+ try {
1912
+ let dataUrl: string | null = null
1913
+ if (live && typeof live.captureFresh === 'function') {
1914
+ // synchronous paint of current canvas state — no
1915
+ // worker round-trip. the background pump keeps the
1916
+ // canvas fresh.
1917
+ const { canvas } = await live.captureFresh()
1918
+ if (canvas) {
1919
+ const bmp = await createImageBitmap(canvas)
1920
+ const enc = document.createElement('canvas')
1921
+ enc.width = bmp.width
1922
+ enc.height = bmp.height
1923
+ const ctx = enc.getContext('2d')
1924
+ if (ctx) {
1925
+ ctx.drawImage(bmp, 0, 0)
1926
+ dataUrl = enc.toDataURL('image/png')
1927
+ }
1928
+ bmp.close()
1929
+ }
1930
+ }
1931
+ if (!dataUrl) {
1932
+ dataUrl = await screenshot({ format: 'png' })
1933
+ }
1934
+ resolve({
1935
+ label: stage.label,
1936
+ offsetMs: stage.offsetMs,
1937
+ actualMs,
1938
+ dataUrl,
1939
+ })
1940
+ } catch {
1941
+ resolve({
1942
+ label: stage.label,
1943
+ offsetMs: stage.offsetMs,
1944
+ actualMs,
1945
+ dataUrl: null,
1946
+ })
1947
+ }
1948
+ }
1949
+ setTimeout(
1950
+ () => {
1951
+ void fire()
1952
+ },
1953
+ Math.max(0, stage.offsetMs),
1954
+ )
1955
+ }),
1956
+ ),
1957
+ )
1958
+ const out: Array<{
1959
+ label: string
1960
+ offsetMs: number
1961
+ actualMs: number
1962
+ dataUrl: string
1963
+ }> = []
1964
+ for (const snap of snaps) {
1965
+ if (!snap.dataUrl) continue
1966
+ out.push({
1967
+ label: snap.label,
1968
+ offsetMs: snap.offsetMs,
1969
+ actualMs: snap.actualMs,
1970
+ dataUrl: snap.dataUrl,
1971
+ })
1972
+ }
1973
+ return out
1974
+ },
1975
+ { stages, skipWarmup: options?.skipWarmup === true },
1976
+ )
1977
+ return writeFastScreenshotFrames(namePrefix, frames)
1978
+ },
1979
+
1980
+ // batched multi-stage capture for animation diffs. all N captures happen
1981
+ // inside one page.evaluate round trip so browser-side timing controls the
1982
+ // offsets — sidesteps the ~300ms per-call bridge overhead that blows
1983
+ // sub-second animation windows. each stage gets a screenshot at the
1984
+ // requested offsetMs (from when the evaluate starts, i.e. right after
1985
+ // trigger() returns). returns absolute paths in stage order.
1986
+ async takeScreenshotStages(
1987
+ namePrefix: string,
1988
+ stages: Array<{ label: string; offsetMs: number }>,
1989
+ ): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
1990
+ const page = getPage()
1991
+ const frames: Array<{
1992
+ label: string
1993
+ offsetMs: number
1994
+ actualMs: number
1995
+ dataUrl: string
1996
+ }> = await page.evaluate(
1997
+ async (input) => {
1998
+ const screenshot = (
1999
+ window as { SootSim?: { bridges?: { screenshot?: unknown } } }
2000
+ ).SootSim?.bridges?.screenshot as
2001
+ | ((opts?: unknown) => Promise<string>)
2002
+ | undefined
2003
+ if (typeof screenshot !== 'function') {
2004
+ throw new Error('sootsim screenshot bridge is not installed')
2005
+ }
2006
+ const start = Date.now()
2007
+ const out: Array<{
2008
+ label: string
2009
+ offsetMs: number
2010
+ actualMs: number
2011
+ dataUrl: string
2012
+ }> = []
2013
+ for (const stage of input.stages) {
2014
+ const elapsed = Date.now() - start
2015
+ const wait = Math.max(0, stage.offsetMs - elapsed)
2016
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait))
2017
+ const actualMs = Date.now() - start
2018
+ const dataUrl = await screenshot({ format: 'png' })
2019
+ out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl })
2020
+ }
2021
+ return out
2022
+ },
2023
+ { stages },
2024
+ )
2025
+ return writeFastScreenshotFrames(namePrefix, frames)
2026
+ },
2027
+
2028
+ async shake() {
2029
+ // no-op for sootsim
2030
+ },
2031
+
2032
+ async setLocation(lat: number, lon: number) {
2033
+ // no-op
2034
+ },
2035
+
2036
+ async setStatusBar(config: StatusBarConfig) {
2037
+ await dispatchStatusBarOverride(config)
2038
+ },
2039
+
2040
+ async setURLBlacklist(urls: string[]) {
2041
+ // no-op
2042
+ },
2043
+
2044
+ async enableSynchronization() {
2045
+ _synchronizationEnabled = true
2046
+ },
2047
+
2048
+ async disableSynchronization() {
2049
+ _synchronizationEnabled = false
2050
+ },
2051
+
2052
+ getPlatform(): 'ios' | 'android' {
2053
+ return device._platform
2054
+ },
2055
+
2056
+ async pressBack() {
2057
+ if (device._platform !== 'android') return
2058
+ const page = getPage()
2059
+ const dispatched = await page.evaluate(async () => {
2060
+ const goBack = window.SootSim?.bridges?.mainShell?.goBack
2061
+ if (typeof goBack !== 'function') return false
2062
+ return (await goBack()) !== false
2063
+ })
2064
+ if (!dispatched) throw new Error('sootsim Android back bridge is not installed')
2065
+ if (_synchronizationEnabled) {
2066
+ await waitForSootsimIdle(page)
2067
+ }
2068
+ },
2069
+
2070
+ // raw coordinate tap, bypassing the element-based visibility/hit-test path.
2071
+ // mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
2072
+ // target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
2073
+ // `element(...).tap({x,y})` would fail Detox's visibility threshold.
2074
+ async tap(point?: { x: number; y: number } | boolean) {
2075
+ if (point == null || typeof point === 'boolean') return
2076
+ const page = getPage()
2077
+ const { x, y } = point
2078
+ await page.evaluate(
2079
+ async ({ x: tx, y: ty }) => {
2080
+ const interactTap = window.SootSim?.bridges?.interact?.tap
2081
+ if (typeof interactTap === 'function') {
2082
+ const result = await interactTap(tx, ty)
2083
+ if (result && (typeof result !== 'object' || result.hit !== false)) return
2084
+ }
2085
+ const tap = window.__sootsimTest?.tap
2086
+ if (typeof tap !== 'function') {
2087
+ throw new Error('sootsim tap bridge is not installed')
2088
+ }
2089
+ await tap(tx, ty)
2090
+ },
2091
+ { x, y },
2092
+ )
2093
+ await page.waitForTimeout(50)
2094
+ if (_synchronizationEnabled) {
2095
+ await waitForSootsimIdle(page)
2096
+ }
2097
+ },
2098
+
2099
+ async sendToHome() {
2100
+ // no-op
2101
+ },
2102
+
2103
+ // get direct access to the playwright page for advanced use
2104
+ getPage(): Page {
2105
+ return getPage()
2106
+ },
2107
+ }
2108
+
2109
+ // exports matching detox API
2110
+ const sootsimExpect = createExpect(findNodeByMatcher, getPage)
2111
+ const sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage)
2112
+
2113
+ export { sootsimExpect as expect, sootsimWaitFor as waitFor }
2114
+
2115
+ // cleanup -- call this in afterAll
2116
+ export async function cleanup() {
2117
+ await closeBrowser()
2118
+ }