sootsim 0.1.82 → 0.1.84

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