rnxsim 0.0.0 → 0.1.313

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-XZ2KTPCU.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-JJYYW2WH.js +17 -0
  25. package/dist-cli/chunks/app-fonts-IXRNQG6B.js +4 -0
  26. package/dist-cli/chunks/assert-54T5SK5F.js +49 -0
  27. package/dist-cli/chunks/auth-FI5UDI45.js +12 -0
  28. package/dist-cli/chunks/beta-JV6UKADW.js +4 -0
  29. package/dist-cli/chunks/camera-VL73YIKP.js +22 -0
  30. package/dist-cli/chunks/chunk-2D2UPBBR.js +4 -0
  31. package/dist-cli/chunks/chunk-2YR5BGA5.js +5 -0
  32. package/dist-cli/chunks/chunk-3NV2NCNX.js +4 -0
  33. package/dist-cli/chunks/chunk-46EUUFJ5.js +4 -0
  34. package/dist-cli/chunks/chunk-4NPPOV2N.js +5 -0
  35. package/dist-cli/chunks/chunk-5DHC6KHQ.js +7 -0
  36. package/dist-cli/chunks/chunk-5TEF3ET3.js +74 -0
  37. package/dist-cli/chunks/chunk-5TPRP5QT.js +4 -0
  38. package/dist-cli/chunks/chunk-5YJCOWCH.js +4 -0
  39. package/dist-cli/chunks/chunk-7GN3LVWB.js +4 -0
  40. package/dist-cli/chunks/chunk-BBULZ7CG.js +568 -0
  41. package/dist-cli/chunks/chunk-BTWORNNG.js +71 -0
  42. package/dist-cli/chunks/chunk-D4FFVGI5.js +5 -0
  43. package/dist-cli/chunks/chunk-DCEMHR2Y.js +4 -0
  44. package/dist-cli/chunks/chunk-DZS6WPUI.js +4 -0
  45. package/dist-cli/chunks/chunk-F5ZRSS3C.js +3 -0
  46. package/dist-cli/chunks/chunk-FSUYIVJ6.js +9 -0
  47. package/dist-cli/chunks/chunk-G2WW6L2C.js +23 -0
  48. package/dist-cli/chunks/chunk-GASE6UBA.js +4 -0
  49. package/dist-cli/chunks/chunk-GGRX24GF.js +142 -0
  50. package/dist-cli/chunks/chunk-HI5TFJWN.js +5 -0
  51. package/dist-cli/chunks/chunk-IJ5CAZZC.js +5 -0
  52. package/dist-cli/chunks/chunk-IJO63TDP.js +4 -0
  53. package/dist-cli/chunks/chunk-KTHV3RUS.js +26 -0
  54. package/dist-cli/chunks/chunk-LF2ZVT7O.js +6 -0
  55. package/dist-cli/chunks/chunk-MJRLLB4R.js +38 -0
  56. package/dist-cli/chunks/chunk-NFK7T35W.js +4 -0
  57. package/dist-cli/chunks/chunk-NMF2ZMZQ.js +15 -0
  58. package/dist-cli/chunks/chunk-OHAZNXLK.js +4 -0
  59. package/dist-cli/chunks/chunk-OVFJFXUD.js +101 -0
  60. package/dist-cli/chunks/chunk-OZSSI4WN.js +28 -0
  61. package/dist-cli/chunks/chunk-QKDWYITG.js +25 -0
  62. package/dist-cli/chunks/chunk-RSZWCKNT.js +30 -0
  63. package/dist-cli/chunks/chunk-RTN5C5RL.js +4 -0
  64. package/dist-cli/chunks/chunk-TIVZIMMW.js +4 -0
  65. package/dist-cli/chunks/chunk-TZFFR3SD.js +7 -0
  66. package/dist-cli/chunks/chunk-UC6U3MML.js +5 -0
  67. package/dist-cli/chunks/chunk-VFCMSYZK.js +6 -0
  68. package/dist-cli/chunks/chunk-VNQEB4L7.js +5 -0
  69. package/dist-cli/chunks/chunk-VZXWHRUZ.js +983 -0
  70. package/dist-cli/chunks/chunk-W6K4EFPH.js +107 -0
  71. package/dist-cli/chunks/chunk-WEXDAC74.js +4 -0
  72. package/dist-cli/chunks/chunk-WF3T4SVI.js +14 -0
  73. package/dist-cli/chunks/chunk-WINYQ44O.js +4 -0
  74. package/dist-cli/chunks/chunk-WMIIKMGK.js +10 -0
  75. package/dist-cli/chunks/chunk-WUSWBCWA.js +507 -0
  76. package/dist-cli/chunks/chunk-WWZIXIRD.js +4 -0
  77. package/dist-cli/chunks/chunk-XEVZYVIW.js +766 -0
  78. package/dist-cli/chunks/chunk-YDGQTMQL.js +4 -0
  79. package/dist-cli/chunks/chunk-YIFT42WN.js +4 -0
  80. package/dist-cli/chunks/chunk-ZMJD5GEC.js +125 -0
  81. package/dist-cli/chunks/cleanup-P27PA6JI.js +25 -0
  82. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +4 -0
  83. package/dist-cli/chunks/compat-ZD65FED3.js +20 -0
  84. package/dist-cli/chunks/config-XMJRNM2A.js +13 -0
  85. package/dist-cli/chunks/control-KMIQT3QP.js +4 -0
  86. package/dist-cli/chunks/daemon-G2ME7NLB.js +4 -0
  87. package/dist-cli/chunks/debug-PT4HOP7N.js +200 -0
  88. package/dist-cli/chunks/desktop-S3FG72AK.js +31 -0
  89. package/dist-cli/chunks/detox-B3D4IFCN.js +52 -0
  90. package/dist-cli/chunks/device-XBNDSB2R.js +18 -0
  91. package/dist-cli/chunks/diagnose-HMQXJE5N.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-BLCZ5BSZ.js +4 -0
  93. package/dist-cli/chunks/drivers-LDECZGP2.js +4 -0
  94. package/dist-cli/chunks/film-BJGTBYZB.js +74 -0
  95. package/dist-cli/chunks/flow-UEQNVTU7.js +4 -0
  96. package/dist-cli/chunks/help-T5FYSVGB.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-ANOYADHM.js +4 -0
  98. package/dist-cli/chunks/home-paths-GT3LFNOR.js +4 -0
  99. package/dist-cli/chunks/inspect-ZA6XF5LD.js +4 -0
  100. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +4 -0
  101. package/dist-cli/chunks/login-FJ737MWG.js +28 -0
  102. package/dist-cli/chunks/logout-ZCNMMHMY.js +4 -0
  103. package/dist-cli/chunks/maestro-SZTNKLDF.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-DCFAIZ4H.js +36 -0
  105. package/dist-cli/chunks/mode-GRMQCRXR.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-W36EWFFB.js +4 -0
  107. package/dist-cli/chunks/perf-QYBAAUZG.js +48 -0
  108. package/dist-cli/chunks/permissions-3QCQ6VF4.js +13 -0
  109. package/dist-cli/chunks/record-QPPC2S4E.js +72 -0
  110. package/dist-cli/chunks/report-issue-7NMFP4HK.js +29 -0
  111. package/dist-cli/chunks/runtime-XOAXMSTU.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-67AECJFB.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-ASWBYPJL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-PXA3HFQK.js +42 -0
  115. package/dist-cli/chunks/serve-BI2NBAXG.js +44 -0
  116. package/dist-cli/chunks/setup-7DWPMRSB.js +32 -0
  117. package/dist-cli/chunks/skills-S3Y22TUA.js +50 -0
  118. package/dist-cli/chunks/storage-XUIMJWAJ.js +37 -0
  119. package/dist-cli/chunks/store-JTHEJLAZ.js +4 -0
  120. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +4 -0
  121. package/dist-cli/chunks/timeline-TMPLQPSP.js +24 -0
  122. package/dist-cli/chunks/upgrade-7HDSIM7K.js +7 -0
  123. package/dist-cli/chunks/upload-GMSZPWM6.js +4 -0
  124. package/dist-cli/chunks/version-HOCHZ37L.js +6 -0
  125. package/dist-cli/chunks/web-DG3WBYD3.js +4 -0
  126. package/dist-cli/chunks/what-happened-XFVUTZR7.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 +23922 -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,2863 @@
