rnxsim 0.0.0 → 0.1.312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-NXVDDCFG.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-DXJCFKL3.js +17 -0
  25. package/dist-cli/chunks/app-fonts-W5AJZQJF.js +4 -0
  26. package/dist-cli/chunks/assert-3LOJEUDT.js +49 -0
  27. package/dist-cli/chunks/auth-MFTYYPNX.js +12 -0
  28. package/dist-cli/chunks/beta-GT3NN67B.js +4 -0
  29. package/dist-cli/chunks/camera-6EAK7CUL.js +22 -0
  30. package/dist-cli/chunks/chunk-274UD5FD.js +4 -0
  31. package/dist-cli/chunks/chunk-2SA2VTGP.js +5 -0
  32. package/dist-cli/chunks/chunk-33FKQH54.js +5 -0
  33. package/dist-cli/chunks/chunk-37I5AIS7.js +3 -0
  34. package/dist-cli/chunks/chunk-3D57IBPC.js +4 -0
  35. package/dist-cli/chunks/chunk-4BXCZHAU.js +4 -0
  36. package/dist-cli/chunks/chunk-5OX7ANRA.js +5 -0
  37. package/dist-cli/chunks/chunk-66NF2KNG.js +9 -0
  38. package/dist-cli/chunks/chunk-6UKJB2VC.js +125 -0
  39. package/dist-cli/chunks/chunk-7INBNA3U.js +26 -0
  40. package/dist-cli/chunks/chunk-7VWOSTD5.js +5 -0
  41. package/dist-cli/chunks/chunk-AY47WKS5.js +23 -0
  42. package/dist-cli/chunks/chunk-B7KMNHX7.js +4 -0
  43. package/dist-cli/chunks/chunk-BKNOLP3J.js +4 -0
  44. package/dist-cli/chunks/chunk-BRIZOR4K.js +28 -0
  45. package/dist-cli/chunks/chunk-DM6KUAOZ.js +4 -0
  46. package/dist-cli/chunks/chunk-EUAVBTKU.js +4 -0
  47. package/dist-cli/chunks/chunk-FTC4M26Q.js +4 -0
  48. package/dist-cli/chunks/chunk-FWYYCUAZ.js +4 -0
  49. package/dist-cli/chunks/chunk-G74YIEVS.js +7 -0
  50. package/dist-cli/chunks/chunk-GZDHEONW.js +507 -0
  51. package/dist-cli/chunks/chunk-HG7ORJMT.js +5 -0
  52. package/dist-cli/chunks/chunk-HMDOLCKG.js +15 -0
  53. package/dist-cli/chunks/chunk-JG6S4RSH.js +4 -0
  54. package/dist-cli/chunks/chunk-KR5BGMBZ.js +4 -0
  55. package/dist-cli/chunks/chunk-KZW5WBGU.js +983 -0
  56. package/dist-cli/chunks/chunk-L24Z3CRS.js +30 -0
  57. package/dist-cli/chunks/chunk-LDQ7HVCW.js +4 -0
  58. package/dist-cli/chunks/chunk-MENV7VLD.js +4 -0
  59. package/dist-cli/chunks/chunk-MNGQ42QE.js +568 -0
  60. package/dist-cli/chunks/chunk-N6A3SXMR.js +4 -0
  61. package/dist-cli/chunks/chunk-NLEMQ4PY.js +6 -0
  62. package/dist-cli/chunks/chunk-NPVGMH2N.js +101 -0
  63. package/dist-cli/chunks/chunk-OR3XTXEB.js +107 -0
  64. package/dist-cli/chunks/chunk-PEPUGJVI.js +4 -0
  65. package/dist-cli/chunks/chunk-Q3RGLERJ.js +142 -0
  66. package/dist-cli/chunks/chunk-SI3BUTQR.js +4 -0
  67. package/dist-cli/chunks/chunk-T4FT6CM7.js +4 -0
  68. package/dist-cli/chunks/chunk-TBMVBI26.js +7 -0
  69. package/dist-cli/chunks/chunk-TCWQJILS.js +5 -0
  70. package/dist-cli/chunks/chunk-TGQNQ6XT.js +766 -0
  71. package/dist-cli/chunks/chunk-TYE3WBSR.js +5 -0
  72. package/dist-cli/chunks/chunk-U4PN6JOT.js +6 -0
  73. package/dist-cli/chunks/chunk-VUFRB347.js +4 -0
  74. package/dist-cli/chunks/chunk-VZBLFMYC.js +10 -0
  75. package/dist-cli/chunks/chunk-WBT32R6N.js +25 -0
  76. package/dist-cli/chunks/chunk-XRGEEPJ5.js +38 -0
  77. package/dist-cli/chunks/chunk-Y2FSZSO7.js +71 -0
  78. package/dist-cli/chunks/chunk-YRV7HR3P.js +74 -0
  79. package/dist-cli/chunks/chunk-YZRFF4YA.js +14 -0
  80. package/dist-cli/chunks/chunk-ZMZTHU7V.js +4 -0
  81. package/dist-cli/chunks/cleanup-H3GWQB63.js +25 -0
  82. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +4 -0
  83. package/dist-cli/chunks/compat-5WG2XJV7.js +20 -0
  84. package/dist-cli/chunks/config-ANMBVVLL.js +13 -0
  85. package/dist-cli/chunks/control-NSGNQFJG.js +4 -0
  86. package/dist-cli/chunks/daemon-JHF4BJ3U.js +4 -0
  87. package/dist-cli/chunks/debug-CHV2O2KF.js +200 -0
  88. package/dist-cli/chunks/desktop-FIPIK4CH.js +31 -0
  89. package/dist-cli/chunks/detox-CVUJP7K6.js +52 -0
  90. package/dist-cli/chunks/device-6WCUM4W6.js +18 -0
  91. package/dist-cli/chunks/diagnose-I6EMWEII.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-P27CYFTN.js +4 -0
  93. package/dist-cli/chunks/drivers-HQ6AKZT6.js +4 -0
  94. package/dist-cli/chunks/film-UOQFFYNF.js +74 -0
  95. package/dist-cli/chunks/flow-N27MRRNL.js +4 -0
  96. package/dist-cli/chunks/help-SF6FEGEE.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-NJYFSJKZ.js +4 -0
  98. package/dist-cli/chunks/home-paths-XOV44JEF.js +4 -0
  99. package/dist-cli/chunks/inspect-CWFRFQTY.js +4 -0
  100. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +4 -0
  101. package/dist-cli/chunks/login-BPFQENCX.js +28 -0
  102. package/dist-cli/chunks/logout-5A45ZGY7.js +4 -0
  103. package/dist-cli/chunks/maestro-E4CSDMLM.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-CKMOJQX2.js +36 -0
  105. package/dist-cli/chunks/mode-SZYNMIQF.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-FRH4SFVG.js +4 -0
  107. package/dist-cli/chunks/perf-VXU2XJAX.js +48 -0
  108. package/dist-cli/chunks/permissions-IRIAOVND.js +13 -0
  109. package/dist-cli/chunks/record-RY7FPLLH.js +72 -0
  110. package/dist-cli/chunks/report-issue-KNWXOE53.js +29 -0
  111. package/dist-cli/chunks/runtime-AR3GJTGK.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-QLALR6GY.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-OQFMFVRL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-6TSRJFGB.js +42 -0
  115. package/dist-cli/chunks/serve-F54J2NQO.js +44 -0
  116. package/dist-cli/chunks/setup-7V6UKX4U.js +32 -0
  117. package/dist-cli/chunks/skills-QQBOSETH.js +50 -0
  118. package/dist-cli/chunks/storage-TAPMKK5O.js +37 -0
  119. package/dist-cli/chunks/store-7NX3S43H.js +4 -0
  120. package/dist-cli/chunks/telemetry-E7BUK6RA.js +4 -0
  121. package/dist-cli/chunks/timeline-IAM564NA.js +24 -0
  122. package/dist-cli/chunks/upgrade-CD55QXIX.js +7 -0
  123. package/dist-cli/chunks/upload-GZ6LIMYM.js +4 -0
  124. package/dist-cli/chunks/version-3DYJ5AS3.js +6 -0
  125. package/dist-cli/chunks/web-6NW6YLPI.js +4 -0
  126. package/dist-cli/chunks/what-happened-CZZDOOVG.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23911 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
