sootsim 0.1.83 → 0.1.85

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 (209) hide show
  1. package/README.md +0 -1
  2. package/detox/colors.ts +54 -0
  3. package/detox/config-loader.ts +135 -0
  4. package/detox/element-types.ts +36 -0
  5. package/detox/expectations.ts +477 -0
  6. package/detox/gestures.ts +442 -0
  7. package/detox/index.ts +1403 -0
  8. package/detox/jest-environment.ts +86 -0
  9. package/detox/jest-preset.cjs +50 -0
  10. package/detox/matchers.ts +29 -0
  11. package/detox/navigation.ts +43 -0
  12. package/detox/run-test.ts +113 -0
  13. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  14. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  15. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/theme-blue-toggle.png +0 -0
  17. package/detox/screenshots/theme-blue.png +0 -0
  18. package/detox/screenshots/theme-red-toggle.png +0 -0
  19. package/detox/screenshots/theme-red.png +0 -0
  20. package/dist-cli/bin.js +3 -3
  21. package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-T3DUH5YJ.js} +2 -2
  22. package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-NSBF4THI.js} +2 -2
  23. package/dist-cli/chunks/{assert-TV46GUNU.js → assert-X3F7TRCZ.js} +2 -2
  24. package/dist-cli/chunks/auto-bootstrap-47RN2V5G.js +2 -0
  25. package/dist-cli/chunks/beta-BRCGAF2N.js +2 -0
  26. package/dist-cli/chunks/chunk-36RPD6JI.js +2 -0
  27. package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-3WGHC7JN.js} +2 -2
  28. package/dist-cli/chunks/chunk-4DBPNLGI.js +1 -0
  29. package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-4EVSIUNB.js} +2 -2
  30. package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-4QZHZ6BC.js} +2 -2
  31. package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-5DIGWOY7.js} +1 -1
  32. package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-5N3V7OCG.js} +2 -2
  33. package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-5S6D7K4L.js} +2 -2
  34. package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-7LKUN46F.js} +2 -2
  35. package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-AC6QGW22.js} +2 -2
  36. package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-AFNDVS4E.js} +2 -2
  37. package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-BESAZ2HA.js} +2 -2
  38. package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-BHZJ6RIH.js} +2 -2
  39. package/dist-cli/chunks/{chunk-277XAALA.js → chunk-BZL6D4TV.js} +3 -3
  40. package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-CF2LPRXD.js} +2 -2
  41. package/dist-cli/chunks/chunk-DWTLRPEN.js +79 -0
  42. package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-E2QE5FFP.js} +1 -1
  43. package/dist-cli/chunks/chunk-EBEL6TTJ.js +4 -0
  44. package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-EFM53PZ5.js} +1 -1
  45. package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-EKXK3SWK.js} +2 -2
  46. package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-G7CIZ5S3.js} +3 -3
  47. package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-GTAD6IUV.js} +1 -1
  48. package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-H44IQHKZ.js} +1 -1
  49. package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-HQDJ5BOF.js} +1 -1
  50. package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-KUSQ4NNJ.js} +1 -1
  51. package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-MAO7F5PH.js} +3 -3
  52. package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-NVTL3JQG.js} +1 -1
  53. package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-O6N2CEET.js} +2 -2
  54. package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-OISHLFON.js} +1 -1
  55. package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-OUNLJM56.js} +2 -2
  56. package/dist-cli/chunks/chunk-OXOARRKR.js +67 -0
  57. package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-PHPXGLME.js} +1 -1
  58. package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PQFFUJR6.js} +2 -2
  59. package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-QLJNSOS7.js} +1 -1
  60. package/dist-cli/chunks/chunk-QQAECG5B.js +2 -0
  61. package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-RZHREO3M.js} +2 -2
  62. package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-SBGOUA6F.js} +2 -2
  63. package/dist-cli/chunks/chunk-SSCA2AEA.js +1 -0
  64. package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-UYRGCJ4N.js} +1 -1
  65. package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-WGDL5V6C.js} +1 -1
  66. package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-Y5PLPEEU.js} +2 -2
  67. package/dist-cli/chunks/chunk-ZFAM4N5B.js +1 -0
  68. package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-ZO3VHP6W.js} +1 -1
  69. package/dist-cli/chunks/cli-version-WPFDM2A6.js +2 -0
  70. package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-PCXGGZBZ.js} +3 -3
  71. package/dist-cli/chunks/{config-2DSLDCXV.js → config-LULEVEYL.js} +2 -2
  72. package/dist-cli/chunks/control-6P6HY7UF.js +2 -0
  73. package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-NOK73ZYW.js} +2 -2
  74. package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-4A3DMUYL.js} +2 -2
  75. package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-74BWB2ZG.js} +3 -3
  76. package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-HEOMINSC.js} +2 -2
  77. package/dist-cli/chunks/{device-AYKXKVIQ.js → device-TTXXBJFZ.js} +2 -2
  78. package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-QZ3GOHSE.js} +2 -2
  79. package/dist-cli/chunks/drivers-QRPWNOIT.js +2 -0
  80. package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-QVOWV44R.js} +3 -3
  81. package/dist-cli/chunks/flow-QMA7GVN6.js +2 -0
  82. package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-YKWRNMJC.js} +2 -2
  83. package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-SFADSTJM.js} +2 -2
  84. package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-LEWGQCIU.js} +3 -3
  85. package/dist-cli/chunks/install-7N2N7Q32.js +2 -0
  86. package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-22HYQZ2G.js} +3 -3
  87. package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-3ZT3MICU.js} +2 -2
  88. package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-ZXW2NFLG.js} +3 -3
  89. package/dist-cli/chunks/{login-EACQXE24.js → login-NJKJ7GZO.js} +4 -4
  90. package/dist-cli/chunks/{logout-IBQLMUML.js → logout-VMMQL7CB.js} +2 -2
  91. package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-OJY4MTI7.js} +2 -2
  92. package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-QU2GXTEV.js} +2 -2
  93. package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-7APWK47T.js} +2 -2
  94. package/dist-cli/chunks/{react-QDHLMVYL.js → react-RSVO5JZZ.js} +2 -2
  95. package/dist-cli/chunks/{record-BUEUWPDI.js → record-UWH4MDEO.js} +2 -2
  96. package/dist-cli/chunks/runtime-3FUENRHM.js +2 -0
  97. package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-QMKGRV7N.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-43M27ALE.js} +2 -2
  99. package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-EBYYN6TY.js} +2 -2
  100. package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-7TQZL6Z6.js} +2 -2
  101. package/dist-cli/chunks/{server-MFFVYUGG.js → server-VCFM25Z6.js} +2 -2
  102. package/dist-cli/chunks/setup-repo-HFH4VKJQ.js +2 -0
  103. package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-RQA6EJQL.js} +2 -2
  104. package/dist-cli/chunks/{start-E3DRYY7W.js → start-ZT6MBYND.js} +4 -4
  105. package/dist-cli/chunks/store-BJBTDSZE.js +2 -0
  106. package/dist-cli/chunks/telemetry-ZZZKTILZ.js +2 -0
  107. package/dist-cli/chunks/{test-ZY3EF62K.js → test-RNRX5SWV.js} +3 -3
  108. package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-TQZH25ZO.js} +2 -2
  109. package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-GGN3AY6P.js} +2 -2
  110. package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-XT22D67C.js} +2 -2
  111. package/dist-cli/chunks/upload-NC2AYLC5.js +2 -0
  112. package/dist-cli/chunks/{web-D2AOZY44.js → web-KEHVF5MB.js} +2 -2
  113. package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PATQRJ5T.js} +2 -2
  114. package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-CXVY26VV.js} +2 -2
  115. package/dist-lib/agent-daemon-client.cjs +1 -1
  116. package/dist-lib/agent-events.cjs +1 -1
  117. package/dist-lib/agent-sessions.cjs +1 -1
  118. package/dist-lib/attached-projects.cjs +1 -1
  119. package/dist-lib/auth/shared-session.cjs +1 -1
  120. package/dist-lib/backend-origin.cjs +1 -1
  121. package/dist-lib/beta.cjs +44 -0
  122. package/dist-lib/bridge-constants.cjs +1 -1
  123. package/dist-lib/cli-constants.cjs +1 -1
  124. package/dist-lib/config.cjs +1 -1
  125. package/dist-lib/detox/index.cjs +1770 -0
  126. package/dist-lib/detox/jest-preset.cjs +50 -0
  127. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  128. package/dist-lib/home-paths.cjs +1 -1
  129. package/dist-lib/host/bridge-host.cjs +1 -1
  130. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  131. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  132. package/dist-lib/index.cjs +136 -138
  133. package/dist-lib/metro.cjs +31 -26
  134. package/dist-lib/profiles.cjs +1 -1
  135. package/dist-lib/render-mode.cjs +1 -1
  136. package/dist-lib/scripts/demo-app-registry.cjs +809 -0
  137. package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
  138. package/dist-lib/skills.cjs +17766 -0
  139. package/dist-lib/vite.cjs +129 -39
  140. package/package.json +39 -14
  141. package/scripts/demo-app-registry.ts +989 -0
  142. package/scripts/dev-server-scanner.ts +674 -0
  143. package/src/agent-daemon-client.ts +390 -0
  144. package/src/agent-events.ts +71 -0
  145. package/src/agent-prompt.ts +71 -0
  146. package/src/agent-sessions.ts +572 -0
  147. package/src/attached-projects.ts +536 -0
  148. package/src/auth/shared-session.ts +199 -0
  149. package/src/backend-origin.ts +49 -0
  150. package/src/beta.ts +21 -0
  151. package/src/bridge-constants.ts +10 -0
  152. package/src/cli-constants.ts +1 -0
  153. package/src/cli-version.ts +30 -0
  154. package/src/codex-client.ts +215 -0
  155. package/src/config.ts +110 -0
  156. package/src/dev-bundle-resolution.ts +180 -0
  157. package/src/home-paths.ts +382 -0
  158. package/src/host/agent-host.ts +576 -0
  159. package/src/host/bridge-host.ts +2293 -0
  160. package/src/host/fetch-proxy-handler.ts +288 -0
  161. package/src/host/fetch-proxy-overrides.ts +39 -0
  162. package/src/host/open-url.ts +234 -0
  163. package/src/index.ts +9 -0
  164. package/src/metro-plugin.ts +139 -0
  165. package/src/native-dev-bundle-url.ts +62 -0
  166. package/src/native-seam-manifest.ts +313 -0
  167. package/src/profiles.ts +179 -0
  168. package/src/render-mode.ts +27 -0
  169. package/src/runtime-assets.ts +84 -0
  170. package/src/runtime-delivery.ts +334 -0
  171. package/src/screenshots/compose.ts +422 -0
  172. package/src/screenshots/frame-compose.ts +438 -0
  173. package/src/screenshots/orchestrate.ts +244 -0
  174. package/src/screenshots/registry.ts +58 -0
  175. package/src/screenshots/schema.ts +364 -0
  176. package/src/skills/builtin/a11y-review.ts +126 -0
  177. package/src/skills/builtin/compat-check.ts +104 -0
  178. package/src/skills/builtin/perf-profile.ts +84 -0
  179. package/src/skills/builtin/screenshot-all.ts +46 -0
  180. package/src/skills/builtin/test-flow.ts +118 -0
  181. package/src/skills/builtin/visual-diff.ts +94 -0
  182. package/src/skills/registry.ts +107 -0
  183. package/src/skills/types.ts +41 -0
  184. package/src/vite-plugin-one.ts +189 -0
  185. package/src/vite-plugin.ts +1381 -0
  186. package/src/worklets-babel.ts +132 -0
  187. package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
  188. package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
  189. package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
  190. package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
  191. package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
  192. package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
  193. package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
  194. package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
  195. package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
  196. package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
  197. package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
  198. package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
  199. package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
  200. package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
  201. package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
  202. package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
  203. package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
  204. package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
  205. package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
  206. package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
  207. package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
  208. package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
  209. package/dist-lib/vite-base.cjs +0 -6937