1
+ /*! rnx v0.1.313 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
+ let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
+ "use strict";
4
+ var __create = Object.create;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
9
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
10
+ var __commonJS = (cb, mod) => function __require() {
11
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
12
+ };
13
+ var __export = (target, all) => {
14
+ for (var name in all)
15
+ __defProp(target, name, { get: all[name], enumerable: true });
16
+ };
17
+ var __copyProps = (to, from, except, desc) => {
18
+ if (from && typeof from === "object" || typeof from === "function") {
19
+ for (let key of __getOwnPropNames(from))
20
+ if (!__hasOwnProp.call(to, key) && key !== except)
21
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
22
+ }
23
+ return to;
24
+ };
25
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
26
+ // If the importer is in node compatibility mode or this is not an ESM
27
+ // file that has been converted to a CommonJS file using a Babel-
28
+ // compatible transform (i.e. "__esModule" has not been set), then set
29
+ // "default" to the CommonJS "module.exports" for node compatibility.
30
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
31
+ mod
32
+ ));
33
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
34
+
35
+ // detox/motion-change.cjs
36
+ var require_motion_change = __commonJS({
37
+ "detox/motion-change.cjs"(exports2, module2) {
38
+ "use strict";
39
+ var MOTION_CHANGE_DEFINITION2 = Object.freeze({
40
+ channelThreshold: 24,
41
+ minimumChangePercent: 0.1
42
+ });
43
+ function measureMotionChange2(input) {
44
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
45
+ throw new Error("motion change measurement requires an input object");
46
+ }
47
+ if (input.installGlobal === true) {
48
+ globalThis.__sootsimMeasureMotionChange = measureMotionChange2;
49
+ return null;
50
+ }
51
+ const definition = input.definition;
52
+ if (!definition || typeof definition !== "object" || !Number.isInteger(definition.channelThreshold) || definition.channelThreshold < 0 || definition.channelThreshold > 255 || !Number.isFinite(definition.minimumChangePercent) || definition.minimumChangePercent <= 0 || definition.minimumChangePercent >= 100) {
53
+ throw new Error(
54
+ "motion change definition needs a channel threshold from 0 to 255 and a minimum greater than 0% and less than 100%"
55
+ );
56
+ }
57
+ const label = typeof input.label === "string" && input.label.length > 0 ? input.label : "motion change";
58
+ let changePixels = input.changePixels;
59
+ let totalPixels = input.totalPixels;
60
+ if (input.baseline !== void 0 || input.candidate !== void 0) {
61
+ const baseline = input.baseline;
62
+ const candidate = input.candidate;
63
+ const width = input.width;
64
+ const height = input.height;
65
+ if (!baseline || !candidate || typeof baseline.length !== "number" || typeof candidate.length !== "number" || !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
66
+ throw new Error(`${label} needs two RGBA pixel arrays and positive dimensions`);
67
+ }
68
+ totalPixels = width * height;
69
+ const expectedBytes = totalPixels * 4;
70
+ if (baseline.length !== expectedBytes || candidate.length !== expectedBytes) {
71
+ throw new Error(
72
+ `${label} pixel data has ${baseline.length}/${candidate.length} bytes; expected ${expectedBytes}`
73
+ );
74
+ }
75
+ changePixels = 0;
76
+ for (let offset = 0; offset < expectedBytes; offset += 4) {
77
+ if (Math.abs(baseline[offset] - candidate[offset]) > definition.channelThreshold || Math.abs(baseline[offset + 1] - candidate[offset + 1]) > definition.channelThreshold || Math.abs(baseline[offset + 2] - candidate[offset + 2]) > definition.channelThreshold || Math.abs(baseline[offset + 3] - candidate[offset + 3]) > definition.channelThreshold) {
78
+ changePixels++;
79
+ }
80
+ }
81
+ }
82
+ if (!Number.isInteger(totalPixels) || totalPixels <= 0) {
83
+ throw new Error(`${label} has no positive measured region area`);
84
+ }
85
+ if (!Number.isInteger(changePixels) || changePixels < 0 || changePixels > totalPixels) {
86
+ throw new Error(
87
+ `${label} changed pixel count ${changePixels} is outside its ${totalPixels}px measured region`
88
+ );
89
+ }
90
+ const changePercent = changePixels / totalPixels * 100;
91
+ return {
92
+ changePercent,
93
+ changePixels,
94
+ passedMinimum: changePercent >= definition.minimumChangePercent,
95
+ totalPixels
96
+ };
97
+ }
98
+ function measureProofMotionChange(input) {
99
+ return measureMotionChange2({
100
+ ...input,
101
+ definition: MOTION_CHANGE_DEFINITION2
102
+ });
103
+ }
104
+ module2.exports = {
105
+ MOTION_CHANGE_DEFINITION: MOTION_CHANGE_DEFINITION2,
106
+ measureMotionChange: measureMotionChange2,
107
+ measureProofMotionChange
108
+ };
109
+ }
110
+ });
111
+
112
+ // detox/index.ts
113
+ var detox_exports = {};
114
+ __export(detox_exports, {
115
+ by: () => by,
116
+ cleanup: () => cleanup,
117
+ device: () => device,
118
+ element: () => element,
119
+ expect: () => sootsimExpect,
120
+ waitFor: () => sootsimWaitFor,
121
+ waitForSootsimIdle: () => waitForSootsimIdle
122
+ });
123
+ module.exports = __toCommonJS(detox_exports);
124
+ var fs = __toESM(require("fs"), 1);
125
+ var path = __toESM(require("path"), 1);
126
+ var import_playwright = require("playwright");
127
+
128
+ // ../../scripts/lib/reap-browser.ts
129
+ var import_node_child_process = require("node:child_process");
130
+ var discoveredChromePids = /* @__PURE__ */ new WeakMap();
131
+ var reapPids = /* @__PURE__ */ new Set();
132
+ var installed = false;
133
+ function currentRuntime() {
134
+ return { platform: process.platform, env: process.env };
135
+ }
136
+ function processCommand(pid) {
137
+ return (0, import_node_child_process.execFileSync)("/bin/ps", ["-p", String(pid), "-o", "command="], {
138
+ encoding: "utf8"
139
+ }).trim();
140
+ }
141
+ function softwareChromeAllowed(runtime = currentRuntime()) {
142
+ return runtime.platform === "linux" && runtime.env.GITHUB_ACTIONS === "true" && runtime.env.RUNNER_OS === "Linux";
143
+ }
144
+ function isSoftwareRendererArgument(value) {
145
+ const arg = String(value);
146
+ return /^--disable-gpu(?:=.*)?$/i.test(arg) || /^--enable-unsafe-swiftshader(?:=.*)?$/i.test(arg) || /^--use-angle=(?:swiftshader|swiftshader-webgl)$/i.test(arg) || /^--use-gl=swiftshader$/i.test(arg);
147
+ }
148
+ function softwareRendererArgumentInText(command) {
149
+ const unsafe = command.match(
150
+ /(?:^|\s)(--disable-gpu(?:=[^\s]+)?|--enable-unsafe-swiftshader(?:=[^\s]+)?|--use-angle=(?:swiftshader|swiftshader-webgl)|--use-gl=swiftshader)(?=\s|$)/i
151
+ );
152
+ return unsafe?.[1] ?? null;
153
+ }
154
+ function expectedAngle(platform) {
155
+ if (platform === "darwin") return "metal";
156
+ if (platform === "linux") return "vulkan";
157
+ return "default";
158
+ }
159
+ function assertGpuBrowserCommand(command, runtime = currentRuntime()) {
160
+ if (softwareChromeAllowed(runtime)) return;
161
+ const unsafe = softwareRendererArgumentInText(command);
162
+ if (unsafe) {
163
+ throw new Error(
164
+ `[reap-browser] refused software-rendering Chrome process argument ${unsafe}`
165
+ );
166
+ }
167
+ const angle = expectedAngle(runtime.platform);
168
+ const args = command.split(/\s+/);
169
+ if (!args.includes(`--use-angle=${angle}`) || !args.includes("--enable-gpu")) {
170
+ throw new Error(
171
+ `[reap-browser] Chrome process did not retain the required ${angle} GPU arguments`
172
+ );
173
+ }
174
+ if (runtime.platform === "darwin" && command.includes("/Applications/Google Chrome.app/")) {
175
+ throw new Error(
176
+ "[reap-browser] refused the user's installed Google Chrome; use Playwright channel chromium"
177
+ );
178
+ }
179
+ }
180
+ function collectGpuRendererStrings(info) {
181
+ if (!info || typeof info !== "object") return [];
182
+ const record = info;
183
+ const gpu = record.gpu;
184
+ if (!gpu || typeof gpu !== "object") return [];
185
+ const gpuRecord = gpu;
186
+ const values = [];
187
+ const aux = gpuRecord.auxAttributes;
188
+ if (aux && typeof aux === "object") {
189
+ for (const value of Object.values(aux)) {
190
+ if (typeof value === "string") values.push(value);
191
+ }
192
+ }
193
+ const devices = gpuRecord.devices;
194
+ if (Array.isArray(devices)) {
195
+ for (const device2 of devices) {
196
+ if (!device2 || typeof device2 !== "object") continue;
197
+ for (const value of Object.values(device2)) {
198
+ if (typeof value === "string") values.push(value);
199
+ }
200
+ }
201
+ }
202
+ return values;
203
+ }
204
+ function browserProcessPid(info) {
205
+ if (!info || typeof info !== "object") return null;
206
+ const record = info;
207
+ if (!Array.isArray(record.processInfo)) return null;
208
+ for (const entry of record.processInfo) {
209
+ if (!entry || typeof entry !== "object") continue;
210
+ const processInfo = entry;
211
+ if (processInfo.type === "browser" && typeof processInfo.id === "number" && Number.isInteger(processInfo.id) && processInfo.id > 0) {
212
+ return processInfo.id;
213
+ }
214
+ }
215
+ return null;
216
+ }
217
+ function assertGpuRendererInfo(info, runtime = currentRuntime()) {
218
+ if (softwareChromeAllowed(runtime)) return;
219
+ const renderers = collectGpuRendererStrings(info);
220
+ const summary = renderers.join(" | ");
221
+ if (!summary || /swiftshader|llvmpipe|software rasterizer/i.test(summary)) {
222
+ throw new Error(
223
+ `[reap-browser] Chrome reported a software renderer${summary ? `: ${summary}` : ""}`
224
+ );
225
+ }
226
+ if (runtime.platform === "darwin" && !/metal/i.test(summary)) {
227
+ throw new Error(
228
+ `[reap-browser] Chrome did not report an ANGLE Metal renderer: ${summary}`
229
+ );
230
+ }
231
+ }
232
+ function rootChromePid(b) {
233
+ if (b && typeof b === "object") {
234
+ const discovered = discoveredChromePids.get(b);
235
+ if (discovered !== void 0) return discovered;
236
+ }
237
+ const w = b;
238
+ if (w && typeof w.process === "function") {
239
+ try {
240
+ const p = w.process();
241
+ if (p && typeof p.pid === "number" && p.pid > 0) return p.pid;
242
+ } catch {
243
+ }
244
+ }
245
+ if (w && typeof w.browser === "function") {
246
+ try {
247
+ const child = w.browser();
248
+ if (child) return rootChromePid(child);
249
+ } catch {
250
+ }
251
+ }
252
+ return null;
253
+ }
254
+ function killTree(pid) {
255
+ let kids = [];
256
+ try {
257
+ kids = (0, import_node_child_process.execSync)(`pgrep -P ${pid} || true`).toString().split("\n").filter(Boolean);
258
+ } catch {
259
+ }
260
+ for (const k of kids) killTree(Number(k));
261
+ try {
262
+ process.kill(pid, "SIGKILL");
263
+ } catch {
264
+ }
265
+ }
266
+ function browserControl(b) {
267
+ const candidate = b;
268
+ if (!candidate || typeof candidate !== "object") return null;
269
+ if (typeof candidate.newBrowserCDPSession === "function" || typeof candidate.process === "function") {
270
+ return candidate;
271
+ }
272
+ if (typeof candidate.browser !== "function") return null;
273
+ try {
274
+ const browser = candidate.browser();
275
+ return browser ? browserControl(browser) : null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+ function registerChromePid(browser, control, pid) {
281
+ if (browser && typeof browser === "object") discoveredChromePids.set(browser, pid);
282
+ discoveredChromePids.set(control, pid);
283
+ reapPids.add(pid);
284
+ installReaper();
285
+ }
286
+ async function discoverChromePid(browser) {
287
+ const known = rootChromePid(browser);
288
+ if (known !== null) return known;
289
+ const control = browserControl(browser);
290
+ if (!control || typeof control.newBrowserCDPSession !== "function") return null;
291
+ const session = await control.newBrowserCDPSession();
292
+ try {
293
+ const pid = browserProcessPid(await session.send("SystemInfo.getProcessInfo"));
294
+ if (pid === null) return null;
295
+ registerChromePid(browser, control, pid);
296
+ return pid;
297
+ } finally {
298
+ await session.detach?.();
299
+ }
300
+ }
301
+ async function rejectLaunchedBrowser(browser) {
302
+ const root = rootChromePid(browser);
303
+ if (root !== null) {
304
+ reapPids.delete(root);
305
+ killTree(root);
306
+ return;
307
+ }
308
+ const candidate = browser;
309
+ if (candidate && typeof candidate.close === "function") {
310
+ try {
311
+ await candidate.close();
312
+ } catch {
313
+ }
314
+ }
315
+ }
316
+ async function validateLaunchedGpuBrowser(browser, runtime, rendererTarget, commandForPid = processCommand) {
317
+ if (softwareChromeAllowed(runtime)) return;
318
+ const root = rootChromePid(browser);
319
+ const launchedControl = browserControl(browser);
320
+ if (root !== null && launchedControl) {
321
+ registerChromePid(browser, launchedControl, root);
322
+ }
323
+ if (root !== null && (runtime.platform === "darwin" || runtime.platform === "linux")) {
324
+ assertGpuBrowserCommand(commandForPid(root), runtime);
325
+ }
326
+ const rendererBrowser = rendererTarget ? await rendererTarget(browser) : browser;
327
+ const control = browserControl(rendererBrowser);
328
+ if (control && typeof control.newBrowserCDPSession === "function") {
329
+ const session = await control.newBrowserCDPSession();
330
+ try {
331
+ const pid = browserProcessPid(await session.send("SystemInfo.getProcessInfo"));
332
+ if (pid === null) {
333
+ throw new Error("[reap-browser] Chrome did not report its browser process id");
334
+ }
335
+ registerChromePid(browser, control, pid);
336
+ if (rendererBrowser && typeof rendererBrowser === "object") {
337
+ discoveredChromePids.set(rendererBrowser, pid);
338
+ }
339
+ if (runtime.platform === "darwin" || runtime.platform === "linux") {
340
+ assertGpuBrowserCommand(commandForPid(pid), runtime);
341
+ }
342
+ assertGpuRendererInfo(await session.send("SystemInfo.getInfo"), runtime);
343
+ return;
344
+ } finally {
345
+ await session.detach?.();
346
+ }
347
+ }
348
+ throw new Error("[reap-browser] could not validate the launched Chrome GPU renderer");
349
+ }
350
+ var signalShutdownClaimed = false;
351
+ function installReaper() {
352
+ if (installed) return;
353
+ installed = true;
354
+ const reap = () => {
355
+ for (const pid of reapPids) killTree(pid);
356
+ };
357
+ if (!signalShutdownClaimed) {
358
+ process.on("SIGTERM", () => {
359
+ reap();
360
+ process.exit(0);
361
+ });
362
+ process.on("SIGINT", () => {
363
+ reap();
364
+ process.exit(0);
365
+ });
366
+ }
367
+ process.on("exit", reap);
368
+ }
369
+ async function launchReapedScoped(launch) {
370
+ const browser = await launch();
371
+ let root = rootChromePid(browser);
372
+ if (root === null) {
373
+ try {
374
+ root = await discoverChromePid(browser);
375
+ } catch {
376
+ root = null;
377
+ }
378
+ }
379
+ const mine = /* @__PURE__ */ new Set();
380
+ if (root != null) {
381
+ mine.add(root);
382
+ reapPids.add(root);
383
+ installReaper();
384
+ }
385
+ const evented = browserControl(browser) ?? browser;
386
+ if (typeof evented.on === "function") {
387
+ const disconnectEvent = typeof evented.newBrowserCDPSession === "function" ? "disconnected" : "close";
388
+ evented.on(disconnectEvent, () => {
389
+ for (const pid of mine) reapPids.delete(pid);
390
+ mine.clear();
391
+ });
392
+ }
393
+ const reapNow = () => {
394
+ for (const pid of mine) {
395
+ killTree(pid);
396
+ reapPids.delete(pid);
397
+ }
398
+ mine.clear();
399
+ };
400
+ return { browser, reapNow };
401
+ }
402
+ function gpuChromeArgs(platform = process.platform) {
403
+ const angle = expectedAngle(platform);
404
+ return [
405
+ // sootsim's compositor renders through skia graphite on webgpu, so a launch
406
+ // without navigator.gpu cannot present a frame at all. chrome enables webgpu
407
+ // by default on gpu-backed macOS but not on every headless configuration,
408
+ // and every caller that renders a sim was passing this by hand. own it here
409
+ // so a launcher cannot silently come up without the only backend there is.
410
+ "--enable-unsafe-webgpu",
411
+ `--use-angle=${angle}`,
412
+ "--enable-gpu",
413
+ "--enable-gpu-rasterization",
414
+ "--enable-zero-copy",
415
+ "--ignore-gpu-blocklist"
416
+ ];
417
+ }
418
+ function gpuChromeLaunchOptions(opts = {}, runtime = currentRuntime()) {
419
+ const callerArgs = Array.isArray(opts.args) ? opts.args : [];
420
+ const unsafe = callerArgs.find(isSoftwareRendererArgument);
421
+ if (unsafe !== void 0) {
422
+ throw new Error(
423
+ `[reap-browser] refused software-rendering Chrome launch argument ${String(unsafe)}`
424
+ );
425
+ }
426
+ if (opts.executablePath !== void 0) {
427
+ throw new Error(
428
+ "[reap-browser] executablePath bypasses Playwright Chrome for Testing and is not allowed"
429
+ );
430
+ }
431
+ if (opts.ignoreDefaultArgs === true) {
432
+ throw new Error(
433
+ "[reap-browser] ignoreDefaultArgs=true bypasses required Chrome launch defaults"
434
+ );
435
+ }
436
+ const managedGpuArgs = /* @__PURE__ */ new Set([
437
+ "--enable-unsafe-webgpu",
438
+ "--enable-gpu",
439
+ "--enable-gpu-rasterization",
440
+ "--enable-zero-copy",
441
+ "--ignore-gpu-blocklist"
442
+ ]);
443
+ const unmanagedCallerArgs = callerArgs.filter((arg) => {
444
+ const value = String(arg);
445
+ return !managedGpuArgs.has(value) && !value.startsWith("--use-angle=");
446
+ });
447
+ const callerIgnored = Array.isArray(opts.ignoreDefaultArgs) ? opts.ignoreDefaultArgs : [];
448
+ const ignoredDefaults = [
449
+ .../* @__PURE__ */ new Set([...callerIgnored, "--disable-gpu", "--enable-unsafe-swiftshader"])
450
+ ];
451
+ return {
452
+ ...opts,
453
+ // playwright's `chromium` channel is the full Google Chrome for Testing
454
+ // app, not chromium_headless_shell. on macOS it has bundle id
455
+ // com.google.chrome.for.testing, so automation cannot become the target of
456
+ // Dock open/quit events intended for the user's com.google.Chrome browser.
457
+ channel: "chromium",
458
+ args: [...unmanagedCallerArgs, ...gpuChromeArgs(runtime.platform)],
459
+ ignoreDefaultArgs: ignoredDefaults
460
+ };
461
+ }
462
+ function platformChromeLaunch(launch, opts, rendererTarget, policy = {}) {
463
+ return async () => {
464
+ const runtime = policy.runtime ?? currentRuntime();
465
+ if (softwareChromeAllowed(runtime)) {
466
+ return launch(chromeLaunchOptions(opts, runtime));
467
+ }
468
+ const gpuOptions = chromeLaunchOptions(opts, runtime);
469
+ let browser;
470
+ try {
471
+ browser = await launch(gpuOptions);
472
+ } catch (err) {
473
+ const msg = err instanceof Error ? err.message : String(err);
474
+ const missing = /not found at|not installed|Chromium distribution|Executable doesn't exist|No such file/i.test(
475
+ msg
476
+ );
477
+ if (!missing) throw err;
478
+ throw new Error(
479
+ "[reap-browser] Playwright Chrome for Testing is required for local GPU browser work. Run `bun scripts/ops/install-playwright-browsers.ts chromium`.",
480
+ { cause: err }
481
+ );
482
+ }
483
+ try {
484
+ await validateLaunchedGpuBrowser(
485
+ browser,
486
+ runtime,
487
+ rendererTarget,
488
+ policy.processCommand
489
+ );
490
+ } catch (err) {
491
+ await rejectLaunchedBrowser(browser);
492
+ throw err;
493
+ }
494
+ return browser;
495
+ };
496
+ }
497
+ function softwareChromeLaunchOptions(opts) {
498
+ if (opts.ignoreDefaultArgs === true) {
499
+ throw new Error(
500
+ "[reap-browser] ignoreDefaultArgs=true bypasses required Chrome launch defaults"
501
+ );
502
+ }
503
+ const callerArgs = Array.isArray(opts.args) ? opts.args.map(String) : [];
504
+ const unmanagedCallerArgs = callerArgs.filter((arg) => {
505
+ return !arg.startsWith("--use-angle=") && arg !== "--enable-gpu" && arg !== "--enable-gpu-rasterization" && arg !== "--enable-zero-copy" && arg !== "--ignore-gpu-blocklist" && !/^--enable-unsafe-webgpu(?:=.*)?$/i.test(arg) && !/^--enable-features=(?:Vulkan|WebGPU)(?:,(?:Vulkan|WebGPU))*$/i.test(arg) && !/^--enable-unsafe-swiftshader(?:=.*)?$/i.test(arg);
506
+ });
507
+ const software = {
508
+ ...opts,
509
+ args: [
510
+ ...unmanagedCallerArgs,
511
+ "--use-angle=swiftshader",
512
+ "--enable-unsafe-swiftshader",
513
+ // playwright's bundled software chromium exposes no navigator.gpu without
514
+ // these two, and the compositor's only backend is webgpu. emitted here
515
+ // rather than per caller so a hosted-linux launch and a local gpu launch
516
+ // agree on what a sim needs.
517
+ "--enable-unsafe-webgpu",
518
+ "--enable-features=Vulkan"
519
+ ]
520
+ };
521
+ delete software.channel;
522
+ delete software.executablePath;
523
+ return software;
524
+ }
525
+ function chromeLaunchOptions(opts = {}, runtime = currentRuntime()) {
526
+ return softwareChromeAllowed(runtime) ? softwareChromeLaunchOptions(opts) : gpuChromeLaunchOptions(opts, runtime);
527
+ }
528
+ async function launchReapedChromeScoped(launch, opts = {}, rendererTarget, policy = {}) {
529
+ return launchReapedScoped(platformChromeLaunch(launch, opts, rendererTarget, policy));
530
+ }
531
+
532
+ // src/poll-until.ts
533
+ async function pollUntil(read, ready, options) {
534
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
535
+ throw new Error("poll timeout must be a positive finite number");
536
+ }
537
+ const intervalMs = options.intervalMs ?? 100;
538
+ if (!Number.isFinite(intervalMs) || intervalMs < 0) {
539
+ throw new Error("poll interval must be a non-negative finite number");
540
+ }
541
+ const deadline = Date.now() + options.timeoutMs;
542
+ let lastError;
543
+ const timeoutError = () => new Error(`${options.label} timed out after ${options.timeoutMs}ms`, {
544
+ cause: lastError
545
+ });
546
+ options.signal?.throwIfAborted();
547
+ let rejectBoundary = () => {
548
+ };
549
+ const boundary = new Promise((_, reject) => {
550
+ rejectBoundary = reject;
551
+ });
552
+ const onAbort = () => rejectBoundary(
553
+ options.signal?.reason ?? new DOMException("The operation was aborted", "AbortError")
554
+ );
555
+ options.signal?.addEventListener("abort", onAbort, { once: true });
556
+ const deadlineTimer = setTimeout(
557
+ () => rejectBoundary(timeoutError()),
558
+ options.timeoutMs
559
+ );
560
+ try {
561
+ while (true) {
562
+ if (Date.now() >= deadline) throw timeoutError();
563
+ const outcome = await Promise.race([
564
+ Promise.resolve().then(read).then(
565
+ (value) => ({ kind: "value", value }),
566
+ (error) => ({ kind: "error", error })
567
+ ),
568
+ boundary
569
+ ]);
570
+ options.signal?.throwIfAborted();
571
+ if (outcome.kind === "error") {
572
+ lastError = outcome.error;
573
+ } else {
574
+ if (Date.now() >= deadline) throw timeoutError();
575
+ try {
576
+ if (ready(outcome.value)) return outcome.value;
577
+ } catch (error) {
578
+ lastError = error;
579
+ }
580
+ }
581
+ const remaining = deadline - Date.now();
582
+ if (remaining <= 0) throw timeoutError();
583
+ if (intervalMs > 0) {
584
+ let intervalTimer;
585
+ try {
586
+ await Promise.race([
587
+ new Promise((resolve) => {
588
+ intervalTimer = setTimeout(resolve, Math.min(intervalMs, remaining));
589
+ }),
590
+ boundary
591
+ ]);
592
+ } finally {
593
+ clearTimeout(intervalTimer);
594
+ }
595
+ }
596
+ }
597
+ } finally {
598
+ clearTimeout(deadlineTimer);
599
+ options.signal?.removeEventListener("abort", onAbort);
600
+ }
601
+ }
602
+
603
+ // detox/gestures.ts
604
+ async function waitForSootsimGestureHandler(page, viewTag, handlerName) {
605
+ await pollUntil(
606
+ () => page.evaluate(
607
+ async ({ viewTag: viewTag2, handlerName: handlerName2 }) => {
608
+ const host = window.SootSim?.bridges?.shellHost;
609
+ if (typeof host?.callTestBridge !== "function") return false;
610
+ let debug = null;
611
+ try {
612
+ debug = await host.callTestBridge("getShellGestureSeamDebug");
613
+ } catch {
614
+ return false;
615
+ }
616
+ return debug?.attached?.some(
617
+ (entry) => entry.viewTag === viewTag2 && entry.nodeFound !== false && entry.handlers?.some((handler) => handler.name === handlerName2)
618
+ ) === true;
619
+ },
620
+ { viewTag, handlerName }
621
+ ),
622
+ Boolean,
623
+ {
624
+ timeoutMs: 5e3,
625
+ intervalMs: 100,
626
+ label: `${handlerName} gesture handler on view ${viewTag}`
627
+ }
628
+ );
629
+ }
630
+ async function readWorkletCallbackStats(page) {
631
+ return page.evaluate(async () => {
632
+ const g = window;
633
+ const tenant = typeof g.__sootsimTest?.getWorkletSlotStats === "function" ? await g.__sootsimTest.getWorkletSlotStats() : null;
634
+ const shellHost = g.SootSim?.bridges?.shellHost;
635
+ let shell = null;
636
+ if (typeof shellHost?.callTestBridge === "function") {
637
+ try {
638
+ shell = await shellHost.callTestBridge("getWorkletSlotStats");
639
+ } catch {
640
+ }
641
+ }
642
+ const tenantReceived = typeof tenant?.runJSCallbackReceived === "number" ? tenant.runJSCallbackReceived : 0;
643
+ const shellSent = typeof shell?.runJSCallbackSent === "number" ? shell.runJSCallbackSent : 0;
644
+ return { tenantReceived, shellSent };
645
+ });
646
+ }
647
+ async function waitForWorkletCallbackDrain(page, before, timeoutMs) {
648
+ if (!before) {
649
+ await page.waitForTimeout(250);
650
+ return;
651
+ }
652
+ let lastShellSent = before.shellSent;
653
+ let stableSince = Date.now();
654
+ let sawCallback = false;
655
+ await pollUntil(
656
+ () => readWorkletCallbackStats(page),
657
+ (current) => {
658
+ if (!current) return false;
659
+ if (current.shellSent !== lastShellSent) {
660
+ lastShellSent = current.shellSent;
661
+ stableSince = Date.now();
662
+ }
663
+ const sentDelta = current.shellSent - before.shellSent;
664
+ if (sentDelta > 0) sawCallback = true;
665
+ const receivedDelta = current.tenantReceived - before.tenantReceived;
666
+ return sawCallback && receivedDelta >= sentDelta && Date.now() - stableSince >= 100;
667
+ },
668
+ {
669
+ timeoutMs,
670
+ intervalMs: 20,
671
+ label: "worklet callback drain"
672
+ }
673
+ ).catch(() => {
674
+ });
675
+ if (!sawCallback) {
676
+ await page.waitForTimeout(250);
677
+ }
678
+ }
679
+ var sootsimCanvasFinderInstalled = /* @__PURE__ */ new WeakSet();
680
+ function installInteractiveCanvasFinder() {
681
+ window.__sootsimFindInteractiveCanvas = () => {
682
+ const interactive = [];
683
+ const appVisible = [];
684
+ for (const c of Array.from(document.querySelectorAll("canvas"))) {
685
+ const s = window.getComputedStyle(c);
686
+ if (s.display === "none" || s.visibility === "hidden") continue;
687
+ const r = c.getBoundingClientRect();
688
+ if (r.width === 0 || r.height === 0) continue;
689
+ const entry = {
690
+ c,
691
+ z: parseInt(s.zIndex, 10) || 0,
692
+ app: c.dataset.surfaceId?.startsWith("app:") === true
693
+ };
694
+ if (entry.app) appVisible.push(entry);
695
+ if (s.pointerEvents !== "none") interactive.push(entry);
696
+ }
697
+ const list = appVisible.length > 0 ? appVisible : interactive;
698
+ if (list.length === 0) return null;
699
+ list.sort((a, b) => Number(b.app) - Number(a.app) || b.z - a.z);
700
+ return list[0].c;
701
+ };
702
+ }
703
+ async function ensureCanvasFinder(page) {
704
+ if (!sootsimCanvasFinderInstalled.has(page)) {
705
+ sootsimCanvasFinderInstalled.add(page);
706
+ await page.addInitScript(installInteractiveCanvasFinder);
707
+ }
708
+ await page.evaluate(installInteractiveCanvasFinder);
709
+ }
710
+ async function readSootsimPageMetrics(page, timeoutMs) {
711
+ await ensureCanvasFinder(page);
712
+ try {
713
+ const metrics = await pollUntil(
714
+ () => page.evaluate(async () => {
715
+ const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
716
+ if (!canvas) return null;
717
+ const rect = canvas.getBoundingClientRect();
718
+ if (rect.width <= 0 || rect.height <= 0) return null;
719
+ const surface = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.();
720
+ const screenWidth = surface?.window?.width;
721
+ const screenHeight = surface?.window?.height;
722
+ if (typeof screenWidth !== "number" || typeof screenHeight !== "number" || screenWidth <= 0 || screenHeight <= 0) {
723
+ return null;
724
+ }
725
+ const homeIndicatorHeight = Number(surface?.device?.homeIndicatorHeight) || 0;
726
+ const safeAreaBottom = Number(surface?.safeAreaInsets?.bottom) || 0;
727
+ const bottomInset = Math.max(0, homeIndicatorHeight, safeAreaBottom);
728
+ const visibleRectBottom = Number(surface?.visibleRect?.y) + Number(surface?.visibleRect?.height);
729
+ const interactiveHeight = Number.isFinite(visibleRectBottom) && visibleRectBottom > 0 ? Math.min(screenHeight, visibleRectBottom) : Math.max(0, screenHeight - bottomInset);
730
+ return {
731
+ canvas: {
732
+ left: rect.left,
733
+ top: rect.top,
734
+ width: rect.width,
735
+ height: rect.height
736
+ },
737
+ window: {
738
+ width: screenWidth,
739
+ height: screenHeight
740
+ },
741
+ interactiveWindow: {
742
+ width: screenWidth,
743
+ height: interactiveHeight
744
+ }
745
+ };
746
+ }),
747
+ (metrics2) => metrics2 !== null,
748
+ {
749
+ timeoutMs,
750
+ intervalMs: 50,
751
+ label: "sootsim surface metrics"
752
+ }
753
+ );
754
+ if (metrics === null) throw new Error("surface metrics poll settled without metrics");
755
+ return metrics;
756
+ } catch (error) {
757
+ throw new Error("sootsim surface metrics are not available", { cause: error });
758
+ }
759
+ }
760
+ async function readSootsimViewport(page, timeoutMs = 5e3) {
761
+ return (await readSootsimPageMetrics(page, timeoutMs)).window;
762
+ }
763
+ async function readSootsimInteractiveViewport(page, timeoutMs = 5e3) {
764
+ return (await readSootsimPageMetrics(page, timeoutMs)).interactiveWindow;
765
+ }
766
+ async function sootsimToPage(page, sootsimX, sootsimY, timeoutMs = 5e3) {
767
+ const metrics = await readSootsimPageMetrics(page, timeoutMs);
768
+ const scaleX = metrics.canvas.width / metrics.window.width;
769
+ const scaleY = metrics.canvas.height / metrics.window.height;
770
+ return {
771
+ x: metrics.canvas.left + sootsimX * scaleX,
772
+ y: metrics.canvas.top + sootsimY * scaleY
773
+ };
774
+ }
775
+ async function dragScrollNode(page, node, pixels, direction) {
776
+ const startSootSimX = node.absolutePosition.x + node.layout.width / 2;
777
+ const startSootSimY = node.absolutePosition.y + node.layout.height / 2;
778
+ let endSootSimX = startSootSimX;
779
+ let endSootSimY = startSootSimY;
780
+ switch (direction) {
781
+ case "down":
782
+ endSootSimY -= pixels;
783
+ break;
784
+ case "up":
785
+ endSootSimY += pixels;
786
+ break;
787
+ case "left":
788
+ endSootSimX += pixels;
789
+ break;
790
+ case "right":
791
+ endSootSimX -= pixels;
792
+ break;
793
+ }
794
+ const startPage = await sootsimToPage(page, startSootSimX, startSootSimY);
795
+ const endPage = await sootsimToPage(page, endSootSimX, endSootSimY);
796
+ const steps = Math.max(16, Math.ceil(pixels / 8));
797
+ await page.mouse.move(startPage.x, startPage.y);
798
+ await page.mouse.down();
799
+ await page.waitForTimeout(20);
800
+ const dx = (endPage.x - startPage.x) / steps;
801
+ const dy = (endPage.y - startPage.y) / steps;
802
+ for (let i = 1; i <= steps; i++) {
803
+ await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i);
804
+ await page.waitForTimeout(24);
805
+ }
806
+ await page.mouse.up();
807
+ await page.waitForTimeout(180);
808
+ }
809
+ async function dispatchTwoFingerGesture(page, startA, startB, endA, endB, options = {}) {
810
+ const steps = options.steps ?? 10;
811
+ const stepDelay = options.stepDelayMs ?? 18;
812
+ const workletStatsTimeoutMs = options.workletStatsTimeoutMs ?? 1500;
813
+ await ensureCanvasFinder(page);
814
+ const beforeWorkletStats = await pollUntil(
815
+ () => readWorkletCallbackStats(page),
816
+ () => true,
817
+ {
818
+ timeoutMs: workletStatsTimeoutMs,
819
+ intervalMs: 0,
820
+ label: "pre-dispatch worklet callback stats"
821
+ }
822
+ ).catch(() => null);
823
+ await page.evaluate(
824
+ ({ startA: startA2, startB: startB2 }) => {
825
+ const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
826
+ if (!canvas) throw new Error("sootsim interactive canvas not found");
827
+ const fire = (point, id, primary) => {
828
+ canvas.dispatchEvent(
829
+ new PointerEvent("pointerdown", {
830
+ bubbles: true,
831
+ cancelable: true,
832
+ clientX: point.x,
833
+ clientY: point.y,
834
+ isPrimary: primary,
835
+ pointerId: id,
836
+ pointerType: "touch"
837
+ })
838
+ );
839
+ };
840
+ fire(startA2, 1001, true);
841
+ fire(startB2, 1002, false);
842
+ },
843
+ { startA, startB }
844
+ );
845
+ for (let i = 1; i <= steps; i++) {
846
+ const t = i / steps;
847
+ const ax = startA.x + (endA.x - startA.x) * t;
848
+ const ay = startA.y + (endA.y - startA.y) * t;
849
+ const bx = startB.x + (endB.x - startB.x) * t;
850
+ const by2 = startB.y + (endB.y - startB.y) * t;
851
+ await page.evaluate(
852
+ ({ ax: ax2, ay: ay2, bx: bx2, by: by3 }) => {
853
+ const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
854
+ if (!canvas) return;
855
+ canvas.dispatchEvent(
856
+ new PointerEvent("pointermove", {
857
+ bubbles: true,
858
+ cancelable: true,
859
+ clientX: ax2,
860
+ clientY: ay2,
861
+ isPrimary: true,
862
+ pointerId: 1001,
863
+ pointerType: "touch"
864
+ })
865
+ );
866
+ canvas.dispatchEvent(
867
+ new PointerEvent("pointermove", {
868
+ bubbles: true,
869
+ cancelable: true,
870
+ clientX: bx2,
871
+ clientY: by3,
872
+ isPrimary: false,
873
+ pointerId: 1002,
874
+ pointerType: "touch"
875
+ })
876
+ );
877
+ },
878
+ { ax, ay, bx, by: by2 }
879
+ );
880
+ await page.waitForTimeout(stepDelay);
881
+ }
882
+ await page.evaluate(
883
+ ({ endA: endA2, endB: endB2 }) => {
884
+ const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
885
+ if (!canvas) return;
886
+ canvas.dispatchEvent(
887
+ new PointerEvent("pointerup", {
888
+ bubbles: true,
889
+ cancelable: true,
890
+ clientX: endA2.x,
891
+ clientY: endA2.y,
892
+ isPrimary: true,
893
+ pointerId: 1001,
894
+ pointerType: "touch"
895
+ })
896
+ );
897
+ canvas.dispatchEvent(
898
+ new PointerEvent("pointerup", {
899
+ bubbles: true,
900
+ cancelable: true,
901
+ clientX: endB2.x,
902
+ clientY: endB2.y,
903
+ isPrimary: false,
904
+ pointerId: 1002,
905
+ pointerType: "touch"
906
+ })
907
+ );
908
+ },
909
+ { endA, endB }
910
+ );
911
+ await waitForWorkletCallbackDrain(page, beforeWorkletStats, workletStatsTimeoutMs);
912
+ await page.waitForTimeout(250);
913
+ }
914
+
915
+ // detox/expectations.ts
916
+ var DEFAULT_VISIBILITY_THRESHOLD = 0.75;
917
+ async function scrollMatcherElement(page, findNode, matcher, pixels, direction) {
918
+ const node = await findNode(matcher);
919
+ if (!node) {
920
+ throw new Error(`scroll container not found: ${JSON.stringify(matcher)}`);
921
+ }
922
+ const before = scrollOffsetOf(node);
923
+ await dragScrollNode(page, node, pixels, direction);
924
+ const after = scrollOffsetOf(await findNode(matcher));
925
+ return Math.abs(after.x - before.x) + Math.abs(after.y - before.y);
926
+ }
927
+ function scrollOffsetOf(node) {
928
+ return { x: node?.scrollOffsetX ?? 0, y: node?.scrollOffsetY ?? 0 };
929
+ }
930
+ function visibleFraction(node) {
931
+ const totalArea = (node?.layout?.width ?? 0) * (node?.layout?.height ?? 0);
932
+ const frame = node?.visibleFrame;
933
+ if (totalArea <= 0 || !frame) return 0;
934
+ const visibleArea = Math.max(0, frame.width) * Math.max(0, frame.height);
935
+ return visibleArea / totalArea;
936
+ }
937
+ function isNodeVisible(node) {
938
+ return visibleFraction(node) >= DEFAULT_VISIBILITY_THRESHOLD;
939
+ }
940
+ async function isNodeFocused(node, page) {
941
+ const nodeId = typeof node?.nodeId === "number" ? node.nodeId : typeof node?.id === "number" ? node.id : null;
942
+ if (nodeId === null) return false;
943
+ return page.evaluate((id) => {
944
+ const active = document.activeElement;
945
+ return active?.getAttribute("data-sootsim-id") === String(id);
946
+ }, nodeId);
947
+ }
948
+ function describeNodeVisibility(node) {
949
+ if (!node) return "not found";
950
+ const absX = node.absolutePosition?.x ?? node.layout?.x ?? 0;
951
+ const absY = node.absolutePosition?.y ?? node.layout?.y ?? 0;
952
+ return JSON.stringify({
953
+ layout: node.layout,
954
+ absolutePosition: node.absolutePosition ?? null,
955
+ frame: {
956
+ x: absX,
957
+ y: absY,
958
+ right: absX + (node.layout?.width ?? 0),
959
+ bottom: absY + (node.layout?.height ?? 0)
960
+ },
961
+ visibleFrame: node.visibleFrame ?? null,
962
+ visibleFraction: visibleFraction(node),
963
+ threshold: DEFAULT_VISIBILITY_THRESHOLD,
964
+ style: node.style ?? null
965
+ });
966
+ }
967
+ function createExpect(findNode, getPage2) {
968
+ return function sootsimExpect2(el) {
969
+ return new SootSimExpectation(el._matcher, findNode, getPage2, false);
970
+ };
971
+ }
972
+ var SootSimExpectation = class _SootSimExpectation {
973
+ matcher;
974
+ findNode;
975
+ getPage;
976
+ negated;
977
+ constructor(matcher, findNode, getPage2, negated) {
978
+ this.matcher = matcher;
979
+ this.findNode = findNode;
980
+ this.getPage = getPage2;
981
+ this.negated = negated;
982
+ }
983
+ get not() {
984
+ return new _SootSimExpectation(
985
+ this.matcher,
986
+ this.findNode,
987
+ this.getPage,
988
+ !this.negated
989
+ );
990
+ }
991
+ async toExist() {
992
+ const node = await this.findNode(this.matcher);
993
+ const exists = node !== null && node !== void 0;
994
+ if (this.negated) {
995
+ if (exists) {
996
+ throw new Error(
997
+ `expected element NOT to exist but it does: ${JSON.stringify(this.matcher)}`
998
+ );
999
+ }
1000
+ } else {
1001
+ if (!exists) {
1002
+ throw new Error(
1003
+ `expected element to exist but it was not found: ${JSON.stringify(this.matcher)}`
1004
+ );
1005
+ }
1006
+ }
1007
+ }
1008
+ async toBeVisible() {
1009
+ const node = await this.findNode(this.matcher);
1010
+ const visible = isNodeVisible(node);
1011
+ if (this.negated) {
1012
+ if (visible) {
1013
+ throw new Error(
1014
+ `expected element NOT to be visible: ${JSON.stringify(this.matcher)}`
1015
+ );
1016
+ }
1017
+ } else {
1018
+ if (!visible) {
1019
+ throw new Error(
1020
+ `expected element to be visible but it is ${describeNodeVisibility(node)}: ${JSON.stringify(this.matcher)}`
1021
+ );
1022
+ }
1023
+ }
1024
+ }
1025
+ async toBeNotVisible() {
1026
+ const node = await this.findNode(this.matcher);
1027
+ const visible = isNodeVisible(node);
1028
+ if (visible) {
1029
+ throw new Error(
1030
+ `expected element NOT to be visible: ${JSON.stringify(this.matcher)}`
1031
+ );
1032
+ }
1033
+ }
1034
+ async toHaveText(expectedText) {
1035
+ const node = await this.findNode(this.matcher);
1036
+ if (!node) {
1037
+ throw new Error(`element not found for toHaveText: ${JSON.stringify(this.matcher)}`);
1038
+ }
1039
+ const actualText = node.text || "";
1040
+ const matches = actualText === expectedText || actualText.includes(expectedText);
1041
+ if (this.negated) {
1042
+ if (matches) {
1043
+ throw new Error(
1044
+ `expected text NOT to be "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`
1045
+ );
1046
+ }
1047
+ } else {
1048
+ if (!matches) {
1049
+ throw new Error(
1050
+ `expected text "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`
1051
+ );
1052
+ }
1053
+ }
1054
+ }
1055
+ async toHaveId(expectedId) {
1056
+ const node = await this.findNode(this.matcher);
1057
+ if (!node) {
1058
+ throw new Error(`element not found for toHaveId: ${JSON.stringify(this.matcher)}`);
1059
+ }
1060
+ const hasId = node.testID === expectedId || node.id === expectedId;
1061
+ if (this.negated) {
1062
+ if (hasId) throw new Error(`expected element NOT to have id "${expectedId}"`);
1063
+ } else {
1064
+ if (!hasId)
1065
+ throw new Error(
1066
+ `expected element to have id "${expectedId}" but got "${node.testID || node.id}"`
1067
+ );
1068
+ }
1069
+ }
1070
+ async toHaveLabel(expectedLabel) {
1071
+ const node = await this.findNode(this.matcher);
1072
+ if (!node) {
1073
+ throw new Error(
1074
+ `element not found for toHaveLabel: ${JSON.stringify(this.matcher)}`
1075
+ );
1076
+ }
1077
+ const actual = node.accessibilityLabel || node.text || "";
1078
+ const matches = actual === expectedLabel;
1079
+ if (this.negated) {
1080
+ if (matches)
1081
+ throw new Error(`expected label NOT to be "${expectedLabel}" but it was`);
1082
+ } else {
1083
+ if (!matches)
1084
+ throw new Error(
1085
+ `expected label "${expectedLabel}" but got "${actual}": ${JSON.stringify(this.matcher)}`
1086
+ );
1087
+ }
1088
+ }
1089
+ async toHaveAccessibilityRole(expectedRole) {
1090
+ const node = await this.findNode(this.matcher);
1091
+ if (!node) {
1092
+ throw new Error(
1093
+ `element not found for toHaveAccessibilityRole: ${JSON.stringify(this.matcher)}`
1094
+ );
1095
+ }
1096
+ const actual = node.accessibilityRole || "";
1097
+ const matches = actual === expectedRole;
1098
+ if (this.negated) {
1099
+ if (matches) throw new Error(`expected role NOT to be "${expectedRole}" but it was`);
1100
+ } else {
1101
+ if (!matches)
1102
+ throw new Error(
1103
+ `expected role "${expectedRole}" but got "${actual}": ${JSON.stringify(this.matcher)}`
1104
+ );
1105
+ }
1106
+ }
1107
+ async toBeEnabled() {
1108
+ const node = await this.findNode(this.matcher);
1109
+ if (!node) {
1110
+ throw new Error(
1111
+ `element not found for toBeEnabled: ${JSON.stringify(this.matcher)}`
1112
+ );
1113
+ }
1114
+ const disabled = node.accessibilityState?.disabled ?? false;
1115
+ if (this.negated) {
1116
+ if (!disabled) throw new Error(`expected element to be disabled but it is enabled`);
1117
+ } else {
1118
+ if (disabled) throw new Error(`expected element to be enabled but it is disabled`);
1119
+ }
1120
+ }
1121
+ async toBeFocused() {
1122
+ const node = await this.findNode(this.matcher);
1123
+ if (!node) {
1124
+ throw new Error(
1125
+ `element not found for toBeFocused: ${JSON.stringify(this.matcher)}`
1126
+ );
1127
+ }
1128
+ const focused = await isNodeFocused(node, this.getPage());
1129
+ if (this.negated) {
1130
+ if (focused) {
1131
+ throw new Error(
1132
+ `expected element NOT to be focused: ${JSON.stringify(this.matcher)}`
1133
+ );
1134
+ }
1135
+ } else if (!focused) {
1136
+ throw new Error(`expected element to be focused: ${JSON.stringify(this.matcher)}`);
1137
+ }
1138
+ }
1139
+ async toHaveValue(value) {
1140
+ const node = await this.findNode(this.matcher);
1141
+ if (!node) {
1142
+ throw new Error(
1143
+ `element not found for toHaveValue: ${JSON.stringify(this.matcher)}`
1144
+ );
1145
+ }
1146
+ const actual = node.text || "";
1147
+ if (this.negated) {
1148
+ if (actual.includes(value)) {
1149
+ throw new Error(
1150
+ `expected element NOT to have value "${value}" but got "${actual}"`
1151
+ );
1152
+ }
1153
+ } else {
1154
+ if (!actual.includes(value)) {
1155
+ throw new Error(`expected element to have value "${value}" but got "${actual}"`);
1156
+ }
1157
+ }
1158
+ }
1159
+ async toHaveSliderPosition(normalizedPosition, tolerance) {
1160
+ await this.toExist();
1161
+ }
1162
+ async toHaveToggleValue(value) {
1163
+ await this.toExist();
1164
+ }
1165
+ };
1166
+ function createWaitFor(findNode, getPage2) {
1167
+ return function sootsimWaitFor2(el) {
1168
+ return new SootSimWaitForChain(el._matcher, findNode, getPage2);
1169
+ };
1170
+ }
1171
+ var SootSimWaitForChain = class {
1172
+ matcher;
1173
+ findNode;
1174
+ getPage;
1175
+ assertionFn = null;
1176
+ _negated = false;
1177
+ constructor(matcher, findNode, getPage2) {
1178
+ this.matcher = matcher;
1179
+ this.findNode = findNode;
1180
+ this.getPage = getPage2;
1181
+ }
1182
+ get not() {
1183
+ this._negated = true;
1184
+ return this;
1185
+ }
1186
+ toExist() {
1187
+ this.assertionFn = async () => {
1188
+ const node = await this.findNode(this.matcher);
1189
+ const exists = node !== null && node !== void 0;
1190
+ if (this._negated ? exists : !exists) {
1191
+ throw new Error(`waitFor toExist failed: ${JSON.stringify(this.matcher)}`);
1192
+ }
1193
+ };
1194
+ return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
1195
+ }
1196
+ toBeVisible() {
1197
+ this.assertionFn = async () => {
1198
+ const node = await this.findNode(this.matcher);
1199
+ const visible = isNodeVisible(node);
1200
+ if (this._negated ? visible : !visible) {
1201
+ throw new Error(
1202
+ `waitFor toBeVisible failed (${describeNodeVisibility(node)}): ${JSON.stringify(this.matcher)}`
1203
+ );
1204
+ }
1205
+ };
1206
+ return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
1207
+ }
1208
+ toBeNotVisible() {
1209
+ this.assertionFn = async () => {
1210
+ const node = await this.findNode(this.matcher);
1211
+ const visible = isNodeVisible(node);
1212
+ if (visible) {
1213
+ throw new Error(`waitFor toBeNotVisible failed: ${JSON.stringify(this.matcher)}`);
1214
+ }
1215
+ };
1216
+ return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
1217
+ }
1218
+ toHaveText(expectedText) {
1219
+ this.assertionFn = async () => {
1220
+ const node = await this.findNode(this.matcher);
1221
+ if (!node)
1222
+ throw new Error(
1223
+ `waitFor toHaveText: element not found: ${JSON.stringify(this.matcher)}`
1224
+ );
1225
+ const actual = node.text || "";
1226
+ const matches = actual === expectedText || actual.includes(expectedText);
1227
+ if (this._negated ? matches : !matches) {
1228
+ throw new Error(
1229
+ `waitFor toHaveText: expected "${expectedText}" but got "${actual}" ${JSON.stringify(this.matcher)}`
1230
+ );
1231
+ }
1232
+ };
1233
+ return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
1234
+ }
1235
+ toHaveValue(value) {
1236
+ this.assertionFn = async () => {
1237
+ const node = await this.findNode(this.matcher);
1238
+ if (!node) throw new Error(`waitFor toHaveValue: element not found`);
1239
+ const actual = node.text || "";
1240
+ if (!actual.includes(value)) {
1241
+ throw new Error(`waitFor toHaveValue: expected "${value}" in "${actual}"`);
1242
+ }
1243
+ };
1244
+ return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
1245
+ }
1246
+ toBeFocused() {
1247
+ this.assertionFn = async () => {
1248
+ const node = await this.findNode(this.matcher);
1249
+ if (!node)
1250
+ throw new Error(
1251
+ `waitFor toBeFocused: element not found: ${JSON.stringify(this.matcher)}`
1252
+ );
1253
+ const focused = await isNodeFocused(node, this.getPage());
1254
+ if (this._negated ? focused : !focused) {
1255
+ throw new Error(`waitFor toBeFocused failed: ${JSON.stringify(this.matcher)}`);
1256
+ }
1257
+ };
1258
+ return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
1259
+ }
1260
+ };
1261
+ var SootSimWaitForAction = class {
1262
+ assertionFn;
1263
+ findNode;
1264
+ getPage;
1265
+ constructor(assertionFn, findNode, getPage2) {
1266
+ this.assertionFn = assertionFn;
1267
+ this.findNode = findNode;
1268
+ this.getPage = getPage2;
1269
+ }
1270
+ async withTimeout(ms) {
1271
+ const start = Date.now();
1272
+ const pollInterval = 100;
1273
+ let lastError = null;
1274
+ while (Date.now() - start < ms) {
1275
+ try {
1276
+ await this.assertionFn();
1277
+ return;
1278
+ } catch (e) {
1279
+ lastError = e;
1280
+ await new Promise((r) => setTimeout(r, pollInterval));
1281
+ }
1282
+ }
1283
+ throw lastError || new Error(`waitFor timed out after ${ms}ms`);
1284
+ }
1285
+ // if no withTimeout is called, just run the assertion once
1286
+ async then(resolve, reject) {
1287
+ try {
1288
+ await this.withTimeout(5e3);
1289
+ resolve(void 0);
1290
+ } catch (e) {
1291
+ reject(e);
1292
+ }
1293
+ }
1294
+ whileElement(matcher) {
1295
+ return new SootSimWaitForScrollAction(
1296
+ this.assertionFn,
1297
+ this.findNode,
1298
+ this.getPage,
1299
+ matcher
1300
+ );
1301
+ }
1302
+ };
1303
+ var SootSimWaitForScrollAction = class {
1304
+ assertionFn;
1305
+ findNode;
1306
+ getPage;
1307
+ matcher;
1308
+ constructor(assertionFn, findNode, getPage2, matcher) {
1309
+ this.assertionFn = assertionFn;
1310
+ this.findNode = findNode;
1311
+ this.getPage = getPage2;
1312
+ this.matcher = matcher;
1313
+ }
1314
+ // detox scrolls the container until the expectation passes or the container
1315
+ // can no longer move. bounding this by wall clock instead made the result
1316
+ // depend on how fast the host dispatches pointer events: the same fixture and
1317
+ // the same assertion pass on an idle machine and fail on a loaded one,
1318
+ // because each 200px drag is a real pointer stream and fewer of them fit in
1319
+ // the budget. end on scroll exhaustion, which is a property of the content.
1320
+ async scroll(pixels, direction, _startPositionX, _startPositionY) {
1321
+ const page = this.getPage();
1322
+ let lastError = null;
1323
+ let stalledSteps = 0;
1324
+ while (stalledSteps < 2) {
1325
+ try {
1326
+ await this.assertionFn();
1327
+ return;
1328
+ } catch (error) {
1329
+ lastError = error;
1330
+ }
1331
+ const travelled = await scrollMatcherElement(
1332
+ page,
1333
+ this.findNode,
1334
+ this.matcher,
1335
+ pixels,
1336
+ direction
1337
+ );
1338
+ stalledSteps = travelled < 0.5 ? stalledSteps + 1 : 0;
1339
+ }
1340
+ throw lastError || new Error(
1341
+ `waitFor whileElement(...).scroll() reached the end of ${JSON.stringify(this.matcher)}`
1342
+ );
1343
+ }
1344
+ };
1345
+
1346
+ // detox/matchers.ts
1347
+ var by = {
1348
+ id(testID) {
1349
+ return { type: "id", value: testID };
1350
+ },
1351
+ text(text) {
1352
+ return { type: "text", value: text };
1353
+ },
1354
+ label(accessibilityLabel) {
1355
+ return { type: "label", value: accessibilityLabel };
1356
+ },
1357
+ type(nativeType) {
1358
+ return { type: "type", value: nativeType };
1359
+ },
1360
+ role(accessibilityRole) {
1361
+ return { type: "role", value: accessibilityRole };
1362
+ }
1363
+ };
1364
+
1365
+ // detox/index.ts
1366
+ var import_motion_change = __toESM(require_motion_change(), 1);
1367
+ var BASE_URL = process.env.SOOTSIM_URL || "http://localhost:5173";
1368
+ var SCREENSHOT_DIR = process.env.SOOTSIM_SCREENSHOT_DIR || path.join(process.cwd(), "test", "detox-driver", "screenshots");
1369
+ var DETOX_PLATFORM = process.env.SOOTSIM_PLATFORM === "android" ? "android" : "ios";
1370
+ var _browser = null;
1371
+ var _reapBrowserNow = null;
1372
+ var _context = null;
1373
+ var _page = null;
1374
+ var _synchronizationEnabled = false;
1375
+ var pageDiagnostics = /* @__PURE__ */ new WeakMap();
1376
+ var STATUS_BAR_OVERRIDE_EVENT = "sootsim:statusBarOverride";
1377
+ function getPage() {
1378
+ if (!_page)
1379
+ throw new Error("sootsim driver not initialized -- call device.launchApp() first");
1380
+ return _page;
1381
+ }
1382
+ function recordPageDiagnostic(page, message) {
1383
+ const entries = pageDiagnostics.get(page);
1384
+ if (!entries) return;
1385
+ entries.push(message);
1386
+ if (entries.length > 40) entries.shift();
1387
+ }
1388
+ function attachPageDiagnostics(page) {
1389
+ if (pageDiagnostics.has(page)) return;
1390
+ pageDiagnostics.set(page, []);
1391
+ page.on("console", (message) => {
1392
+ recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`);
1393
+ });
1394
+ page.on("pageerror", (error) => {
1395
+ recordPageDiagnostic(page, `[pageerror] ${error.message}`);
1396
+ });
1397
+ page.on("requestfailed", (request) => {
1398
+ const failure = request.failure();
1399
+ recordPageDiagnostic(
1400
+ page,
1401
+ `[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ""}`
1402
+ );
1403
+ });
1404
+ }
1405
+ async function describeSootsimBridgeFailure(page, cause) {
1406
+ let pageState = null;
1407
+ try {
1408
+ pageState = await page.evaluate(() => ({
1409
+ bodyText: document.body?.innerText?.slice(0, 500) ?? "",
1410
+ globals: Object.keys(window).filter((key) => key.startsWith("__sootsim") || key === "SootSim").sort(),
1411
+ readyState: document.readyState,
1412
+ title: document.title,
1413
+ url: window.location.href
1414
+ }));
1415
+ } catch (error) {
1416
+ pageState = {
1417
+ evaluateError: error instanceof Error ? error.message : String(error)
1418
+ };
1419
+ }
1420
+ return new Error(
1421
+ [
1422
+ `timed out waiting for sootsim test bridge`,
1423
+ `cause: ${cause instanceof Error ? cause.message : String(cause)}`,
1424
+ `page: ${JSON.stringify(pageState)}`,
1425
+ `recent page diagnostics:
1426
+ ${(pageDiagnostics.get(page) ?? []).join("\n") || "(none)"}`
1427
+ ].join("\n")
1428
+ );
1429
+ }
1430
+ async function waitForSootsimTree(page, timeout = 3e4) {
1431
+ try {
1432
+ await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
1433
+ timeout
1434
+ });
1435
+ await page.evaluate(async (timeoutMs) => {
1436
+ await Promise.race([
1437
+ window.__sootsimTest.waitForTree(),
1438
+ new Promise((_, reject) => {
1439
+ setTimeout(
1440
+ () => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)),
1441
+ timeoutMs
1442
+ );
1443
+ })
1444
+ ]);
1445
+ }, timeout);
1446
+ } catch (error) {
1447
+ throw await describeSootsimBridgeFailure(page, error);
1448
+ }
1449
+ }
1450
+ async function waitForSootsimSurfaceMetrics(page, timeout = 3e4) {
1451
+ const deadline = Date.now() + timeout;
1452
+ let lastSnapshot = null;
1453
+ while (Date.now() < deadline) {
1454
+ lastSnapshot = await page.evaluate(async () => {
1455
+ const metricsWindow = (value) => {
1456
+ if (!value || typeof value !== "object") return null;
1457
+ const windowValue = value.window;
1458
+ if (!windowValue || typeof windowValue !== "object") return null;
1459
+ return windowValue;
1460
+ };
1461
+ const snapshot = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.();
1462
+ const windowMetrics = metricsWindow(snapshot);
1463
+ const width = windowMetrics?.width;
1464
+ const height = windowMetrics?.height;
1465
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
1466
+ return { ready: true };
1467
+ }
1468
+ return { ready: false, snapshot };
1469
+ });
1470
+ if (lastSnapshot && typeof lastSnapshot === "object" && lastSnapshot.ready) {
1471
+ return;
1472
+ }
1473
+ await page.waitForTimeout(50);
1474
+ }
1475
+ throw new Error(
1476
+ `timed out waiting for sootsim surface metrics: ${JSON.stringify(lastSnapshot)}`
1477
+ );
1478
+ }
1479
+ async function waitForSootsimShellReady(page, timeout = 3e4) {
1480
+ try {
1481
+ await page.evaluate(async (timeoutMs) => {
1482
+ const start = Date.now();
1483
+ const deadline = start + timeoutMs;
1484
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1485
+ const remaining = () => Math.max(1, deadline - Date.now());
1486
+ const withTimeout = async (label, promise) => {
1487
+ let timer = null;
1488
+ try {
1489
+ await Promise.race([
1490
+ promise,
1491
+ new Promise((_, reject) => {
1492
+ timer = setTimeout(
1493
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
1494
+ remaining()
1495
+ );
1496
+ })
1497
+ ]);
1498
+ } finally {
1499
+ if (timer) clearTimeout(timer);
1500
+ }
1501
+ };
1502
+ const isTenantOnly = new URL(window.location.href).searchParams.get("renderMode") === "tenant-only";
1503
+ const currentUrl = new URL(window.location.href);
1504
+ const expectsInitialAppPaint = currentUrl.searchParams.has("test") || currentUrl.pathname.includes("/app/");
1505
+ let shellHost = null;
1506
+ while (Date.now() < deadline) {
1507
+ const candidate = window.SootSim?.bridges?.shellHost;
1508
+ if (candidate || isTenantOnly) {
1509
+ shellHost = candidate ?? null;
1510
+ break;
1511
+ }
1512
+ await sleep(25);
1513
+ }
1514
+ if (!shellHost) {
1515
+ if (isTenantOnly) return;
1516
+ throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`);
1517
+ }
1518
+ if (shellHost.ready && typeof shellHost.ready.then === "function") {
1519
+ await withTimeout("sootsim shell ready", shellHost.ready);
1520
+ }
1521
+ if (!expectsInitialAppPaint && shellHost.firstContentPainted && typeof shellHost.firstContentPainted.then === "function") {
1522
+ await withTimeout(
1523
+ "sootsim shell first content paint",
1524
+ shellHost.firstContentPainted
1525
+ );
1526
+ }
1527
+ const waitForScreenTransitions = window.__sootsimTest?.waitForScreenTransitions;
1528
+ if (typeof waitForScreenTransitions === "function") {
1529
+ const transitionResult = await waitForScreenTransitions({
1530
+ timeoutMs: Math.min(remaining(), 3e3),
1531
+ settleMs: 64,
1532
+ startWindowMs: 700
1533
+ });
1534
+ if (transitionResult?.timedOut === true) {
1535
+ throw new Error(
1536
+ `sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`
1537
+ );
1538
+ }
1539
+ }
1540
+ }, timeout);
1541
+ } catch (error) {
1542
+ throw await describeSootsimBridgeFailure(page, error);
1543
+ }
1544
+ }
1545
+ async function waitForSootsimWorkletsIdle(page, timeout = 3e3) {
1546
+ const result = await page.evaluate(
1547
+ async ({ timeout: timeout2 }) => {
1548
+ const getPeerStats = window.__sootsimTest?.getPeerStats;
1549
+ if (typeof getPeerStats !== "function") {
1550
+ return { settled: true, skipped: true };
1551
+ }
1552
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1553
+ const readStats = () => Promise.race([
1554
+ getPeerStats(false),
1555
+ new Promise((resolve) => setTimeout(() => resolve(null), 250))
1556
+ ]);
1557
+ const start = Date.now();
1558
+ const deadline = start + timeout2;
1559
+ const requiredStableMs = 120;
1560
+ let stableSince = null;
1561
+ let lastPending = 0;
1562
+ await sleep(0);
1563
+ while (Date.now() < deadline) {
1564
+ const stats = await readStats();
1565
+ const pending = stats && typeof stats === "object" && typeof stats.pendingRunWorklets === "number" ? stats.pendingRunWorklets : 0;
1566
+ lastPending = pending;
1567
+ if (pending === 0) {
1568
+ stableSince ??= Date.now();
1569
+ if (Date.now() - stableSince >= requiredStableMs) {
1570
+ return { settled: true, elapsed: Date.now() - start, pending };
1571
+ }
1572
+ } else {
1573
+ stableSince = null;
1574
+ }
1575
+ await sleep(25);
1576
+ }
1577
+ return {
1578
+ settled: false,
1579
+ elapsed: Date.now() - start,
1580
+ pending: lastPending
1581
+ };
1582
+ },
1583
+ { timeout }
1584
+ );
1585
+ if (!result?.settled) {
1586
+ throw new Error(
1587
+ `sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${result?.pending ?? "unknown"})`
1588
+ );
1589
+ }
1590
+ }
1591
+ async function closeContext() {
1592
+ const context = _context;
1593
+ _context = null;
1594
+ _page = null;
1595
+ _synchronizationEnabled = false;
1596
+ if (context) await context.close();
1597
+ }
1598
+ async function closeBrowser() {
1599
+ if (!_browser) return;
1600
+ const browser = _browser;
1601
+ const reapBrowserNow = _reapBrowserNow;
1602
+ _browser = null;
1603
+ _reapBrowserNow = null;
1604
+ await closeContext();
1605
+ try {
1606
+ await browser.close();
1607
+ } finally {
1608
+ reapBrowserNow?.();
1609
+ }
1610
+ }
1611
+ async function waitForSootsimIdle(page, maxMs = 3e3) {
1612
+ const result = await page.evaluate(
1613
+ async ({ maxMs: maxMs2 }) => {
1614
+ let transitionError = null;
1615
+ try {
1616
+ const waitForScreenTransitions = window.__sootsimTest?.waitForScreenTransitions;
1617
+ if (typeof waitForScreenTransitions === "function") {
1618
+ const transitionResult = await waitForScreenTransitions({
1619
+ timeoutMs: Math.min(maxMs2, 1800),
1620
+ settleMs: 48,
1621
+ startWindowMs: 600
1622
+ });
1623
+ if (transitionResult?.started === true && transitionResult.settled === true && transitionResult.timedOut !== true) {
1624
+ return { settled: true, elapsed: transitionResult.waitedMs ?? 0 };
1625
+ }
1626
+ if (transitionResult?.timedOut) {
1627
+ return {
1628
+ settled: false,
1629
+ elapsed: transitionResult.waitedMs,
1630
+ reason: "screen transition"
1631
+ };
1632
+ }
1633
+ }
1634
+ } catch (error) {
1635
+ transitionError = error instanceof Error ? error.message : String(error);
1636
+ }
1637
+ const start = Date.now();
1638
+ const deadline = start + maxMs2;
1639
+ const pollMs = 50;
1640
+ const requiredStablePolls = 3;
1641
+ const layoutTolerance = 1;
1642
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1643
+ const isLayoutStable = (a, b) => {
1644
+ if (!a || a.length !== b.length) return false;
1645
+ for (let i = 0; i < b.length; i++) {
1646
+ if (Math.abs(a[i] - b[i]) > layoutTolerance) return false;
1647
+ }
1648
+ return true;
1649
+ };
1650
+ const readSnapshot = async () => {
1651
+ const root = window.__sootsimRoot;
1652
+ const layout = [];
1653
+ if (root) {
1654
+ const walk = (node) => {
1655
+ if (node.layout && node.layout.width > 0) {
1656
+ layout.push(
1657
+ Math.round(node.layout.x),
1658
+ Math.round(node.layout.y),
1659
+ Math.round(node.layout.width),
1660
+ Math.round(node.layout.height)
1661
+ );
1662
+ }
1663
+ for (const child of node.children || []) walk(child);
1664
+ };
1665
+ walk(root);
1666
+ }
1667
+ return layout;
1668
+ };
1669
+ let stableLayout = null;
1670
+ let stable = 0;
1671
+ while (Date.now() < deadline) {
1672
+ const layout = await readSnapshot();
1673
+ if (isLayoutStable(stableLayout, layout)) {
1674
+ stable++;
1675
+ if (stable >= requiredStablePolls) {
1676
+ return { settled: true, elapsed: Date.now() - start };
1677
+ }
1678
+ } else {
1679
+ stableLayout = layout;
1680
+ stable = 0;
1681
+ }
1682
+ await sleep(pollMs);
1683
+ }
1684
+ return {
1685
+ settled: false,
1686
+ elapsed: Date.now() - start,
1687
+ reason: transitionError ? `layout fallback after screen transition wait failed: ${transitionError}` : "layout fallback"
1688
+ };
1689
+ },
1690
+ { maxMs }
1691
+ );
1692
+ if (!result?.settled) {
1693
+ throw new Error(
1694
+ `sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ""}`
1695
+ );
1696
+ }
1697
+ }
1698
+ async function findNodeByMatcher(matcher) {
1699
+ const page = getPage();
1700
+ if (matcher.index !== void 0) {
1701
+ return page.evaluate(async (indexedMatcher) => {
1702
+ const filter = indexedMatcher.type === "id" ? { hasId: indexedMatcher.value } : indexedMatcher.type === "text" ? { hasText: indexedMatcher.value } : indexedMatcher.type === "label" ? { hasLabel: indexedMatcher.value } : indexedMatcher.type === "role" ? { hasRole: indexedMatcher.value } : { type: indexedMatcher.value };
1703
+ const results = await window.__sootsimTest.queryAll(filter);
1704
+ return results[indexedMatcher.index ?? 0] ?? null;
1705
+ }, matcher);
1706
+ }
1707
+ if (matcher.type === "id") {
1708
+ return page.evaluate(
1709
+ (id) => window.__sootsimTest.findByTestId(id),
1710
+ matcher.value
1711
+ );
1712
+ } else if (matcher.type === "text") {
1713
+ return page.evaluate(
1714
+ (text) => window.__sootsimTest.findByText(text),
1715
+ matcher.value
1716
+ );
1717
+ } else if (matcher.type === "label") {
1718
+ return page.evaluate(
1719
+ (label) => window.__sootsimTest.findByLabel(label),
1720
+ matcher.value
1721
+ );
1722
+ } else if (matcher.type === "role") {
1723
+ return page.evaluate(
1724
+ (role) => window.__sootsimTest.findByRole(role),
1725
+ matcher.value
1726
+ );
1727
+ } else if (matcher.type === "type") {
1728
+ return page.evaluate(async (type) => {
1729
+ const results = await window.__sootsimTest.queryAll({ type });
1730
+ return results[0] || null;
1731
+ }, matcher.value);
1732
+ }
1733
+ return null;
1734
+ }
1735
+ function getNodeCenter(nodeInfo) {
1736
+ return {
1737
+ x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
1738
+ y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2
1739
+ };
1740
+ }
1741
+ async function nodeOffsetToPage(page, nodeInfo, dx, dy) {
1742
+ const center = getNodeCenter(nodeInfo);
1743
+ return sootsimToPage(page, center.x + dx, center.y + dy);
1744
+ }
1745
+ async function getDefaultTapPoint(page, nodeInfo) {
1746
+ const resolvedTarget = await page.evaluate(
1747
+ (nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null,
1748
+ nodeInfo.nodeId
1749
+ );
1750
+ const center = resolvedTarget && typeof resolvedTarget.cx === "number" && typeof resolvedTarget.cy === "number" ? { x: resolvedTarget.cx, y: resolvedTarget.cy } : getNodeCenter(nodeInfo);
1751
+ const frame = nodeInfo.visibleFrame ?? {
1752
+ x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
1753
+ y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
1754
+ width: nodeInfo.layout?.width ?? 0,
1755
+ height: nodeInfo.layout?.height ?? 0
1756
+ };
1757
+ const fullFrameTolerance = 0.5;
1758
+ const fullyVisible = typeof nodeInfo.absolutePosition?.x === "number" && typeof nodeInfo.absolutePosition?.y === "number" && typeof nodeInfo.layout?.width === "number" && typeof nodeInfo.layout?.height === "number" && Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance && Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance && frame.width >= nodeInfo.layout.width - fullFrameTolerance && frame.height >= nodeInfo.layout.height - fullFrameTolerance;
1759
+ if (fullyVisible) return center;
1760
+ const viewport = await readSootsimInteractiveViewport(page);
1761
+ const left = Math.max(0, frame.x);
1762
+ const top = Math.max(0, frame.y);
1763
+ const right = Math.min(viewport.width, frame.x + frame.width);
1764
+ const bottom = Math.min(viewport.height, frame.y + frame.height);
1765
+ if (right <= left || bottom <= top) {
1766
+ return center;
1767
+ }
1768
+ return {
1769
+ x: left + (right - left) / 2,
1770
+ y: top + (bottom - top) / 2
1771
+ };
1772
+ }
1773
+ async function dispatchTapAtSootsimPoint(page, x, y) {
1774
+ await page.evaluate(
1775
+ async ({ x: x2, y: y2 }) => {
1776
+ const interactTap = window.SootSim?.bridges?.interact?.tap;
1777
+ if (typeof interactTap === "function") {
1778
+ const result = await interactTap(x2, y2);
1779
+ if (result && (typeof result !== "object" || result.hit !== false)) return;
1780
+ }
1781
+ const tap = window.__sootsimTest?.tap;
1782
+ if (typeof tap !== "function") {
1783
+ throw new Error("sootsim tap bridge is not installed");
1784
+ }
1785
+ await tap(x2, y2);
1786
+ },
1787
+ { x, y }
1788
+ );
1789
+ }
1790
+ async function isFocusedTextInputNode(page, nodeInfo) {
1791
+ const focused = await page.evaluate(
1792
+ () => window.__sootsimTest.getFocusedNode?.() ?? null
1793
+ );
1794
+ return !!focused && typeof focused === "object" && focused.nodeId === nodeInfo?.nodeId;
1795
+ }
1796
+ async function focusElementForTextEntry(el) {
1797
+ const page = getPage();
1798
+ const node = await findNodeByMatcher(el._matcher);
1799
+ if (!node) {
1800
+ throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`);
1801
+ }
1802
+ if (await isFocusedTextInputNode(page, node)) return;
1803
+ await el.tap();
1804
+ await page.waitForTimeout(100);
1805
+ }
1806
+ function ensureScreenshotDir() {
1807
+ if (!fs.existsSync(SCREENSHOT_DIR)) {
1808
+ fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
1809
+ }
1810
+ }
1811
+ function decodePngDataUrl(dataUrl) {
1812
+ const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl);
1813
+ if (!match) {
1814
+ throw new Error(
1815
+ `sootsim screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`
1816
+ );
1817
+ }
1818
+ return Buffer.from(match[1], "base64");
1819
+ }
1820
+ function writeFastScreenshotFrames(namePrefix, frames) {
1821
+ ensureScreenshotDir();
1822
+ return frames.map((frame) => {
1823
+ const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, "-");
1824
+ const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`);
1825
+ fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl));
1826
+ const { dataUrl: _dataUrl, ...metadata } = frame;
1827
+ return {
1828
+ ...metadata,
1829
+ path: filePath
1830
+ };
1831
+ });
1832
+ }
1833
+ async function captureSootsimPngFast(opts) {
1834
+ const page = getPage();
1835
+ const dataUrl = await page.evaluate(
1836
+ async (captureOpts) => {
1837
+ const screenshot = window.SootSim?.bridges?.screenshot;
1838
+ if (typeof screenshot !== "function") {
1839
+ throw new Error("sootsim screenshot bridge is not installed");
1840
+ }
1841
+ return screenshot(captureOpts);
1842
+ },
1843
+ { crop: opts?.crop, format: "png" }
1844
+ );
1845
+ if (!dataUrl) {
1846
+ throw new Error("sootsim screenshot bridge returned an empty image");
1847
+ }
1848
+ return decodePngDataUrl(dataUrl);
1849
+ }
1850
+ async function captureSootsimPng(opts) {
1851
+ const page = getPage();
1852
+ const result = await page.evaluate(
1853
+ async (captureOpts) => {
1854
+ const screenshot = window.SootSim?.bridges?.screenshot;
1855
+ if (typeof screenshot !== "function") {
1856
+ throw new Error("sootsim screenshot bridge is not installed");
1857
+ }
1858
+ const gapMs = 100;
1859
+ const maxWaitMs = 8e3;
1860
+ const minAttempts = 3;
1861
+ const t0 = Date.now();
1862
+ let prev = await screenshot(captureOpts);
1863
+ let attempts = 1;
1864
+ while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
1865
+ await new Promise((r) => setTimeout(r, gapMs));
1866
+ const next = await screenshot(captureOpts);
1867
+ attempts++;
1868
+ if (next === prev) {
1869
+ return {
1870
+ dataUrl: next,
1871
+ stable: true,
1872
+ attempts,
1873
+ elapsedMs: Date.now() - t0
1874
+ };
1875
+ }
1876
+ prev = next;
1877
+ }
1878
+ return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 };
1879
+ },
1880
+ {
1881
+ crop: opts?.crop,
1882
+ format: "png"
1883
+ }
1884
+ );
1885
+ if (!result.dataUrl) {
1886
+ throw new Error("sootsim screenshot bridge returned an empty image");
1887
+ }
1888
+ if (!result.stable) {
1889
+ console.warn(
1890
+ `[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`
1891
+ );
1892
+ }
1893
+ return decodePngDataUrl(result.dataUrl);
1894
+ }
1895
+ async function writeSootsimScreenshot(name, opts) {
1896
+ ensureScreenshotDir();
1897
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
1898
+ fs.writeFileSync(screenshotPath, await captureSootsimPng(opts));
1899
+ return screenshotPath;
1900
+ }
1901
+ async function dispatchStatusBarOverride(config) {
1902
+ const page = getPage();
1903
+ const time = typeof config.time === "string" && config.time.length > 0 ? config.time : null;
1904
+ await page.evaluate(
1905
+ ({ eventType, time: time2 }) => {
1906
+ window.dispatchEvent(
1907
+ new CustomEvent(eventType, {
1908
+ detail: { time: time2 }
1909
+ })
1910
+ );
1911
+ },
1912
+ { eventType: STATUS_BAR_OVERRIDE_EVENT, time }
1913
+ );
1914
+ await page.waitForTimeout(50);
1915
+ }
1916
+ async function dispatchSootsimEventInPage(type, detail) {
1917
+ const page = getPage();
1918
+ await page.evaluate(
1919
+ ({ detail: detail2, type: type2 }) => {
1920
+ window.dispatchEvent(new CustomEvent(type2, { detail: detail2 }));
1921
+ },
1922
+ { detail, type }
1923
+ );
1924
+ }
1925
+ function createSootSimElement(matcher) {
1926
+ const el = {
1927
+ _matcher: matcher,
1928
+ async tap(point) {
1929
+ const page = getPage();
1930
+ await waitForSootsimWorkletsIdle(page, 3e3);
1931
+ const node = await findNodeByMatcher(matcher);
1932
+ if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`);
1933
+ const defaultPoint = point ? null : await getDefaultTapPoint(page, node);
1934
+ const targetSootSimX = typeof point?.x === "number" ? node.absolutePosition.x + point.x : defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2;
1935
+ const targetSootSimY = typeof point?.y === "number" ? node.absolutePosition.y + point.y : defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2;
1936
+ await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY);
1937
+ if (_synchronizationEnabled) {
1938
+ await waitForSootsimIdle(page);
1939
+ }
1940
+ },
1941
+ async multiTap(times) {
1942
+ const page = getPage();
1943
+ const node = await findNodeByMatcher(matcher);
1944
+ if (!node)
1945
+ throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`);
1946
+ const center = getNodeCenter(node);
1947
+ const pageCoords = await sootsimToPage(page, center.x, center.y);
1948
+ for (let i = 0; i < times; i++) {
1949
+ await page.mouse.move(pageCoords.x, pageCoords.y);
1950
+ await page.mouse.down();
1951
+ await page.waitForTimeout(50);
1952
+ await page.mouse.up();
1953
+ if (i < times - 1) {
1954
+ await page.waitForTimeout(80);
1955
+ }
1956
+ }
1957
+ await page.waitForTimeout(80);
1958
+ },
1959
+ async longPress(duration = 1e3) {
1960
+ const page = getPage();
1961
+ const node = await findNodeByMatcher(matcher);
1962
+ if (!node)
1963
+ throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`);
1964
+ const center = getNodeCenter(node);
1965
+ await page.evaluate(
1966
+ async ({ duration: duration2, x, y }) => {
1967
+ const longPress = window.SootSim?.bridges?.interact?.longPress;
1968
+ if (typeof longPress !== "function") {
1969
+ throw new Error("sootsim longPress bridge is not installed");
1970
+ }
1971
+ const hit = await longPress(x, y, duration2);
1972
+ if (!hit) {
1973
+ throw new Error(`sootsim longPress missed at ${x},${y}`);
1974
+ }
1975
+ },
1976
+ { duration, x: center.x, y: center.y }
1977
+ );
1978
+ await page.waitForTimeout(50);
1979
+ },
1980
+ async longPressAndDrag(duration, normalizedStartX, normalizedStartY, targetElement, normalizedEndX, normalizedEndY, speed = "fast", holdDuration = 0) {
1981
+ const page = getPage();
1982
+ const srcNode = await findNodeByMatcher(matcher);
1983
+ if (!srcNode)
1984
+ throw new Error(`source element not found: ${JSON.stringify(matcher)}`);
1985
+ const tgtNode = await findNodeByMatcher(targetElement._matcher);
1986
+ if (!tgtNode)
1987
+ throw new Error(
1988
+ `target element not found: ${JSON.stringify(targetElement._matcher)}`
1989
+ );
1990
+ const startSootSimX = srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX;
1991
+ const startSootSimY = srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY;
1992
+ const endSootSimX = tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX;
1993
+ const endSootSimY = tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY;
1994
+ const startPage = await sootsimToPage(page, startSootSimX, startSootSimY);
1995
+ const endPage = await sootsimToPage(page, endSootSimX, endSootSimY);
1996
+ const steps = speed === "slow" ? 20 : 10;
1997
+ await page.mouse.move(startPage.x, startPage.y);
1998
+ await page.mouse.down();
1999
+ await page.waitForTimeout(Math.max(duration, holdDuration));
2000
+ const dx = (endPage.x - startPage.x) / steps;
2001
+ const dy = (endPage.y - startPage.y) / steps;
2002
+ for (let i = 1; i <= steps; i++) {
2003
+ await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i);
2004
+ await page.waitForTimeout(speed === "slow" ? 30 : 10);
2005
+ }
2006
+ await page.mouse.up();
2007
+ await page.waitForTimeout(50);
2008
+ },
2009
+ async typeText(text) {
2010
+ const page = getPage();
2011
+ await focusElementForTextEntry(el);
2012
+ await page.keyboard.type(text);
2013
+ },
2014
+ async replaceText(text) {
2015
+ const page = getPage();
2016
+ await focusElementForTextEntry(el);
2017
+ const node = await findNodeByMatcher(matcher);
2018
+ const value = typeof node?.text === "string" ? node.text : "";
2019
+ for (let i = 0; i < value.length; i++) {
2020
+ await page.keyboard.press("Backspace");
2021
+ }
2022
+ await page.keyboard.type(text);
2023
+ },
2024
+ async clearText() {
2025
+ const page = getPage();
2026
+ await focusElementForTextEntry(el);
2027
+ const node = await findNodeByMatcher(matcher);
2028
+ const value = typeof node?.text === "string" ? node.text : "";
2029
+ for (let i = 0; i < value.length; i++) {
2030
+ await page.keyboard.press("Backspace");
2031
+ }
2032
+ },
2033
+ async scroll(pixels, direction) {
2034
+ const node = await findNodeByMatcher(matcher);
2035
+ if (!node)
2036
+ throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`);
2037
+ const page = getPage();
2038
+ await dragScrollNode(page, node, pixels, direction);
2039
+ },
2040
+ async scrollTo(edge) {
2041
+ const node = await findNodeByMatcher(matcher);
2042
+ if (!node)
2043
+ throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`);
2044
+ const targetId = typeof node.testID === "string" && node.testID ? node.testID : typeof node.id === "string" && node.id ? node.id : null;
2045
+ if (!targetId) {
2046
+ throw new Error(
2047
+ `scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`
2048
+ );
2049
+ }
2050
+ const hitX = node.absolutePosition.x + node.layout.width / 2;
2051
+ const hitY = node.absolutePosition.y + node.layout.height / 2;
2052
+ const page = getPage();
2053
+ const result = await page.evaluate(
2054
+ async ({ edge: edge2, hitX: hitX2, hitY: hitY2, targetId: targetId2 }) => {
2055
+ const bridge = window.__sootsimTest;
2056
+ if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
2057
+ return { ok: false, reason: "scroll bridge unavailable" };
2058
+ }
2059
+ const state = await bridge.getScrollStateAt(hitX2, hitY2);
2060
+ if (!state) return { ok: false, reason: "scroll state not found" };
2061
+ const maxOffset = state.maxOffset && typeof state.maxOffset === "object" ? state.maxOffset : null;
2062
+ const offset = state.offset && typeof state.offset === "object" ? state.offset : null;
2063
+ const maxX = typeof state.maxOffsetX === "number" ? state.maxOffsetX : maxOffset?.x ?? 0;
2064
+ const maxY = typeof state.maxOffsetY === "number" ? state.maxOffsetY : maxOffset?.y ?? 0;
2065
+ const currentX = typeof state.offsetX === "number" ? state.offsetX : offset?.x ?? 0;
2066
+ const currentY = typeof state.offsetY === "number" ? state.offsetY : offset?.y ?? 0;
2067
+ const x = edge2 === "left" ? 0 : edge2 === "right" ? maxX : currentX;
2068
+ const y = edge2 === "top" ? 0 : edge2 === "bottom" ? maxY : currentY;
2069
+ return bridge.scrollTo(targetId2, x, y, false);
2070
+ },
2071
+ { edge, hitX, hitY, targetId }
2072
+ );
2073
+ if (!result?.ok) {
2074
+ throw new Error(
2075
+ `scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? "unknown error"}`
2076
+ );
2077
+ }
2078
+ await page.evaluate(
2079
+ () => new Promise((resolve) => {
2080
+ requestAnimationFrame(() => resolve());
2081
+ })
2082
+ );
2083
+ if (_synchronizationEnabled) {
2084
+ await waitForSootsimIdle(page);
2085
+ }
2086
+ },
2087
+ async swipe(direction, speed = "fast", percentage = 0.75, normalizedStartingPointX = 0.5, normalizedStartingPointY = 0.5) {
2088
+ const node = await findNodeByMatcher(matcher);
2089
+ if (!node)
2090
+ throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`);
2091
+ const page = getPage();
2092
+ const { absolutePosition, layout } = node;
2093
+ const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX;
2094
+ const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY;
2095
+ let endSootSimX = startSootSimX;
2096
+ let endSootSimY = startSootSimY;
2097
+ const viewport = await readSootsimViewport(page);
2098
+ const dist = direction === "up" || direction === "down" ? viewport.height * percentage : viewport.width * percentage;
2099
+ switch (direction) {
2100
+ case "up":
2101
+ endSootSimY -= dist;
2102
+ break;
2103
+ case "down":
2104
+ endSootSimY += dist;
2105
+ break;
2106
+ case "left":
2107
+ endSootSimX -= dist;
2108
+ break;
2109
+ case "right":
2110
+ endSootSimX += dist;
2111
+ break;
2112
+ }
2113
+ const steps = speed === "slow" ? 40 : 20;
2114
+ const stepDelay = 1e3 / 60;
2115
+ await page.evaluate(
2116
+ async ({ fromX, fromY, toX, toY, steps: steps2, stepMs }) => {
2117
+ const drag = window.__sootsimInteract?.drag;
2118
+ if (typeof drag !== "function") {
2119
+ throw new Error("sootsim interact bridge is not installed");
2120
+ }
2121
+ await drag(fromX, fromY, toX, toY, steps2, stepMs);
2122
+ },
2123
+ {
2124
+ fromX: startSootSimX,
2125
+ fromY: startSootSimY,
2126
+ toX: endSootSimX,
2127
+ toY: endSootSimY,
2128
+ steps,
2129
+ stepMs: stepDelay
2130
+ }
2131
+ );
2132
+ await page.waitForTimeout(100);
2133
+ },
2134
+ async setColumnToValue(column, value) {
2135
+ if (!Number.isInteger(column) || column < 0) {
2136
+ throw new Error(`picker column must be a non-negative integer, got ${column}`);
2137
+ }
2138
+ if (typeof value !== "string") {
2139
+ throw new Error(`picker value must be a string, got ${typeof value}`);
2140
+ }
2141
+ const picker = await findNodeByMatcher(matcher);
2142
+ if (!picker) {
2143
+ throw new Error(
2144
+ `element not found for setColumnToValue: ${JSON.stringify(matcher)}`
2145
+ );
2146
+ }
2147
+ const page = getPage();
2148
+ const candidates = await page.evaluate(
2149
+ async ({ picker: picker2, column: column2, value: value2 }) => {
2150
+ const result = await window.__sootsimTest?.queryTextCandidates?.({
2151
+ query: value2,
2152
+ exact: true
2153
+ });
2154
+ if (!result || !Array.isArray(result.candidates)) return [];
2155
+ const left = picker2.absolutePosition.x;
2156
+ const top = picker2.absolutePosition.y;
2157
+ const right = left + picker2.layout.width;
2158
+ const bottom = top + picker2.layout.height;
2159
+ return result.candidates.filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column2)).map((candidate) => candidate.info).filter((candidate) => {
2160
+ const x = candidate.absolutePosition?.x;
2161
+ const y = candidate.absolutePosition?.y;
2162
+ const width = candidate.layout?.width;
2163
+ const height = candidate.layout?.height;
2164
+ if (typeof x !== "number" || typeof y !== "number" || typeof width !== "number" || typeof height !== "number") {
2165
+ return false;
2166
+ }
2167
+ const centerX = x + width / 2;
2168
+ const centerY = y + height / 2;
2169
+ return centerX >= left && centerX <= right && centerY >= top && centerY <= bottom;
2170
+ });
2171
+ },
2172
+ { picker, column, value }
2173
+ );
2174
+ if (candidates.length !== 1) {
2175
+ throw new Error(
2176
+ `setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`
2177
+ );
2178
+ }
2179
+ const target = candidates[0];
2180
+ const tapTarget = await page.evaluate(
2181
+ async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId),
2182
+ target.nodeId
2183
+ );
2184
+ if (!tapTarget) {
2185
+ throw new Error(
2186
+ `setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`
2187
+ );
2188
+ }
2189
+ await dispatchTapAtSootsimPoint(
2190
+ page,
2191
+ tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
2192
+ tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2
2193
+ );
2194
+ },
2195
+ async pinch(scale, speed = "fast", angle = 0) {
2196
+ const page = getPage();
2197
+ const node = await findNodeByMatcher(matcher);
2198
+ if (!node)
2199
+ throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`);
2200
+ await waitForSootsimGestureHandler(page, node.nodeId, "PinchGestureHandler");
2201
+ const startSpread = Math.min(
2202
+ 60,
2203
+ Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25)
2204
+ );
2205
+ const endSpread = startSpread * Math.max(scale, 0.05);
2206
+ const cos = Math.cos(angle);
2207
+ const sin = Math.sin(angle);
2208
+ const offset = (d) => ({ dx: d * cos, dy: d * sin });
2209
+ const a0 = offset(-startSpread);
2210
+ const b0 = offset(startSpread);
2211
+ const a1 = offset(-endSpread);
2212
+ const b1 = offset(endSpread);
2213
+ await dispatchTwoFingerGesture(
2214
+ page,
2215
+ await nodeOffsetToPage(page, node, a0.dx, a0.dy),
2216
+ await nodeOffsetToPage(page, node, b0.dx, b0.dy),
2217
+ await nodeOffsetToPage(page, node, a1.dx, a1.dy),
2218
+ await nodeOffsetToPage(page, node, b1.dx, b1.dy),
2219
+ { steps: speed === "fast" ? 4 : 12, stepDelayMs: speed === "fast" ? 18 : 24 }
2220
+ );
2221
+ },
2222
+ async rotate(radians, speed = "fast") {
2223
+ const page = getPage();
2224
+ const node = await findNodeByMatcher(matcher);
2225
+ if (!node)
2226
+ throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`);
2227
+ await waitForSootsimGestureHandler(page, node.nodeId, "RotationGestureHandler");
2228
+ const radius = Math.min(
2229
+ 60,
2230
+ Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25)
2231
+ );
2232
+ const startA = await nodeOffsetToPage(page, node, -radius, 0);
2233
+ const startB = await nodeOffsetToPage(page, node, radius, 0);
2234
+ const cos = Math.cos(radians);
2235
+ const sin = Math.sin(radians);
2236
+ const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin);
2237
+ const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin);
2238
+ await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
2239
+ steps: speed === "fast" ? 10 : 20,
2240
+ stepDelayMs: speed === "fast" ? 14 : 24
2241
+ });
2242
+ },
2243
+ async takeScreenshot(name) {
2244
+ const node = await findNodeByMatcher(matcher);
2245
+ if (!node)
2246
+ throw new Error(
2247
+ `element not found for takeScreenshot: ${JSON.stringify(matcher)}`
2248
+ );
2249
+ const box = node.frame ?? {
2250
+ x: node.absolutePosition.x,
2251
+ y: node.absolutePosition.y,
2252
+ width: node.layout.width,
2253
+ height: node.layout.height
2254
+ };
2255
+ return writeSootsimScreenshot(name, {
2256
+ crop: {
2257
+ x: box.x,
2258
+ y: box.y,
2259
+ w: box.width,
2260
+ h: box.height
2261
+ }
2262
+ });
2263
+ },
2264
+ async getAttributes() {
2265
+ const node = await findNodeByMatcher(matcher);
2266
+ if (!node)
2267
+ throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`);
2268
+ return {
2269
+ text: node.text || "",
2270
+ label: node.accessibilityLabel || node.text || "",
2271
+ identifier: node.testID || node.id || "",
2272
+ visible: node.layout.width > 0 && node.layout.height > 0,
2273
+ enabled: !node.accessibilityState?.disabled,
2274
+ ...node
2275
+ };
2276
+ },
2277
+ atIndex(index) {
2278
+ return createSootSimElement({ ...matcher, index });
2279
+ }
2280
+ };
2281
+ return el;
2282
+ }
2283
+ function element(matcher) {
2284
+ return createSootSimElement(matcher);
2285
+ }
2286
+ var device = {
2287
+ _currentUrl: "",
2288
+ _platform: DETOX_PLATFORM,
2289
+ async launchApp(opts) {
2290
+ if (!_browser) {
2291
+ const launched = await launchReapedChromeScoped(
2292
+ (options) => import_playwright.chromium.launch(options),
2293
+ { headless: true }
2294
+ );
2295
+ _browser = launched.browser;
2296
+ _reapBrowserNow = launched.reapNow;
2297
+ }
2298
+ if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
2299
+ await closeContext();
2300
+ const contextOpts = {
2301
+ hasTouch: true,
2302
+ viewport: { width: 500, height: 900 }
2303
+ };
2304
+ if (opts?.recordVideoDir) {
2305
+ fs.mkdirSync(opts.recordVideoDir, { recursive: true });
2306
+ contextOpts.recordVideo = {
2307
+ dir: opts.recordVideoDir,
2308
+ size: { width: 500, height: 900 }
2309
+ };
2310
+ }
2311
+ _context = await _browser.newContext(contextOpts);
2312
+ _page = await _context.newPage();
2313
+ attachPageDiagnostics(_page);
2314
+ }
2315
+ const url = opts?.url || BASE_URL;
2316
+ device._currentUrl = url;
2317
+ await _page.goto(url, { waitUntil: "load", timeout: 3e4 });
2318
+ await waitForSootsimTree(_page, 3e4);
2319
+ await waitForSootsimSurfaceMetrics(_page, 3e4);
2320
+ await waitForSootsimShellReady(_page, 3e4);
2321
+ await waitForSootsimWorkletsIdle(_page, 3e3);
2322
+ device._lastReadyAtMs = Date.now();
2323
+ },
2324
+ _lastReadyAtMs: 0,
2325
+ async reloadReactNative() {
2326
+ const page = getPage();
2327
+ await page.reload({ waitUntil: "load", timeout: 3e4 });
2328
+ await waitForSootsimTree(page, 3e4);
2329
+ await waitForSootsimSurfaceMetrics(page, 3e4);
2330
+ await waitForSootsimShellReady(page, 3e4);
2331
+ await waitForSootsimWorkletsIdle(page, 3e3);
2332
+ },
2333
+ async terminateApp() {
2334
+ await closeBrowser();
2335
+ },
2336
+ async installApp() {
2337
+ },
2338
+ async uninstallApp() {
2339
+ },
2340
+ async openURL(url) {
2341
+ const page = getPage();
2342
+ await page.goto(url.url, { waitUntil: "load", timeout: 3e4 });
2343
+ await waitForSootsimTree(page, 1e4);
2344
+ await waitForSootsimSurfaceMetrics(page, 1e4);
2345
+ await waitForSootsimShellReady(page, 1e4);
2346
+ await waitForSootsimWorkletsIdle(page, 3e3);
2347
+ },
2348
+ async takeScreenshot(name) {
2349
+ return writeSootsimScreenshot(name);
2350
+ },
2351
+ // single-shot variant for multi-stage animation captures. skips the
2352
+ // frame-stability poll so callers can sample mid-transition.
2353
+ async takeScreenshotFast(name) {
2354
+ ensureScreenshotDir();
2355
+ const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
2356
+ fs.writeFileSync(screenshotPath, await captureSootsimPngFast());
2357
+ return screenshotPath;
2358
+ },
2359
+ // record the SootSim canvas via the engine's headless recorder
2360
+ // (window.__sootsimRecorder — @sootsim/plugin-recording). this is the
2361
+ // SAME code path `sootsim record video` drives, NOT the rail-button
2362
+ // path (SootSim.bridges.startRecording wires the dialog state machine
2363
+ // and never stashes the resulting blob into __sootsimRecorder.lastBlob,
2364
+ // which is what getBlobBase64 streams from).
2365
+ //
2366
+ // unlike playwright's page-level recordVideo, the headless recorder
2367
+ // captures ONLY the device canvas (composited shell + tenant surfaces)
2368
+ // — no browser chrome, no menu bar, no device frame. uses the EXISTING
2369
+ // page/context so per-test state is preserved across capture.
2370
+ async startBridgeRecording(opts) {
2371
+ const page = getPage();
2372
+ const startOpts = {
2373
+ format: "webm",
2374
+ fps: opts?.fps ?? 60,
2375
+ layers: opts?.layers ?? "tenant",
2376
+ durationMs: opts?.durationMs ?? 5e3
2377
+ };
2378
+ const result = await page.evaluate(async (startOpts2) => {
2379
+ const rec = window.__sootsimRecorder;
2380
+ const start = rec?.start;
2381
+ if (typeof start !== "function") {
2382
+ return { ok: false, error: "__sootsimRecorder.start not installed" };
2383
+ }
2384
+ return start(startOpts2);
2385
+ }, startOpts);
2386
+ if (!result.ok) {
2387
+ throw new Error(`startBridgeRecording failed: ${result.error ?? "unknown"}`);
2388
+ }
2389
+ },
2390
+ // stop the headless recorder, stream the resulting blob in chunks (same
2391
+ // protocol as `sootsim record`), and write the webm to outPath.
2392
+ async stopAndSaveBridgeRecording(outPath) {
2393
+ const page = getPage();
2394
+ const stopResult = await page.evaluate(async () => {
2395
+ const rec = window.__sootsimRecorder;
2396
+ const stop = rec?.stop;
2397
+ if (typeof stop !== "function") {
2398
+ return { ok: false, error: "__sootsimRecorder.stop not installed" };
2399
+ }
2400
+ return stop();
2401
+ });
2402
+ if (!stopResult.ok) {
2403
+ throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? "unknown"}`);
2404
+ }
2405
+ if (!stopResult.size) {
2406
+ throw new Error("stopBridgeRecording: recorder returned empty blob");
2407
+ }
2408
+ const chunks = [];
2409
+ let offset = 0;
2410
+ const CHUNK = 2 * 1024 * 1024;
2411
+ while (true) {
2412
+ const result = await page.evaluate(
2413
+ ({ offset: offset2, chunk }) => {
2414
+ const rec = window.__sootsimRecorder;
2415
+ const get = rec?.getBlobBase64;
2416
+ if (typeof get !== "function") return null;
2417
+ return get({ offset: offset2, chunk });
2418
+ },
2419
+ { offset, chunk: CHUNK }
2420
+ );
2421
+ if (!result) throw new Error("rnx recorder produced no blob");
2422
+ chunks.push(Buffer.from(result.data, "base64"));
2423
+ offset = result.offset;
2424
+ if (result.done) break;
2425
+ }
2426
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
2427
+ fs.writeFileSync(outPath, Buffer.concat(chunks));
2428
+ this._lastRecordingPath = outPath;
2429
+ return outPath;
2430
+ },
2431
+ _lastRecordingPath: "",
2432
+ // set the engine's ContextMenu animation-progress override. visual-proof
2433
+ // harnesses use this to drive each capture stage to a known animProgress
2434
+ // (matched against the iOS oracle's curve) instead of fighting capture
2435
+ // latency vs. sub-second spring timing. pass null to clear.
2436
+ //
2437
+ // dispatches a host CustomEvent that the engine forwards to the tenant
2438
+ // worker (see shell-event-registry.ts + input-bridge.ts handleShellEvent).
2439
+ // the worker writes globalThis.__sootsimMenuAnimOverride which the engine
2440
+ // ContextMenu reads each rAF tick.
2441
+ async setMenuAnimOverride(progress) {
2442
+ await dispatchSootsimEventInPage("sootsim:menuAnimOverride", { value: progress });
2443
+ },
2444
+ // park every render clock before the interaction, detect the first changed
2445
+ // stepped frame inside the named component, then advance exact 60fps frames
2446
+ // to each requested offset. native extraction also chooses the first frame
2447
+ // at or after an offset, so both sides use the same rounding convention.
2448
+ async takeScreenshotStagesFromVisibleChangeFast(namePrefix, stages, options) {
2449
+ const page = getPage();
2450
+ const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`;
2451
+ await page.evaluate(import_motion_change.measureMotionChange, {
2452
+ definition: import_motion_change.MOTION_CHANGE_DEFINITION,
2453
+ installGlobal: true
2454
+ });
2455
+ const capturePromise = page.evaluate(
2456
+ async (input) => {
2457
+ const captureWindow = window;
2458
+ const live = captureWindow.SootSim?.bridges?.liveComposite;
2459
+ const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture;
2460
+ if (!live || typeof live.captureFresh !== "function") {
2461
+ throw new Error("rnx live composite bridge is not installed");
2462
+ }
2463
+ if (!lockstep || typeof lockstep.begin !== "function") {
2464
+ throw new Error("SootSim lockstep capture bridge is not installed");
2465
+ }
2466
+ const registry = captureWindow.__sootsimVisibleChangeCaptures ?? (captureWindow.__sootsimVisibleChangeCaptures = {});
2467
+ const state = {
2468
+ cancelled: false,
2469
+ ready: false,
2470
+ triggerCompleted: false
2471
+ };
2472
+ registry[input.token] = state;
2473
+ const measureChange = captureWindow.__sootsimMeasureMotionChange;
2474
+ if (!measureChange) {
2475
+ throw new Error("rnx motion change definition is not installed");
2476
+ }
2477
+ const captureBitmap = async () => {
2478
+ const { canvas } = await live.captureFresh();
2479
+ if (!canvas) throw new Error("rnx live composite produced no canvas");
2480
+ return createImageBitmap(canvas);
2481
+ };
2482
+ const boundsFor = (bitmap) => {
2483
+ const scale = input.rootFrame && input.rootFrame.width > 0 ? bitmap.width / input.rootFrame.width : 1;
2484
+ return {
2485
+ height: input.frame ? Math.max(
2486
+ 1,
2487
+ Math.min(
2488
+ bitmap.height,
2489
+ Math.ceil((input.frame.y + input.frame.height) * scale)
2490
+ ) - Math.max(0, Math.floor(input.frame.y * scale))
2491
+ ) : bitmap.height,
2492
+ left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
2493
+ top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
2494
+ width: input.frame ? Math.max(
2495
+ 1,
2496
+ Math.min(
2497
+ bitmap.width,
2498
+ Math.ceil((input.frame.x + input.frame.width) * scale)
2499
+ ) - Math.max(0, Math.floor(input.frame.x * scale))
2500
+ ) : bitmap.width
2501
+ };
2502
+ };
2503
+ const regionPixels = (bitmap) => {
2504
+ const bounds = boundsFor(bitmap);
2505
+ const canvas = document.createElement("canvas");
2506
+ canvas.width = bounds.width;
2507
+ canvas.height = bounds.height;
2508
+ const context = canvas.getContext("2d", { willReadFrequently: true });
2509
+ if (!context) throw new Error("rnx motion detector needs a 2d canvas");
2510
+ context.drawImage(
2511
+ bitmap,
2512
+ bounds.left,
2513
+ bounds.top,
2514
+ bounds.width,
2515
+ bounds.height,
2516
+ 0,
2517
+ 0,
2518
+ bounds.width,
2519
+ bounds.height
2520
+ );
2521
+ return context.getImageData(0, 0, bounds.width, bounds.height).data;
2522
+ };
2523
+ const encode = (snapshot) => {
2524
+ const canvas = document.createElement("canvas");
2525
+ canvas.width = snapshot.bitmap.width;
2526
+ canvas.height = snapshot.bitmap.height;
2527
+ const context = canvas.getContext("2d");
2528
+ if (!context) throw new Error("rnx motion capture needs a 2d canvas");
2529
+ context.drawImage(snapshot.bitmap, 0, 0);
2530
+ snapshot.bitmap.close();
2531
+ return {
2532
+ actualMs: snapshot.actualMs,
2533
+ dataUrl: canvas.toDataURL("image/png"),
2534
+ label: snapshot.label,
2535
+ offsetMs: snapshot.offsetMs,
2536
+ frameIndex: snapshot.frameIndex,
2537
+ virtualTimestampMs: snapshot.virtualTimestampMs
2538
+ };
2539
+ };
2540
+ const controller = await lockstep.begin(60);
2541
+ try {
2542
+ const baselineBitmap = await captureBitmap();
2543
+ const baseline = regionPixels(baselineBitmap);
2544
+ baselineBitmap.close();
2545
+ state.ready = true;
2546
+ while (!state.triggerCompleted) {
2547
+ if (state.cancelled) throw new Error("rnx motion capture was cancelled");
2548
+ await new Promise((resolve) => setTimeout(resolve, 0));
2549
+ }
2550
+ let visibleStart = null;
2551
+ let detectedMeasurement = null;
2552
+ const detectionFrameLimit = Math.ceil(input.detectionTimeoutMs * 60 / 1e3);
2553
+ for (let frame = 0; frame < detectionFrameLimit; frame++) {
2554
+ if (state.cancelled) throw new Error("rnx motion capture was cancelled");
2555
+ const receipt = await controller.stepFrame();
2556
+ const candidateBitmap = await captureBitmap();
2557
+ const bounds = boundsFor(candidateBitmap);
2558
+ const measurement = measureChange({
2559
+ baseline,
2560
+ candidate: regionPixels(candidateBitmap),
2561
+ definition: input.motionChangeDefinition,
2562
+ height: bounds.height,
2563
+ label: "SootSim visible-change detector",
2564
+ width: bounds.width
2565
+ });
2566
+ candidateBitmap.close();
2567
+ if (measurement.passedMinimum) {
2568
+ visibleStart = receipt;
2569
+ detectedMeasurement = measurement;
2570
+ break;
2571
+ }
2572
+ }
2573
+ if (!visibleStart || !detectedMeasurement) {
2574
+ throw new Error(
2575
+ `rnx motion had no visible change before ${input.detectionTimeoutMs}ms`
2576
+ );
2577
+ }
2578
+ const snapshots = [];
2579
+ for (const stage of input.stages) {
2580
+ if (state.cancelled) throw new Error("rnx motion capture was cancelled");
2581
+ const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs);
2582
+ snapshots.push({
2583
+ actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
2584
+ bitmap: await captureBitmap(),
2585
+ frameIndex: receipt.frameIndex,
2586
+ label: stage.label,
2587
+ offsetMs: stage.offsetMs,
2588
+ virtualTimestampMs: receipt.virtualTimestampMs
2589
+ });
2590
+ }
2591
+ return {
2592
+ frames: snapshots.map(encode),
2593
+ visibleChangeDetection: {
2594
+ actualMs: visibleStart.frameIndex * 1e3 / 60,
2595
+ changedPixelPercent: detectedMeasurement.changePercent,
2596
+ changedPixels: detectedMeasurement.changePixels,
2597
+ channelThreshold: input.motionChangeDefinition.channelThreshold,
2598
+ minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
2599
+ totalPixels: detectedMeasurement.totalPixels,
2600
+ frameIndex: visibleStart.frameIndex,
2601
+ virtualTimestampMs: visibleStart.virtualTimestampMs
2602
+ }
2603
+ };
2604
+ } finally {
2605
+ controller.end();
2606
+ delete registry[input.token];
2607
+ }
2608
+ },
2609
+ {
2610
+ detectionTimeoutMs: options.detectionTimeoutMs ?? 3e3,
2611
+ frame: options.frame ?? null,
2612
+ motionChangeDefinition: import_motion_change.MOTION_CHANGE_DEFINITION,
2613
+ rootFrame: options.rootFrame ?? null,
2614
+ stages,
2615
+ token
2616
+ }
2617
+ );
2618
+ await page.waitForFunction(
2619
+ (captureToken) => {
2620
+ const registry = window.__sootsimVisibleChangeCaptures;
2621
+ return registry?.[captureToken]?.ready === true;
2622
+ },
2623
+ token,
2624
+ { timeout: 5e3 }
2625
+ );
2626
+ const triggerStartMs = Date.now();
2627
+ let triggerCompletedMs = 0;
2628
+ try {
2629
+ await Promise.resolve(options.trigger());
2630
+ triggerCompletedMs = Date.now();
2631
+ await page.evaluate((captureToken) => {
2632
+ const registry = window.__sootsimVisibleChangeCaptures;
2633
+ const capture2 = registry?.[captureToken];
2634
+ if (!capture2) throw new Error("rnx motion capture disarmed before trigger");
2635
+ capture2.triggerCompleted = true;
2636
+ }, token);
2637
+ const capture = await capturePromise;
2638
+ return {
2639
+ frames: writeFastScreenshotFrames(namePrefix, capture.frames),
2640
+ triggerCompletedMs,
2641
+ triggerStartMs,
2642
+ visibleChangeDetection: {
2643
+ ...capture.visibleChangeDetection,
2644
+ frame: options.frame ?? null,
2645
+ rootFrame: options.rootFrame ?? null
2646
+ }
2647
+ };
2648
+ } catch (error) {
2649
+ await page.evaluate((captureToken) => {
2650
+ const registry = window.__sootsimVisibleChangeCaptures;
2651
+ const capture = registry?.[captureToken];
2652
+ if (capture) capture.cancelled = true;
2653
+ }, token).catch(() => {
2654
+ });
2655
+ await capturePromise.catch(() => {
2656
+ });
2657
+ throw error;
2658
+ }
2659
+ },
2660
+ // FAST multi-stage capture for sub-second animation diffs. each stage
2661
+ // schedules a setTimeout at its requested offsetMs and at that moment
2662
+ // SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
2663
+ // (drawImage from the worker-transferred home + overlay canvases). PNG
2664
+ // encoding is queued after all snapshots land — encoding stalls don't
2665
+ // contaminate the animation window.
2666
+ //
2667
+ // unlike `takeScreenshotStages`, this does NOT go through screenshot()
2668
+ // (which calls forceRenderAll + composite + toDataURL serially at
2669
+ // 300-1300ms per call and blows the spring-open timing). the worker's
2670
+ // vsync-pumped rAF keeps the home canvas fresh; we just sample it.
2671
+ //
2672
+ // returns paths in stage order; actualMs reports when the snapshot
2673
+ // actually fired (typically within a few ms of offsetMs).
2674
+ async takeScreenshotStagesFast(namePrefix, stages, options) {
2675
+ const page = getPage();
2676
+ const frames = await page.evaluate(
2677
+ async (input) => {
2678
+ const sim = window;
2679
+ const screenshot = sim.SootSim?.bridges?.screenshot;
2680
+ const live = sim.SootSim?.bridges?.liveComposite;
2681
+ if (typeof screenshot !== "function") {
2682
+ throw new Error("rnx screenshot bridge is not installed");
2683
+ }
2684
+ if (!input.skipWarmup) {
2685
+ await screenshot({ format: "png" });
2686
+ }
2687
+ const start = performance.now();
2688
+ const snaps = await Promise.all(
2689
+ input.stages.map(
2690
+ (stage) => new Promise((resolve) => {
2691
+ const fire = async () => {
2692
+ const actualMs = performance.now() - start;
2693
+ try {
2694
+ let dataUrl = null;
2695
+ if (live && typeof live.captureFresh === "function") {
2696
+ const { canvas } = await live.captureFresh();
2697
+ if (canvas) {
2698
+ const bmp = await createImageBitmap(canvas);
2699
+ const enc = document.createElement("canvas");
2700
+ enc.width = bmp.width;
2701
+ enc.height = bmp.height;
2702
+ const ctx = enc.getContext("2d");
2703
+ if (ctx) {
2704
+ ctx.drawImage(bmp, 0, 0);
2705
+ dataUrl = enc.toDataURL("image/png");
2706
+ }
2707
+ bmp.close();
2708
+ }
2709
+ }
2710
+ if (!dataUrl) {
2711
+ dataUrl = await screenshot({ format: "png" });
2712
+ }
2713
+ resolve({
2714
+ label: stage.label,
2715
+ offsetMs: stage.offsetMs,
2716
+ actualMs,
2717
+ dataUrl
2718
+ });
2719
+ } catch {
2720
+ resolve({
2721
+ label: stage.label,
2722
+ offsetMs: stage.offsetMs,
2723
+ actualMs,
2724
+ dataUrl: null
2725
+ });
2726
+ }
2727
+ };
2728
+ setTimeout(
2729
+ () => {
2730
+ void fire();
2731
+ },
2732
+ Math.max(0, stage.offsetMs)
2733
+ );
2734
+ })
2735
+ )
2736
+ );
2737
+ const out = [];
2738
+ for (const snap of snaps) {
2739
+ if (!snap.dataUrl) continue;
2740
+ out.push({
2741
+ label: snap.label,
2742
+ offsetMs: snap.offsetMs,
2743
+ actualMs: snap.actualMs,
2744
+ dataUrl: snap.dataUrl
2745
+ });
2746
+ }
2747
+ return out;
2748
+ },
2749
+ { stages, skipWarmup: options?.skipWarmup === true }
2750
+ );
2751
+ return writeFastScreenshotFrames(namePrefix, frames);
2752
+ },
2753
+ // batched multi-stage capture for animation diffs. all N captures happen
2754
+ // inside one page.evaluate round trip so browser-side timing controls the
2755
+ // offsets — sidesteps the ~300ms per-call bridge overhead that blows
2756
+ // sub-second animation windows. each stage gets a screenshot at the
2757
+ // requested offsetMs (from when the evaluate starts, i.e. right after
2758
+ // trigger() returns). returns absolute paths in stage order.
2759
+ async takeScreenshotStages(namePrefix, stages) {
2760
+ const page = getPage();
2761
+ const frames = await page.evaluate(
2762
+ async (input) => {
2763
+ const screenshot = window.SootSim?.bridges?.screenshot;
2764
+ if (typeof screenshot !== "function") {
2765
+ throw new Error("sootsim screenshot bridge is not installed");
2766
+ }
2767
+ const start = Date.now();
2768
+ const out = [];
2769
+ for (const stage of input.stages) {
2770
+ const elapsed = Date.now() - start;
2771
+ const wait = Math.max(0, stage.offsetMs - elapsed);
2772
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
2773
+ const actualMs = Date.now() - start;
2774
+ const dataUrl = await screenshot({ format: "png" });
2775
+ out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl });
2776
+ }
2777
+ return out;
2778
+ },
2779
+ { stages }
2780
+ );
2781
+ return writeFastScreenshotFrames(namePrefix, frames);
2782
+ },
2783
+ async shake() {
2784
+ },
2785
+ async setLocation(lat, lon) {
2786
+ },
2787
+ async setStatusBar(config) {
2788
+ await dispatchStatusBarOverride(config);
2789
+ },
2790
+ async setURLBlacklist(urls) {
2791
+ },
2792
+ async enableSynchronization() {
2793
+ _synchronizationEnabled = true;
2794
+ },
2795
+ async disableSynchronization() {
2796
+ _synchronizationEnabled = false;
2797
+ },
2798
+ getPlatform() {
2799
+ return device._platform;
2800
+ },
2801
+ async pressBack() {
2802
+ if (device._platform !== "android") return;
2803
+ const page = getPage();
2804
+ const dispatched = await page.evaluate(async () => {
2805
+ const goBack = window.SootSim?.bridges?.mainShell?.goBack;
2806
+ if (typeof goBack !== "function") return false;
2807
+ return await goBack() !== false;
2808
+ });
2809
+ if (!dispatched) throw new Error("sootsim Android back bridge is not installed");
2810
+ if (_synchronizationEnabled) {
2811
+ await waitForSootsimIdle(page);
2812
+ }
2813
+ },
2814
+ // raw coordinate tap, bypassing the element-based visibility/hit-test path.
2815
+ // mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
2816
+ // target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
2817
+ // `element(...).tap({x,y})` would fail Detox's visibility threshold.
2818
+ async tap(point) {
2819
+ if (point == null || typeof point === "boolean") return;
2820
+ const page = getPage();
2821
+ const { x, y } = point;
2822
+ await page.evaluate(
2823
+ async ({ x: tx, y: ty }) => {
2824
+ const interactTap = window.SootSim?.bridges?.interact?.tap;
2825
+ if (typeof interactTap === "function") {
2826
+ const result = await interactTap(tx, ty);
2827
+ if (result && (typeof result !== "object" || result.hit !== false)) return;
2828
+ }
2829
+ const tap = window.__sootsimTest?.tap;
2830
+ if (typeof tap !== "function") {
2831
+ throw new Error("sootsim tap bridge is not installed");
2832
+ }
2833
+ await tap(tx, ty);
2834
+ },
2835
+ { x, y }
2836
+ );
2837
+ await page.waitForTimeout(50);
2838
+ if (_synchronizationEnabled) {
2839
+ await waitForSootsimIdle(page);
2840
+ }
2841
+ },
2842
+ async sendToHome() {
2843
+ },
2844
+ // get direct access to the playwright page for advanced use
2845
+ getPage() {
2846
+ return getPage();
2847
+ }
2848
+ };
2849
+ var sootsimExpect = createExpect(findNodeByMatcher, getPage);
2850
+ var sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage);
2851
+ async function cleanup() {
2852
+ await closeBrowser();
2853
+ }
2854
+ // Annotate the CommonJS export names for ESM import in node:
2855
+ 0 && (module.exports = {
2856
+ by,
2857
+ cleanup,
2858
+ device,
2859
+ element,
2860
+ expect,
2861
+ waitFor,
2862
+ waitForSootsimIdle
2863
+ });