@@ -0,0 +1,480 @@
1
+ import { pollUntil } from '../src/poll-until'
2
+ // pull in @sootsim/globals so window.__sootsimTest and
3
+ // window.__sootsimFindInteractiveCanvas are typed inside page.evaluate
4
+ // bodies. all sootsim-namespaced globals are declared canonically there —
5
+ // add new entries to packages/sootsim-globals/src/index.ts, not here.
6
+ import type {} from '@sootsim/globals'
7
+ import type { Page } from 'playwright'
8
+
9
+ // shape of the surface-metrics bridge response we care about. the bridge
10
+ // types it as `Record<string, unknown> | null` (the contract is open) so we
11
+ // narrow at the call sites by casting to this local view.
12
+ type SurfaceMetricsView = {
13
+ window?: { width?: number; height?: number; scale?: number }
14
+ device?: { homeIndicatorHeight?: number }
15
+ safeAreaInsets?: { bottom?: number }
16
+ visibleRect?: { y?: number; height?: number }
17
+ }
18
+ type SurfaceMetricsResult = SurfaceMetricsView | null | undefined
19
+
20
+ type SootsimPageMetrics = {
21
+ canvas: { left: number; top: number; width: number; height: number }
22
+ window: { width: number; height: number }
23
+ interactiveWindow: { width: number; height: number }
24
+ }
25
+ type WorkletCallbackStats = {
26
+ tenantReceived: number
27
+ shellSent: number
28
+ }
29
+
30
+ type ShellGestureSeamDebug = {
31
+ attached?: Array<{
32
+ viewTag?: unknown
33
+ nodeFound?: unknown
34
+ handlers?: Array<{ name?: unknown }>
35
+ }>
36
+ }
37
+
38
+ export async function waitForSootsimGestureHandler(
39
+ page: Page,
40
+ viewTag: number,
41
+ handlerName: string,
42
+ ): Promise<void> {
43
+ await pollUntil(
44
+ () =>
45
+ page.evaluate(
46
+ async ({ viewTag, handlerName }) => {
47
+ const host = (
48
+ window as typeof window & {
49
+ SootSim?: {
50
+ bridges?: {
51
+ shellHost?: {
52
+ callTestBridge?: (
53
+ method: string,
54
+ ...args: unknown[]
55
+ ) => Promise<ShellGestureSeamDebug>
56
+ } | null
57
+ }
58
+ }
59
+ }
60
+ ).SootSim?.bridges?.shellHost
61
+ if (typeof host?.callTestBridge !== 'function') return false
62
+ let debug: ShellGestureSeamDebug | null = null
63
+ try {
64
+ debug = await host.callTestBridge('getShellGestureSeamDebug')
65
+ } catch {
66
+ return false
67
+ }
68
+ return (
69
+ debug?.attached?.some(
70
+ (entry) =>
71
+ entry.viewTag === viewTag &&
72
+ entry.nodeFound !== false &&
73
+ entry.handlers?.some((handler) => handler.name === handlerName),
74
+ ) === true
75
+ )
76
+ },
77
+ { viewTag, handlerName },
78
+ ),
79
+ Boolean,
80
+ {
81
+ timeoutMs: 5000,
82
+ intervalMs: 100,
83
+ label: `${handlerName} gesture handler on view ${viewTag}`,
84
+ },
85
+ )
86
+ }
87
+
88
+ async function readWorkletCallbackStats(
89
+ page: Page,
90
+ ): Promise<WorkletCallbackStats | null> {
91
+ return page.evaluate(async () => {
92
+ type Stats = {
93
+ runJSCallbackSent?: unknown
94
+ runJSCallbackReceived?: unknown
95
+ } | null
96
+ const g = window as typeof window & {
97
+ __sootsimTest?: { getWorkletSlotStats?: () => Promise<Stats> | Stats }
98
+ SootSim?: {
99
+ bridges?: {
100
+ shellHost?: {
101
+ callTestBridge?: (method: string, ...args: unknown[]) => Promise<Stats>
102
+ } | null
103
+ }
104
+ }
105
+ }
106
+ const tenant =
107
+ typeof g.__sootsimTest?.getWorkletSlotStats === 'function'
108
+ ? await g.__sootsimTest.getWorkletSlotStats()
109
+ : null
110
+ const shellHost = g.SootSim?.bridges?.shellHost
111
+ let shell: Stats = null
112
+ if (typeof shellHost?.callTestBridge === 'function') {
113
+ try {
114
+ shell = await shellHost.callTestBridge('getWorkletSlotStats')
115
+ } catch {}
116
+ }
117
+ const tenantReceived =
118
+ typeof tenant?.runJSCallbackReceived === 'number' ? tenant.runJSCallbackReceived : 0
119
+ const shellSent =
120
+ typeof shell?.runJSCallbackSent === 'number' ? shell.runJSCallbackSent : 0
121
+ return { tenantReceived, shellSent }
122
+ })
123
+ }
124
+
125
+ async function waitForWorkletCallbackDrain(
126
+ page: Page,
127
+ before: WorkletCallbackStats | null,
128
+ timeoutMs: number,
129
+ ): Promise<void> {
130
+ if (!before) {
131
+ await page.waitForTimeout(250)
132
+ return
133
+ }
134
+ let lastShellSent = before.shellSent
135
+ let stableSince = Date.now()
136
+ let sawCallback = false
137
+
138
+ await pollUntil(
139
+ () => readWorkletCallbackStats(page),
140
+ (current) => {
141
+ if (!current) return false
142
+ if (current.shellSent !== lastShellSent) {
143
+ lastShellSent = current.shellSent
144
+ stableSince = Date.now()
145
+ }
146
+ const sentDelta = current.shellSent - before.shellSent
147
+ if (sentDelta > 0) sawCallback = true
148
+ const receivedDelta = current.tenantReceived - before.tenantReceived
149
+ return sawCallback && receivedDelta >= sentDelta && Date.now() - stableSince >= 100
150
+ },
151
+ {
152
+ timeoutMs,
153
+ intervalMs: 20,
154
+ label: 'worklet callback drain',
155
+ },
156
+ ).catch(() => {})
157
+
158
+ if (!sawCallback) {
159
+ // Some gestures are purely native and do not schedule RN callbacks. A
160
+ // bounded fallback still preserves Detox's "gesture has settled" contract.
161
+ await page.waitForTimeout(250)
162
+ }
163
+ }
164
+
165
+ // helper script injected via page.addInitScript so every page.evaluate
166
+ // can reach it as `__sootsimFindInteractiveCanvas()`. picks the canvas
167
+ // that actually receives pointer input. sootsim renders multiple
168
+ // <canvas> layers per surface (background/render targets, hidden
169
+ // offscreen, overlay) and `document.querySelector('canvas')` returns
170
+ // the first one in DOM order — usually a non-interactive layer with
171
+ // `pointer-events: none`. dispatching synthetic PointerEvents onto
172
+ // that layer silently no-ops because the gesture system only listens
173
+ // on the interactive layer (pointer-events: auto, non-zero rect).
174
+ const sootsimCanvasFinderInstalled = new WeakSet<Page>()
175
+ function installInteractiveCanvasFinder() {
176
+ window.__sootsimFindInteractiveCanvas = (): HTMLCanvasElement | null => {
177
+ const interactive: Array<{ c: HTMLCanvasElement; z: number; app: boolean }> = []
178
+ const appVisible: Array<{ c: HTMLCanvasElement; z: number; app: boolean }> = []
179
+ for (const c of Array.from(document.querySelectorAll('canvas'))) {
180
+ const s = window.getComputedStyle(c)
181
+ if (s.display === 'none' || s.visibility === 'hidden') continue
182
+ const r = c.getBoundingClientRect()
183
+ if (r.width === 0 || r.height === 0) continue
184
+ const entry = {
185
+ c,
186
+ z: parseInt(s.zIndex, 10) || 0,
187
+ app: c.dataset.surfaceId?.startsWith('app:') === true,
188
+ }
189
+ if (entry.app) appVisible.push(entry)
190
+ if (s.pointerEvents !== 'none') interactive.push(entry)
191
+ }
192
+ const list = appVisible.length > 0 ? appVisible : interactive
193
+ if (list.length === 0) return null
194
+ list.sort((a, b) => Number(b.app) - Number(a.app) || b.z - a.z)
195
+ return list[0].c
196
+ }
197
+ }
198
+
199
+ async function ensureCanvasFinder(page: Page) {
200
+ if (!sootsimCanvasFinderInstalled.has(page)) {
201
+ sootsimCanvasFinderInstalled.add(page)
202
+ await page.addInitScript(installInteractiveCanvasFinder)
203
+ }
204
+ await page.evaluate(installInteractiveCanvasFinder)
205
+ }
206
+
207
+ async function readSootsimPageMetrics(
208
+ page: Page,
209
+ timeoutMs: number,
210
+ ): Promise<SootsimPageMetrics> {
211
+ await ensureCanvasFinder(page)
212
+ try {
213
+ const metrics = await pollUntil(
214
+ () =>
215
+ page.evaluate(async () => {
216
+ const canvas =
217
+ window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas')
218
+ if (!canvas) return null
219
+ const rect = canvas.getBoundingClientRect()
220
+ if (rect.width <= 0 || rect.height <= 0) return null
221
+ const surface =
222
+ (await window.__sootsimTest?.getSurfaceMetricsSnapshot?.()) as SurfaceMetricsResult
223
+ const screenWidth = surface?.window?.width
224
+ const screenHeight = surface?.window?.height
225
+ if (
226
+ typeof screenWidth !== 'number' ||
227
+ typeof screenHeight !== 'number' ||
228
+ screenWidth <= 0 ||
229
+ screenHeight <= 0
230
+ ) {
231
+ return null
232
+ }
233
+ const homeIndicatorHeight = Number(surface?.device?.homeIndicatorHeight) || 0
234
+ const safeAreaBottom = Number(surface?.safeAreaInsets?.bottom) || 0
235
+ const bottomInset = Math.max(0, homeIndicatorHeight, safeAreaBottom)
236
+ const visibleRectBottom =
237
+ Number(surface?.visibleRect?.y) + Number(surface?.visibleRect?.height)
238
+ const interactiveHeight =
239
+ Number.isFinite(visibleRectBottom) && visibleRectBottom > 0
240
+ ? Math.min(screenHeight, visibleRectBottom)
241
+ : Math.max(0, screenHeight - bottomInset)
242
+ return {
243
+ canvas: {
244
+ left: rect.left,
245
+ top: rect.top,
246
+ width: rect.width,
247
+ height: rect.height,
248
+ },
249
+ window: {
250
+ width: screenWidth,
251
+ height: screenHeight,
252
+ },
253
+ interactiveWindow: {
254
+ width: screenWidth,
255
+ height: interactiveHeight,
256
+ },
257
+ }
258
+ }),
259
+ (metrics) => metrics !== null,
260
+ {
261
+ timeoutMs,
262
+ intervalMs: 50,
263
+ label: 'sootsim surface metrics',
264
+ },
265
+ )
266
+ if (metrics === null) throw new Error('surface metrics poll settled without metrics')
267
+ return metrics
268
+ } catch (error) {
269
+ throw new Error('sootsim surface metrics are not available', { cause: error })
270
+ }
271
+ }
272
+
273
+ export async function readSootsimViewport(
274
+ page: Page,
275
+ timeoutMs = 5000,
276
+ ): Promise<{ width: number; height: number }> {
277
+ return (await readSootsimPageMetrics(page, timeoutMs)).window
278
+ }
279
+
280
+ export async function readSootsimInteractiveViewport(
281
+ page: Page,
282
+ timeoutMs = 5000,
283
+ ): Promise<{ width: number; height: number }> {
284
+ return (await readSootsimPageMetrics(page, timeoutMs)).interactiveWindow
285
+ }
286
+
287
+ export async function sootsimToPage(
288
+ page: Page,
289
+ sootsimX: number,
290
+ sootsimY: number,
291
+ timeoutMs = 5000,
292
+ ): Promise<{ x: number; y: number }> {
293
+ const metrics = await readSootsimPageMetrics(page, timeoutMs)
294
+ const scaleX = metrics.canvas.width / metrics.window.width
295
+ const scaleY = metrics.canvas.height / metrics.window.height
296
+ return {
297
+ x: metrics.canvas.left + sootsimX * scaleX,
298
+ y: metrics.canvas.top + sootsimY * scaleY,
299
+ }
300
+ }
301
+
302
+ export async function dragScrollNode(
303
+ page: Page,
304
+ node: {
305
+ absolutePosition: { x: number; y: number }
306
+ layout: { width: number; height: number }
307
+ },
308
+ pixels: number,
309
+ direction: 'up' | 'down' | 'left' | 'right',
310
+ ): Promise<void> {
311
+ const startSootSimX = node.absolutePosition.x + node.layout.width / 2
312
+ const startSootSimY = node.absolutePosition.y + node.layout.height / 2
313
+ let endSootSimX = startSootSimX
314
+ let endSootSimY = startSootSimY
315
+
316
+ switch (direction) {
317
+ case 'down':
318
+ endSootSimY -= pixels
319
+ break
320
+ case 'up':
321
+ endSootSimY += pixels
322
+ break
323
+ case 'left':
324
+ endSootSimX += pixels
325
+ break
326
+ case 'right':
327
+ endSootSimX -= pixels
328
+ break
329
+ }
330
+
331
+ const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
332
+ const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
333
+ const steps = Math.max(16, Math.ceil(pixels / 8))
334
+
335
+ await page.mouse.move(startPage.x, startPage.y)
336
+ await page.mouse.down()
337
+ await page.waitForTimeout(20)
338
+
339
+ const dx = (endPage.x - startPage.x) / steps
340
+ const dy = (endPage.y - startPage.y) / steps
341
+ for (let i = 1; i <= steps; i++) {
342
+ await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
343
+ await page.waitForTimeout(24)
344
+ }
345
+
346
+ await page.mouse.up()
347
+ await page.waitForTimeout(180)
348
+ }
349
+
350
+ // Fire synthetic two-finger pointer events directly on the sootsim canvas. We
351
+ // use PointerEvents (not page.mouse) because page.mouse is single-cursor;
352
+ // sootsim's touch tracker keys on pointerId, so we just need to dispatch
353
+ // events with two distinct ids and pointerType 'touch'. Used for pinch /
354
+ // rotation drivers — RNGH's PinchGesture/RotationGesture both require ≥2
355
+ // concurrent active touches on iOS UIPinchGestureRecognizer parity.
356
+ export async function dispatchTwoFingerGesture(
357
+ page: Page,
358
+ startA: { x: number; y: number },
359
+ startB: { x: number; y: number },
360
+ endA: { x: number; y: number },
361
+ endB: { x: number; y: number },
362
+ options: {
363
+ steps?: number
364
+ stepDelayMs?: number
365
+ workletStatsTimeoutMs?: number
366
+ } = {},
367
+ ): Promise<void> {
368
+ const steps = options.steps ?? 10
369
+ const stepDelay = options.stepDelayMs ?? 18
370
+ const workletStatsTimeoutMs = options.workletStatsTimeoutMs ?? 1500
371
+ await ensureCanvasFinder(page)
372
+ const beforeWorkletStats = await pollUntil(
373
+ () => readWorkletCallbackStats(page),
374
+ () => true,
375
+ {
376
+ timeoutMs: workletStatsTimeoutMs,
377
+ intervalMs: 0,
378
+ label: 'pre-dispatch worklet callback stats',
379
+ },
380
+ ).catch(() => null)
381
+
382
+ await page.evaluate(
383
+ ({ startA, startB }) => {
384
+ const canvas: HTMLCanvasElement | null =
385
+ window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas')
386
+ if (!canvas) throw new Error('sootsim interactive canvas not found')
387
+ const fire = (point: { x: number; y: number }, id: number, primary: boolean) => {
388
+ canvas.dispatchEvent(
389
+ new PointerEvent('pointerdown', {
390
+ bubbles: true,
391
+ cancelable: true,
392
+ clientX: point.x,
393
+ clientY: point.y,
394
+ isPrimary: primary,
395
+ pointerId: id,
396
+ pointerType: 'touch',
397
+ }),
398
+ )
399
+ }
400
+ fire(startA, 1001, true)
401
+ fire(startB, 1002, false)
402
+ },
403
+ { startA, startB },
404
+ )
405
+
406
+ for (let i = 1; i <= steps; i++) {
407
+ const t = i / steps
408
+ const ax = startA.x + (endA.x - startA.x) * t
409
+ const ay = startA.y + (endA.y - startA.y) * t
410
+ const bx = startB.x + (endB.x - startB.x) * t
411
+ const by = startB.y + (endB.y - startB.y) * t
412
+ await page.evaluate(
413
+ ({ ax, ay, bx, by }) => {
414
+ const canvas: HTMLCanvasElement | null =
415
+ window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas')
416
+ if (!canvas) return
417
+ canvas.dispatchEvent(
418
+ new PointerEvent('pointermove', {
419
+ bubbles: true,
420
+ cancelable: true,
421
+ clientX: ax,
422
+ clientY: ay,
423
+ isPrimary: true,
424
+ pointerId: 1001,
425
+ pointerType: 'touch',
426
+ }),
427
+ )
428
+ canvas.dispatchEvent(
429
+ new PointerEvent('pointermove', {
430
+ bubbles: true,
431
+ cancelable: true,
432
+ clientX: bx,
433
+ clientY: by,
434
+ isPrimary: false,
435
+ pointerId: 1002,
436
+ pointerType: 'touch',
437
+ }),
438
+ )
439
+ },
440
+ { ax, ay, bx, by },
441
+ )
442
+ await page.waitForTimeout(stepDelay)
443
+ }
444
+
445
+ await page.evaluate(
446
+ ({ endA, endB }) => {
447
+ const canvas: HTMLCanvasElement | null =
448
+ window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector('canvas')
449
+ if (!canvas) return
450
+ canvas.dispatchEvent(
451
+ new PointerEvent('pointerup', {
452
+ bubbles: true,
453
+ cancelable: true,
454
+ clientX: endA.x,
455
+ clientY: endA.y,
456
+ isPrimary: true,
457
+ pointerId: 1001,
458
+ pointerType: 'touch',
459
+ }),
460
+ )
461
+ canvas.dispatchEvent(
462
+ new PointerEvent('pointerup', {
463
+ bubbles: true,
464
+ cancelable: true,
465
+ clientX: endB.x,
466
+ clientY: endB.y,
467
+ isPrimary: false,
468
+ pointerId: 1002,
469
+ pointerType: 'touch',
470
+ }),
471
+ )
472
+ },
473
+ { endA, endB },
474
+ )
475
+ await waitForWorkletCallbackDrain(page, beforeWorkletStats, workletStatsTimeoutMs)
476
+ // runOnJS delivery increments before the callback's React setState commits.
477
+ // Detox gestures settle after native UI has observed the gesture result, so
478
+ // leave one bounded commit window after the worklet bridge drains.
479
+ await page.waitForTimeout(250)
480
+ }