package/detox/index.ts ADDED
@@ -0,0 +1,1403 @@
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 { createExpect, createWaitFor } from './expectations'
9
+ import {
10
+ dispatchTwoFingerGesture,
11
+ dragScrollNode,
12
+ readSootsimInteractiveViewport,
13
+ sootsimToPage,
14
+ waitForSootsimGestureHandler,
15
+ } from './gestures'
16
+ import { by, type Matcher } from './matchers'
17
+ import type { SootElement } from './element-types'
18
+ import type { Browser, BrowserContext, Page } from 'playwright'
19
+ import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
20
+
21
+ export { by }
22
+ export type { SootElement } from './element-types'
23
+
24
+ const BASE_URL = process.env.SOOTSIM_URL || 'http://localhost:5173'
25
+ const SCREENSHOT_DIR =
26
+ process.env.SOOTSIM_SCREENSHOT_DIR ||
27
+ path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
28
+ const DETOX_PLATFORM =
29
+ process.env.SOOTSIM_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
30
+
31
+ // shared state -- playwright page and browser
32
+ let _browser: Browser | null = null
33
+ let _context: BrowserContext | null = null
34
+ let _page: Page | null = null
35
+ let _synchronizationEnabled = false
36
+ const pageDiagnostics = new WeakMap<Page, string[]>()
37
+
38
+ const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
39
+
40
+ type StatusBarConfig = {
41
+ time?: string
42
+ dataNetwork?: string
43
+ wifiMode?: string
44
+ wifiBars?: string
45
+ cellularMode?: string
46
+ cellularBars?: string
47
+ operatorName?: string
48
+ batteryState?: string
49
+ batteryLevel?: string | number
50
+ }
51
+
52
+ function getPage(): Page {
53
+ if (!_page)
54
+ throw new Error('sootsim driver not initialized -- call device.launchApp() first')
55
+ return _page
56
+ }
57
+
58
+ function recordPageDiagnostic(page: Page, message: string) {
59
+ const entries = pageDiagnostics.get(page)
60
+ if (!entries) return
61
+ entries.push(message)
62
+ if (entries.length > 40) entries.shift()
63
+ }
64
+
65
+ function attachPageDiagnostics(page: Page) {
66
+ if (pageDiagnostics.has(page)) return
67
+ pageDiagnostics.set(page, [])
68
+ page.on('console', (message) => {
69
+ recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`)
70
+ })
71
+ page.on('pageerror', (error) => {
72
+ recordPageDiagnostic(page, `[pageerror] ${error.message}`)
73
+ })
74
+ page.on('requestfailed', (request) => {
75
+ const failure = request.failure()
76
+ recordPageDiagnostic(
77
+ page,
78
+ `[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ''}`,
79
+ )
80
+ })
81
+ }
82
+
83
+ async function describeSootsimBridgeFailure(page: Page, cause: unknown) {
84
+ let pageState: unknown = null
85
+ try {
86
+ pageState = await page.evaluate(() => ({
87
+ bodyText: document.body?.innerText?.slice(0, 500) ?? '',
88
+ globals: Object.keys(window)
89
+ .filter((key) => key.startsWith('__sootsim') || key === 'SootSim')
90
+ .sort(),
91
+ readyState: document.readyState,
92
+ title: document.title,
93
+ url: window.location.href,
94
+ }))
95
+ } catch (error) {
96
+ pageState = {
97
+ evaluateError: error instanceof Error ? error.message : String(error),
98
+ }
99
+ }
100
+
101
+ return new Error(
102
+ [
103
+ `timed out waiting for sootsim test bridge`,
104
+ `cause: ${cause instanceof Error ? cause.message : String(cause)}`,
105
+ `page: ${JSON.stringify(pageState)}`,
106
+ `recent page diagnostics:\n${(pageDiagnostics.get(page) ?? []).join('\n') || '(none)'}`,
107
+ ].join('\n'),
108
+ )
109
+ }
110
+
111
+ async function waitForSootsimTree(page: Page, timeout = 30000): Promise<void> {
112
+ try {
113
+ await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
114
+ timeout,
115
+ })
116
+ await page.evaluate(async (timeoutMs) => {
117
+ await Promise.race([
118
+ window.__sootsimTest!.waitForTree(),
119
+ new Promise((_, reject) => {
120
+ setTimeout(
121
+ () => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)),
122
+ timeoutMs,
123
+ )
124
+ }),
125
+ ])
126
+ }, timeout)
127
+ } catch (error) {
128
+ throw await describeSootsimBridgeFailure(page, error)
129
+ }
130
+ }
131
+
132
+ async function closeContext() {
133
+ const context = _context
134
+ _context = null
135
+ _page = null
136
+ _synchronizationEnabled = false
137
+ if (context) await context.close()
138
+ }
139
+
140
+ async function closeBrowser() {
141
+ if (!_browser) return
142
+ const browser = _browser
143
+ _browser = null
144
+ await closeContext()
145
+ await browser.close()
146
+ }
147
+
148
+ async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void> {
149
+ const result = await page.evaluate(
150
+ async ({ maxMs }) => {
151
+ let transitionError: string | null = null
152
+ try {
153
+ const waitForScreenTransitions = (window as any).__sootsimTest
154
+ ?.waitForScreenTransitions
155
+ if (typeof waitForScreenTransitions === 'function') {
156
+ const transitionResult = await waitForScreenTransitions({
157
+ timeoutMs: Math.min(maxMs, 1800),
158
+ settleMs: 48,
159
+ startWindowMs: 600,
160
+ })
161
+ if (
162
+ transitionResult?.started === true &&
163
+ transitionResult.settled === true &&
164
+ transitionResult.timedOut !== true
165
+ ) {
166
+ return { settled: true, elapsed: transitionResult.waitedMs ?? 0 }
167
+ }
168
+ if (transitionResult?.timedOut) {
169
+ return {
170
+ settled: false,
171
+ elapsed: transitionResult.waitedMs,
172
+ reason: 'screen transition',
173
+ }
174
+ }
175
+ }
176
+ } catch (error) {
177
+ transitionError = error instanceof Error ? error.message : String(error)
178
+ }
179
+
180
+ const start = Date.now()
181
+ const deadline = start + maxMs
182
+ const pollMs = 50
183
+ const requiredStablePolls = 3
184
+ const layoutTolerance = 1
185
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
186
+ const isLayoutStable = (a: number[] | null, b: number[]) => {
187
+ if (!a || a.length !== b.length) return false
188
+ for (let i = 0; i < b.length; i++) {
189
+ if (Math.abs(a[i] - b[i]) > layoutTolerance) return false
190
+ }
191
+ return true
192
+ }
193
+
194
+ const readSnapshot = async () => {
195
+ const root = (window as any).__sootsimRoot
196
+ const layout: number[] = []
197
+ if (root) {
198
+ const walk = (node: any) => {
199
+ if (node.layout && node.layout.width > 0) {
200
+ layout.push(
201
+ Math.round(node.layout.x),
202
+ Math.round(node.layout.y),
203
+ Math.round(node.layout.width),
204
+ Math.round(node.layout.height),
205
+ )
206
+ }
207
+ for (const child of node.children || []) walk(child)
208
+ }
209
+ walk(root)
210
+ }
211
+ return layout
212
+ }
213
+
214
+ let stableLayout: number[] | null = null
215
+ let stable = 0
216
+ while (Date.now() < deadline) {
217
+ const layout = await readSnapshot()
218
+ if (isLayoutStable(stableLayout, layout)) {
219
+ stable++
220
+ if (stable >= requiredStablePolls) {
221
+ return { settled: true, elapsed: Date.now() - start }
222
+ }
223
+ } else {
224
+ stableLayout = layout
225
+ stable = 0
226
+ }
227
+ await sleep(pollMs)
228
+ }
229
+ return {
230
+ settled: false,
231
+ elapsed: Date.now() - start,
232
+ reason: transitionError
233
+ ? `layout fallback after screen transition wait failed: ${transitionError}`
234
+ : 'layout fallback',
235
+ }
236
+ },
237
+ { maxMs },
238
+ )
239
+
240
+ if (!result?.settled) {
241
+ throw new Error(
242
+ `sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`,
243
+ )
244
+ }
245
+ }
246
+
247
+ // find a node in the sootsim tree matching a matcher descriptor
248
+ async function findNodeByMatcher(matcher: Matcher): Promise<any> {
249
+ const page = getPage()
250
+ if (matcher.type === 'id') {
251
+ return page.evaluate(
252
+ (id: string) => window.__sootsimTest!.findByTestId(id),
253
+ matcher.value,
254
+ )
255
+ } else if (matcher.type === 'text') {
256
+ return page.evaluate(
257
+ (text: string) => window.__sootsimTest!.findByText(text),
258
+ matcher.value,
259
+ )
260
+ } else if (matcher.type === 'label') {
261
+ return page.evaluate(
262
+ (label: string) => window.__sootsimTest!.findByLabel(label),
263
+ matcher.value,
264
+ )
265
+ } else if (matcher.type === 'role') {
266
+ return page.evaluate(
267
+ (role: string) => window.__sootsimTest!.findByRole(role),
268
+ matcher.value,
269
+ )
270
+ } else if (matcher.type === 'type') {
271
+ return page.evaluate(async (type: string) => {
272
+ const results = await window.__sootsimTest!.queryAll({ type })
273
+ return results[0] || null
274
+ }, matcher.value)
275
+ }
276
+ return null
277
+ }
278
+
279
+ // get the absolute center coordinates of a node in sootsim coordinate space
280
+ function getNodeCenter(nodeInfo: any): { x: number; y: number } {
281
+ return {
282
+ x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
283
+ y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2,
284
+ }
285
+ }
286
+
287
+ async function getDefaultTapPoint(
288
+ page: Page,
289
+ nodeInfo: any,
290
+ ): Promise<{ x: number; y: number }> {
291
+ const center = getNodeCenter(nodeInfo)
292
+ const viewport = await readSootsimInteractiveViewport(page)
293
+ const frame = nodeInfo.visibleFrame ?? {
294
+ x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
295
+ y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
296
+ width: nodeInfo.layout?.width ?? 0,
297
+ height: nodeInfo.layout?.height ?? 0,
298
+ }
299
+ const left = Math.max(0, frame.x)
300
+ const top = Math.max(0, frame.y)
301
+ const right = Math.min(viewport.width, frame.x + frame.width)
302
+ const bottom = Math.min(viewport.height, frame.y + frame.height)
303
+
304
+ if (right <= left || bottom <= top) {
305
+ return center
306
+ }
307
+
308
+ return {
309
+ x: left + (right - left) / 2,
310
+ y: top + (bottom - top) / 2,
311
+ }
312
+ }
313
+
314
+ async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
315
+ const focused = await page.evaluate(
316
+ () => window.__sootsimTest!.getFocusedNode?.() ?? null,
317
+ )
318
+ return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
319
+ }
320
+
321
+ async function focusElementForTextEntry(el: SootElement): Promise<void> {
322
+ const page = getPage()
323
+ const node = await findNodeByMatcher(el._matcher)
324
+ if (!node) {
325
+ throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`)
326
+ }
327
+ if (await isFocusedTextInputNode(page, node)) return
328
+ await el.tap()
329
+ await page.waitForTimeout(100)
330
+ }
331
+
332
+ function ensureScreenshotDir() {
333
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
334
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
335
+ }
336
+ }
337
+
338
+ function decodePngDataUrl(dataUrl: string): Buffer {
339
+ const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl)
340
+ if (!match) {
341
+ throw new Error('sootsim screenshot bridge returned a non-png payload')
342
+ }
343
+ return Buffer.from(match[1], 'base64')
344
+ }
345
+
346
+ // single-shot canvas capture — no frame-stability poll. used for multi-stage
347
+ // animation captures where we explicitly want to sample mid-transition. the
348
+ // usual captureSootsimPng polls until two consecutive frames are byte-equal
349
+ // which is ideal for settled screenshots but blocks for the full timeout
350
+ // while an animation is running.
351
+ async function captureSootsimPngFast(opts?: {
352
+ crop?: { h: number; w: number; x: number; y: number }
353
+ }) {
354
+ const page = getPage()
355
+ const dataUrl = await page.evaluate(
356
+ async (captureOpts) => {
357
+ const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
358
+ .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
359
+ if (typeof screenshot !== 'function') {
360
+ throw new Error('sootsim screenshot bridge is not installed')
361
+ }
362
+ return screenshot(captureOpts)
363
+ },
364
+ { crop: opts?.crop },
365
+ )
366
+ if (!dataUrl) {
367
+ throw new Error('sootsim screenshot bridge returned an empty image')
368
+ }
369
+ return decodePngDataUrl(dataUrl)
370
+ }
371
+
372
+ async function captureSootsimPng(opts?: {
373
+ crop?: { h: number; w: number; x: number; y: number }
374
+ }) {
375
+ const page = getPage()
376
+ // frame-stability poll: capture twice with a gap > one tenant live-blur
377
+ // push interval (~67ms) and accept only when both PNGs are byte-equal.
378
+ // PNGs from canvas.toDataURL are deterministic for stable RGBA, so byte-
379
+ // equal = pixel-equal. without this, the keyboard's blur stream and the
380
+ // text-input cursor blink produce 1-12pp frame-to-frame variance in
381
+ // captures fired blindly after a settle, which then drifts the published
382
+ // proof manifest. matches the same poll in scripts/conformance-fast-iter.ts
383
+ // so cron and publish lock onto the same stable frame.
384
+ const result: {
385
+ dataUrl: string
386
+ stable: boolean
387
+ attempts: number
388
+ elapsedMs: number
389
+ } = await page.evaluate(
390
+ async (captureOpts) => {
391
+ const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
392
+ .SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
393
+ if (typeof screenshot !== 'function') {
394
+ throw new Error('sootsim screenshot bridge is not installed')
395
+ }
396
+ const gapMs = 100
397
+ const maxWaitMs = 8000
398
+ const minAttempts = 3
399
+ const t0 = Date.now()
400
+ let prev = await screenshot(captureOpts)
401
+ let attempts = 1
402
+ while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
403
+ await new Promise((r) => setTimeout(r, gapMs))
404
+ const next = await screenshot(captureOpts)
405
+ attempts++
406
+ if (next === prev) {
407
+ return {
408
+ dataUrl: next,
409
+ stable: true,
410
+ attempts,
411
+ elapsedMs: Date.now() - t0,
412
+ }
413
+ }
414
+ prev = next
415
+ }
416
+ return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 }
417
+ },
418
+ {
419
+ crop: opts?.crop,
420
+ },
421
+ )
422
+ if (!result.dataUrl) {
423
+ throw new Error('sootsim screenshot bridge returned an empty image')
424
+ }
425
+ if (!result.stable) {
426
+ console.warn(
427
+ `[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`,
428
+ )
429
+ }
430
+ return decodePngDataUrl(result.dataUrl)
431
+ }
432
+
433
+ async function writeSootsimScreenshot(
434
+ name: string,
435
+ opts?: { crop?: { h: number; w: number; x: number; y: number } },
436
+ ) {
437
+ ensureScreenshotDir()
438
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
439
+ fs.writeFileSync(screenshotPath, await captureSootsimPng(opts))
440
+ return screenshotPath
441
+ }
442
+
443
+ async function dispatchStatusBarOverride(config: StatusBarConfig) {
444
+ const page = getPage()
445
+ const time =
446
+ typeof config.time === 'string' && config.time.length > 0 ? config.time : null
447
+ await page.evaluate(
448
+ ({ eventType, time }) => {
449
+ window.dispatchEvent(
450
+ new CustomEvent(eventType, {
451
+ detail: { time },
452
+ }),
453
+ )
454
+ },
455
+ { eventType: STATUS_BAR_OVERRIDE_EVENT, time },
456
+ )
457
+ await page.waitForTimeout(50)
458
+ }
459
+
460
+ async function dispatchSootsimEventInPage<K extends SootsimEventName>(
461
+ type: K,
462
+ detail: SootsimEventMap[K],
463
+ ) {
464
+ const page = getPage()
465
+ await page.evaluate(
466
+ ({ detail, type }) => {
467
+ window.dispatchEvent(new CustomEvent(type, { detail }))
468
+ },
469
+ { detail, type },
470
+ )
471
+ }
472
+
473
+ function createSootElement(matcher: Matcher): SootElement {
474
+ const el: SootElement = {
475
+ _matcher: matcher,
476
+
477
+ async tap(point) {
478
+ const page = getPage()
479
+ const node = await findNodeByMatcher(matcher)
480
+ if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`)
481
+ const defaultPoint = point ? null : await getDefaultTapPoint(page, node)
482
+ const targetSootX =
483
+ typeof point?.x === 'number'
484
+ ? node.absolutePosition.x + point.x
485
+ : (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
486
+ const targetSootY =
487
+ typeof point?.y === 'number'
488
+ ? node.absolutePosition.y + point.y
489
+ : (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
490
+ await page.evaluate(
491
+ async ({ x, y }) => {
492
+ const interactTap = window.SootSim?.bridges?.interact?.tap
493
+ if (typeof interactTap === 'function') {
494
+ const result = await interactTap(x, y)
495
+ if (result && (typeof result !== 'object' || result.hit !== false)) return
496
+ }
497
+ const tap = window.__sootsimTest?.tap
498
+ if (typeof tap !== 'function') {
499
+ throw new Error('sootsim tap bridge is not installed')
500
+ }
501
+ await tap(x, y)
502
+ },
503
+ { x: targetSootX, y: targetSootY },
504
+ )
505
+ // small settling time for react state updates
506
+ await page.waitForTimeout(50)
507
+ if (_synchronizationEnabled) {
508
+ await waitForSootsimIdle(page)
509
+ }
510
+ },
511
+
512
+ async multiTap(times: number) {
513
+ // detox iOS multiTap performs N taps with the system's natural double-
514
+ // tap pause. UITapGestureRecognizer treats taps within ~250ms as a
515
+ // multi-tap. emit each tap as a discrete down/up at the same node
516
+ // center with a small inter-tap pause that stays inside RNGH's
517
+ // default maxDelay window of 200ms.
518
+ const page = getPage()
519
+ const node = await findNodeByMatcher(matcher)
520
+ if (!node)
521
+ throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`)
522
+ const center = getNodeCenter(node)
523
+ const pageCoords = await sootsimToPage(page, center.x, center.y)
524
+
525
+ for (let i = 0; i < times; i++) {
526
+ await page.mouse.move(pageCoords.x, pageCoords.y)
527
+ await page.mouse.down()
528
+ await page.waitForTimeout(50)
529
+ await page.mouse.up()
530
+ if (i < times - 1) {
531
+ await page.waitForTimeout(80)
532
+ }
533
+ }
534
+ await page.waitForTimeout(80)
535
+ },
536
+
537
+ async longPress(duration = 1000) {
538
+ const page = getPage()
539
+ const node = await findNodeByMatcher(matcher)
540
+ if (!node)
541
+ throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`)
542
+ const center = getNodeCenter(node)
543
+ const pageCoords = await sootsimToPage(page, center.x, center.y)
544
+
545
+ await page.mouse.move(pageCoords.x, pageCoords.y)
546
+ await page.mouse.down()
547
+ await page.waitForTimeout(duration)
548
+ await page.mouse.up()
549
+ await page.waitForTimeout(50)
550
+ },
551
+
552
+ async longPressAndDrag(
553
+ duration: number,
554
+ normalizedStartX: number,
555
+ normalizedStartY: number,
556
+ targetElement: SootElement,
557
+ normalizedEndX: number,
558
+ normalizedEndY: number,
559
+ speed = 'fast',
560
+ holdDuration = 0,
561
+ ) {
562
+ const page = getPage()
563
+ // resolve source element
564
+ const srcNode = await findNodeByMatcher(matcher)
565
+ if (!srcNode)
566
+ throw new Error(`source element not found: ${JSON.stringify(matcher)}`)
567
+
568
+ // resolve target element
569
+ const tgtNode = await findNodeByMatcher(targetElement._matcher)
570
+ if (!tgtNode)
571
+ throw new Error(
572
+ `target element not found: ${JSON.stringify(targetElement._matcher)}`,
573
+ )
574
+
575
+ // compute start and end in sootsim coords
576
+ const startSootX =
577
+ srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX
578
+ const startSootY =
579
+ srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY
580
+ const endSootX = tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX
581
+ const endSootY = tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY
582
+
583
+ const startPage = await sootsimToPage(page, startSootX, startSootY)
584
+ const endPage = await sootsimToPage(page, endSootX, endSootY)
585
+ const steps = speed === 'slow' ? 20 : 10
586
+
587
+ // press at start position
588
+ await page.mouse.move(startPage.x, startPage.y)
589
+ await page.mouse.down()
590
+ await page.waitForTimeout(Math.max(duration, holdDuration))
591
+
592
+ // drag to end position
593
+ const dx = (endPage.x - startPage.x) / steps
594
+ const dy = (endPage.y - startPage.y) / steps
595
+ for (let i = 1; i <= steps; i++) {
596
+ await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
597
+ await page.waitForTimeout(speed === 'slow' ? 30 : 10)
598
+ }
599
+
600
+ await page.mouse.up()
601
+ await page.waitForTimeout(50)
602
+ },
603
+
604
+ async typeText(text: string) {
605
+ const page = getPage()
606
+ await focusElementForTextEntry(el)
607
+ await page.keyboard.type(text)
608
+ },
609
+
610
+ async replaceText(text: string) {
611
+ const page = getPage()
612
+ await focusElementForTextEntry(el)
613
+ const node = await findNodeByMatcher(matcher)
614
+ const value = typeof node?.text === 'string' ? node.text : ''
615
+ for (let i = 0; i < value.length; i++) {
616
+ await page.keyboard.press('Backspace')
617
+ }
618
+ await page.keyboard.type(text)
619
+ },
620
+
621
+ async clearText() {
622
+ const page = getPage()
623
+ await focusElementForTextEntry(el)
624
+ const node = await findNodeByMatcher(matcher)
625
+ const value = typeof node?.text === 'string' ? node.text : ''
626
+ for (let i = 0; i < value.length; i++) {
627
+ await page.keyboard.press('Backspace')
628
+ }
629
+ },
630
+
631
+ async scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right') {
632
+ const node = await findNodeByMatcher(matcher)
633
+ if (!node)
634
+ throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
635
+ const page = getPage()
636
+ await dragScrollNode(page, node, pixels, direction)
637
+ },
638
+
639
+ async scrollTo(edge: 'top' | 'bottom' | 'left' | 'right') {
640
+ const node = await findNodeByMatcher(matcher)
641
+ if (!node)
642
+ throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
643
+
644
+ const targetId =
645
+ typeof node.testID === 'string' && node.testID
646
+ ? node.testID
647
+ : typeof node.id === 'string' && node.id
648
+ ? node.id
649
+ : null
650
+ if (!targetId) {
651
+ throw new Error(
652
+ `scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`,
653
+ )
654
+ }
655
+
656
+ const hitX = node.absolutePosition.x + node.layout.width / 2
657
+ const hitY = node.absolutePosition.y + node.layout.height / 2
658
+ const page = getPage()
659
+ const result = await page.evaluate(
660
+ async ({ edge, hitX, hitY, targetId }) => {
661
+ const bridge = window.__sootsimTest
662
+ if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
663
+ return { ok: false, reason: 'scroll bridge unavailable' }
664
+ }
665
+
666
+ const state = await bridge.getScrollStateAt(hitX, hitY)
667
+ if (!state) return { ok: false, reason: 'scroll state not found' }
668
+
669
+ const maxOffset =
670
+ state.maxOffset && typeof state.maxOffset === 'object'
671
+ ? (state.maxOffset as { x?: number; y?: number })
672
+ : null
673
+ const offset =
674
+ state.offset && typeof state.offset === 'object'
675
+ ? (state.offset as { x?: number; y?: number })
676
+ : null
677
+ const maxX =
678
+ typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0)
679
+ const maxY =
680
+ typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0)
681
+ const currentX =
682
+ typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0)
683
+ const currentY =
684
+ typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0)
685
+
686
+ const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX
687
+ const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY
688
+ return bridge.scrollTo(targetId, x, y, false)
689
+ },
690
+ { edge, hitX, hitY, targetId },
691
+ )
692
+
693
+ if (!result?.ok) {
694
+ throw new Error(
695
+ `scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`,
696
+ )
697
+ }
698
+
699
+ await page.evaluate(
700
+ () =>
701
+ new Promise<void>((resolve) => {
702
+ requestAnimationFrame(() => resolve())
703
+ }),
704
+ )
705
+ if (_synchronizationEnabled) {
706
+ await waitForSootsimIdle(page)
707
+ }
708
+ },
709
+
710
+ async swipe(
711
+ direction: 'up' | 'down' | 'left' | 'right',
712
+ speed = 'fast',
713
+ percentage = 0.75,
714
+ ) {
715
+ const node = await findNodeByMatcher(matcher)
716
+ if (!node)
717
+ throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
718
+
719
+ const page = getPage()
720
+ const { absolutePosition, layout } = node
721
+
722
+ // start from center
723
+ const startSootX = absolutePosition.x + layout.width * 0.5
724
+ const startSootY = absolutePosition.y + layout.height * 0.5
725
+
726
+ // compute distance
727
+ let endSootX = startSootX
728
+ let endSootY = startSootY
729
+ const dist =
730
+ direction === 'up' || direction === 'down'
731
+ ? layout.height * percentage
732
+ : layout.width * percentage
733
+
734
+ switch (direction) {
735
+ case 'up':
736
+ endSootY -= dist
737
+ break
738
+ case 'down':
739
+ endSootY += dist
740
+ break
741
+ case 'left':
742
+ endSootX -= dist
743
+ break
744
+ case 'right':
745
+ endSootX += dist
746
+ break
747
+ }
748
+
749
+ const steps = speed === 'slow' ? 20 : speed === 'fast' ? 8 : 12
750
+ const stepDelay = speed === 'slow' ? 25 : speed === 'fast' ? 5 : 15
751
+
752
+ await page.evaluate(
753
+ async ({ fromX, fromY, toX, toY, steps, stepMs }) => {
754
+ const drag = window.__sootsimTest?.drag
755
+ if (typeof drag !== 'function') {
756
+ throw new Error('sootsim drag bridge is not installed')
757
+ }
758
+ await drag(fromX, fromY, toX, toY, steps, stepMs)
759
+ },
760
+ {
761
+ fromX: startSootX,
762
+ fromY: startSootY,
763
+ toX: endSootX,
764
+ toY: endSootY,
765
+ steps,
766
+ stepMs: stepDelay,
767
+ },
768
+ )
769
+ await page.waitForTimeout(100)
770
+ },
771
+
772
+ async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) {
773
+ // detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1
774
+ // brings them together (zoom out). speed maps to step delay so RNGH's
775
+ // velocity tracking sees a realistic delta. angle (radians) tilts the
776
+ // pinch axis off horizontal — matches Detox's third arg.
777
+ const page = getPage()
778
+ const node = await findNodeByMatcher(matcher)
779
+ if (!node)
780
+ throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`)
781
+ await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler')
782
+ const center = getNodeCenter(node)
783
+ const centerPage = await sootsimToPage(page, center.x, center.y)
784
+ const startSpread = 60
785
+ const endSpread = startSpread * Math.max(scale, 0.05)
786
+ const cos = Math.cos(angle)
787
+ const sin = Math.sin(angle)
788
+ const offset = (d: number) => ({ dx: d * cos, dy: d * sin })
789
+ const a0 = offset(-startSpread)
790
+ const b0 = offset(startSpread)
791
+ const a1 = offset(-endSpread)
792
+ const b1 = offset(endSpread)
793
+ await dispatchTwoFingerGesture(
794
+ page,
795
+ { x: centerPage.x + a0.dx, y: centerPage.y + a0.dy },
796
+ { x: centerPage.x + b0.dx, y: centerPage.y + b0.dy },
797
+ { x: centerPage.x + a1.dx, y: centerPage.y + a1.dy },
798
+ { x: centerPage.x + b1.dx, y: centerPage.y + b1.dy },
799
+ { steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 },
800
+ )
801
+ },
802
+
803
+ async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
804
+ // two fingers held on a horizontal axis around the node center, then
805
+ // both rotated to `radians` around the same center. positive radians
806
+ // rotate counter-clockwise to match RNGH RotationGesture's reported
807
+ // sign (its 'rotation' delta is positive for a counter-clockwise
808
+ // motion of the rear finger relative to the front finger).
809
+ const page = getPage()
810
+ const node = await findNodeByMatcher(matcher)
811
+ if (!node)
812
+ throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`)
813
+ await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler')
814
+ const center = getNodeCenter(node)
815
+ const centerPage = await sootsimToPage(page, center.x, center.y)
816
+ const radius = 60
817
+ const startA = { x: centerPage.x - radius, y: centerPage.y }
818
+ const startB = { x: centerPage.x + radius, y: centerPage.y }
819
+ const cos = Math.cos(radians)
820
+ const sin = Math.sin(radians)
821
+ const endA = {
822
+ x: centerPage.x - radius * cos,
823
+ y: centerPage.y - radius * sin,
824
+ }
825
+ const endB = {
826
+ x: centerPage.x + radius * cos,
827
+ y: centerPage.y + radius * sin,
828
+ }
829
+ await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
830
+ steps: speed === 'fast' ? 10 : 20,
831
+ stepDelayMs: speed === 'fast' ? 14 : 24,
832
+ })
833
+ },
834
+
835
+ async takeScreenshot(name: string): Promise<string> {
836
+ const node = await findNodeByMatcher(matcher)
837
+ if (!node)
838
+ throw new Error(
839
+ `element not found for takeScreenshot: ${JSON.stringify(matcher)}`,
840
+ )
841
+
842
+ return writeSootsimScreenshot(name, {
843
+ crop: {
844
+ x: node.absolutePosition.x,
845
+ y: node.absolutePosition.y,
846
+ w: node.layout.width,
847
+ h: node.layout.height,
848
+ },
849
+ })
850
+ },
851
+
852
+ async getAttributes(): Promise<any> {
853
+ const node = await findNodeByMatcher(matcher)
854
+ if (!node)
855
+ throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`)
856
+ return {
857
+ text: node.text || '',
858
+ label: node.accessibilityLabel || node.text || '',
859
+ identifier: node.testID || node.id || '',
860
+ visible: node.layout.width > 0 && node.layout.height > 0,
861
+ enabled: !node.accessibilityState?.disabled,
862
+ ...node,
863
+ }
864
+ },
865
+
866
+ atIndex(_index: number) {
867
+ // sootsim resolves a testID to exactly one SootSimNode, so the
868
+ // detox-style atIndex is a passthrough. supplied for parity with
869
+ // real Detox so library tests can wrap atIndex around matchers
870
+ // that surface multiple native views on iOS (e.g. RNGH RectButton)
871
+ // without diverging code paths between the two lanes.
872
+ return el
873
+ },
874
+ }
875
+
876
+ return el
877
+ }
878
+
879
+ // element() factory -- takes a matcher and returns an element interaction object
880
+ export function element(matcher: Matcher): SootElement {
881
+ return createSootElement(matcher)
882
+ }
883
+
884
+ // device object -- app lifecycle
885
+ export const device = {
886
+ _currentUrl: '',
887
+ _platform: DETOX_PLATFORM as 'ios' | 'android',
888
+
889
+ async launchApp(opts?: {
890
+ delete?: boolean
891
+ newInstance?: boolean
892
+ url?: string
893
+ launchArgs?: Record<string, any>
894
+ // when set, the new BrowserContext records video at this dir. used by
895
+ // captureProofMenuStages for the multi-stage animation diff path. must
896
+ // be set at launchApp time because playwright recordVideo can't be
897
+ // toggled on an existing context.
898
+ recordVideoDir?: string
899
+ }) {
900
+ if (!_browser) {
901
+ _browser = await chromium.launch({ headless: true })
902
+ }
903
+
904
+ if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
905
+ await closeContext()
906
+ const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
907
+ hasTouch: true,
908
+ viewport: { width: 500, height: 900 },
909
+ }
910
+ if (opts?.recordVideoDir) {
911
+ fs.mkdirSync(opts.recordVideoDir, { recursive: true })
912
+ contextOpts.recordVideo = {
913
+ dir: opts.recordVideoDir,
914
+ size: { width: 500, height: 900 },
915
+ }
916
+ }
917
+ _context = await _browser.newContext(contextOpts)
918
+ _page = await _context.newPage()
919
+ attachPageDiagnostics(_page)
920
+ }
921
+
922
+ const url = opts?.url || BASE_URL
923
+ device._currentUrl = url
924
+ await _page!.goto(url, { waitUntil: 'load', timeout: 30000 })
925
+
926
+ await waitForSootsimTree(_page!, 30000)
927
+ // record the wall-clock time the page is ready — captureProofMenuStages
928
+ // uses this to compute trigger-in-video offsets
929
+ device._lastReadyAtMs = Date.now()
930
+ await _page!.waitForTimeout(500)
931
+ },
932
+
933
+ _lastReadyAtMs: 0,
934
+
935
+ async reloadReactNative() {
936
+ const page = getPage()
937
+ await page.reload({ waitUntil: 'load', timeout: 30000 })
938
+ await waitForSootsimTree(page, 30000)
939
+ await page.waitForTimeout(500)
940
+ },
941
+
942
+ async terminateApp() {
943
+ await closeBrowser()
944
+ },
945
+
946
+ async installApp() {
947
+ // no-op for sootsim -- app runs in browser
948
+ },
949
+
950
+ async uninstallApp() {
951
+ // no-op
952
+ },
953
+
954
+ async openURL(url: { url: string; sourceApp?: string }) {
955
+ const page = getPage()
956
+ await page.goto(url.url, { waitUntil: 'load', timeout: 30000 })
957
+ await waitForSootsimTree(page, 10000)
958
+ },
959
+
960
+ async takeScreenshot(name: string): Promise<string> {
961
+ return writeSootsimScreenshot(name)
962
+ },
963
+
964
+ // single-shot variant for multi-stage animation captures. skips the
965
+ // frame-stability poll so callers can sample mid-transition.
966
+ async takeScreenshotFast(name: string): Promise<string> {
967
+ ensureScreenshotDir()
968
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
969
+ fs.writeFileSync(screenshotPath, await captureSootsimPngFast())
970
+ return screenshotPath
971
+ },
972
+
973
+ // record the SootSim canvas via the engine's headless recorder
974
+ // (window.__sootsimRecorder — @sootsim/plugin-recording). this is the
975
+ // SAME code path `bun sootsim record video` drives, NOT the rail-button
976
+ // path (SootSim.bridges.startRecording wires the dialog state machine
977
+ // and never stashes the resulting blob into __sootsimRecorder.lastBlob,
978
+ // which is what getBlobBase64 streams from).
979
+ //
980
+ // unlike playwright's page-level recordVideo, the headless recorder
981
+ // captures ONLY the device canvas (composited shell + tenant surfaces)
982
+ // — no browser chrome, no menu bar, no device frame. uses the EXISTING
983
+ // page/context so per-test state is preserved across capture.
984
+ async startBridgeRecording(opts?: {
985
+ durationMs?: number
986
+ layers?: 'full' | 'tenant' | 'shell'
987
+ fps?: number
988
+ }): Promise<void> {
989
+ const page = getPage()
990
+ const startOpts = {
991
+ format: 'webm' as const,
992
+ fps: opts?.fps ?? 60,
993
+ layers: opts?.layers ?? 'tenant',
994
+ durationMs: opts?.durationMs ?? 5000,
995
+ }
996
+ const result = await page.evaluate(async (startOpts) => {
997
+ const rec = (
998
+ window as {
999
+ __sootsimRecorder?: {
1000
+ start?: (
1001
+ o: unknown,
1002
+ ) => Promise<{ ok: boolean; error?: string; format?: string }>
1003
+ }
1004
+ }
1005
+ ).__sootsimRecorder
1006
+ const start = rec?.start
1007
+ if (typeof start !== 'function') {
1008
+ return { ok: false, error: '__sootsimRecorder.start not installed' }
1009
+ }
1010
+ return start(startOpts)
1011
+ }, startOpts)
1012
+ if (!result.ok) {
1013
+ throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`)
1014
+ }
1015
+ },
1016
+
1017
+ // stop the headless recorder, stream the resulting blob in chunks (same
1018
+ // protocol as `bun sootsim record`), and write the webm to outPath.
1019
+ async stopAndSaveBridgeRecording(outPath: string): Promise<string> {
1020
+ const page = getPage()
1021
+ const stopResult = await page.evaluate(async () => {
1022
+ const rec = (
1023
+ window as {
1024
+ __sootsimRecorder?: {
1025
+ stop?: () => Promise<{
1026
+ ok: boolean
1027
+ error?: string
1028
+ size?: number
1029
+ mime?: string
1030
+ durationMs?: number
1031
+ }>
1032
+ }
1033
+ }
1034
+ ).__sootsimRecorder
1035
+ const stop = rec?.stop
1036
+ if (typeof stop !== 'function') {
1037
+ return { ok: false, error: '__sootsimRecorder.stop not installed' }
1038
+ }
1039
+ return stop()
1040
+ })
1041
+ if (!stopResult.ok) {
1042
+ throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`)
1043
+ }
1044
+ if (!stopResult.size) {
1045
+ throw new Error('stopBridgeRecording: recorder returned empty blob')
1046
+ }
1047
+ const chunks: Buffer[] = []
1048
+ let offset = 0
1049
+ const CHUNK = 2 * 1024 * 1024
1050
+ while (true) {
1051
+ const result: {
1052
+ data: string
1053
+ size: number
1054
+ offset: number
1055
+ done: boolean
1056
+ mime: string
1057
+ } | null = await page.evaluate(
1058
+ ({ offset, chunk }) => {
1059
+ const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } })
1060
+ .__sootsimRecorder
1061
+ const get = rec?.getBlobBase64 as
1062
+ | ((args: { offset: number; chunk: number }) => Promise<{
1063
+ data: string
1064
+ size: number
1065
+ offset: number
1066
+ done: boolean
1067
+ mime: string
1068
+ } | null>)
1069
+ | undefined
1070
+ if (typeof get !== 'function') return null
1071
+ return get({ offset, chunk })
1072
+ },
1073
+ { offset, chunk: CHUNK },
1074
+ )
1075
+ if (!result) throw new Error('SootSim recorder produced no blob')
1076
+ chunks.push(Buffer.from(result.data, 'base64'))
1077
+ offset = result.offset
1078
+ if (result.done) break
1079
+ }
1080
+ fs.mkdirSync(path.dirname(outPath), { recursive: true })
1081
+ fs.writeFileSync(outPath, Buffer.concat(chunks))
1082
+ ;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath
1083
+ return outPath
1084
+ },
1085
+
1086
+ _lastRecordingPath: '' as string,
1087
+
1088
+ // set the engine's ContextMenu animation-progress override. visual-proof
1089
+ // harnesses use this to drive each capture stage to a known animProgress
1090
+ // (matched against the iOS oracle's curve) instead of fighting capture
1091
+ // latency vs. sub-second spring timing. pass null to clear.
1092
+ //
1093
+ // dispatches a host CustomEvent that the engine forwards to the tenant
1094
+ // worker (see shell-event-registry.ts + input-bridge.ts handleShellEvent).
1095
+ // the worker writes globalThis.__sootsimMenuAnimOverride which the engine
1096
+ // ContextMenu reads each rAF tick.
1097
+ async setMenuAnimOverride(progress: number | null): Promise<void> {
1098
+ // dispatched from the playwright host; the typed `dispatchSootsimEvent`
1099
+ // helper lives in the engine bundle and isn't reachable from outside
1100
+ // the page. keep this host-side wrapper typed against SootsimEventMap so
1101
+ // the event names and payload shapes still come from the central registry.
1102
+ await dispatchSootsimEventInPage('sootsim:menuAnimOverride', { value: progress })
1103
+ },
1104
+
1105
+ async setBottomTabAnimOverride(
1106
+ value: { fromIndex: number; progress: number; toIndex: number } | null,
1107
+ ): Promise<void> {
1108
+ await dispatchSootsimEventInPage('sootsim:bottomTabAnimOverride', { value })
1109
+ },
1110
+
1111
+ // FAST multi-stage capture for sub-second animation diffs. each stage
1112
+ // schedules a setTimeout at its requested offsetMs and at that moment
1113
+ // SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
1114
+ // (drawImage from the worker-transferred home + overlay canvases). PNG
1115
+ // encoding is queued after all snapshots land — encoding stalls don't
1116
+ // contaminate the animation window.
1117
+ //
1118
+ // unlike `takeScreenshotStages`, this does NOT go through screenshot()
1119
+ // (which calls forceRenderAll + composite + toDataURL serially at
1120
+ // 300-1300ms per call and blows the spring-open timing). the worker's
1121
+ // vsync-pumped rAF keeps the home canvas fresh; we just sample it.
1122
+ //
1123
+ // returns paths in stage order; actualMs reports when the snapshot
1124
+ // actually fired (typically within a few ms of offsetMs).
1125
+ async takeScreenshotStagesFast(
1126
+ namePrefix: string,
1127
+ stages: Array<{ label: string; offsetMs: number }>,
1128
+ ): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
1129
+ const page = getPage()
1130
+ const frames: Array<{
1131
+ label: string
1132
+ offsetMs: number
1133
+ actualMs: number
1134
+ dataUrl: string
1135
+ }> = await page.evaluate(
1136
+ async (input) => {
1137
+ const sim = window as {
1138
+ SootSim?: {
1139
+ bridges?: {
1140
+ screenshot?: (opts?: unknown) => Promise<string>
1141
+ liveComposite?: {
1142
+ captureFresh: () => Promise<{
1143
+ canvas: HTMLCanvasElement | null
1144
+ frameToken: number
1145
+ }>
1146
+ }
1147
+ }
1148
+ }
1149
+ }
1150
+ const screenshot = sim.SootSim?.bridges?.screenshot
1151
+ const live = sim.SootSim?.bridges?.liveComposite
1152
+ if (typeof screenshot !== 'function') {
1153
+ throw new Error('SootSim screenshot bridge is not installed')
1154
+ }
1155
+ // initial warm-up: ensure the worker has produced at least one
1156
+ // frame after the menu's portal mount before we start sampling.
1157
+ // (a pump at higher cadence was tried and starved tap propagation —
1158
+ // the tenant React commit for setOpen never landed between
1159
+ // forceRender calls.)
1160
+ await screenshot()
1161
+ const start = performance.now()
1162
+ type Snapshot = {
1163
+ label: string
1164
+ offsetMs: number
1165
+ actualMs: number
1166
+ dataUrl: string | null
1167
+ }
1168
+ // each stage fires at its requested offset and SYNCHRONOUSLY
1169
+ // snapshots the engine's host-side composite canvas (drawImage
1170
+ // from the worker-transferred home canvas + overlay). encoding
1171
+ // happens after — encoding stalls don't disturb the next stage's
1172
+ // capture moment.
1173
+ const snaps: Snapshot[] = await Promise.all(
1174
+ input.stages.map(
1175
+ (stage) =>
1176
+ new Promise<Snapshot>((resolve) => {
1177
+ const fire = async () => {
1178
+ const actualMs = performance.now() - start
1179
+ try {
1180
+ let dataUrl: string | null = null
1181
+ if (live && typeof live.captureFresh === 'function') {
1182
+ // synchronous paint of current canvas state — no
1183
+ // worker round-trip. the background pump keeps the
1184
+ // canvas fresh.
1185
+ const { canvas } = await live.captureFresh()
1186
+ if (canvas) {
1187
+ const bmp = await createImageBitmap(canvas)
1188
+ const enc = document.createElement('canvas')
1189
+ enc.width = bmp.width
1190
+ enc.height = bmp.height
1191
+ const ctx = enc.getContext('2d')
1192
+ if (ctx) {
1193
+ ctx.drawImage(bmp, 0, 0)
1194
+ dataUrl = enc.toDataURL('image/png')
1195
+ }
1196
+ bmp.close()
1197
+ }
1198
+ }
1199
+ if (!dataUrl) {
1200
+ dataUrl = await screenshot()
1201
+ }
1202
+ resolve({
1203
+ label: stage.label,
1204
+ offsetMs: stage.offsetMs,
1205
+ actualMs,
1206
+ dataUrl,
1207
+ })
1208
+ } catch {
1209
+ resolve({
1210
+ label: stage.label,
1211
+ offsetMs: stage.offsetMs,
1212
+ actualMs,
1213
+ dataUrl: null,
1214
+ })
1215
+ }
1216
+ }
1217
+ setTimeout(
1218
+ () => {
1219
+ void fire()
1220
+ },
1221
+ Math.max(0, stage.offsetMs),
1222
+ )
1223
+ }),
1224
+ ),
1225
+ )
1226
+ const out: Array<{
1227
+ label: string
1228
+ offsetMs: number
1229
+ actualMs: number
1230
+ dataUrl: string
1231
+ }> = []
1232
+ for (const snap of snaps) {
1233
+ if (!snap.dataUrl) continue
1234
+ out.push({
1235
+ label: snap.label,
1236
+ offsetMs: snap.offsetMs,
1237
+ actualMs: snap.actualMs,
1238
+ dataUrl: snap.dataUrl,
1239
+ })
1240
+ }
1241
+ return out
1242
+ },
1243
+ { stages },
1244
+ )
1245
+ ensureScreenshotDir()
1246
+ return frames.map((f) => {
1247
+ const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
1248
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
1249
+ fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
1250
+ return {
1251
+ label: f.label,
1252
+ offsetMs: f.offsetMs,
1253
+ actualMs: f.actualMs,
1254
+ path: filePath,
1255
+ }
1256
+ })
1257
+ },
1258
+
1259
+ // batched multi-stage capture for animation diffs. all N captures happen
1260
+ // inside one page.evaluate round trip so browser-side timing controls the
1261
+ // offsets — sidesteps the ~300ms per-call bridge overhead that blows
1262
+ // sub-second animation windows. each stage gets a screenshot at the
1263
+ // requested offsetMs (from when the evaluate starts, i.e. right after
1264
+ // trigger() returns). returns absolute paths in stage order.
1265
+ async takeScreenshotStages(
1266
+ namePrefix: string,
1267
+ stages: Array<{ label: string; offsetMs: number }>,
1268
+ ): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
1269
+ const page = getPage()
1270
+ const frames: Array<{
1271
+ label: string
1272
+ offsetMs: number
1273
+ actualMs: number
1274
+ dataUrl: string
1275
+ }> = await page.evaluate(
1276
+ async (input) => {
1277
+ const screenshot = (
1278
+ window as { SootSim?: { bridges?: { screenshot?: unknown } } }
1279
+ ).SootSim?.bridges?.screenshot as
1280
+ | ((opts?: unknown) => Promise<string>)
1281
+ | undefined
1282
+ if (typeof screenshot !== 'function') {
1283
+ throw new Error('sootsim screenshot bridge is not installed')
1284
+ }
1285
+ const start = Date.now()
1286
+ const out: Array<{
1287
+ label: string
1288
+ offsetMs: number
1289
+ actualMs: number
1290
+ dataUrl: string
1291
+ }> = []
1292
+ for (const stage of input.stages) {
1293
+ const elapsed = Date.now() - start
1294
+ const wait = Math.max(0, stage.offsetMs - elapsed)
1295
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait))
1296
+ const actualMs = Date.now() - start
1297
+ const dataUrl = await screenshot()
1298
+ out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl })
1299
+ }
1300
+ return out
1301
+ },
1302
+ { stages },
1303
+ )
1304
+ ensureScreenshotDir()
1305
+ return frames.map((f) => {
1306
+ const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
1307
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
1308
+ fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
1309
+ return {
1310
+ label: f.label,
1311
+ offsetMs: f.offsetMs,
1312
+ actualMs: f.actualMs,
1313
+ path: filePath,
1314
+ }
1315
+ })
1316
+ },
1317
+
1318
+ async shake() {
1319
+ // no-op for sootsim
1320
+ },
1321
+
1322
+ async setLocation(lat: number, lon: number) {
1323
+ // no-op
1324
+ },
1325
+
1326
+ async setStatusBar(config: StatusBarConfig) {
1327
+ await dispatchStatusBarOverride(config)
1328
+ },
1329
+
1330
+ async setURLBlacklist(urls: string[]) {
1331
+ // no-op
1332
+ },
1333
+
1334
+ async enableSynchronization() {
1335
+ _synchronizationEnabled = true
1336
+ },
1337
+
1338
+ async disableSynchronization() {
1339
+ _synchronizationEnabled = false
1340
+ },
1341
+
1342
+ getPlatform(): 'ios' | 'android' {
1343
+ return device._platform
1344
+ },
1345
+
1346
+ async pressBack() {
1347
+ if (device._platform !== 'android') return
1348
+ await element(by.id('android-nav-back')).tap()
1349
+ },
1350
+
1351
+ // raw coordinate tap, bypassing the element-based visibility/hit-test path.
1352
+ // mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
1353
+ // target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
1354
+ // `element(...).tap({x,y})` would fail Detox's visibility threshold.
1355
+ async tap(point?: { x: number; y: number } | boolean) {
1356
+ if (point == null || typeof point === 'boolean') return
1357
+ const page = getPage()
1358
+ const { x, y } = point
1359
+ await page.evaluate(
1360
+ async ({ x: tx, y: ty }) => {
1361
+ const interactTap = window.SootSim?.bridges?.interact?.tap
1362
+ if (typeof interactTap === 'function') {
1363
+ const result = await interactTap(tx, ty)
1364
+ if (result && (typeof result !== 'object' || result.hit !== false)) return
1365
+ }
1366
+ const tap = window.__sootsimTest?.tap
1367
+ if (typeof tap !== 'function') {
1368
+ throw new Error('sootsim tap bridge is not installed')
1369
+ }
1370
+ await tap(tx, ty)
1371
+ },
1372
+ { x, y },
1373
+ )
1374
+ await page.waitForTimeout(50)
1375
+ if (_synchronizationEnabled) {
1376
+ await waitForSootsimIdle(page)
1377
+ }
1378
+ },
1379
+
1380
+ async sendToHome() {
1381
+ // no-op
1382
+ },
1383
+
1384
+ // get direct access to the playwright page for advanced use
1385
+ getPage(): Page {
1386
+ return getPage()
1387
+ },
1388
+ }
1389
+
1390
+ // exports matching detox API
1391
+ const sootExpect = createExpect(findNodeByMatcher, getPage)
1392
+ const sootWaitFor = createWaitFor(findNodeByMatcher, getPage)
1393
+
1394
+ export { sootExpect as expect, sootWaitFor as waitFor }
1395
+
1396
+ // cleanup -- call this in afterAll
1397
+ export async function cleanup() {
1398
+ if (_browser) {
1399
+ await _browser.close()
1400
+ _browser = null
1401
+ _page = null
1402
+ }
1403
+ }