peachsim 0.1.527
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.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,3789 @@
|
|
|
1
|
+
/*! rnx v0.1.527 | (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/attempt-events.cjs
|
|
113
|
+
var require_attempt_events = __commonJS({
|
|
114
|
+
"detox/attempt-events.cjs"(exports2, module2) {
|
|
115
|
+
"use strict";
|
|
116
|
+
var fs2 = require("node:fs");
|
|
117
|
+
var path2 = require("node:path");
|
|
118
|
+
function attemptEventsPath() {
|
|
119
|
+
const enabled = process.env.CONFORMANCE_PROOF === "1" || Boolean(process.env.CONFORMANCE_PROOF_DIR);
|
|
120
|
+
if (!enabled) return null;
|
|
121
|
+
const root = path2.resolve(
|
|
122
|
+
process.cwd(),
|
|
123
|
+
process.env.CONFORMANCE_PROOF_DIR || "artifacts/conformance-proof/latest"
|
|
124
|
+
);
|
|
125
|
+
return path2.join(root, "events.jsonl");
|
|
126
|
+
}
|
|
127
|
+
function writeAttemptEvent2(event) {
|
|
128
|
+
const output = attemptEventsPath();
|
|
129
|
+
if (output === null) return false;
|
|
130
|
+
fs2.mkdirSync(path2.dirname(output), { recursive: true });
|
|
131
|
+
fs2.appendFileSync(output, `${JSON.stringify(event)}
|
|
132
|
+
`);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
module2.exports = { attemptEventsPath, writeAttemptEvent: writeAttemptEvent2 };
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// detox/proof-frame.cjs
|
|
140
|
+
var require_proof_frame = __commonJS({
|
|
141
|
+
"detox/proof-frame.cjs"(exports2, module2) {
|
|
142
|
+
"use strict";
|
|
143
|
+
var PROOF_CONTENT_DEFINITION = Object.freeze({
|
|
144
|
+
channelThreshold: 24,
|
|
145
|
+
contentBandInsetFraction: 0.08,
|
|
146
|
+
minimumContentPercent: 5e-3
|
|
147
|
+
});
|
|
148
|
+
var REQUIRED_IDENTICAL_PROOF_FRAMES2 = 3;
|
|
149
|
+
var REQUIRED_IDENTICAL_FLOW_FRAMES = 1;
|
|
150
|
+
function framesIdentical2(a, b) {
|
|
151
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
152
|
+
return a.equals(b);
|
|
153
|
+
}
|
|
154
|
+
function measureProofFrameContent(input) {
|
|
155
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
156
|
+
throw new Error("proof content measurement requires an input object");
|
|
157
|
+
}
|
|
158
|
+
const { data, width, height } = input;
|
|
159
|
+
if (!data || typeof data.length !== "number" || !Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || data.length !== width * height * 4) {
|
|
160
|
+
throw new Error("proof content measurement needs RGBA pixels and dimensions");
|
|
161
|
+
}
|
|
162
|
+
const yStart = Math.floor(height * PROOF_CONTENT_DEFINITION.contentBandInsetFraction);
|
|
163
|
+
const yEnd = Math.ceil(height * (1 - PROOF_CONTENT_DEFINITION.contentBandInsetFraction));
|
|
164
|
+
const bucketCounts = new Uint32Array(32 * 32 * 32);
|
|
165
|
+
let totalPixels = 0;
|
|
166
|
+
for (let y = yStart; y < yEnd; y++) {
|
|
167
|
+
for (let x = 0; x < width; x++) {
|
|
168
|
+
const offset = (y * width + x) * 4;
|
|
169
|
+
const bucket = data[offset] >> 3 << 10 | data[offset + 1] >> 3 << 5 | data[offset + 2] >> 3;
|
|
170
|
+
bucketCounts[bucket]++;
|
|
171
|
+
totalPixels++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
let dominantBucket = 0;
|
|
175
|
+
for (let index = 1; index < bucketCounts.length; index++) {
|
|
176
|
+
if (bucketCounts[index] > bucketCounts[dominantBucket]) dominantBucket = index;
|
|
177
|
+
}
|
|
178
|
+
const dominant = {
|
|
179
|
+
red: ((dominantBucket >> 10 & 31) << 3) + 4,
|
|
180
|
+
green: ((dominantBucket >> 5 & 31) << 3) + 4,
|
|
181
|
+
blue: ((dominantBucket & 31) << 3) + 4
|
|
182
|
+
};
|
|
183
|
+
let contentPixels = 0;
|
|
184
|
+
for (let y = yStart; y < yEnd; y++) {
|
|
185
|
+
for (let x = 0; x < width; x++) {
|
|
186
|
+
const offset = (y * width + x) * 4;
|
|
187
|
+
if (Math.max(
|
|
188
|
+
Math.abs(data[offset] - dominant.red),
|
|
189
|
+
Math.abs(data[offset + 1] - dominant.green),
|
|
190
|
+
Math.abs(data[offset + 2] - dominant.blue)
|
|
191
|
+
) >= PROOF_CONTENT_DEFINITION.channelThreshold) {
|
|
192
|
+
contentPixels++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const contentPercent = contentPixels / totalPixels * 100;
|
|
197
|
+
return {
|
|
198
|
+
contentPercent,
|
|
199
|
+
contentPixels,
|
|
200
|
+
dominant,
|
|
201
|
+
passedMinimum: contentPercent >= PROOF_CONTENT_DEFINITION.minimumContentPercent,
|
|
202
|
+
totalPixels
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function settleDecision(previousPixels, currentFrame) {
|
|
206
|
+
if (!currentFrame) return { decision: "continue", content: null };
|
|
207
|
+
const content = measureProofFrameContent(currentFrame);
|
|
208
|
+
if (!content.passedMinimum) {
|
|
209
|
+
return { decision: "insufficient-content", content };
|
|
210
|
+
}
|
|
211
|
+
if (previousPixels && framesIdentical2(previousPixels, currentFrame.data)) {
|
|
212
|
+
return { decision: "identical", content };
|
|
213
|
+
}
|
|
214
|
+
return { decision: "changed", content };
|
|
215
|
+
}
|
|
216
|
+
module2.exports = {
|
|
217
|
+
PROOF_CONTENT_DEFINITION,
|
|
218
|
+
REQUIRED_IDENTICAL_PROOF_FRAMES: REQUIRED_IDENTICAL_PROOF_FRAMES2,
|
|
219
|
+
REQUIRED_IDENTICAL_FLOW_FRAMES,
|
|
220
|
+
framesIdentical: framesIdentical2,
|
|
221
|
+
measureProofFrameContent,
|
|
222
|
+
settleDecision
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// detox/index.ts
|
|
228
|
+
var detox_exports = {};
|
|
229
|
+
__export(detox_exports, {
|
|
230
|
+
by: () => by,
|
|
231
|
+
cleanup: () => cleanup,
|
|
232
|
+
device: () => device,
|
|
233
|
+
element: () => element,
|
|
234
|
+
expect: () => sootsimExpect,
|
|
235
|
+
waitFor: () => sootsimWaitFor,
|
|
236
|
+
waitForSootsimIdle: () => waitForSootsimIdle
|
|
237
|
+
});
|
|
238
|
+
module.exports = __toCommonJS(detox_exports);
|
|
239
|
+
var fs = __toESM(require("fs"), 1);
|
|
240
|
+
var path = __toESM(require("path"), 1);
|
|
241
|
+
var import_playwright = require("playwright");
|
|
242
|
+
|
|
243
|
+
// ../../scripts/lib/reap-browser.ts
|
|
244
|
+
var import_node_child_process = require("node:child_process");
|
|
245
|
+
var discoveredChromePids = /* @__PURE__ */ new WeakMap();
|
|
246
|
+
var discoveredChromeProcesses = /* @__PURE__ */ new WeakMap();
|
|
247
|
+
var reapPids = /* @__PURE__ */ new Map();
|
|
248
|
+
var installed = false;
|
|
249
|
+
function currentRuntime() {
|
|
250
|
+
return { platform: process.platform, env: process.env };
|
|
251
|
+
}
|
|
252
|
+
function processCommand(pid) {
|
|
253
|
+
return (0, import_node_child_process.execFileSync)("/bin/ps", ["-p", String(pid), "-o", "command="], {
|
|
254
|
+
encoding: "utf8"
|
|
255
|
+
}).trim();
|
|
256
|
+
}
|
|
257
|
+
function processIdentity(pid) {
|
|
258
|
+
try {
|
|
259
|
+
const identity = (0, import_node_child_process.execFileSync)(
|
|
260
|
+
"/bin/ps",
|
|
261
|
+
["-p", String(pid), "-o", "lstart=", "-o", "command="],
|
|
262
|
+
{ encoding: "utf8" }
|
|
263
|
+
).trim();
|
|
264
|
+
return identity || null;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function softwareChromeAllowed(runtime = currentRuntime()) {
|
|
270
|
+
if (runtime.platform !== "linux") return false;
|
|
271
|
+
return runtime.env.GITHUB_ACTIONS === "true" && runtime.env.RUNNER_OS === "Linux" || runtime.env.CONTRAST_CF_FACTORY_CONTAINER === "1";
|
|
272
|
+
}
|
|
273
|
+
function isSoftwareRendererArgument(value) {
|
|
274
|
+
const arg = String(value);
|
|
275
|
+
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);
|
|
276
|
+
}
|
|
277
|
+
function softwareRendererArgumentInText(command) {
|
|
278
|
+
const unsafe = command.match(
|
|
279
|
+
/(?:^|\s)(--disable-gpu(?:=[^\s]+)?|--enable-unsafe-swiftshader(?:=[^\s]+)?|--use-angle=(?:swiftshader|swiftshader-webgl)|--use-gl=swiftshader)(?=\s|$)/i
|
|
280
|
+
);
|
|
281
|
+
return unsafe?.[1] ?? null;
|
|
282
|
+
}
|
|
283
|
+
function expectedAngle(platform) {
|
|
284
|
+
if (platform === "darwin") return "metal";
|
|
285
|
+
if (platform === "linux") return "vulkan";
|
|
286
|
+
return "default";
|
|
287
|
+
}
|
|
288
|
+
function assertGpuBrowserCommand(command, runtime = currentRuntime()) {
|
|
289
|
+
if (softwareChromeAllowed(runtime)) return;
|
|
290
|
+
const unsafe = softwareRendererArgumentInText(command);
|
|
291
|
+
if (unsafe) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`[reap-browser] refused software-rendering Chrome process argument ${unsafe}`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const angle = expectedAngle(runtime.platform);
|
|
297
|
+
const args = command.split(/\s+/);
|
|
298
|
+
if (!args.includes(`--use-angle=${angle}`) || !args.includes("--enable-gpu")) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`[reap-browser] Chrome process did not retain the required ${angle} GPU arguments`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
for (const name of ["--enable-features=", "--disable-features="]) {
|
|
304
|
+
const lists = args.filter((arg) => arg.startsWith(name)).map((arg) => arg.slice(name.length).split(",").filter(Boolean));
|
|
305
|
+
const kept = new Set(lists.at(-1) ?? []);
|
|
306
|
+
const dropped = lists.slice(0, -1).flat().filter((feature) => !kept.has(feature));
|
|
307
|
+
if (dropped.length > 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`[reap-browser] Chrome keeps only its last ${name.slice(0, -1)} switch, which dropped ${dropped.join(",")}; add them to the merged list in scripts/lib/reap-browser.ts`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (runtime.platform === "darwin" && command.includes("/Applications/Google Chrome.app/")) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
"[reap-browser] refused the user's installed Google Chrome; use Playwright channel chromium"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function collectGpuRendererStrings(info) {
|
|
320
|
+
if (!info || typeof info !== "object") return [];
|
|
321
|
+
const record = info;
|
|
322
|
+
const gpu = record.gpu;
|
|
323
|
+
if (!gpu || typeof gpu !== "object") return [];
|
|
324
|
+
const gpuRecord = gpu;
|
|
325
|
+
const values = [];
|
|
326
|
+
const aux = gpuRecord.auxAttributes;
|
|
327
|
+
if (aux && typeof aux === "object") {
|
|
328
|
+
for (const value of Object.values(aux)) {
|
|
329
|
+
if (typeof value === "string") values.push(value);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const devices = gpuRecord.devices;
|
|
333
|
+
if (Array.isArray(devices)) {
|
|
334
|
+
for (const device2 of devices) {
|
|
335
|
+
if (!device2 || typeof device2 !== "object") continue;
|
|
336
|
+
for (const value of Object.values(device2)) {
|
|
337
|
+
if (typeof value === "string") values.push(value);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return values;
|
|
342
|
+
}
|
|
343
|
+
function browserProcessPid(info) {
|
|
344
|
+
if (!info || typeof info !== "object") return null;
|
|
345
|
+
const record = info;
|
|
346
|
+
if (!Array.isArray(record.processInfo)) return null;
|
|
347
|
+
for (const entry of record.processInfo) {
|
|
348
|
+
if (!entry || typeof entry !== "object") continue;
|
|
349
|
+
const processInfo = entry;
|
|
350
|
+
if (processInfo.type === "browser" && typeof processInfo.id === "number" && Number.isInteger(processInfo.id) && processInfo.id > 0) {
|
|
351
|
+
return processInfo.id;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
function assertGpuRendererInfo(info, runtime = currentRuntime()) {
|
|
357
|
+
if (softwareChromeAllowed(runtime)) return;
|
|
358
|
+
const renderers = collectGpuRendererStrings(info);
|
|
359
|
+
const summary = renderers.join(" | ");
|
|
360
|
+
if (!summary || /swiftshader|llvmpipe|software rasterizer/i.test(summary)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`[reap-browser] Chrome reported a software renderer${summary ? `: ${summary}` : ""}`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (runtime.platform === "darwin" && !/metal/i.test(summary)) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`[reap-browser] Chrome did not report an ANGLE Metal renderer: ${summary}`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function rootChromePid(b) {
|
|
372
|
+
if (b && typeof b === "object") {
|
|
373
|
+
const discovered = discoveredChromePids.get(b);
|
|
374
|
+
if (discovered !== void 0) return discovered;
|
|
375
|
+
}
|
|
376
|
+
const w = b;
|
|
377
|
+
if (w && typeof w.process === "function") {
|
|
378
|
+
try {
|
|
379
|
+
const p = w.process();
|
|
380
|
+
if (p && typeof p.pid === "number" && p.pid > 0) return p.pid;
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (w && typeof w.browser === "function") {
|
|
385
|
+
try {
|
|
386
|
+
const child = w.browser();
|
|
387
|
+
if (child) return rootChromePid(child);
|
|
388
|
+
} catch {
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
function killTree(pid) {
|
|
394
|
+
let kids = [];
|
|
395
|
+
try {
|
|
396
|
+
kids = (0, import_node_child_process.execSync)(`pgrep -P ${pid} || true`).toString().split("\n").filter(Boolean);
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
for (const k of kids) killTree(Number(k));
|
|
400
|
+
try {
|
|
401
|
+
process.kill(pid, "SIGKILL");
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function killOwnedTree(tracked) {
|
|
406
|
+
if (tracked.readIdentity(tracked.pid) !== tracked.identity) return;
|
|
407
|
+
killTree(tracked.pid);
|
|
408
|
+
}
|
|
409
|
+
function trackReapPid(pid, readIdentity = processIdentity) {
|
|
410
|
+
const identity = readIdentity(pid);
|
|
411
|
+
if (identity === null) return null;
|
|
412
|
+
const current = reapPids.get(pid);
|
|
413
|
+
if (current?.identity === identity && current.readIdentity === readIdentity) {
|
|
414
|
+
return current;
|
|
415
|
+
}
|
|
416
|
+
const tracked = { pid, identity, readIdentity };
|
|
417
|
+
reapPids.set(pid, tracked);
|
|
418
|
+
installReaper();
|
|
419
|
+
return tracked;
|
|
420
|
+
}
|
|
421
|
+
function untrackReapPid(tracked) {
|
|
422
|
+
if (reapPids.get(tracked.pid) === tracked) reapPids.delete(tracked.pid);
|
|
423
|
+
}
|
|
424
|
+
function browserControl(b) {
|
|
425
|
+
const candidate = b;
|
|
426
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
427
|
+
if (typeof candidate.newBrowserCDPSession === "function" || typeof candidate.process === "function") {
|
|
428
|
+
return candidate;
|
|
429
|
+
}
|
|
430
|
+
if (typeof candidate.browser !== "function") return null;
|
|
431
|
+
try {
|
|
432
|
+
const browser = candidate.browser();
|
|
433
|
+
return browser ? browserControl(browser) : null;
|
|
434
|
+
} catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function registerChromePid(browser, control, pid, readIdentity = processIdentity) {
|
|
439
|
+
if (browser && typeof browser === "object") discoveredChromePids.set(browser, pid);
|
|
440
|
+
discoveredChromePids.set(control, pid);
|
|
441
|
+
const tracked = trackReapPid(pid, readIdentity);
|
|
442
|
+
if (tracked === null) return;
|
|
443
|
+
if (browser && typeof browser === "object") {
|
|
444
|
+
discoveredChromeProcesses.set(browser, tracked);
|
|
445
|
+
}
|
|
446
|
+
discoveredChromeProcesses.set(control, tracked);
|
|
447
|
+
}
|
|
448
|
+
async function discoverChromePid(browser) {
|
|
449
|
+
const known = rootChromePid(browser);
|
|
450
|
+
if (known !== null) return known;
|
|
451
|
+
const control = browserControl(browser);
|
|
452
|
+
if (!control || typeof control.newBrowserCDPSession !== "function") return null;
|
|
453
|
+
const session = await control.newBrowserCDPSession();
|
|
454
|
+
try {
|
|
455
|
+
const pid = browserProcessPid(await session.send("SystemInfo.getProcessInfo"));
|
|
456
|
+
if (pid === null) return null;
|
|
457
|
+
registerChromePid(browser, control, pid);
|
|
458
|
+
return pid;
|
|
459
|
+
} finally {
|
|
460
|
+
await session.detach?.();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async function rejectLaunchedBrowser(browser) {
|
|
464
|
+
const tracked = browser && typeof browser === "object" ? discoveredChromeProcesses.get(browser) : void 0;
|
|
465
|
+
if (tracked) {
|
|
466
|
+
killOwnedTree(tracked);
|
|
467
|
+
untrackReapPid(tracked);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const candidate = browser;
|
|
471
|
+
if (candidate && typeof candidate.close === "function") {
|
|
472
|
+
try {
|
|
473
|
+
await candidate.close();
|
|
474
|
+
} catch {
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
async function validateLaunchedGpuBrowser(browser, runtime, rendererTarget, commandForPid = processCommand, readIdentity = processIdentity) {
|
|
479
|
+
if (softwareChromeAllowed(runtime)) return;
|
|
480
|
+
const root = rootChromePid(browser);
|
|
481
|
+
const launchedControl = browserControl(browser);
|
|
482
|
+
if (root !== null && launchedControl) {
|
|
483
|
+
registerChromePid(browser, launchedControl, root, readIdentity);
|
|
484
|
+
}
|
|
485
|
+
if (root !== null && (runtime.platform === "darwin" || runtime.platform === "linux")) {
|
|
486
|
+
assertGpuBrowserCommand(commandForPid(root), runtime);
|
|
487
|
+
}
|
|
488
|
+
const rendererBrowser = rendererTarget ? await rendererTarget(browser) : browser;
|
|
489
|
+
const control = browserControl(rendererBrowser);
|
|
490
|
+
if (control && typeof control.newBrowserCDPSession === "function") {
|
|
491
|
+
const session = await control.newBrowserCDPSession();
|
|
492
|
+
try {
|
|
493
|
+
const pid = browserProcessPid(await session.send("SystemInfo.getProcessInfo"));
|
|
494
|
+
if (pid === null) {
|
|
495
|
+
throw new Error("[reap-browser] Chrome did not report its browser process id");
|
|
496
|
+
}
|
|
497
|
+
registerChromePid(browser, control, pid, readIdentity);
|
|
498
|
+
if (rendererBrowser && typeof rendererBrowser === "object") {
|
|
499
|
+
discoveredChromePids.set(rendererBrowser, pid);
|
|
500
|
+
}
|
|
501
|
+
if (runtime.platform === "darwin" || runtime.platform === "linux") {
|
|
502
|
+
assertGpuBrowserCommand(commandForPid(pid), runtime);
|
|
503
|
+
}
|
|
504
|
+
assertGpuRendererInfo(await session.send("SystemInfo.getInfo"), runtime);
|
|
505
|
+
return;
|
|
506
|
+
} finally {
|
|
507
|
+
await session.detach?.();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
throw new Error("[reap-browser] could not validate the launched Chrome GPU renderer");
|
|
511
|
+
}
|
|
512
|
+
var signalShutdownClaimed = false;
|
|
513
|
+
function installReaper() {
|
|
514
|
+
if (installed) return;
|
|
515
|
+
installed = true;
|
|
516
|
+
const reap = () => {
|
|
517
|
+
for (const tracked of reapPids.values()) killOwnedTree(tracked);
|
|
518
|
+
};
|
|
519
|
+
if (!signalShutdownClaimed) {
|
|
520
|
+
process.on("SIGTERM", () => {
|
|
521
|
+
reap();
|
|
522
|
+
process.exit(0);
|
|
523
|
+
});
|
|
524
|
+
process.on("SIGINT", () => {
|
|
525
|
+
reap();
|
|
526
|
+
process.exit(0);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
process.on("exit", reap);
|
|
530
|
+
}
|
|
531
|
+
async function launchReapedScoped(launch, readIdentity = processIdentity) {
|
|
532
|
+
const browser = await launch();
|
|
533
|
+
let root = rootChromePid(browser);
|
|
534
|
+
if (root === null) {
|
|
535
|
+
try {
|
|
536
|
+
root = await discoverChromePid(browser);
|
|
537
|
+
} catch {
|
|
538
|
+
root = null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const mine = /* @__PURE__ */ new Set();
|
|
542
|
+
if (root != null) {
|
|
543
|
+
const tracked = trackReapPid(root, readIdentity);
|
|
544
|
+
if (tracked !== null) mine.add(tracked);
|
|
545
|
+
}
|
|
546
|
+
const evented = browserControl(browser) ?? browser;
|
|
547
|
+
if (typeof evented.on === "function") {
|
|
548
|
+
const disconnectEvent = typeof evented.newBrowserCDPSession === "function" ? "disconnected" : "close";
|
|
549
|
+
evented.on(disconnectEvent, () => {
|
|
550
|
+
for (const tracked of mine) {
|
|
551
|
+
killOwnedTree(tracked);
|
|
552
|
+
untrackReapPid(tracked);
|
|
553
|
+
}
|
|
554
|
+
mine.clear();
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const reapNow = () => {
|
|
558
|
+
for (const tracked of mine) {
|
|
559
|
+
killOwnedTree(tracked);
|
|
560
|
+
untrackReapPid(tracked);
|
|
561
|
+
}
|
|
562
|
+
mine.clear();
|
|
563
|
+
};
|
|
564
|
+
return { browser, reapNow };
|
|
565
|
+
}
|
|
566
|
+
function gpuChromeArgs(platform = process.platform) {
|
|
567
|
+
const angle = expectedAngle(platform);
|
|
568
|
+
return [
|
|
569
|
+
// sootsim's compositor renders through skia graphite on webgpu, so a launch
|
|
570
|
+
// without navigator.gpu cannot present a frame at all. chrome enables webgpu
|
|
571
|
+
// by default on gpu-backed macOS but not on every headless configuration,
|
|
572
|
+
// and every caller that renders a sim was passing this by hand. own it here
|
|
573
|
+
// so a launcher cannot silently come up without the only backend there is.
|
|
574
|
+
"--enable-unsafe-webgpu",
|
|
575
|
+
`--use-angle=${angle}`,
|
|
576
|
+
"--enable-gpu",
|
|
577
|
+
"--enable-gpu-rasterization",
|
|
578
|
+
"--enable-zero-copy",
|
|
579
|
+
"--ignore-gpu-blocklist"
|
|
580
|
+
];
|
|
581
|
+
}
|
|
582
|
+
var PLAYWRIGHT_DISABLED_FEATURES = [
|
|
583
|
+
"AvoidUnnecessaryBeforeUnloadCheckSync",
|
|
584
|
+
"BlockOriginHeaderModificationOnRedirect",
|
|
585
|
+
"BoundaryEventDispatchTracksNodeRemoval",
|
|
586
|
+
"DestroyProfileOnBrowserClose",
|
|
587
|
+
"DialMediaRouteProvider",
|
|
588
|
+
"GlobalMediaControls",
|
|
589
|
+
"HttpsUpgrades",
|
|
590
|
+
"LensOverlay",
|
|
591
|
+
"MediaRouter",
|
|
592
|
+
"PaintHolding",
|
|
593
|
+
"ThirdPartyStoragePartitioning",
|
|
594
|
+
"Translate",
|
|
595
|
+
"AutoDeElevate",
|
|
596
|
+
"RenderDocument",
|
|
597
|
+
"OptimizationHints",
|
|
598
|
+
"msForceBrowserSignIn",
|
|
599
|
+
"msEdgeUpdateLaunchServicesPreferredVersion"
|
|
600
|
+
];
|
|
601
|
+
var PLAYWRIGHT_ENABLED_FEATURES = ["CDPScreenshotNewSurface"];
|
|
602
|
+
var DISABLED_FEATURES = ["NetworkTimeServiceQuerying"];
|
|
603
|
+
function mergeFeatureSwitches(args, enabled = []) {
|
|
604
|
+
const enable = /* @__PURE__ */ new Set([...PLAYWRIGHT_ENABLED_FEATURES, ...enabled]);
|
|
605
|
+
const disable = /* @__PURE__ */ new Set([...PLAYWRIGHT_DISABLED_FEATURES, ...DISABLED_FEATURES]);
|
|
606
|
+
const rest = [];
|
|
607
|
+
for (const arg of args) {
|
|
608
|
+
const match = /^--(enable|disable)-features=(.*)$/.exec(arg);
|
|
609
|
+
if (!match) {
|
|
610
|
+
rest.push(arg);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
for (const feature of match[2].split(",")) {
|
|
614
|
+
if (feature) (match[1] === "enable" ? enable : disable).add(feature);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return [
|
|
618
|
+
...rest,
|
|
619
|
+
`--enable-features=${[...enable].join(",")}`,
|
|
620
|
+
`--disable-features=${[...disable].join(",")}`
|
|
621
|
+
];
|
|
622
|
+
}
|
|
623
|
+
function gpuChromeLaunchOptions(opts = {}, runtime = currentRuntime()) {
|
|
624
|
+
const callerArgs = Array.isArray(opts.args) ? opts.args : [];
|
|
625
|
+
const unsafe = callerArgs.find(isSoftwareRendererArgument);
|
|
626
|
+
if (unsafe !== void 0) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
`[reap-browser] refused software-rendering Chrome launch argument ${String(unsafe)}`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (opts.executablePath !== void 0) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
"[reap-browser] executablePath bypasses Playwright Chrome for Testing and is not allowed"
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (opts.ignoreDefaultArgs === true) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
"[reap-browser] ignoreDefaultArgs=true bypasses required Chrome launch defaults"
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
const managedGpuArgs = /* @__PURE__ */ new Set([
|
|
642
|
+
"--enable-unsafe-webgpu",
|
|
643
|
+
"--enable-gpu",
|
|
644
|
+
"--enable-gpu-rasterization",
|
|
645
|
+
"--enable-zero-copy",
|
|
646
|
+
"--ignore-gpu-blocklist"
|
|
647
|
+
]);
|
|
648
|
+
const unmanagedCallerArgs = callerArgs.filter((arg) => {
|
|
649
|
+
const value = String(arg);
|
|
650
|
+
return !managedGpuArgs.has(value) && !value.startsWith("--use-angle=");
|
|
651
|
+
});
|
|
652
|
+
const callerIgnored = Array.isArray(opts.ignoreDefaultArgs) ? opts.ignoreDefaultArgs : [];
|
|
653
|
+
const ignoredDefaults = [
|
|
654
|
+
.../* @__PURE__ */ new Set([...callerIgnored, "--disable-gpu", "--enable-unsafe-swiftshader"])
|
|
655
|
+
];
|
|
656
|
+
return {
|
|
657
|
+
...opts,
|
|
658
|
+
// playwright's `chromium` channel is the full Google Chrome for Testing
|
|
659
|
+
// app, not chromium_headless_shell. on macOS it has bundle id
|
|
660
|
+
// com.google.chrome.for.testing, so automation cannot become the target of
|
|
661
|
+
// Dock open/quit events intended for the user's com.google.Chrome browser.
|
|
662
|
+
channel: "chromium",
|
|
663
|
+
args: mergeFeatureSwitches([
|
|
664
|
+
...unmanagedCallerArgs,
|
|
665
|
+
...gpuChromeArgs(runtime.platform)
|
|
666
|
+
]),
|
|
667
|
+
ignoreDefaultArgs: ignoredDefaults
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function platformChromeLaunch(launch, opts, rendererTarget, policy = {}) {
|
|
671
|
+
return async () => {
|
|
672
|
+
const runtime = policy.runtime ?? currentRuntime();
|
|
673
|
+
if (softwareChromeAllowed(runtime)) {
|
|
674
|
+
return launch(chromeLaunchOptions(opts, runtime));
|
|
675
|
+
}
|
|
676
|
+
const gpuOptions = chromeLaunchOptions(opts, runtime);
|
|
677
|
+
let browser;
|
|
678
|
+
try {
|
|
679
|
+
browser = await launch(gpuOptions);
|
|
680
|
+
} catch (err) {
|
|
681
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
682
|
+
const missing = /not found at|not installed|Chromium distribution|Executable doesn't exist|No such file/i.test(
|
|
683
|
+
msg
|
|
684
|
+
);
|
|
685
|
+
if (!missing) throw err;
|
|
686
|
+
throw new Error(
|
|
687
|
+
"[reap-browser] Playwright Chrome for Testing is required for local GPU browser work. Run `bun scripts/ops/install-playwright-browsers.ts chromium`.",
|
|
688
|
+
{ cause: err }
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
await validateLaunchedGpuBrowser(
|
|
693
|
+
browser,
|
|
694
|
+
runtime,
|
|
695
|
+
rendererTarget,
|
|
696
|
+
policy.processCommand,
|
|
697
|
+
policy.processIdentity
|
|
698
|
+
);
|
|
699
|
+
} catch (err) {
|
|
700
|
+
await rejectLaunchedBrowser(browser);
|
|
701
|
+
throw err;
|
|
702
|
+
}
|
|
703
|
+
return browser;
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function softwareChromeLaunchOptions(opts) {
|
|
707
|
+
if (opts.ignoreDefaultArgs === true) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
"[reap-browser] ignoreDefaultArgs=true bypasses required Chrome launch defaults"
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
const callerArgs = Array.isArray(opts.args) ? opts.args.map(String) : [];
|
|
713
|
+
const unmanagedCallerArgs = callerArgs.filter((arg) => {
|
|
714
|
+
return !arg.startsWith("--use-angle=") && arg !== "--enable-gpu" && arg !== "--enable-gpu-rasterization" && arg !== "--enable-zero-copy" && arg !== "--ignore-gpu-blocklist" && arg !== "--disable-gpu-watchdog" && arg !== "--enable-skia-graphite" && !/^--enable-unsafe-webgpu(?:=.*)?$/i.test(arg) && !/^--enable-unsafe-swiftshader(?:=.*)?$/i.test(arg);
|
|
715
|
+
});
|
|
716
|
+
const software = {
|
|
717
|
+
...opts,
|
|
718
|
+
// playwright's bundled software chromium exposes no navigator.gpu without
|
|
719
|
+
// --enable-unsafe-webgpu plus the Vulkan feature, and the compositor's only
|
|
720
|
+
// backend is webgpu. emitted here rather than per caller so a hosted-linux
|
|
721
|
+
// launch and a local gpu launch agree on what a sim needs.
|
|
722
|
+
args: mergeFeatureSwitches(
|
|
723
|
+
[
|
|
724
|
+
...unmanagedCallerArgs,
|
|
725
|
+
"--use-angle=swiftshader",
|
|
726
|
+
"--enable-unsafe-swiftshader",
|
|
727
|
+
"--enable-unsafe-webgpu",
|
|
728
|
+
// without Skia Graphite as chromium's own compositor, SwiftShader has no
|
|
729
|
+
// shared-image backing for a WebGPU canvas swap chain at any size: the
|
|
730
|
+
// first configured canvas fails "Could not find SharedImageBackingFactory"
|
|
731
|
+
// and chromium destroys the device mid-capture. compute-only WebGPU
|
|
732
|
+
// still works, so an adapter probe cannot see this.
|
|
733
|
+
"--enable-skia-graphite",
|
|
734
|
+
// the GPU watchdog kills the GPU process when a task outlives its
|
|
735
|
+
// timeout, and SwiftShader precompiling Graphite pipelines on two CPU
|
|
736
|
+
// cores can legitimately outlive it. the cost: a genuinely wedged GPU
|
|
737
|
+
// process now holds its job until the job's own timeout instead of
|
|
738
|
+
// dying fast.
|
|
739
|
+
"--disable-gpu-watchdog"
|
|
740
|
+
],
|
|
741
|
+
["Vulkan"]
|
|
742
|
+
)
|
|
743
|
+
};
|
|
744
|
+
delete software.channel;
|
|
745
|
+
delete software.executablePath;
|
|
746
|
+
return software;
|
|
747
|
+
}
|
|
748
|
+
function chromeLaunchOptions(opts = {}, runtime = currentRuntime()) {
|
|
749
|
+
if (signalShutdownClaimed) {
|
|
750
|
+
opts = { ...opts, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false };
|
|
751
|
+
}
|
|
752
|
+
return softwareChromeAllowed(runtime) ? softwareChromeLaunchOptions(opts) : gpuChromeLaunchOptions(opts, runtime);
|
|
753
|
+
}
|
|
754
|
+
async function launchReapedChromeScoped(launch, opts = {}, rendererTarget, policy = {}) {
|
|
755
|
+
return launchReapedScoped(
|
|
756
|
+
platformChromeLaunch(launch, opts, rendererTarget, policy),
|
|
757
|
+
policy.processIdentity
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ../sootsim-engine/src/capture/browser-composite.ts
|
|
762
|
+
function resolveBrowserCompositeCdpClip(request, metrics) {
|
|
763
|
+
if (!metrics || typeof metrics !== "object") {
|
|
764
|
+
throw new Error("browser composite capture requires CDP layout metrics");
|
|
765
|
+
}
|
|
766
|
+
const physical = Reflect.get(metrics, "layoutViewport");
|
|
767
|
+
const css = Reflect.get(metrics, "cssLayoutViewport");
|
|
768
|
+
if (!physical || typeof physical !== "object" || !css || typeof css !== "object") {
|
|
769
|
+
throw new Error("browser composite capture requires physical and CSS viewports");
|
|
770
|
+
}
|
|
771
|
+
const physicalWidth = Reflect.get(physical, "clientWidth");
|
|
772
|
+
const cssWidth = Reflect.get(css, "clientWidth");
|
|
773
|
+
if (typeof physicalWidth !== "number" || !Number.isFinite(physicalWidth) || physicalWidth <= 0 || typeof cssWidth !== "number" || !Number.isFinite(cssWidth) || cssWidth <= 0) {
|
|
774
|
+
throw new Error("browser composite capture requires positive viewport widths");
|
|
775
|
+
}
|
|
776
|
+
const devicePixelRatio = physicalWidth / cssWidth;
|
|
777
|
+
const padding = 1;
|
|
778
|
+
return {
|
|
779
|
+
x: request.rect.x - padding,
|
|
780
|
+
y: request.rect.y - padding,
|
|
781
|
+
width: request.rect.width + padding * 2,
|
|
782
|
+
height: request.rect.height + padding * 2,
|
|
783
|
+
scale: request.outputWidth / request.rect.width / devicePixelRatio
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
function readBrowserCompositeCaptureRequest(value) {
|
|
787
|
+
if (!value || typeof value !== "object") {
|
|
788
|
+
throw new Error("browser composite capture request must be an object");
|
|
789
|
+
}
|
|
790
|
+
const rect = Reflect.get(value, "rect");
|
|
791
|
+
if (!rect || typeof rect !== "object") {
|
|
792
|
+
throw new Error("browser composite capture request requires a rect");
|
|
793
|
+
}
|
|
794
|
+
const request = {
|
|
795
|
+
rect: {
|
|
796
|
+
x: Reflect.get(rect, "x"),
|
|
797
|
+
y: Reflect.get(rect, "y"),
|
|
798
|
+
width: Reflect.get(rect, "width"),
|
|
799
|
+
height: Reflect.get(rect, "height")
|
|
800
|
+
},
|
|
801
|
+
outputWidth: Reflect.get(value, "outputWidth"),
|
|
802
|
+
outputHeight: Reflect.get(value, "outputHeight")
|
|
803
|
+
};
|
|
804
|
+
const positions = [request.rect.x, request.rect.y];
|
|
805
|
+
const dimensions = [
|
|
806
|
+
request.rect.width,
|
|
807
|
+
request.rect.height,
|
|
808
|
+
request.outputWidth,
|
|
809
|
+
request.outputHeight
|
|
810
|
+
];
|
|
811
|
+
if (positions.some((number) => typeof number !== "number" || !Number.isFinite(number))) {
|
|
812
|
+
throw new Error("browser composite capture request positions must be finite");
|
|
813
|
+
}
|
|
814
|
+
if (dimensions.some(
|
|
815
|
+
(number) => typeof number !== "number" || !Number.isFinite(number) || number <= 0
|
|
816
|
+
) || !Number.isInteger(request.outputWidth) || !Number.isInteger(request.outputHeight)) {
|
|
817
|
+
throw new Error("browser composite capture request dimensions must be positive");
|
|
818
|
+
}
|
|
819
|
+
const scaleX = request.outputWidth / request.rect.width;
|
|
820
|
+
const scaleY = request.outputHeight / request.rect.height;
|
|
821
|
+
if (Math.abs(scaleX - scaleY) > 1e-3) {
|
|
822
|
+
throw new Error("browser composite capture output scale must be uniform");
|
|
823
|
+
}
|
|
824
|
+
return request;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/poll-until.ts
|
|
828
|
+
async function pollUntil(read, ready, options) {
|
|
829
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
|
|
830
|
+
throw new Error("poll timeout must be a positive finite number");
|
|
831
|
+
}
|
|
832
|
+
const intervalMs = options.intervalMs ?? 100;
|
|
833
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 0) {
|
|
834
|
+
throw new Error("poll interval must be a non-negative finite number");
|
|
835
|
+
}
|
|
836
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
837
|
+
let lastError;
|
|
838
|
+
let lastRead = null;
|
|
839
|
+
const describeLastRead = () => {
|
|
840
|
+
if (lastRead === null) {
|
|
841
|
+
if (lastError === void 0) return "no read settled";
|
|
842
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
843
|
+
return `last read failed: ${message}`;
|
|
844
|
+
}
|
|
845
|
+
let serialized;
|
|
846
|
+
try {
|
|
847
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
848
|
+
serialized = JSON.stringify(lastRead.value, (_key, entry) => {
|
|
849
|
+
if (typeof entry !== "object" || entry === null) return entry;
|
|
850
|
+
if (seen.has(entry)) return "[circular]";
|
|
851
|
+
seen.add(entry);
|
|
852
|
+
return entry;
|
|
853
|
+
}) ?? String(lastRead.value);
|
|
854
|
+
} catch {
|
|
855
|
+
return "last value (unserializable)";
|
|
856
|
+
}
|
|
857
|
+
if (serialized.length <= 300) return `last value ${serialized}`;
|
|
858
|
+
return `last value ${serialized.slice(0, 300)}... (${serialized.length} chars)`;
|
|
859
|
+
};
|
|
860
|
+
const timeoutError = () => new Error(
|
|
861
|
+
`${options.label} timed out after ${options.timeoutMs}ms; ${describeLastRead()}`,
|
|
862
|
+
{ cause: lastError }
|
|
863
|
+
);
|
|
864
|
+
options.signal?.throwIfAborted();
|
|
865
|
+
let rejectBoundary = () => {
|
|
866
|
+
};
|
|
867
|
+
const boundary = new Promise((_, reject) => {
|
|
868
|
+
rejectBoundary = reject;
|
|
869
|
+
});
|
|
870
|
+
const onAbort = () => rejectBoundary(
|
|
871
|
+
options.signal?.reason ?? new DOMException("The operation was aborted", "AbortError")
|
|
872
|
+
);
|
|
873
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
874
|
+
const deadlineTimer = setTimeout(
|
|
875
|
+
() => rejectBoundary(timeoutError()),
|
|
876
|
+
options.timeoutMs
|
|
877
|
+
);
|
|
878
|
+
try {
|
|
879
|
+
while (true) {
|
|
880
|
+
if (Date.now() >= deadline) throw timeoutError();
|
|
881
|
+
const outcome = await Promise.race([
|
|
882
|
+
Promise.resolve().then(read).then(
|
|
883
|
+
(value) => ({ kind: "value", value }),
|
|
884
|
+
(error) => ({ kind: "error", error })
|
|
885
|
+
),
|
|
886
|
+
boundary
|
|
887
|
+
]);
|
|
888
|
+
options.signal?.throwIfAborted();
|
|
889
|
+
if (outcome.kind === "error") {
|
|
890
|
+
lastError = outcome.error;
|
|
891
|
+
} else {
|
|
892
|
+
lastRead = { value: outcome.value };
|
|
893
|
+
if (Date.now() >= deadline) throw timeoutError();
|
|
894
|
+
try {
|
|
895
|
+
if (ready(outcome.value)) return outcome.value;
|
|
896
|
+
} catch (error) {
|
|
897
|
+
lastError = error;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
const remaining = deadline - Date.now();
|
|
901
|
+
if (remaining <= 0) throw timeoutError();
|
|
902
|
+
if (intervalMs > 0) {
|
|
903
|
+
let intervalTimer;
|
|
904
|
+
try {
|
|
905
|
+
await Promise.race([
|
|
906
|
+
new Promise((resolve) => {
|
|
907
|
+
intervalTimer = setTimeout(resolve, Math.min(intervalMs, remaining));
|
|
908
|
+
}),
|
|
909
|
+
boundary
|
|
910
|
+
]);
|
|
911
|
+
} finally {
|
|
912
|
+
clearTimeout(intervalTimer);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
} finally {
|
|
917
|
+
clearTimeout(deadlineTimer);
|
|
918
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// detox/gestures.ts
|
|
923
|
+
function resolveScrollStartPosition(value, label) {
|
|
924
|
+
const normalized = value === void 0 || Number.isNaN(value) ? 0.5 : value;
|
|
925
|
+
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 1) {
|
|
926
|
+
throw new Error(`${label} must be between 0 and 1`);
|
|
927
|
+
}
|
|
928
|
+
return normalized;
|
|
929
|
+
}
|
|
930
|
+
async function waitForSootsimGestureHandler(page, viewTag, handlerName) {
|
|
931
|
+
await pollUntil(
|
|
932
|
+
() => page.evaluate(
|
|
933
|
+
async ({ viewTag: viewTag2, handlerName: handlerName2 }) => {
|
|
934
|
+
const host = window.SootSim?.bridges?.shellHost;
|
|
935
|
+
if (typeof host?.callTestBridge !== "function") return false;
|
|
936
|
+
let debug = null;
|
|
937
|
+
try {
|
|
938
|
+
debug = await host.callTestBridge("getShellGestureSeamDebug");
|
|
939
|
+
} catch {
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
return debug?.attached?.some(
|
|
943
|
+
(entry) => entry.viewTag === viewTag2 && entry.nodeFound !== false && entry.handlers?.some((handler) => handler.name === handlerName2)
|
|
944
|
+
) === true;
|
|
945
|
+
},
|
|
946
|
+
{ viewTag, handlerName }
|
|
947
|
+
),
|
|
948
|
+
Boolean,
|
|
949
|
+
{
|
|
950
|
+
timeoutMs: 5e3,
|
|
951
|
+
intervalMs: 100,
|
|
952
|
+
label: `${handlerName} gesture handler on view ${viewTag}`
|
|
953
|
+
}
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
async function readWorkletCallbackStats(page) {
|
|
957
|
+
return page.evaluate(async () => {
|
|
958
|
+
const g = window;
|
|
959
|
+
const tenant = typeof g.__sootsimTest?.getWorkletSlotStats === "function" ? await g.__sootsimTest.getWorkletSlotStats() : null;
|
|
960
|
+
const shellHost = g.SootSim?.bridges?.shellHost;
|
|
961
|
+
let shell = null;
|
|
962
|
+
if (typeof shellHost?.callTestBridge === "function") {
|
|
963
|
+
try {
|
|
964
|
+
shell = await shellHost.callTestBridge("getWorkletSlotStats");
|
|
965
|
+
} catch {
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const tenantReceived = typeof tenant?.runJSCallbackReceived === "number" ? tenant.runJSCallbackReceived : 0;
|
|
969
|
+
const shellSent = typeof shell?.runJSCallbackSent === "number" ? shell.runJSCallbackSent : 0;
|
|
970
|
+
return { tenantReceived, shellSent };
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
async function waitForWorkletCallbackDrain(page, before, timeoutMs) {
|
|
974
|
+
if (!before) {
|
|
975
|
+
await page.waitForTimeout(250);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
let lastShellSent = before.shellSent;
|
|
979
|
+
let stableSince = Date.now();
|
|
980
|
+
let sawCallback = false;
|
|
981
|
+
await pollUntil(
|
|
982
|
+
() => readWorkletCallbackStats(page),
|
|
983
|
+
(current) => {
|
|
984
|
+
if (!current) return false;
|
|
985
|
+
if (current.shellSent !== lastShellSent) {
|
|
986
|
+
lastShellSent = current.shellSent;
|
|
987
|
+
stableSince = Date.now();
|
|
988
|
+
}
|
|
989
|
+
const sentDelta = current.shellSent - before.shellSent;
|
|
990
|
+
if (sentDelta > 0) sawCallback = true;
|
|
991
|
+
const receivedDelta = current.tenantReceived - before.tenantReceived;
|
|
992
|
+
return sawCallback && receivedDelta >= sentDelta && Date.now() - stableSince >= 100;
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
timeoutMs,
|
|
996
|
+
intervalMs: 20,
|
|
997
|
+
label: "worklet callback drain"
|
|
998
|
+
}
|
|
999
|
+
).catch(() => {
|
|
1000
|
+
});
|
|
1001
|
+
if (!sawCallback) {
|
|
1002
|
+
await page.waitForTimeout(250);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
var sootsimCanvasFinderInstalled = /* @__PURE__ */ new WeakSet();
|
|
1006
|
+
function installInteractiveCanvasFinder() {
|
|
1007
|
+
window.__sootsimFindInteractiveCanvas = () => {
|
|
1008
|
+
const interactive = [];
|
|
1009
|
+
const appVisible = [];
|
|
1010
|
+
for (const c of Array.from(document.querySelectorAll("canvas"))) {
|
|
1011
|
+
const s = window.getComputedStyle(c);
|
|
1012
|
+
if (s.display === "none" || s.visibility === "hidden") continue;
|
|
1013
|
+
const r = c.getBoundingClientRect();
|
|
1014
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
1015
|
+
const entry = {
|
|
1016
|
+
c,
|
|
1017
|
+
z: parseInt(s.zIndex, 10) || 0,
|
|
1018
|
+
app: c.dataset.surfaceId?.startsWith("app:") === true
|
|
1019
|
+
};
|
|
1020
|
+
if (entry.app) appVisible.push(entry);
|
|
1021
|
+
if (s.pointerEvents !== "none") interactive.push(entry);
|
|
1022
|
+
}
|
|
1023
|
+
const list = appVisible.length > 0 ? appVisible : interactive;
|
|
1024
|
+
if (list.length === 0) return null;
|
|
1025
|
+
list.sort((a, b) => Number(b.app) - Number(a.app) || b.z - a.z);
|
|
1026
|
+
return list[0].c;
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
async function ensureCanvasFinder(page) {
|
|
1030
|
+
if (!sootsimCanvasFinderInstalled.has(page)) {
|
|
1031
|
+
sootsimCanvasFinderInstalled.add(page);
|
|
1032
|
+
await page.addInitScript(installInteractiveCanvasFinder);
|
|
1033
|
+
}
|
|
1034
|
+
await page.evaluate(installInteractiveCanvasFinder);
|
|
1035
|
+
}
|
|
1036
|
+
async function readSootsimPageMetrics(page, timeoutMs) {
|
|
1037
|
+
await ensureCanvasFinder(page);
|
|
1038
|
+
try {
|
|
1039
|
+
const metrics = await pollUntil(
|
|
1040
|
+
() => page.evaluate(async () => {
|
|
1041
|
+
const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
|
|
1042
|
+
if (!canvas) return null;
|
|
1043
|
+
const rect = canvas.getBoundingClientRect();
|
|
1044
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
1045
|
+
const surface = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.();
|
|
1046
|
+
const screenWidth = surface?.window?.width;
|
|
1047
|
+
const screenHeight = surface?.window?.height;
|
|
1048
|
+
if (typeof screenWidth !== "number" || typeof screenHeight !== "number" || screenWidth <= 0 || screenHeight <= 0) {
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
const homeIndicatorHeight = Number(surface?.device?.homeIndicatorHeight) || 0;
|
|
1052
|
+
const safeAreaBottom = Number(surface?.safeAreaInsets?.bottom) || 0;
|
|
1053
|
+
const bottomInset = Math.max(0, homeIndicatorHeight, safeAreaBottom);
|
|
1054
|
+
const visibleRectBottom = Number(surface?.visibleRect?.y) + Number(surface?.visibleRect?.height);
|
|
1055
|
+
const interactiveHeight = Number.isFinite(visibleRectBottom) && visibleRectBottom > 0 ? Math.min(screenHeight, visibleRectBottom) : Math.max(0, screenHeight - bottomInset);
|
|
1056
|
+
return {
|
|
1057
|
+
canvas: {
|
|
1058
|
+
left: rect.left,
|
|
1059
|
+
top: rect.top,
|
|
1060
|
+
width: rect.width,
|
|
1061
|
+
height: rect.height
|
|
1062
|
+
},
|
|
1063
|
+
window: {
|
|
1064
|
+
width: screenWidth,
|
|
1065
|
+
height: screenHeight
|
|
1066
|
+
},
|
|
1067
|
+
interactiveWindow: {
|
|
1068
|
+
width: screenWidth,
|
|
1069
|
+
height: interactiveHeight
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
}),
|
|
1073
|
+
(metrics2) => metrics2 !== null,
|
|
1074
|
+
{
|
|
1075
|
+
timeoutMs,
|
|
1076
|
+
intervalMs: 50,
|
|
1077
|
+
label: "sootsim surface metrics"
|
|
1078
|
+
}
|
|
1079
|
+
);
|
|
1080
|
+
if (metrics === null) throw new Error("surface metrics poll settled without metrics");
|
|
1081
|
+
return metrics;
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
throw new Error("sootsim surface metrics are not available", { cause: error });
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function readSootsimViewport(page, timeoutMs = 5e3) {
|
|
1087
|
+
return (await readSootsimPageMetrics(page, timeoutMs)).window;
|
|
1088
|
+
}
|
|
1089
|
+
async function readSootsimInteractiveViewport(page, timeoutMs = 5e3) {
|
|
1090
|
+
return (await readSootsimPageMetrics(page, timeoutMs)).interactiveWindow;
|
|
1091
|
+
}
|
|
1092
|
+
async function sootsimToPage(page, sootsimX, sootsimY, timeoutMs = 5e3) {
|
|
1093
|
+
const metrics = await readSootsimPageMetrics(page, timeoutMs);
|
|
1094
|
+
const scaleX = metrics.canvas.width / metrics.window.width;
|
|
1095
|
+
const scaleY = metrics.canvas.height / metrics.window.height;
|
|
1096
|
+
return {
|
|
1097
|
+
x: metrics.canvas.left + sootsimX * scaleX,
|
|
1098
|
+
y: metrics.canvas.top + sootsimY * scaleY
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
async function dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY) {
|
|
1102
|
+
const normalizedStartX = resolveScrollStartPosition(
|
|
1103
|
+
startPositionX,
|
|
1104
|
+
"scroll startPositionX"
|
|
1105
|
+
);
|
|
1106
|
+
const normalizedStartY = resolveScrollStartPosition(
|
|
1107
|
+
startPositionY,
|
|
1108
|
+
"scroll startPositionY"
|
|
1109
|
+
);
|
|
1110
|
+
const startSootSimX = node.absolutePosition.x + node.layout.width * normalizedStartX;
|
|
1111
|
+
const startSootSimY = node.absolutePosition.y + node.layout.height * normalizedStartY;
|
|
1112
|
+
let endSootSimX = startSootSimX;
|
|
1113
|
+
let endSootSimY = startSootSimY;
|
|
1114
|
+
switch (direction) {
|
|
1115
|
+
case "down":
|
|
1116
|
+
endSootSimY -= pixels;
|
|
1117
|
+
break;
|
|
1118
|
+
case "up":
|
|
1119
|
+
endSootSimY += pixels;
|
|
1120
|
+
break;
|
|
1121
|
+
case "left":
|
|
1122
|
+
endSootSimX += pixels;
|
|
1123
|
+
break;
|
|
1124
|
+
case "right":
|
|
1125
|
+
endSootSimX -= pixels;
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
const startPage = await sootsimToPage(page, startSootSimX, startSootSimY);
|
|
1129
|
+
const endPage = await sootsimToPage(page, endSootSimX, endSootSimY);
|
|
1130
|
+
const steps = Math.max(16, Math.ceil(pixels / 8));
|
|
1131
|
+
await page.mouse.move(startPage.x, startPage.y);
|
|
1132
|
+
await page.mouse.down();
|
|
1133
|
+
await page.waitForTimeout(20);
|
|
1134
|
+
const dx = (endPage.x - startPage.x) / steps;
|
|
1135
|
+
const dy = (endPage.y - startPage.y) / steps;
|
|
1136
|
+
for (let i = 1; i <= steps; i++) {
|
|
1137
|
+
await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i);
|
|
1138
|
+
await page.waitForTimeout(24);
|
|
1139
|
+
}
|
|
1140
|
+
await page.mouse.up();
|
|
1141
|
+
await page.waitForTimeout(180);
|
|
1142
|
+
}
|
|
1143
|
+
async function dispatchTwoFingerGesture(page, startA, startB, endA, endB, options = {}) {
|
|
1144
|
+
const steps = options.steps ?? 10;
|
|
1145
|
+
const stepDelay = options.stepDelayMs ?? 18;
|
|
1146
|
+
const workletStatsTimeoutMs = options.workletStatsTimeoutMs ?? 1500;
|
|
1147
|
+
await ensureCanvasFinder(page);
|
|
1148
|
+
const beforeWorkletStats = await pollUntil(
|
|
1149
|
+
() => readWorkletCallbackStats(page),
|
|
1150
|
+
() => true,
|
|
1151
|
+
{
|
|
1152
|
+
timeoutMs: workletStatsTimeoutMs,
|
|
1153
|
+
intervalMs: 0,
|
|
1154
|
+
label: "pre-dispatch worklet callback stats"
|
|
1155
|
+
}
|
|
1156
|
+
).catch(() => null);
|
|
1157
|
+
await page.evaluate(
|
|
1158
|
+
({ startA: startA2, startB: startB2 }) => {
|
|
1159
|
+
const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
|
|
1160
|
+
if (!canvas) throw new Error("sootsim interactive canvas not found");
|
|
1161
|
+
const fire = (point, id, primary) => {
|
|
1162
|
+
canvas.dispatchEvent(
|
|
1163
|
+
new PointerEvent("pointerdown", {
|
|
1164
|
+
bubbles: true,
|
|
1165
|
+
cancelable: true,
|
|
1166
|
+
clientX: point.x,
|
|
1167
|
+
clientY: point.y,
|
|
1168
|
+
isPrimary: primary,
|
|
1169
|
+
pointerId: id,
|
|
1170
|
+
pointerType: "touch"
|
|
1171
|
+
})
|
|
1172
|
+
);
|
|
1173
|
+
};
|
|
1174
|
+
fire(startA2, 1001, true);
|
|
1175
|
+
fire(startB2, 1002, false);
|
|
1176
|
+
},
|
|
1177
|
+
{ startA, startB }
|
|
1178
|
+
);
|
|
1179
|
+
for (let i = 1; i <= steps; i++) {
|
|
1180
|
+
const t = i / steps;
|
|
1181
|
+
const ax = startA.x + (endA.x - startA.x) * t;
|
|
1182
|
+
const ay = startA.y + (endA.y - startA.y) * t;
|
|
1183
|
+
const bx = startB.x + (endB.x - startB.x) * t;
|
|
1184
|
+
const by2 = startB.y + (endB.y - startB.y) * t;
|
|
1185
|
+
await page.evaluate(
|
|
1186
|
+
({ ax: ax2, ay: ay2, bx: bx2, by: by3 }) => {
|
|
1187
|
+
const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
|
|
1188
|
+
if (!canvas) return;
|
|
1189
|
+
canvas.dispatchEvent(
|
|
1190
|
+
new PointerEvent("pointermove", {
|
|
1191
|
+
bubbles: true,
|
|
1192
|
+
cancelable: true,
|
|
1193
|
+
clientX: ax2,
|
|
1194
|
+
clientY: ay2,
|
|
1195
|
+
isPrimary: true,
|
|
1196
|
+
pointerId: 1001,
|
|
1197
|
+
pointerType: "touch"
|
|
1198
|
+
})
|
|
1199
|
+
);
|
|
1200
|
+
canvas.dispatchEvent(
|
|
1201
|
+
new PointerEvent("pointermove", {
|
|
1202
|
+
bubbles: true,
|
|
1203
|
+
cancelable: true,
|
|
1204
|
+
clientX: bx2,
|
|
1205
|
+
clientY: by3,
|
|
1206
|
+
isPrimary: false,
|
|
1207
|
+
pointerId: 1002,
|
|
1208
|
+
pointerType: "touch"
|
|
1209
|
+
})
|
|
1210
|
+
);
|
|
1211
|
+
},
|
|
1212
|
+
{ ax, ay, bx, by: by2 }
|
|
1213
|
+
);
|
|
1214
|
+
await page.waitForTimeout(stepDelay);
|
|
1215
|
+
}
|
|
1216
|
+
await page.evaluate(
|
|
1217
|
+
({ endA: endA2, endB: endB2 }) => {
|
|
1218
|
+
const canvas = window.__sootsimFindInteractiveCanvas?.() ?? document.querySelector("canvas");
|
|
1219
|
+
if (!canvas) return;
|
|
1220
|
+
canvas.dispatchEvent(
|
|
1221
|
+
new PointerEvent("pointerup", {
|
|
1222
|
+
bubbles: true,
|
|
1223
|
+
cancelable: true,
|
|
1224
|
+
clientX: endA2.x,
|
|
1225
|
+
clientY: endA2.y,
|
|
1226
|
+
isPrimary: true,
|
|
1227
|
+
pointerId: 1001,
|
|
1228
|
+
pointerType: "touch"
|
|
1229
|
+
})
|
|
1230
|
+
);
|
|
1231
|
+
canvas.dispatchEvent(
|
|
1232
|
+
new PointerEvent("pointerup", {
|
|
1233
|
+
bubbles: true,
|
|
1234
|
+
cancelable: true,
|
|
1235
|
+
clientX: endB2.x,
|
|
1236
|
+
clientY: endB2.y,
|
|
1237
|
+
isPrimary: false,
|
|
1238
|
+
pointerId: 1002,
|
|
1239
|
+
pointerType: "touch"
|
|
1240
|
+
})
|
|
1241
|
+
);
|
|
1242
|
+
},
|
|
1243
|
+
{ endA, endB }
|
|
1244
|
+
);
|
|
1245
|
+
await waitForWorkletCallbackDrain(page, beforeWorkletStats, workletStatsTimeoutMs);
|
|
1246
|
+
await page.waitForTimeout(250);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// detox/expectations.ts
|
|
1250
|
+
var DEFAULT_VISIBILITY_THRESHOLD = 0.75;
|
|
1251
|
+
async function scrollMatcherElement(page, findNode, matcher, pixels, direction, startPositionX, startPositionY) {
|
|
1252
|
+
const node = await findNode(matcher);
|
|
1253
|
+
if (!node) {
|
|
1254
|
+
throw new Error(`scroll container not found: ${JSON.stringify(matcher)}`);
|
|
1255
|
+
}
|
|
1256
|
+
const before = scrollOffsetOf(node);
|
|
1257
|
+
await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY);
|
|
1258
|
+
const after = scrollOffsetOf(await findNode(matcher));
|
|
1259
|
+
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y);
|
|
1260
|
+
}
|
|
1261
|
+
function scrollOffsetOf(node) {
|
|
1262
|
+
return { x: node?.scrollOffsetX ?? 0, y: node?.scrollOffsetY ?? 0 };
|
|
1263
|
+
}
|
|
1264
|
+
function visibleFraction(node) {
|
|
1265
|
+
const totalArea = (node?.layout?.width ?? 0) * (node?.layout?.height ?? 0);
|
|
1266
|
+
const frame = node?.visibleFrame;
|
|
1267
|
+
if (totalArea <= 0 || typeof frame?.area !== "number") return 0;
|
|
1268
|
+
return frame.area / totalArea;
|
|
1269
|
+
}
|
|
1270
|
+
function isNodeVisible(node) {
|
|
1271
|
+
return visibleFraction(node) >= DEFAULT_VISIBILITY_THRESHOLD;
|
|
1272
|
+
}
|
|
1273
|
+
async function isNodeFocused(node, page) {
|
|
1274
|
+
const nodeId = typeof node?.nodeId === "number" ? node.nodeId : typeof node?.id === "number" ? node.id : null;
|
|
1275
|
+
if (nodeId === null) return false;
|
|
1276
|
+
return page.evaluate((id) => {
|
|
1277
|
+
const active = document.activeElement;
|
|
1278
|
+
return active?.getAttribute("data-sootsim-id") === String(id);
|
|
1279
|
+
}, nodeId);
|
|
1280
|
+
}
|
|
1281
|
+
function describeNodeVisibility(node) {
|
|
1282
|
+
if (!node) return "not found";
|
|
1283
|
+
const absX = node.absolutePosition?.x ?? node.layout?.x ?? 0;
|
|
1284
|
+
const absY = node.absolutePosition?.y ?? node.layout?.y ?? 0;
|
|
1285
|
+
return JSON.stringify({
|
|
1286
|
+
layout: node.layout,
|
|
1287
|
+
absolutePosition: node.absolutePosition ?? null,
|
|
1288
|
+
frame: {
|
|
1289
|
+
x: absX,
|
|
1290
|
+
y: absY,
|
|
1291
|
+
right: absX + (node.layout?.width ?? 0),
|
|
1292
|
+
bottom: absY + (node.layout?.height ?? 0)
|
|
1293
|
+
},
|
|
1294
|
+
visibleFrame: node.visibleFrame ?? null,
|
|
1295
|
+
visibleFraction: visibleFraction(node),
|
|
1296
|
+
threshold: DEFAULT_VISIBILITY_THRESHOLD,
|
|
1297
|
+
style: node.style ?? null
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
function createExpect(findNode, getPage2) {
|
|
1301
|
+
return function sootsimExpect2(el) {
|
|
1302
|
+
return new SootSimExpectation(el._matcher, findNode, getPage2, false);
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
var SootSimExpectation = class _SootSimExpectation {
|
|
1306
|
+
matcher;
|
|
1307
|
+
findNode;
|
|
1308
|
+
getPage;
|
|
1309
|
+
negated;
|
|
1310
|
+
constructor(matcher, findNode, getPage2, negated) {
|
|
1311
|
+
this.matcher = matcher;
|
|
1312
|
+
this.findNode = findNode;
|
|
1313
|
+
this.getPage = getPage2;
|
|
1314
|
+
this.negated = negated;
|
|
1315
|
+
}
|
|
1316
|
+
get not() {
|
|
1317
|
+
return new _SootSimExpectation(
|
|
1318
|
+
this.matcher,
|
|
1319
|
+
this.findNode,
|
|
1320
|
+
this.getPage,
|
|
1321
|
+
!this.negated
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
async toExist() {
|
|
1325
|
+
const node = await this.findNode(this.matcher);
|
|
1326
|
+
const exists = node !== null && node !== void 0;
|
|
1327
|
+
if (this.negated) {
|
|
1328
|
+
if (exists) {
|
|
1329
|
+
throw new Error(
|
|
1330
|
+
`expected element NOT to exist but it does: ${JSON.stringify(this.matcher)}`
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
} else {
|
|
1334
|
+
if (!exists) {
|
|
1335
|
+
throw new Error(
|
|
1336
|
+
`expected element to exist but it was not found: ${JSON.stringify(this.matcher)}`
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
async toBeVisible() {
|
|
1342
|
+
const node = await this.findNode(this.matcher);
|
|
1343
|
+
const visible = isNodeVisible(node);
|
|
1344
|
+
if (this.negated) {
|
|
1345
|
+
if (visible) {
|
|
1346
|
+
throw new Error(
|
|
1347
|
+
`expected element NOT to be visible: ${JSON.stringify(this.matcher)}`
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
} else {
|
|
1351
|
+
if (!visible) {
|
|
1352
|
+
throw new Error(
|
|
1353
|
+
`expected element to be visible but it is ${describeNodeVisibility(node)}: ${JSON.stringify(this.matcher)}`
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
async toBeNotVisible() {
|
|
1359
|
+
const node = await this.findNode(this.matcher);
|
|
1360
|
+
const visible = isNodeVisible(node);
|
|
1361
|
+
if (visible) {
|
|
1362
|
+
throw new Error(
|
|
1363
|
+
`expected element NOT to be visible: ${JSON.stringify(this.matcher)}`
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
async toHaveText(expectedText) {
|
|
1368
|
+
const node = await this.findNode(this.matcher);
|
|
1369
|
+
if (!node) {
|
|
1370
|
+
throw new Error(`element not found for toHaveText: ${JSON.stringify(this.matcher)}`);
|
|
1371
|
+
}
|
|
1372
|
+
const actualText = node.text || "";
|
|
1373
|
+
const matches = actualText === expectedText;
|
|
1374
|
+
if (this.negated) {
|
|
1375
|
+
if (matches) {
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
`expected text NOT to be "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
} else {
|
|
1381
|
+
if (!matches) {
|
|
1382
|
+
throw new Error(
|
|
1383
|
+
`expected text "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
async toHaveId(expectedId) {
|
|
1389
|
+
const node = await this.findNode(this.matcher);
|
|
1390
|
+
if (!node) {
|
|
1391
|
+
throw new Error(`element not found for toHaveId: ${JSON.stringify(this.matcher)}`);
|
|
1392
|
+
}
|
|
1393
|
+
const hasId = node.testID === expectedId || node.id === expectedId;
|
|
1394
|
+
if (this.negated) {
|
|
1395
|
+
if (hasId) throw new Error(`expected element NOT to have id "${expectedId}"`);
|
|
1396
|
+
} else {
|
|
1397
|
+
if (!hasId)
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
`expected element to have id "${expectedId}" but got "${node.testID || node.id}"`
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
async toHaveLabel(expectedLabel) {
|
|
1404
|
+
const node = await this.findNode(this.matcher);
|
|
1405
|
+
if (!node) {
|
|
1406
|
+
throw new Error(
|
|
1407
|
+
`element not found for toHaveLabel: ${JSON.stringify(this.matcher)}`
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
const actual = node.accessibilityLabel || node.text || "";
|
|
1411
|
+
const matches = actual === expectedLabel;
|
|
1412
|
+
if (this.negated) {
|
|
1413
|
+
if (matches)
|
|
1414
|
+
throw new Error(`expected label NOT to be "${expectedLabel}" but it was`);
|
|
1415
|
+
} else {
|
|
1416
|
+
if (!matches)
|
|
1417
|
+
throw new Error(
|
|
1418
|
+
`expected label "${expectedLabel}" but got "${actual}": ${JSON.stringify(this.matcher)}`
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
async toHaveAccessibilityRole(expectedRole) {
|
|
1423
|
+
const node = await this.findNode(this.matcher);
|
|
1424
|
+
if (!node) {
|
|
1425
|
+
throw new Error(
|
|
1426
|
+
`element not found for toHaveAccessibilityRole: ${JSON.stringify(this.matcher)}`
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
const actual = node.accessibilityRole || "";
|
|
1430
|
+
const matches = actual === expectedRole;
|
|
1431
|
+
if (this.negated) {
|
|
1432
|
+
if (matches) throw new Error(`expected role NOT to be "${expectedRole}" but it was`);
|
|
1433
|
+
} else {
|
|
1434
|
+
if (!matches)
|
|
1435
|
+
throw new Error(
|
|
1436
|
+
`expected role "${expectedRole}" but got "${actual}": ${JSON.stringify(this.matcher)}`
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
async toBeEnabled() {
|
|
1441
|
+
const node = await this.findNode(this.matcher);
|
|
1442
|
+
if (!node) {
|
|
1443
|
+
throw new Error(
|
|
1444
|
+
`element not found for toBeEnabled: ${JSON.stringify(this.matcher)}`
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
const disabled = node.accessibilityState?.disabled ?? false;
|
|
1448
|
+
if (this.negated) {
|
|
1449
|
+
if (!disabled) throw new Error(`expected element to be disabled but it is enabled`);
|
|
1450
|
+
} else {
|
|
1451
|
+
if (disabled) throw new Error(`expected element to be enabled but it is disabled`);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
async toBeFocused() {
|
|
1455
|
+
const node = await this.findNode(this.matcher);
|
|
1456
|
+
if (!node) {
|
|
1457
|
+
throw new Error(
|
|
1458
|
+
`element not found for toBeFocused: ${JSON.stringify(this.matcher)}`
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
const focused = await isNodeFocused(node, this.getPage());
|
|
1462
|
+
if (this.negated) {
|
|
1463
|
+
if (focused) {
|
|
1464
|
+
throw new Error(
|
|
1465
|
+
`expected element NOT to be focused: ${JSON.stringify(this.matcher)}`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
} else if (!focused) {
|
|
1469
|
+
throw new Error(`expected element to be focused: ${JSON.stringify(this.matcher)}`);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
async toHaveValue(value) {
|
|
1473
|
+
const node = await this.findNode(this.matcher);
|
|
1474
|
+
if (!node) {
|
|
1475
|
+
throw new Error(
|
|
1476
|
+
`element not found for toHaveValue: ${JSON.stringify(this.matcher)}`
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
const actual = node.text || "";
|
|
1480
|
+
if (this.negated) {
|
|
1481
|
+
if (actual.includes(value)) {
|
|
1482
|
+
throw new Error(
|
|
1483
|
+
`expected element NOT to have value "${value}" but got "${actual}"`
|
|
1484
|
+
);
|
|
1485
|
+
}
|
|
1486
|
+
} else {
|
|
1487
|
+
if (!actual.includes(value)) {
|
|
1488
|
+
throw new Error(`expected element to have value "${value}" but got "${actual}"`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
async toHaveSliderPosition(normalizedPosition, tolerance) {
|
|
1493
|
+
await this.toExist();
|
|
1494
|
+
}
|
|
1495
|
+
async toHaveToggleValue(value) {
|
|
1496
|
+
await this.toExist();
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
function createWaitFor(findNode, getPage2) {
|
|
1500
|
+
return function sootsimWaitFor2(el) {
|
|
1501
|
+
return new SootSimWaitForChain(el._matcher, findNode, getPage2);
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
var SootSimWaitForChain = class {
|
|
1505
|
+
matcher;
|
|
1506
|
+
findNode;
|
|
1507
|
+
getPage;
|
|
1508
|
+
assertionFn = null;
|
|
1509
|
+
_negated = false;
|
|
1510
|
+
constructor(matcher, findNode, getPage2) {
|
|
1511
|
+
this.matcher = matcher;
|
|
1512
|
+
this.findNode = findNode;
|
|
1513
|
+
this.getPage = getPage2;
|
|
1514
|
+
}
|
|
1515
|
+
get not() {
|
|
1516
|
+
this._negated = true;
|
|
1517
|
+
return this;
|
|
1518
|
+
}
|
|
1519
|
+
toExist() {
|
|
1520
|
+
this.assertionFn = async () => {
|
|
1521
|
+
const node = await this.findNode(this.matcher);
|
|
1522
|
+
const exists = node !== null && node !== void 0;
|
|
1523
|
+
if (this._negated ? exists : !exists) {
|
|
1524
|
+
throw new Error(`waitFor toExist failed: ${JSON.stringify(this.matcher)}`);
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
|
|
1528
|
+
}
|
|
1529
|
+
toBeVisible() {
|
|
1530
|
+
this.assertionFn = async () => {
|
|
1531
|
+
const node = await this.findNode(this.matcher);
|
|
1532
|
+
const visible = isNodeVisible(node);
|
|
1533
|
+
if (this._negated ? visible : !visible) {
|
|
1534
|
+
throw new Error(
|
|
1535
|
+
`waitFor toBeVisible failed (${describeNodeVisibility(node)}): ${JSON.stringify(this.matcher)}`
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
};
|
|
1539
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
|
|
1540
|
+
}
|
|
1541
|
+
toBeNotVisible() {
|
|
1542
|
+
this.assertionFn = async () => {
|
|
1543
|
+
const node = await this.findNode(this.matcher);
|
|
1544
|
+
const visible = isNodeVisible(node);
|
|
1545
|
+
if (visible) {
|
|
1546
|
+
throw new Error(`waitFor toBeNotVisible failed: ${JSON.stringify(this.matcher)}`);
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
|
|
1550
|
+
}
|
|
1551
|
+
toHaveText(expectedText) {
|
|
1552
|
+
this.assertionFn = async () => {
|
|
1553
|
+
const node = await this.findNode(this.matcher);
|
|
1554
|
+
if (!node)
|
|
1555
|
+
throw new Error(
|
|
1556
|
+
`waitFor toHaveText: element not found: ${JSON.stringify(this.matcher)}`
|
|
1557
|
+
);
|
|
1558
|
+
const actual = node.text || "";
|
|
1559
|
+
const matches = actual === expectedText;
|
|
1560
|
+
if (this._negated ? matches : !matches) {
|
|
1561
|
+
throw new Error(
|
|
1562
|
+
`waitFor toHaveText: expected "${expectedText}" but got "${actual}" ${JSON.stringify(this.matcher)}`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
|
|
1567
|
+
}
|
|
1568
|
+
toHaveValue(value) {
|
|
1569
|
+
this.assertionFn = async () => {
|
|
1570
|
+
const node = await this.findNode(this.matcher);
|
|
1571
|
+
if (!node) throw new Error(`waitFor toHaveValue: element not found`);
|
|
1572
|
+
const actual = node.text || "";
|
|
1573
|
+
if (!actual.includes(value)) {
|
|
1574
|
+
throw new Error(`waitFor toHaveValue: expected "${value}" in "${actual}"`);
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
|
|
1578
|
+
}
|
|
1579
|
+
toBeFocused() {
|
|
1580
|
+
this.assertionFn = async () => {
|
|
1581
|
+
const node = await this.findNode(this.matcher);
|
|
1582
|
+
if (!node)
|
|
1583
|
+
throw new Error(
|
|
1584
|
+
`waitFor toBeFocused: element not found: ${JSON.stringify(this.matcher)}`
|
|
1585
|
+
);
|
|
1586
|
+
const focused = await isNodeFocused(node, this.getPage());
|
|
1587
|
+
if (this._negated ? focused : !focused) {
|
|
1588
|
+
throw new Error(`waitFor toBeFocused failed: ${JSON.stringify(this.matcher)}`);
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage);
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
var SootSimWaitForAction = class {
|
|
1595
|
+
assertionFn;
|
|
1596
|
+
findNode;
|
|
1597
|
+
getPage;
|
|
1598
|
+
constructor(assertionFn, findNode, getPage2) {
|
|
1599
|
+
this.assertionFn = assertionFn;
|
|
1600
|
+
this.findNode = findNode;
|
|
1601
|
+
this.getPage = getPage2;
|
|
1602
|
+
}
|
|
1603
|
+
async withTimeout(ms) {
|
|
1604
|
+
const start = Date.now();
|
|
1605
|
+
const pollInterval = 100;
|
|
1606
|
+
let lastError = null;
|
|
1607
|
+
while (Date.now() - start < ms) {
|
|
1608
|
+
try {
|
|
1609
|
+
await this.assertionFn();
|
|
1610
|
+
return;
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
lastError = e;
|
|
1613
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
throw lastError || new Error(`waitFor timed out after ${ms}ms`);
|
|
1617
|
+
}
|
|
1618
|
+
// if no withTimeout is called, just run the assertion once
|
|
1619
|
+
async then(resolve, reject) {
|
|
1620
|
+
try {
|
|
1621
|
+
await this.withTimeout(5e3);
|
|
1622
|
+
resolve(void 0);
|
|
1623
|
+
} catch (e) {
|
|
1624
|
+
reject(e);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
whileElement(matcher) {
|
|
1628
|
+
return new SootSimWaitForScrollAction(
|
|
1629
|
+
this.assertionFn,
|
|
1630
|
+
this.findNode,
|
|
1631
|
+
this.getPage,
|
|
1632
|
+
matcher
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
var SootSimWaitForScrollAction = class {
|
|
1637
|
+
assertionFn;
|
|
1638
|
+
findNode;
|
|
1639
|
+
getPage;
|
|
1640
|
+
matcher;
|
|
1641
|
+
constructor(assertionFn, findNode, getPage2, matcher) {
|
|
1642
|
+
this.assertionFn = assertionFn;
|
|
1643
|
+
this.findNode = findNode;
|
|
1644
|
+
this.getPage = getPage2;
|
|
1645
|
+
this.matcher = matcher;
|
|
1646
|
+
}
|
|
1647
|
+
// detox scrolls the container until the expectation passes or the container
|
|
1648
|
+
// can no longer move. bounding this by wall clock instead made the result
|
|
1649
|
+
// depend on how fast the host dispatches pointer events: the same fixture and
|
|
1650
|
+
// the same assertion pass on an idle machine and fail on a loaded one,
|
|
1651
|
+
// because each 200px drag is a real pointer stream and fewer of them fit in
|
|
1652
|
+
// the budget. end on scroll exhaustion, which is a property of the content.
|
|
1653
|
+
async scroll(pixels, direction, startPositionX, startPositionY) {
|
|
1654
|
+
const page = this.getPage();
|
|
1655
|
+
let lastError = null;
|
|
1656
|
+
let stalledSteps = 0;
|
|
1657
|
+
while (stalledSteps < 2) {
|
|
1658
|
+
try {
|
|
1659
|
+
await this.assertionFn();
|
|
1660
|
+
return;
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
lastError = error;
|
|
1663
|
+
}
|
|
1664
|
+
const travelled = await scrollMatcherElement(
|
|
1665
|
+
page,
|
|
1666
|
+
this.findNode,
|
|
1667
|
+
this.matcher,
|
|
1668
|
+
pixels,
|
|
1669
|
+
direction,
|
|
1670
|
+
startPositionX,
|
|
1671
|
+
startPositionY
|
|
1672
|
+
);
|
|
1673
|
+
stalledSteps = travelled < 0.5 ? stalledSteps + 1 : 0;
|
|
1674
|
+
}
|
|
1675
|
+
throw lastError || new Error(
|
|
1676
|
+
`waitFor whileElement(...).scroll() reached the end of ${JSON.stringify(this.matcher)}`
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1681
|
+
// detox/matchers.ts
|
|
1682
|
+
var by = {
|
|
1683
|
+
id(testID) {
|
|
1684
|
+
return { type: "id", value: testID };
|
|
1685
|
+
},
|
|
1686
|
+
text(text) {
|
|
1687
|
+
return { type: "text", value: text };
|
|
1688
|
+
},
|
|
1689
|
+
label(accessibilityLabel) {
|
|
1690
|
+
return { type: "label", value: accessibilityLabel };
|
|
1691
|
+
},
|
|
1692
|
+
type(nativeType) {
|
|
1693
|
+
return { type: "type", value: nativeType };
|
|
1694
|
+
},
|
|
1695
|
+
role(accessibilityRole) {
|
|
1696
|
+
return { type: "role", value: accessibilityRole };
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
// detox/index.ts
|
|
1701
|
+
var import_motion_change = __toESM(require_motion_change(), 1);
|
|
1702
|
+
|
|
1703
|
+
// detox/navigation-lifecycle.ts
|
|
1704
|
+
var import_attempt_events = __toESM(require_attempt_events(), 1);
|
|
1705
|
+
var DIAGNOSTIC_LIMIT = 40;
|
|
1706
|
+
var pageDiagnostics = /* @__PURE__ */ new WeakMap();
|
|
1707
|
+
var activeNavigations = /* @__PURE__ */ new WeakMap();
|
|
1708
|
+
var lastNavigations = /* @__PURE__ */ new WeakMap();
|
|
1709
|
+
var nextNavigationIdentity = 0;
|
|
1710
|
+
function recordPageDiagnostic(page, diagnostic, persist = false) {
|
|
1711
|
+
const entries = pageDiagnostics.get(page);
|
|
1712
|
+
if (!entries) return;
|
|
1713
|
+
entries.push(diagnostic);
|
|
1714
|
+
if (entries.length > DIAGNOSTIC_LIMIT) entries.shift();
|
|
1715
|
+
if (persist) (0, import_attempt_events.writeAttemptEvent)(diagnostic);
|
|
1716
|
+
}
|
|
1717
|
+
function isTopLevelDocumentRequest(page, request) {
|
|
1718
|
+
return request.isNavigationRequest() && request.resourceType() === "document" && request.frame() === page.mainFrame();
|
|
1719
|
+
}
|
|
1720
|
+
function recordNavigationMilestoneFor(page, active, milestone, details = {}) {
|
|
1721
|
+
recordPageDiagnostic(
|
|
1722
|
+
page,
|
|
1723
|
+
{
|
|
1724
|
+
...active,
|
|
1725
|
+
...details,
|
|
1726
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1727
|
+
milestone,
|
|
1728
|
+
target: process.env.CONFORMANCE_PROOF_TARGET || "sootsim",
|
|
1729
|
+
type: "rnx-navigation",
|
|
1730
|
+
url: details.url ?? active.navigationUrl
|
|
1731
|
+
},
|
|
1732
|
+
true
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
function recordNavigationMilestone(page, milestone, details = {}) {
|
|
1736
|
+
const active = activeNavigations.get(page);
|
|
1737
|
+
if (active) recordNavigationMilestoneFor(page, active, milestone, details);
|
|
1738
|
+
}
|
|
1739
|
+
function attachPageDiagnostics(page) {
|
|
1740
|
+
if (pageDiagnostics.has(page)) return;
|
|
1741
|
+
pageDiagnostics.set(page, []);
|
|
1742
|
+
page.on("console", (message) => {
|
|
1743
|
+
recordPageDiagnostic(page, {
|
|
1744
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1745
|
+
level: message.type(),
|
|
1746
|
+
message: message.text(),
|
|
1747
|
+
type: "page-console"
|
|
1748
|
+
});
|
|
1749
|
+
});
|
|
1750
|
+
page.on("pageerror", (error) => {
|
|
1751
|
+
recordPageDiagnostic(page, {
|
|
1752
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1753
|
+
message: error.message,
|
|
1754
|
+
type: "page-error"
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
page.on("request", (request) => {
|
|
1758
|
+
if (!isTopLevelDocumentRequest(page, request)) return;
|
|
1759
|
+
recordNavigationMilestone(page, "document-request", {
|
|
1760
|
+
method: request.method(),
|
|
1761
|
+
url: request.url()
|
|
1762
|
+
});
|
|
1763
|
+
});
|
|
1764
|
+
page.on("response", (response) => {
|
|
1765
|
+
const request = response.request();
|
|
1766
|
+
if (!isTopLevelDocumentRequest(page, request)) return;
|
|
1767
|
+
recordNavigationMilestone(page, "document-response", {
|
|
1768
|
+
headersReceived: true,
|
|
1769
|
+
status: response.status(),
|
|
1770
|
+
url: request.url()
|
|
1771
|
+
});
|
|
1772
|
+
});
|
|
1773
|
+
page.on("requestfinished", (request) => {
|
|
1774
|
+
if (!isTopLevelDocumentRequest(page, request)) return;
|
|
1775
|
+
recordNavigationMilestone(page, "document-request-finished", { url: request.url() });
|
|
1776
|
+
});
|
|
1777
|
+
page.on("requestfailed", (request) => {
|
|
1778
|
+
const failure = request.failure();
|
|
1779
|
+
const errorText = failure?.errorText ?? "";
|
|
1780
|
+
if (isTopLevelDocumentRequest(page, request)) {
|
|
1781
|
+
recordNavigationMilestone(page, "document-request-failed", {
|
|
1782
|
+
errorText,
|
|
1783
|
+
url: request.url()
|
|
1784
|
+
});
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
recordPageDiagnostic(page, {
|
|
1788
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1789
|
+
errorText,
|
|
1790
|
+
method: request.method(),
|
|
1791
|
+
type: "subresource-request-failed",
|
|
1792
|
+
url: request.url()
|
|
1793
|
+
});
|
|
1794
|
+
});
|
|
1795
|
+
page.on("domcontentloaded", () => {
|
|
1796
|
+
recordNavigationMilestone(page, "domcontentloaded");
|
|
1797
|
+
});
|
|
1798
|
+
page.on("load", () => {
|
|
1799
|
+
recordNavigationMilestone(page, "load");
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
async function readSootsimPageState(page) {
|
|
1803
|
+
try {
|
|
1804
|
+
return await page.evaluate(() => ({
|
|
1805
|
+
bodyText: document.body?.innerText?.slice(0, 500) ?? "",
|
|
1806
|
+
bridges: {
|
|
1807
|
+
sootsim: Boolean(window.SootSim),
|
|
1808
|
+
test: Boolean(window.__sootsimTest)
|
|
1809
|
+
},
|
|
1810
|
+
globals: Object.keys(window).filter((key) => key.startsWith("__sootsim") || key === "SootSim").sort(),
|
|
1811
|
+
readyState: document.readyState,
|
|
1812
|
+
title: document.title,
|
|
1813
|
+
url: window.location.href
|
|
1814
|
+
}));
|
|
1815
|
+
} catch (error) {
|
|
1816
|
+
return {
|
|
1817
|
+
evaluateError: error instanceof Error ? error.message : String(error)
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
async function describeSootsimBridgeFailure(page, cause, summary = "timed out waiting for sootsim test bridge", pageState) {
|
|
1822
|
+
const recordBridgeFailure = pageState === void 0;
|
|
1823
|
+
const state = pageState ?? await readSootsimPageState(page);
|
|
1824
|
+
const navigation = lastNavigations.get(page);
|
|
1825
|
+
if (recordBridgeFailure && navigation) {
|
|
1826
|
+
recordNavigationMilestoneFor(page, navigation, "error", {
|
|
1827
|
+
errorText: cause instanceof Error ? cause.message : String(cause),
|
|
1828
|
+
failurePhase: "bridge",
|
|
1829
|
+
pageState: state
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
return new Error(
|
|
1833
|
+
[
|
|
1834
|
+
summary,
|
|
1835
|
+
`cause: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
1836
|
+
`page: ${JSON.stringify(state)}`,
|
|
1837
|
+
`recent page diagnostics:
|
|
1838
|
+
${JSON.stringify(getSootsimNavigationDiagnostics(page))}`
|
|
1839
|
+
].join("\n")
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
async function navigateSootsimPage(page, url, identity) {
|
|
1843
|
+
attachPageDiagnostics(page);
|
|
1844
|
+
const navigation = {
|
|
1845
|
+
...identity,
|
|
1846
|
+
navigationUrl: url,
|
|
1847
|
+
navigationId: ++nextNavigationIdentity
|
|
1848
|
+
};
|
|
1849
|
+
activeNavigations.set(page, navigation);
|
|
1850
|
+
lastNavigations.set(page, navigation);
|
|
1851
|
+
recordNavigationMilestone(page, "start");
|
|
1852
|
+
try {
|
|
1853
|
+
return await page.goto(url, { waitUntil: "load", timeout: 3e4 });
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
const pageState = await readSootsimPageState(page);
|
|
1856
|
+
recordNavigationMilestone(page, "error", {
|
|
1857
|
+
errorText: error instanceof Error ? error.message : String(error),
|
|
1858
|
+
failurePhase: "navigation",
|
|
1859
|
+
pageState
|
|
1860
|
+
});
|
|
1861
|
+
throw await describeSootsimBridgeFailure(
|
|
1862
|
+
page,
|
|
1863
|
+
error,
|
|
1864
|
+
"sootsim navigation failed",
|
|
1865
|
+
pageState
|
|
1866
|
+
);
|
|
1867
|
+
} finally {
|
|
1868
|
+
activeNavigations.delete(page);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
function getSootsimNavigationDiagnostics(page) {
|
|
1872
|
+
return [...pageDiagnostics.get(page) ?? []];
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// detox/index.ts
|
|
1876
|
+
var import_proof_frame = __toESM(require_proof_frame(), 1);
|
|
1877
|
+
var BASE_URL = process.env.RNX_URL || "http://localhost:5173";
|
|
1878
|
+
var SCREENSHOT_DIR = process.env.RNX_SCREENSHOT_DIR || path.join(process.cwd(), "test", "detox-driver", "screenshots");
|
|
1879
|
+
var DETOX_PLATFORM = process.env.RNX_PLATFORM === "android" ? "android" : "ios";
|
|
1880
|
+
var _browser = null;
|
|
1881
|
+
var _reapBrowserNow = null;
|
|
1882
|
+
var _context = null;
|
|
1883
|
+
var _page = null;
|
|
1884
|
+
var _synchronizationEnabled = false;
|
|
1885
|
+
var _suspendedAppId = null;
|
|
1886
|
+
var _browserCompromised = null;
|
|
1887
|
+
var browserIdentity = 0;
|
|
1888
|
+
var contextIdentity = 0;
|
|
1889
|
+
var nextBrowserIdentity = 0;
|
|
1890
|
+
var nextContextIdentity = 0;
|
|
1891
|
+
var BROWSER_LIFECYCLE_TIMEOUT_MS = 3e4;
|
|
1892
|
+
async function withLifecycleTimeout(actionName, targetContextId, fn, timeoutMs = BROWSER_LIFECYCLE_TIMEOUT_MS) {
|
|
1893
|
+
const start = Date.now();
|
|
1894
|
+
let timer;
|
|
1895
|
+
try {
|
|
1896
|
+
return await Promise.race([
|
|
1897
|
+
fn(),
|
|
1898
|
+
new Promise((_, reject) => {
|
|
1899
|
+
timer = setTimeout(() => {
|
|
1900
|
+
const elapsed = Date.now() - start;
|
|
1901
|
+
reject(
|
|
1902
|
+
new Error(
|
|
1903
|
+
`sootsim browser lifecycle error: ${actionName} did not settle after ${elapsed}ms (context=${targetContextId})`
|
|
1904
|
+
)
|
|
1905
|
+
);
|
|
1906
|
+
}, timeoutMs);
|
|
1907
|
+
})
|
|
1908
|
+
]);
|
|
1909
|
+
} finally {
|
|
1910
|
+
if (timer) clearTimeout(timer);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
var STATUS_BAR_OVERRIDE_EVENT = "sootsim:statusBarOverride";
|
|
1914
|
+
function getPage() {
|
|
1915
|
+
if (_browserCompromised) {
|
|
1916
|
+
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`);
|
|
1917
|
+
}
|
|
1918
|
+
if (!_page)
|
|
1919
|
+
throw new Error("sootsim driver not initialized -- call device.launchApp() first");
|
|
1920
|
+
return _page;
|
|
1921
|
+
}
|
|
1922
|
+
async function waitForSootsimTree(page, timeout = 3e4) {
|
|
1923
|
+
try {
|
|
1924
|
+
await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
|
|
1925
|
+
timeout
|
|
1926
|
+
});
|
|
1927
|
+
await page.evaluate(async (timeoutMs) => {
|
|
1928
|
+
await Promise.race([
|
|
1929
|
+
window.__sootsimTest.waitForTree(),
|
|
1930
|
+
new Promise((_, reject) => {
|
|
1931
|
+
setTimeout(
|
|
1932
|
+
() => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)),
|
|
1933
|
+
timeoutMs
|
|
1934
|
+
);
|
|
1935
|
+
})
|
|
1936
|
+
]);
|
|
1937
|
+
}, timeout);
|
|
1938
|
+
} catch (error) {
|
|
1939
|
+
throw await describeSootsimBridgeFailure(page, error);
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
async function waitForSootsimSurfaceMetrics(page, timeout = 3e4) {
|
|
1943
|
+
const deadline = Date.now() + timeout;
|
|
1944
|
+
let lastSnapshot = null;
|
|
1945
|
+
while (Date.now() < deadline) {
|
|
1946
|
+
lastSnapshot = await page.evaluate(async () => {
|
|
1947
|
+
const metricsWindow = (value) => {
|
|
1948
|
+
if (!value || typeof value !== "object") return null;
|
|
1949
|
+
const windowValue = value.window;
|
|
1950
|
+
if (!windowValue || typeof windowValue !== "object") return null;
|
|
1951
|
+
return windowValue;
|
|
1952
|
+
};
|
|
1953
|
+
const snapshot = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.();
|
|
1954
|
+
const windowMetrics = metricsWindow(snapshot);
|
|
1955
|
+
const width = windowMetrics?.width;
|
|
1956
|
+
const height = windowMetrics?.height;
|
|
1957
|
+
if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
|
|
1958
|
+
return { ready: true };
|
|
1959
|
+
}
|
|
1960
|
+
return { ready: false, snapshot };
|
|
1961
|
+
});
|
|
1962
|
+
if (lastSnapshot && typeof lastSnapshot === "object" && lastSnapshot.ready) {
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
await page.waitForTimeout(50);
|
|
1966
|
+
}
|
|
1967
|
+
throw new Error(
|
|
1968
|
+
`timed out waiting for sootsim surface metrics: ${JSON.stringify(lastSnapshot)}`
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
async function waitForSootsimShellReady(page, timeout = 3e4) {
|
|
1972
|
+
try {
|
|
1973
|
+
await page.evaluate(async (timeoutMs) => {
|
|
1974
|
+
const start = Date.now();
|
|
1975
|
+
const deadline = start + timeoutMs;
|
|
1976
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1977
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
1978
|
+
const withTimeout = async (label, promise) => {
|
|
1979
|
+
let timer = null;
|
|
1980
|
+
try {
|
|
1981
|
+
await Promise.race([
|
|
1982
|
+
promise,
|
|
1983
|
+
new Promise((_, reject) => {
|
|
1984
|
+
timer = setTimeout(
|
|
1985
|
+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
|
1986
|
+
remaining()
|
|
1987
|
+
);
|
|
1988
|
+
})
|
|
1989
|
+
]);
|
|
1990
|
+
} finally {
|
|
1991
|
+
if (timer) clearTimeout(timer);
|
|
1992
|
+
}
|
|
1993
|
+
};
|
|
1994
|
+
const isTenantOnly = new URL(window.location.href).searchParams.get("renderMode") === "tenant-only";
|
|
1995
|
+
const currentUrl = new URL(window.location.href);
|
|
1996
|
+
const expectsInitialAppPaint = currentUrl.searchParams.has("test") || currentUrl.pathname.includes("/app/");
|
|
1997
|
+
let shellHost = null;
|
|
1998
|
+
while (Date.now() < deadline) {
|
|
1999
|
+
const candidate = window.SootSim?.bridges?.shellHost;
|
|
2000
|
+
if (candidate || isTenantOnly) {
|
|
2001
|
+
shellHost = candidate ?? null;
|
|
2002
|
+
break;
|
|
2003
|
+
}
|
|
2004
|
+
await sleep(25);
|
|
2005
|
+
}
|
|
2006
|
+
if (!shellHost) {
|
|
2007
|
+
if (isTenantOnly) return;
|
|
2008
|
+
throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`);
|
|
2009
|
+
}
|
|
2010
|
+
if (shellHost.ready && typeof shellHost.ready.then === "function") {
|
|
2011
|
+
await withTimeout("sootsim shell ready", shellHost.ready);
|
|
2012
|
+
}
|
|
2013
|
+
if (!expectsInitialAppPaint && shellHost.firstContentPainted && typeof shellHost.firstContentPainted.then === "function") {
|
|
2014
|
+
await withTimeout(
|
|
2015
|
+
"sootsim shell first content paint",
|
|
2016
|
+
shellHost.firstContentPainted
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
const waitForScreenTransitions = window.__sootsimTest?.waitForScreenTransitions;
|
|
2020
|
+
if (typeof waitForScreenTransitions === "function") {
|
|
2021
|
+
const transitionResult = await waitForScreenTransitions({
|
|
2022
|
+
timeoutMs: Math.min(remaining(), 3e3),
|
|
2023
|
+
settleMs: 64,
|
|
2024
|
+
startWindowMs: 700
|
|
2025
|
+
});
|
|
2026
|
+
if (transitionResult?.timedOut === true) {
|
|
2027
|
+
throw new Error(
|
|
2028
|
+
`sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}, timeout);
|
|
2033
|
+
} catch (error) {
|
|
2034
|
+
throw await describeSootsimBridgeFailure(page, error);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
async function waitForSootsimWorkletsIdle(page, timeout = 3e3) {
|
|
2038
|
+
const result = await page.evaluate(
|
|
2039
|
+
async ({ timeout: timeout2 }) => {
|
|
2040
|
+
const getPeerStats = window.__sootsimTest?.getPeerStats;
|
|
2041
|
+
if (typeof getPeerStats !== "function") {
|
|
2042
|
+
return { settled: true, skipped: true };
|
|
2043
|
+
}
|
|
2044
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2045
|
+
const readStats = () => Promise.race([
|
|
2046
|
+
getPeerStats(false),
|
|
2047
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 250))
|
|
2048
|
+
]);
|
|
2049
|
+
const start = Date.now();
|
|
2050
|
+
const deadline = start + timeout2;
|
|
2051
|
+
const requiredStableMs = 120;
|
|
2052
|
+
let stableSince = null;
|
|
2053
|
+
let lastPending = 0;
|
|
2054
|
+
await sleep(0);
|
|
2055
|
+
while (Date.now() < deadline) {
|
|
2056
|
+
const stats = await readStats();
|
|
2057
|
+
const pending = stats && typeof stats === "object" && typeof stats.pendingRunWorklets === "number" ? stats.pendingRunWorklets : 0;
|
|
2058
|
+
lastPending = pending;
|
|
2059
|
+
if (pending === 0) {
|
|
2060
|
+
stableSince ??= Date.now();
|
|
2061
|
+
if (Date.now() - stableSince >= requiredStableMs) {
|
|
2062
|
+
return { settled: true, elapsed: Date.now() - start, pending };
|
|
2063
|
+
}
|
|
2064
|
+
} else {
|
|
2065
|
+
stableSince = null;
|
|
2066
|
+
}
|
|
2067
|
+
await sleep(25);
|
|
2068
|
+
}
|
|
2069
|
+
return {
|
|
2070
|
+
settled: false,
|
|
2071
|
+
elapsed: Date.now() - start,
|
|
2072
|
+
pending: lastPending
|
|
2073
|
+
};
|
|
2074
|
+
},
|
|
2075
|
+
{ timeout }
|
|
2076
|
+
);
|
|
2077
|
+
if (!result?.settled) {
|
|
2078
|
+
throw new Error(
|
|
2079
|
+
`sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${result?.pending ?? "unknown"})`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
function reapOwnedBrowser() {
|
|
2084
|
+
const reap = _reapBrowserNow;
|
|
2085
|
+
_reapBrowserNow = null;
|
|
2086
|
+
_browser = null;
|
|
2087
|
+
try {
|
|
2088
|
+
reap?.();
|
|
2089
|
+
} catch {
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
async function closeContext() {
|
|
2093
|
+
if (_browserCompromised) {
|
|
2094
|
+
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`);
|
|
2095
|
+
}
|
|
2096
|
+
if (!_context) return;
|
|
2097
|
+
const context = _context;
|
|
2098
|
+
const closingContextId = contextIdentity;
|
|
2099
|
+
try {
|
|
2100
|
+
await withLifecycleTimeout("context.close()", closingContextId, () => context.close());
|
|
2101
|
+
_context = null;
|
|
2102
|
+
_page = null;
|
|
2103
|
+
contextIdentity = 0;
|
|
2104
|
+
_synchronizationEnabled = false;
|
|
2105
|
+
} catch (error) {
|
|
2106
|
+
_browserCompromised = error instanceof Error ? error.message : String(error);
|
|
2107
|
+
reapOwnedBrowser();
|
|
2108
|
+
throw error;
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
async function closeBrowser() {
|
|
2112
|
+
if (!_browser) return;
|
|
2113
|
+
const browser = _browser;
|
|
2114
|
+
const reapBrowserNow = _reapBrowserNow;
|
|
2115
|
+
_browser = null;
|
|
2116
|
+
_reapBrowserNow = null;
|
|
2117
|
+
browserIdentity = 0;
|
|
2118
|
+
let closeError = null;
|
|
2119
|
+
try {
|
|
2120
|
+
await closeContext();
|
|
2121
|
+
} catch (error) {
|
|
2122
|
+
closeError = error instanceof Error ? error : new Error(String(error));
|
|
2123
|
+
}
|
|
2124
|
+
try {
|
|
2125
|
+
reapBrowserNow?.();
|
|
2126
|
+
} catch {
|
|
2127
|
+
}
|
|
2128
|
+
try {
|
|
2129
|
+
await browser.close();
|
|
2130
|
+
} catch {
|
|
2131
|
+
}
|
|
2132
|
+
if (closeError) {
|
|
2133
|
+
throw closeError;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
async function waitForSootsimIdle(page, maxMs = 3e3, strict = false) {
|
|
2137
|
+
const result = await page.evaluate(
|
|
2138
|
+
async ({ maxMs: maxMs2, strict: strict2 }) => {
|
|
2139
|
+
const start = Date.now();
|
|
2140
|
+
let transitionError = null;
|
|
2141
|
+
try {
|
|
2142
|
+
const waitForScreenTransitions = window.__sootsimTest?.waitForScreenTransitions;
|
|
2143
|
+
if (typeof waitForScreenTransitions === "function") {
|
|
2144
|
+
const transitionResult = await waitForScreenTransitions({
|
|
2145
|
+
timeoutMs: Math.min(maxMs2, 1800),
|
|
2146
|
+
settleMs: 48,
|
|
2147
|
+
startWindowMs: 600
|
|
2148
|
+
});
|
|
2149
|
+
if (transitionResult?.timedOut) {
|
|
2150
|
+
return {
|
|
2151
|
+
settled: false,
|
|
2152
|
+
elapsed: transitionResult.waitedMs,
|
|
2153
|
+
reason: "screen transition"
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
transitionError = error instanceof Error ? error.message : String(error);
|
|
2159
|
+
}
|
|
2160
|
+
const deadline = start + maxMs2;
|
|
2161
|
+
const pollMs = 50;
|
|
2162
|
+
const requiredStablePolls = 3;
|
|
2163
|
+
const layoutTolerance = 1;
|
|
2164
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2165
|
+
const isLayoutStable = (a, b) => {
|
|
2166
|
+
if (!a || a.length !== b.length) return false;
|
|
2167
|
+
for (let i = 0; i < b.length; i++) {
|
|
2168
|
+
if (Math.abs(a[i] - b[i]) > layoutTolerance) return false;
|
|
2169
|
+
}
|
|
2170
|
+
return true;
|
|
2171
|
+
};
|
|
2172
|
+
const readSnapshot = async () => {
|
|
2173
|
+
const root = window.__sootsimRoot;
|
|
2174
|
+
const layout = [];
|
|
2175
|
+
let animating = false;
|
|
2176
|
+
let auxSurfaceDebug = null;
|
|
2177
|
+
let layoutDirty = false;
|
|
2178
|
+
let renderStatsAvailable = false;
|
|
2179
|
+
if (strict2) {
|
|
2180
|
+
try {
|
|
2181
|
+
const renderHost = Reflect.get(window, "__sootsimRenderHost");
|
|
2182
|
+
const queryStats = typeof renderHost === "object" && renderHost !== null ? Reflect.get(renderHost, "queryStats") : null;
|
|
2183
|
+
const stats = typeof queryStats === "function" ? await queryStats.call(renderHost) : null;
|
|
2184
|
+
renderStatsAvailable = !!stats;
|
|
2185
|
+
auxSurfaceDebug = stats?.auxSurfaceDebug ?? null;
|
|
2186
|
+
animating = stats?.hasActiveAnims === true || stats?.hasActiveNativeAnimations === true || stats?.hasPendingAnimationFrames === true;
|
|
2187
|
+
layoutDirty = stats?.layoutDirty === true && stats?.renderRequested === true;
|
|
2188
|
+
} catch {
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
if (root) {
|
|
2192
|
+
const walk = (node) => {
|
|
2193
|
+
if (node.layout && node.layout.width > 0) {
|
|
2194
|
+
layout.push(
|
|
2195
|
+
Math.round(node.layout.x),
|
|
2196
|
+
Math.round(node.layout.y),
|
|
2197
|
+
Math.round(node.layout.width),
|
|
2198
|
+
Math.round(node.layout.height)
|
|
2199
|
+
);
|
|
2200
|
+
}
|
|
2201
|
+
for (const child of node.children || []) walk(child);
|
|
2202
|
+
};
|
|
2203
|
+
walk(root);
|
|
2204
|
+
}
|
|
2205
|
+
return {
|
|
2206
|
+
animating,
|
|
2207
|
+
auxSurfaceDebug,
|
|
2208
|
+
layout,
|
|
2209
|
+
layoutDirty,
|
|
2210
|
+
renderStatsAvailable
|
|
2211
|
+
};
|
|
2212
|
+
};
|
|
2213
|
+
let stableLayout = null;
|
|
2214
|
+
let stable = 0;
|
|
2215
|
+
let lastSnapshot = null;
|
|
2216
|
+
while (Date.now() < deadline) {
|
|
2217
|
+
const snapshot = await readSnapshot();
|
|
2218
|
+
lastSnapshot = snapshot;
|
|
2219
|
+
if ((!strict2 || snapshot.renderStatsAvailable) && !snapshot.animating && !snapshot.layoutDirty && isLayoutStable(stableLayout, snapshot.layout)) {
|
|
2220
|
+
stable++;
|
|
2221
|
+
if (stable >= requiredStablePolls) {
|
|
2222
|
+
return { settled: true, elapsed: Date.now() - start };
|
|
2223
|
+
}
|
|
2224
|
+
} else {
|
|
2225
|
+
stableLayout = snapshot.layout;
|
|
2226
|
+
stable = 0;
|
|
2227
|
+
}
|
|
2228
|
+
await sleep(pollMs);
|
|
2229
|
+
}
|
|
2230
|
+
return {
|
|
2231
|
+
settled: false,
|
|
2232
|
+
elapsed: Date.now() - start,
|
|
2233
|
+
reason: transitionError ? `layout fallback after screen transition wait failed: ${transitionError}` : strict2 ? `strict idle state unavailable: renderStats=${lastSnapshot?.renderStatsAvailable === true} animating=${lastSnapshot?.animating === true} layoutDirty=${lastSnapshot?.layoutDirty === true} layoutNodes=${(lastSnapshot?.layout.length ?? 0) / 4} aux=${JSON.stringify(lastSnapshot?.auxSurfaceDebug)}` : "layout fallback"
|
|
2234
|
+
};
|
|
2235
|
+
},
|
|
2236
|
+
{ maxMs, strict }
|
|
2237
|
+
);
|
|
2238
|
+
if (!result?.settled) {
|
|
2239
|
+
throw new Error(
|
|
2240
|
+
`sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ""}`
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async function findNodeByMatcher(matcher) {
|
|
2245
|
+
const page = getPage();
|
|
2246
|
+
if (matcher.index !== void 0) {
|
|
2247
|
+
return page.evaluate(async (indexedMatcher) => {
|
|
2248
|
+
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 };
|
|
2249
|
+
const results = await window.__sootsimTest.queryAll(filter);
|
|
2250
|
+
const indexed = indexedMatcher.type === "text" ? results.filter(
|
|
2251
|
+
(node) => node.type === "text" && node.text === indexedMatcher.value
|
|
2252
|
+
) : results;
|
|
2253
|
+
return indexed[indexedMatcher.index ?? 0] ?? null;
|
|
2254
|
+
}, matcher);
|
|
2255
|
+
}
|
|
2256
|
+
if (matcher.type === "id") {
|
|
2257
|
+
return page.evaluate(
|
|
2258
|
+
(id) => window.__sootsimTest.findByTestId(id),
|
|
2259
|
+
matcher.value
|
|
2260
|
+
);
|
|
2261
|
+
} else if (matcher.type === "text") {
|
|
2262
|
+
return page.evaluate(
|
|
2263
|
+
(text) => window.__sootsimTest.findByText(text),
|
|
2264
|
+
matcher.value
|
|
2265
|
+
);
|
|
2266
|
+
} else if (matcher.type === "label") {
|
|
2267
|
+
return page.evaluate(
|
|
2268
|
+
(label) => window.__sootsimTest.findByLabel(label),
|
|
2269
|
+
matcher.value
|
|
2270
|
+
);
|
|
2271
|
+
} else if (matcher.type === "role") {
|
|
2272
|
+
return page.evaluate(
|
|
2273
|
+
(role) => window.__sootsimTest.findByRole(role),
|
|
2274
|
+
matcher.value
|
|
2275
|
+
);
|
|
2276
|
+
} else if (matcher.type === "type") {
|
|
2277
|
+
return page.evaluate(async (type) => {
|
|
2278
|
+
const results = await window.__sootsimTest.queryAll({ type });
|
|
2279
|
+
return results[0] || null;
|
|
2280
|
+
}, matcher.value);
|
|
2281
|
+
}
|
|
2282
|
+
return null;
|
|
2283
|
+
}
|
|
2284
|
+
function getNodeCenter(nodeInfo) {
|
|
2285
|
+
return {
|
|
2286
|
+
x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
|
|
2287
|
+
y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
async function nodeOffsetToPage(page, nodeInfo, dx, dy) {
|
|
2291
|
+
const center = getNodeCenter(nodeInfo);
|
|
2292
|
+
return sootsimToPage(page, center.x + dx, center.y + dy);
|
|
2293
|
+
}
|
|
2294
|
+
async function getDefaultTapPoint(page, nodeInfo) {
|
|
2295
|
+
const resolvedTarget = await page.evaluate(
|
|
2296
|
+
(nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null,
|
|
2297
|
+
nodeInfo.nodeId
|
|
2298
|
+
);
|
|
2299
|
+
const center = resolvedTarget && typeof resolvedTarget.cx === "number" && typeof resolvedTarget.cy === "number" ? { x: resolvedTarget.cx, y: resolvedTarget.cy } : getNodeCenter(nodeInfo);
|
|
2300
|
+
const frame = nodeInfo.visibleFrame ?? {
|
|
2301
|
+
x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
|
|
2302
|
+
y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
|
|
2303
|
+
width: nodeInfo.layout?.width ?? 0,
|
|
2304
|
+
height: nodeInfo.layout?.height ?? 0
|
|
2305
|
+
};
|
|
2306
|
+
const viewport = await readSootsimInteractiveViewport(page);
|
|
2307
|
+
const fullFrameTolerance = 0.5;
|
|
2308
|
+
const canTapCenter = 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 && center.x >= 0 && center.y >= 0 && center.x < viewport.width && center.y < viewport.height;
|
|
2309
|
+
if (canTapCenter) return center;
|
|
2310
|
+
const left = Math.max(0, frame.x);
|
|
2311
|
+
const top = Math.max(0, frame.y);
|
|
2312
|
+
const right = Math.min(viewport.width, frame.x + frame.width);
|
|
2313
|
+
const bottom = Math.min(viewport.height, frame.y + frame.height);
|
|
2314
|
+
if (right <= left || bottom <= top) {
|
|
2315
|
+
return center;
|
|
2316
|
+
}
|
|
2317
|
+
return {
|
|
2318
|
+
x: left + (right - left) / 2,
|
|
2319
|
+
y: top + (bottom - top) / 2
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
async function dispatchTapAtSootsimPoint(page, x, y) {
|
|
2323
|
+
await page.evaluate(
|
|
2324
|
+
async ({ x: x2, y: y2 }) => {
|
|
2325
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap;
|
|
2326
|
+
if (typeof interactTap === "function") {
|
|
2327
|
+
const result = await interactTap(x2, y2);
|
|
2328
|
+
if (result && (typeof result !== "object" || result.hit !== false)) return;
|
|
2329
|
+
}
|
|
2330
|
+
const tap = window.__sootsimTest?.tap;
|
|
2331
|
+
if (typeof tap !== "function") {
|
|
2332
|
+
throw new Error("sootsim tap bridge is not installed");
|
|
2333
|
+
}
|
|
2334
|
+
await tap(x2, y2);
|
|
2335
|
+
},
|
|
2336
|
+
{ x, y }
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
async function isFocusedTextInputNode(page, nodeInfo) {
|
|
2340
|
+
const focused = await page.evaluate(
|
|
2341
|
+
() => window.__sootsimTest.getFocusedNode?.() ?? null
|
|
2342
|
+
);
|
|
2343
|
+
return !!focused && typeof focused === "object" && focused.nodeId === nodeInfo?.nodeId;
|
|
2344
|
+
}
|
|
2345
|
+
async function focusElementForTextEntry(el) {
|
|
2346
|
+
const page = getPage();
|
|
2347
|
+
const node = await findNodeByMatcher(el._matcher);
|
|
2348
|
+
if (!node) {
|
|
2349
|
+
throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`);
|
|
2350
|
+
}
|
|
2351
|
+
if (await isFocusedTextInputNode(page, node)) return;
|
|
2352
|
+
await el.tap();
|
|
2353
|
+
await page.waitForTimeout(100);
|
|
2354
|
+
}
|
|
2355
|
+
function ensureScreenshotDir() {
|
|
2356
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
2357
|
+
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
function decodePngDataUrl(dataUrl) {
|
|
2361
|
+
const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl);
|
|
2362
|
+
if (!match) {
|
|
2363
|
+
throw new Error(
|
|
2364
|
+
`rnx screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
return Buffer.from(match[1], "base64");
|
|
2368
|
+
}
|
|
2369
|
+
function writeFastScreenshotFrames(namePrefix, frames) {
|
|
2370
|
+
ensureScreenshotDir();
|
|
2371
|
+
return frames.map((frame) => {
|
|
2372
|
+
const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
2373
|
+
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`);
|
|
2374
|
+
fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl));
|
|
2375
|
+
const { dataUrl: _dataUrl, ...metadata } = frame;
|
|
2376
|
+
return {
|
|
2377
|
+
...metadata,
|
|
2378
|
+
path: filePath
|
|
2379
|
+
};
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
async function captureSootsimPngFast(opts) {
|
|
2383
|
+
const page = getPage();
|
|
2384
|
+
const dataUrl = await page.evaluate(
|
|
2385
|
+
async (captureOpts) => {
|
|
2386
|
+
const screenshot = window.SootSim?.bridges?.screenshot;
|
|
2387
|
+
if (typeof screenshot !== "function") {
|
|
2388
|
+
throw new Error("rnx screenshot bridge is not installed");
|
|
2389
|
+
}
|
|
2390
|
+
return screenshot(captureOpts);
|
|
2391
|
+
},
|
|
2392
|
+
{ crop: opts?.crop, format: "png" }
|
|
2393
|
+
);
|
|
2394
|
+
if (!dataUrl) {
|
|
2395
|
+
throw new Error("rnx screenshot bridge returned an empty image");
|
|
2396
|
+
}
|
|
2397
|
+
return decodePngDataUrl(dataUrl);
|
|
2398
|
+
}
|
|
2399
|
+
async function setSootsimCursorCaptureActive(page, active) {
|
|
2400
|
+
await page.evaluate(async (captureActive) => {
|
|
2401
|
+
const bridge = window.__sootsimTest;
|
|
2402
|
+
if (typeof bridge?.setCursorCaptureActive !== "function") {
|
|
2403
|
+
throw new Error("sootsim cursor capture control is not installed");
|
|
2404
|
+
}
|
|
2405
|
+
await bridge.setCursorCaptureActive(captureActive);
|
|
2406
|
+
}, active);
|
|
2407
|
+
}
|
|
2408
|
+
async function captureSootsimPng(opts) {
|
|
2409
|
+
const page = getPage();
|
|
2410
|
+
await setSootsimCursorCaptureActive(page, true);
|
|
2411
|
+
try {
|
|
2412
|
+
const deadline = Date.now() + 8e3;
|
|
2413
|
+
let previous = null;
|
|
2414
|
+
let identicalFrames = 1;
|
|
2415
|
+
let attempts = 0;
|
|
2416
|
+
while (Date.now() < deadline) {
|
|
2417
|
+
await waitForSootsimIdle(page, Math.max(1, deadline - Date.now()), true);
|
|
2418
|
+
const current = await captureSootsimPngFast(opts);
|
|
2419
|
+
attempts++;
|
|
2420
|
+
if ((0, import_proof_frame.framesIdentical)(previous, current)) {
|
|
2421
|
+
identicalFrames++;
|
|
2422
|
+
if (identicalFrames >= import_proof_frame.REQUIRED_IDENTICAL_PROOF_FRAMES) return current;
|
|
2423
|
+
} else {
|
|
2424
|
+
identicalFrames = 1;
|
|
2425
|
+
}
|
|
2426
|
+
previous = current;
|
|
2427
|
+
}
|
|
2428
|
+
throw new Error(
|
|
2429
|
+
`rnx screenshot did not reach ${import_proof_frame.REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames in ${attempts} captures`
|
|
2430
|
+
);
|
|
2431
|
+
} finally {
|
|
2432
|
+
await setSootsimCursorCaptureActive(page, false);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
async function writeSootsimScreenshot(name, opts) {
|
|
2436
|
+
ensureScreenshotDir();
|
|
2437
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
|
|
2438
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPng(opts));
|
|
2439
|
+
return screenshotPath;
|
|
2440
|
+
}
|
|
2441
|
+
async function dispatchStatusBarOverride(config) {
|
|
2442
|
+
const page = getPage();
|
|
2443
|
+
const time = typeof config.time === "string" && config.time.length > 0 ? config.time : null;
|
|
2444
|
+
await page.evaluate(
|
|
2445
|
+
({ eventType, time: time2 }) => {
|
|
2446
|
+
window.dispatchEvent(
|
|
2447
|
+
new CustomEvent(eventType, {
|
|
2448
|
+
detail: { time: time2 }
|
|
2449
|
+
})
|
|
2450
|
+
);
|
|
2451
|
+
},
|
|
2452
|
+
{ eventType: STATUS_BAR_OVERRIDE_EVENT, time }
|
|
2453
|
+
);
|
|
2454
|
+
await page.waitForTimeout(50);
|
|
2455
|
+
}
|
|
2456
|
+
var MAX_SETTLED_SPEED_PT_PER_SEC = 2;
|
|
2457
|
+
async function waitForFrameClock(page, minMs = 25) {
|
|
2458
|
+
const start = performance.now();
|
|
2459
|
+
await page.evaluate(
|
|
2460
|
+
() => new Promise((resolve) => {
|
|
2461
|
+
const timeoutId = setTimeout(() => resolve(), 50);
|
|
2462
|
+
requestAnimationFrame(() => {
|
|
2463
|
+
clearTimeout(timeoutId);
|
|
2464
|
+
resolve();
|
|
2465
|
+
});
|
|
2466
|
+
})
|
|
2467
|
+
);
|
|
2468
|
+
const elapsed = performance.now() - start;
|
|
2469
|
+
if (elapsed < minMs) {
|
|
2470
|
+
await page.waitForTimeout(minMs - elapsed);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
async function findStableNodeByMatcher(matcher, action = "tap", maxWaitMs = 1500) {
|
|
2474
|
+
const page = getPage();
|
|
2475
|
+
const start = performance.now();
|
|
2476
|
+
let lastPos = null;
|
|
2477
|
+
let lastTime = null;
|
|
2478
|
+
let lastRate = null;
|
|
2479
|
+
let sampleCount = 0;
|
|
2480
|
+
let stablePolls = 0;
|
|
2481
|
+
const requiredStable = 2;
|
|
2482
|
+
let hadNumericLayout = false;
|
|
2483
|
+
let disappearedMidWait = false;
|
|
2484
|
+
while (performance.now() - start < maxWaitMs) {
|
|
2485
|
+
const node = await findNodeByMatcher(matcher);
|
|
2486
|
+
if (!node) {
|
|
2487
|
+
if (hadNumericLayout) {
|
|
2488
|
+
disappearedMidWait = true;
|
|
2489
|
+
} else {
|
|
2490
|
+
return null;
|
|
2491
|
+
}
|
|
2492
|
+
} else {
|
|
2493
|
+
disappearedMidWait = false;
|
|
2494
|
+
const pos = node.absolutePosition ?? node.layout;
|
|
2495
|
+
if (pos && typeof pos.x === "number" && typeof pos.y === "number") {
|
|
2496
|
+
hadNumericLayout = true;
|
|
2497
|
+
sampleCount++;
|
|
2498
|
+
const now = performance.now();
|
|
2499
|
+
if (lastPos && lastTime !== null) {
|
|
2500
|
+
const dtSec = (now - lastTime) / 1e3;
|
|
2501
|
+
if (dtSec > 5e-3) {
|
|
2502
|
+
const vx = Math.abs(pos.x - lastPos.x) / dtSec;
|
|
2503
|
+
const vy = Math.abs(pos.y - lastPos.y) / dtSec;
|
|
2504
|
+
const speed = Math.sqrt(vx * vx + vy * vy);
|
|
2505
|
+
lastRate = { vx, vy, speed };
|
|
2506
|
+
if (speed < MAX_SETTLED_SPEED_PT_PER_SEC) {
|
|
2507
|
+
stablePolls++;
|
|
2508
|
+
if (stablePolls >= requiredStable) {
|
|
2509
|
+
return node;
|
|
2510
|
+
}
|
|
2511
|
+
} else {
|
|
2512
|
+
stablePolls = 0;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
lastPos = { x: pos.x, y: pos.y };
|
|
2517
|
+
lastTime = now;
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
await waitForFrameClock(page, 25);
|
|
2521
|
+
}
|
|
2522
|
+
const elapsed = Math.round(performance.now() - start);
|
|
2523
|
+
if (disappearedMidWait) {
|
|
2524
|
+
throw new Error(
|
|
2525
|
+
`element disappeared while waiting to come to rest for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
if (!hadNumericLayout) {
|
|
2529
|
+
throw new Error(
|
|
2530
|
+
`element layout is not available for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`
|
|
2531
|
+
);
|
|
2532
|
+
}
|
|
2533
|
+
if (sampleCount < 2 || lastRate === null) {
|
|
2534
|
+
throw new Error(
|
|
2535
|
+
`element did not come to rest for ${action}: ${JSON.stringify(matcher)}; only one sample obtained after ${elapsed}ms`
|
|
2536
|
+
);
|
|
2537
|
+
}
|
|
2538
|
+
throw new Error(
|
|
2539
|
+
`element did not come to rest for ${action}: ${JSON.stringify(matcher)}; still moving ${lastRate.vx.toFixed(1)} points/sec x / ${lastRate.vy.toFixed(1)} points/sec y after ${elapsed}ms`
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
function createSootSimElement(matcher) {
|
|
2543
|
+
const el = {
|
|
2544
|
+
_matcher: matcher,
|
|
2545
|
+
async tap(point) {
|
|
2546
|
+
const page = getPage();
|
|
2547
|
+
await waitForSootsimWorkletsIdle(page, 3e3);
|
|
2548
|
+
const node = await findStableNodeByMatcher(matcher, "tap");
|
|
2549
|
+
if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`);
|
|
2550
|
+
if (!isNodeVisible(node)) {
|
|
2551
|
+
throw new Error(
|
|
2552
|
+
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
const defaultPoint = point ? null : await getDefaultTapPoint(page, node);
|
|
2556
|
+
const targetSootSimX = typeof point?.x === "number" ? node.absolutePosition.x + point.x : defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2;
|
|
2557
|
+
const targetSootSimY = typeof point?.y === "number" ? node.absolutePosition.y + point.y : defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2;
|
|
2558
|
+
await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY);
|
|
2559
|
+
if (_synchronizationEnabled) {
|
|
2560
|
+
await waitForSootsimIdle(page);
|
|
2561
|
+
}
|
|
2562
|
+
},
|
|
2563
|
+
async multiTap(times) {
|
|
2564
|
+
const page = getPage();
|
|
2565
|
+
const node = await findStableNodeByMatcher(matcher, "multiTap");
|
|
2566
|
+
if (!node)
|
|
2567
|
+
throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`);
|
|
2568
|
+
if (!isNodeVisible(node)) {
|
|
2569
|
+
throw new Error(
|
|
2570
|
+
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
const center = getNodeCenter(node);
|
|
2574
|
+
const pageCoords = await sootsimToPage(page, center.x, center.y);
|
|
2575
|
+
for (let i = 0; i < times; i++) {
|
|
2576
|
+
await page.mouse.move(pageCoords.x, pageCoords.y);
|
|
2577
|
+
await page.mouse.down();
|
|
2578
|
+
await page.waitForTimeout(50);
|
|
2579
|
+
await page.mouse.up();
|
|
2580
|
+
if (i < times - 1) {
|
|
2581
|
+
await page.waitForTimeout(80);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
await page.waitForTimeout(80);
|
|
2585
|
+
},
|
|
2586
|
+
// mirrors detox element.longPress(duration) and longPress(point, duration),
|
|
2587
|
+
// where the point is relative to the element's origin
|
|
2588
|
+
async longPress(pointOrDuration, durationArg = 1e3) {
|
|
2589
|
+
const page = getPage();
|
|
2590
|
+
const node = await findStableNodeByMatcher(matcher, "longPress");
|
|
2591
|
+
if (!node)
|
|
2592
|
+
throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`);
|
|
2593
|
+
if (!isNodeVisible(node)) {
|
|
2594
|
+
throw new Error(
|
|
2595
|
+
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
const duration = typeof pointOrDuration === "number" ? pointOrDuration : durationArg;
|
|
2599
|
+
const target = typeof pointOrDuration === "object" ? {
|
|
2600
|
+
x: node.absolutePosition.x + pointOrDuration.x,
|
|
2601
|
+
y: node.absolutePosition.y + pointOrDuration.y
|
|
2602
|
+
} : getNodeCenter(node);
|
|
2603
|
+
await page.evaluate(
|
|
2604
|
+
async ({ duration: duration2, x, y }) => {
|
|
2605
|
+
const longPress = window.SootSim?.bridges?.interact?.longPress;
|
|
2606
|
+
if (typeof longPress !== "function") {
|
|
2607
|
+
throw new Error("sootsim longPress bridge is not installed");
|
|
2608
|
+
}
|
|
2609
|
+
const hit = await longPress(x, y, duration2);
|
|
2610
|
+
if (!hit) {
|
|
2611
|
+
throw new Error(`sootsim longPress missed at ${x},${y}`);
|
|
2612
|
+
}
|
|
2613
|
+
},
|
|
2614
|
+
{ duration, x: target.x, y: target.y }
|
|
2615
|
+
);
|
|
2616
|
+
await page.waitForTimeout(50);
|
|
2617
|
+
},
|
|
2618
|
+
async longPressAndDrag(duration, normalizedStartX, normalizedStartY, targetElement, normalizedEndX, normalizedEndY, speed = "fast", holdDuration = 0) {
|
|
2619
|
+
const page = getPage();
|
|
2620
|
+
const srcNode = await findStableNodeByMatcher(matcher, "longPressAndDrag");
|
|
2621
|
+
if (!srcNode)
|
|
2622
|
+
throw new Error(`source element not found: ${JSON.stringify(matcher)}`);
|
|
2623
|
+
const tgtNode = await findStableNodeByMatcher(
|
|
2624
|
+
targetElement._matcher,
|
|
2625
|
+
"longPressAndDrag"
|
|
2626
|
+
);
|
|
2627
|
+
if (!tgtNode)
|
|
2628
|
+
throw new Error(
|
|
2629
|
+
`target element not found: ${JSON.stringify(targetElement._matcher)}`
|
|
2630
|
+
);
|
|
2631
|
+
const startSootSimX = srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX;
|
|
2632
|
+
const startSootSimY = srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY;
|
|
2633
|
+
const endSootSimX = tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX;
|
|
2634
|
+
const endSootSimY = tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY;
|
|
2635
|
+
const startPage = await sootsimToPage(page, startSootSimX, startSootSimY);
|
|
2636
|
+
const endPage = await sootsimToPage(page, endSootSimX, endSootSimY);
|
|
2637
|
+
const steps = speed === "slow" ? 20 : 10;
|
|
2638
|
+
await page.mouse.move(startPage.x, startPage.y);
|
|
2639
|
+
await page.mouse.down();
|
|
2640
|
+
await page.waitForTimeout(Math.max(duration, holdDuration));
|
|
2641
|
+
const dx = (endPage.x - startPage.x) / steps;
|
|
2642
|
+
const dy = (endPage.y - startPage.y) / steps;
|
|
2643
|
+
for (let i = 1; i <= steps; i++) {
|
|
2644
|
+
await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i);
|
|
2645
|
+
await page.waitForTimeout(speed === "slow" ? 30 : 10);
|
|
2646
|
+
}
|
|
2647
|
+
await page.mouse.up();
|
|
2648
|
+
await page.waitForTimeout(50);
|
|
2649
|
+
},
|
|
2650
|
+
async typeText(text) {
|
|
2651
|
+
const page = getPage();
|
|
2652
|
+
await focusElementForTextEntry(el);
|
|
2653
|
+
await page.keyboard.type(text);
|
|
2654
|
+
},
|
|
2655
|
+
async replaceText(text) {
|
|
2656
|
+
const page = getPage();
|
|
2657
|
+
const node = await findNodeByMatcher(matcher);
|
|
2658
|
+
if (matcher.type === "id" && typeof node?.nodeId === "number" && node.nodeId >= 1073741824) {
|
|
2659
|
+
const replaced = await page.evaluate(
|
|
2660
|
+
async ({ id, value: value2 }) => {
|
|
2661
|
+
const mainShell = window.SootSim?.bridges?.mainShell;
|
|
2662
|
+
if (typeof mainShell?.callTestBridge !== "function") return false;
|
|
2663
|
+
return mainShell.callTestBridge("replaceTextByTestId", id, value2);
|
|
2664
|
+
},
|
|
2665
|
+
{ id: matcher.value, value: text }
|
|
2666
|
+
);
|
|
2667
|
+
if (replaced === true) return;
|
|
2668
|
+
}
|
|
2669
|
+
await focusElementForTextEntry(el);
|
|
2670
|
+
const value = typeof node?.text === "string" ? node.text : "";
|
|
2671
|
+
for (let i = 0; i < value.length; i++) {
|
|
2672
|
+
await page.keyboard.press("Backspace");
|
|
2673
|
+
}
|
|
2674
|
+
await page.keyboard.type(text);
|
|
2675
|
+
},
|
|
2676
|
+
async clearText() {
|
|
2677
|
+
const page = getPage();
|
|
2678
|
+
await focusElementForTextEntry(el);
|
|
2679
|
+
const node = await findNodeByMatcher(matcher);
|
|
2680
|
+
const value = typeof node?.text === "string" ? node.text : "";
|
|
2681
|
+
for (let i = 0; i < value.length; i++) {
|
|
2682
|
+
await page.keyboard.press("Backspace");
|
|
2683
|
+
}
|
|
2684
|
+
},
|
|
2685
|
+
async scroll(pixels, direction, startPositionX, startPositionY) {
|
|
2686
|
+
const node = await findNodeByMatcher(matcher);
|
|
2687
|
+
if (!node)
|
|
2688
|
+
throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`);
|
|
2689
|
+
const page = getPage();
|
|
2690
|
+
await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY);
|
|
2691
|
+
},
|
|
2692
|
+
async scrollTo(edge, startPositionX, startPositionY) {
|
|
2693
|
+
const node = await findNodeByMatcher(matcher);
|
|
2694
|
+
if (!node)
|
|
2695
|
+
throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`);
|
|
2696
|
+
const targetId = typeof node.testID === "string" && node.testID ? node.testID : typeof node.id === "string" && node.id ? node.id : null;
|
|
2697
|
+
if (!targetId) {
|
|
2698
|
+
throw new Error(
|
|
2699
|
+
`scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`
|
|
2700
|
+
);
|
|
2701
|
+
}
|
|
2702
|
+
const normalizedStartX = resolveScrollStartPosition(
|
|
2703
|
+
startPositionX,
|
|
2704
|
+
"scrollTo startPositionX"
|
|
2705
|
+
);
|
|
2706
|
+
const normalizedStartY = resolveScrollStartPosition(
|
|
2707
|
+
startPositionY,
|
|
2708
|
+
"scrollTo startPositionY"
|
|
2709
|
+
);
|
|
2710
|
+
const hitX = node.absolutePosition.x + node.layout.width * normalizedStartX;
|
|
2711
|
+
const hitY = node.absolutePosition.y + node.layout.height * normalizedStartY;
|
|
2712
|
+
const page = getPage();
|
|
2713
|
+
const result = await page.evaluate(
|
|
2714
|
+
async ({ edge: edge2, hitX: hitX2, hitY: hitY2, targetId: targetId2 }) => {
|
|
2715
|
+
const bridge = window.__sootsimTest;
|
|
2716
|
+
if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
|
|
2717
|
+
return { ok: false, reason: "scroll bridge unavailable" };
|
|
2718
|
+
}
|
|
2719
|
+
const state = await bridge.getScrollStateAt(hitX2, hitY2);
|
|
2720
|
+
if (!state) return { ok: false, reason: "scroll state not found" };
|
|
2721
|
+
const maxOffset = state.maxOffset && typeof state.maxOffset === "object" ? state.maxOffset : null;
|
|
2722
|
+
const offset = state.offset && typeof state.offset === "object" ? state.offset : null;
|
|
2723
|
+
const maxX = typeof state.maxOffsetX === "number" ? state.maxOffsetX : maxOffset?.x ?? 0;
|
|
2724
|
+
const maxY = typeof state.maxOffsetY === "number" ? state.maxOffsetY : maxOffset?.y ?? 0;
|
|
2725
|
+
const currentX = typeof state.offsetX === "number" ? state.offsetX : offset?.x ?? 0;
|
|
2726
|
+
const currentY = typeof state.offsetY === "number" ? state.offsetY : offset?.y ?? 0;
|
|
2727
|
+
const x = edge2 === "left" ? 0 : edge2 === "right" ? maxX : currentX;
|
|
2728
|
+
const y = edge2 === "top" ? 0 : edge2 === "bottom" ? maxY : currentY;
|
|
2729
|
+
return bridge.scrollTo(targetId2, x, y, false);
|
|
2730
|
+
},
|
|
2731
|
+
{ edge, hitX, hitY, targetId }
|
|
2732
|
+
);
|
|
2733
|
+
if (!result?.ok) {
|
|
2734
|
+
throw new Error(
|
|
2735
|
+
`scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? "unknown error"}`
|
|
2736
|
+
);
|
|
2737
|
+
}
|
|
2738
|
+
await page.evaluate(
|
|
2739
|
+
() => new Promise((resolve) => {
|
|
2740
|
+
requestAnimationFrame(() => resolve());
|
|
2741
|
+
})
|
|
2742
|
+
);
|
|
2743
|
+
if (_synchronizationEnabled) {
|
|
2744
|
+
await waitForSootsimIdle(page);
|
|
2745
|
+
}
|
|
2746
|
+
},
|
|
2747
|
+
async swipe(direction, speed = "fast", percentage = 0.75, normalizedStartingPointX = 0.5, normalizedStartingPointY = 0.5) {
|
|
2748
|
+
const node = await findStableNodeByMatcher(matcher, "swipe");
|
|
2749
|
+
if (!node)
|
|
2750
|
+
throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`);
|
|
2751
|
+
const page = getPage();
|
|
2752
|
+
const { absolutePosition, layout } = node;
|
|
2753
|
+
const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX;
|
|
2754
|
+
const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY;
|
|
2755
|
+
let endSootSimX = startSootSimX;
|
|
2756
|
+
let endSootSimY = startSootSimY;
|
|
2757
|
+
const viewport = await readSootsimViewport(page);
|
|
2758
|
+
const dist = direction === "up" || direction === "down" ? viewport.height * percentage : viewport.width * percentage;
|
|
2759
|
+
switch (direction) {
|
|
2760
|
+
case "up":
|
|
2761
|
+
endSootSimY -= dist;
|
|
2762
|
+
break;
|
|
2763
|
+
case "down":
|
|
2764
|
+
endSootSimY += dist;
|
|
2765
|
+
break;
|
|
2766
|
+
case "left":
|
|
2767
|
+
endSootSimX -= dist;
|
|
2768
|
+
break;
|
|
2769
|
+
case "right":
|
|
2770
|
+
endSootSimX += dist;
|
|
2771
|
+
break;
|
|
2772
|
+
}
|
|
2773
|
+
const steps = speed === "slow" ? 40 : 20;
|
|
2774
|
+
const stepDelay = 1e3 / 60;
|
|
2775
|
+
await page.evaluate(
|
|
2776
|
+
async ({ fromX, fromY, toX, toY, steps: steps2, stepMs }) => {
|
|
2777
|
+
const drag = window.__sootsimInteract?.drag;
|
|
2778
|
+
if (typeof drag !== "function") {
|
|
2779
|
+
throw new Error("sootsim interact bridge is not installed");
|
|
2780
|
+
}
|
|
2781
|
+
await drag(fromX, fromY, toX, toY, steps2, stepMs);
|
|
2782
|
+
},
|
|
2783
|
+
{
|
|
2784
|
+
fromX: startSootSimX,
|
|
2785
|
+
fromY: startSootSimY,
|
|
2786
|
+
toX: endSootSimX,
|
|
2787
|
+
toY: endSootSimY,
|
|
2788
|
+
steps,
|
|
2789
|
+
stepMs: stepDelay
|
|
2790
|
+
}
|
|
2791
|
+
);
|
|
2792
|
+
await page.waitForTimeout(100);
|
|
2793
|
+
},
|
|
2794
|
+
async setColumnToValue(column, value) {
|
|
2795
|
+
if (!Number.isInteger(column) || column < 0) {
|
|
2796
|
+
throw new Error(`picker column must be a non-negative integer, got ${column}`);
|
|
2797
|
+
}
|
|
2798
|
+
if (typeof value !== "string") {
|
|
2799
|
+
throw new Error(`picker value must be a string, got ${typeof value}`);
|
|
2800
|
+
}
|
|
2801
|
+
const picker = await findStableNodeByMatcher(matcher, "setColumnToValue");
|
|
2802
|
+
if (!picker) {
|
|
2803
|
+
throw new Error(
|
|
2804
|
+
`element not found for setColumnToValue: ${JSON.stringify(matcher)}`
|
|
2805
|
+
);
|
|
2806
|
+
}
|
|
2807
|
+
const page = getPage();
|
|
2808
|
+
const candidates = await page.evaluate(
|
|
2809
|
+
async ({ picker: picker2, column: column2, value: value2 }) => {
|
|
2810
|
+
const result = await window.__sootsimTest?.queryTextCandidates?.({
|
|
2811
|
+
query: value2,
|
|
2812
|
+
exact: true
|
|
2813
|
+
});
|
|
2814
|
+
if (!result || !Array.isArray(result.candidates)) return [];
|
|
2815
|
+
const left = picker2.absolutePosition.x;
|
|
2816
|
+
const top = picker2.absolutePosition.y;
|
|
2817
|
+
const right = left + picker2.layout.width;
|
|
2818
|
+
const bottom = top + picker2.layout.height;
|
|
2819
|
+
return result.candidates.filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column2)).map((candidate) => candidate.info).filter((candidate) => {
|
|
2820
|
+
const x = candidate.absolutePosition?.x;
|
|
2821
|
+
const y = candidate.absolutePosition?.y;
|
|
2822
|
+
const width = candidate.layout?.width;
|
|
2823
|
+
const height = candidate.layout?.height;
|
|
2824
|
+
if (typeof x !== "number" || typeof y !== "number" || typeof width !== "number" || typeof height !== "number") {
|
|
2825
|
+
return false;
|
|
2826
|
+
}
|
|
2827
|
+
const centerX = x + width / 2;
|
|
2828
|
+
const centerY = y + height / 2;
|
|
2829
|
+
return centerX >= left && centerX <= right && centerY >= top && centerY <= bottom;
|
|
2830
|
+
});
|
|
2831
|
+
},
|
|
2832
|
+
{ picker, column, value }
|
|
2833
|
+
);
|
|
2834
|
+
if (candidates.length !== 1) {
|
|
2835
|
+
throw new Error(
|
|
2836
|
+
`setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2839
|
+
const target = candidates[0];
|
|
2840
|
+
const tapTarget = await page.evaluate(
|
|
2841
|
+
async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId),
|
|
2842
|
+
target.nodeId
|
|
2843
|
+
);
|
|
2844
|
+
if (!tapTarget) {
|
|
2845
|
+
throw new Error(
|
|
2846
|
+
`setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
await dispatchTapAtSootsimPoint(
|
|
2850
|
+
page,
|
|
2851
|
+
tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
|
|
2852
|
+
tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2
|
|
2853
|
+
);
|
|
2854
|
+
},
|
|
2855
|
+
async pinch(scale, speed = "fast", angle = 0) {
|
|
2856
|
+
const page = getPage();
|
|
2857
|
+
const node = await findStableNodeByMatcher(matcher, "pinch");
|
|
2858
|
+
if (!node)
|
|
2859
|
+
throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`);
|
|
2860
|
+
if (!node.nativePinchEnabled) {
|
|
2861
|
+
await waitForSootsimGestureHandler(page, node.nodeId, "PinchGestureHandler");
|
|
2862
|
+
}
|
|
2863
|
+
const startSpread = Math.min(
|
|
2864
|
+
60,
|
|
2865
|
+
Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25)
|
|
2866
|
+
);
|
|
2867
|
+
const endSpread = startSpread * Math.max(scale, 0.05);
|
|
2868
|
+
const cos = Math.cos(angle);
|
|
2869
|
+
const sin = Math.sin(angle);
|
|
2870
|
+
const offset = (d) => ({ dx: d * cos, dy: d * sin });
|
|
2871
|
+
const a0 = offset(-startSpread);
|
|
2872
|
+
const b0 = offset(startSpread);
|
|
2873
|
+
const a1 = offset(-endSpread);
|
|
2874
|
+
const b1 = offset(endSpread);
|
|
2875
|
+
await dispatchTwoFingerGesture(
|
|
2876
|
+
page,
|
|
2877
|
+
await nodeOffsetToPage(page, node, a0.dx, a0.dy),
|
|
2878
|
+
await nodeOffsetToPage(page, node, b0.dx, b0.dy),
|
|
2879
|
+
await nodeOffsetToPage(page, node, a1.dx, a1.dy),
|
|
2880
|
+
await nodeOffsetToPage(page, node, b1.dx, b1.dy),
|
|
2881
|
+
{ steps: speed === "fast" ? 4 : 12, stepDelayMs: speed === "fast" ? 18 : 24 }
|
|
2882
|
+
);
|
|
2883
|
+
},
|
|
2884
|
+
async rotate(radians, speed = "fast") {
|
|
2885
|
+
const page = getPage();
|
|
2886
|
+
const node = await findStableNodeByMatcher(matcher, "rotate");
|
|
2887
|
+
if (!node)
|
|
2888
|
+
throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`);
|
|
2889
|
+
await waitForSootsimGestureHandler(page, node.nodeId, "RotationGestureHandler");
|
|
2890
|
+
const radius = Math.min(
|
|
2891
|
+
60,
|
|
2892
|
+
Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25)
|
|
2893
|
+
);
|
|
2894
|
+
const startA = await nodeOffsetToPage(page, node, -radius, 0);
|
|
2895
|
+
const startB = await nodeOffsetToPage(page, node, radius, 0);
|
|
2896
|
+
const cos = Math.cos(radians);
|
|
2897
|
+
const sin = Math.sin(radians);
|
|
2898
|
+
const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin);
|
|
2899
|
+
const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin);
|
|
2900
|
+
await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
|
|
2901
|
+
steps: speed === "fast" ? 10 : 20,
|
|
2902
|
+
stepDelayMs: speed === "fast" ? 14 : 24
|
|
2903
|
+
});
|
|
2904
|
+
},
|
|
2905
|
+
async takeScreenshot(name) {
|
|
2906
|
+
const node = await findNodeByMatcher(matcher);
|
|
2907
|
+
if (!node)
|
|
2908
|
+
throw new Error(
|
|
2909
|
+
`element not found for takeScreenshot: ${JSON.stringify(matcher)}`
|
|
2910
|
+
);
|
|
2911
|
+
const box = node.frame ?? {
|
|
2912
|
+
x: node.absolutePosition.x,
|
|
2913
|
+
y: node.absolutePosition.y,
|
|
2914
|
+
width: node.layout.width,
|
|
2915
|
+
height: node.layout.height
|
|
2916
|
+
};
|
|
2917
|
+
return writeSootsimScreenshot(name, {
|
|
2918
|
+
crop: {
|
|
2919
|
+
x: box.x,
|
|
2920
|
+
y: box.y,
|
|
2921
|
+
w: box.width,
|
|
2922
|
+
h: box.height
|
|
2923
|
+
}
|
|
2924
|
+
});
|
|
2925
|
+
},
|
|
2926
|
+
async getAttributes() {
|
|
2927
|
+
const node = await findNodeByMatcher(matcher);
|
|
2928
|
+
if (!node)
|
|
2929
|
+
throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`);
|
|
2930
|
+
return {
|
|
2931
|
+
text: node.text || "",
|
|
2932
|
+
label: node.accessibilityLabel || node.text || "",
|
|
2933
|
+
identifier: node.testID || node.id || "",
|
|
2934
|
+
visible: node.layout.width > 0 && node.layout.height > 0,
|
|
2935
|
+
enabled: !node.accessibilityState?.disabled,
|
|
2936
|
+
...node
|
|
2937
|
+
};
|
|
2938
|
+
},
|
|
2939
|
+
atIndex(index) {
|
|
2940
|
+
return createSootSimElement({ ...matcher, index });
|
|
2941
|
+
}
|
|
2942
|
+
};
|
|
2943
|
+
return el;
|
|
2944
|
+
}
|
|
2945
|
+
function element(matcher) {
|
|
2946
|
+
return createSootSimElement(matcher);
|
|
2947
|
+
}
|
|
2948
|
+
var device = {
|
|
2949
|
+
_currentUrl: "",
|
|
2950
|
+
_platform: DETOX_PLATFORM,
|
|
2951
|
+
async launchApp(opts) {
|
|
2952
|
+
if (_browserCompromised) {
|
|
2953
|
+
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`);
|
|
2954
|
+
}
|
|
2955
|
+
if (_page && opts?.newInstance === false && !opts.delete && !opts.recordVideoDir && _suspendedAppId) {
|
|
2956
|
+
const page = _page;
|
|
2957
|
+
const appId = _suspendedAppId;
|
|
2958
|
+
await page.evaluate(
|
|
2959
|
+
(id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id),
|
|
2960
|
+
appId
|
|
2961
|
+
);
|
|
2962
|
+
await pollUntil(
|
|
2963
|
+
() => page.evaluate(async (id) => {
|
|
2964
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.();
|
|
2965
|
+
return state?.state === "app" && state.activeApp === id && state.switcherPhase === "idle";
|
|
2966
|
+
}, appId),
|
|
2967
|
+
Boolean,
|
|
2968
|
+
{
|
|
2969
|
+
timeoutMs: 1e4,
|
|
2970
|
+
label: `SootSim app ${appId} to resume`
|
|
2971
|
+
}
|
|
2972
|
+
);
|
|
2973
|
+
_suspendedAppId = null;
|
|
2974
|
+
await waitForSootsimTree(page, 1e4);
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
if (!_browser) {
|
|
2978
|
+
const launched = await launchReapedChromeScoped(
|
|
2979
|
+
(options) => import_playwright.chromium.launch(options),
|
|
2980
|
+
{ headless: true, ignoreDefaultArgs: ["--disable-popup-blocking"] }
|
|
2981
|
+
);
|
|
2982
|
+
_browser = launched.browser;
|
|
2983
|
+
_reapBrowserNow = launched.reapNow;
|
|
2984
|
+
browserIdentity = ++nextBrowserIdentity;
|
|
2985
|
+
}
|
|
2986
|
+
if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
|
|
2987
|
+
await closeContext();
|
|
2988
|
+
const contextOpts = {
|
|
2989
|
+
hasTouch: true,
|
|
2990
|
+
viewport: { width: 500, height: 900 }
|
|
2991
|
+
};
|
|
2992
|
+
if (opts?.recordVideoDir) {
|
|
2993
|
+
fs.mkdirSync(opts.recordVideoDir, { recursive: true });
|
|
2994
|
+
contextOpts.recordVideo = {
|
|
2995
|
+
dir: opts.recordVideoDir,
|
|
2996
|
+
size: { width: 500, height: 900 }
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
const newContextIdentity = ++nextContextIdentity;
|
|
3000
|
+
try {
|
|
3001
|
+
_context = await withLifecycleTimeout(
|
|
3002
|
+
"_browser.newContext()",
|
|
3003
|
+
newContextIdentity,
|
|
3004
|
+
() => _browser.newContext(contextOpts)
|
|
3005
|
+
);
|
|
3006
|
+
} catch (error) {
|
|
3007
|
+
_browserCompromised = error instanceof Error ? error.message : String(error);
|
|
3008
|
+
reapOwnedBrowser();
|
|
3009
|
+
throw error;
|
|
3010
|
+
}
|
|
3011
|
+
await _context.exposeBinding("__sootsimHostCapture", async (source, value) => {
|
|
3012
|
+
const request = readBrowserCompositeCaptureRequest(value);
|
|
3013
|
+
const session = await source.page.context().newCDPSession(source.page);
|
|
3014
|
+
try {
|
|
3015
|
+
const result = await session.send("Page.captureScreenshot", {
|
|
3016
|
+
format: "png",
|
|
3017
|
+
fromSurface: true,
|
|
3018
|
+
captureBeyondViewport: true,
|
|
3019
|
+
clip: resolveBrowserCompositeCdpClip(
|
|
3020
|
+
request,
|
|
3021
|
+
await session.send("Page.getLayoutMetrics")
|
|
3022
|
+
)
|
|
3023
|
+
});
|
|
3024
|
+
return "data:image/png;base64," + result.data;
|
|
3025
|
+
} finally {
|
|
3026
|
+
await session.detach();
|
|
3027
|
+
}
|
|
3028
|
+
});
|
|
3029
|
+
try {
|
|
3030
|
+
_page = await withLifecycleTimeout(
|
|
3031
|
+
"_context.newPage()",
|
|
3032
|
+
newContextIdentity,
|
|
3033
|
+
() => _context.newPage()
|
|
3034
|
+
);
|
|
3035
|
+
} catch (error) {
|
|
3036
|
+
_browserCompromised = error instanceof Error ? error.message : String(error);
|
|
3037
|
+
reapOwnedBrowser();
|
|
3038
|
+
throw error;
|
|
3039
|
+
}
|
|
3040
|
+
contextIdentity = newContextIdentity;
|
|
3041
|
+
}
|
|
3042
|
+
const url = opts?.url || BASE_URL;
|
|
3043
|
+
device._currentUrl = url;
|
|
3044
|
+
await navigateSootsimPage(_page, url, {
|
|
3045
|
+
browserId: browserIdentity,
|
|
3046
|
+
contextId: contextIdentity,
|
|
3047
|
+
operation: "launchApp"
|
|
3048
|
+
});
|
|
3049
|
+
await waitForSootsimTree(_page, 3e4);
|
|
3050
|
+
await waitForSootsimSurfaceMetrics(_page, 3e4);
|
|
3051
|
+
await waitForSootsimShellReady(_page, 3e4);
|
|
3052
|
+
await waitForSootsimWorkletsIdle(_page, 3e3);
|
|
3053
|
+
device._lastReadyAtMs = Date.now();
|
|
3054
|
+
},
|
|
3055
|
+
_lastReadyAtMs: 0,
|
|
3056
|
+
async reloadReactNative() {
|
|
3057
|
+
const page = getPage();
|
|
3058
|
+
await page.reload({ waitUntil: "load", timeout: 3e4 });
|
|
3059
|
+
await waitForSootsimTree(page, 3e4);
|
|
3060
|
+
await waitForSootsimSurfaceMetrics(page, 3e4);
|
|
3061
|
+
await waitForSootsimShellReady(page, 3e4);
|
|
3062
|
+
await waitForSootsimWorkletsIdle(page, 3e3);
|
|
3063
|
+
},
|
|
3064
|
+
async terminateApp() {
|
|
3065
|
+
await closeBrowser();
|
|
3066
|
+
},
|
|
3067
|
+
async installApp() {
|
|
3068
|
+
},
|
|
3069
|
+
async uninstallApp() {
|
|
3070
|
+
},
|
|
3071
|
+
async openURL(url) {
|
|
3072
|
+
const page = getPage();
|
|
3073
|
+
await navigateSootsimPage(page, url.url, {
|
|
3074
|
+
browserId: browserIdentity,
|
|
3075
|
+
contextId: contextIdentity,
|
|
3076
|
+
operation: "openURL"
|
|
3077
|
+
});
|
|
3078
|
+
await waitForSootsimTree(page, 1e4);
|
|
3079
|
+
await waitForSootsimSurfaceMetrics(page, 1e4);
|
|
3080
|
+
await waitForSootsimShellReady(page, 1e4);
|
|
3081
|
+
await waitForSootsimWorkletsIdle(page, 3e3);
|
|
3082
|
+
},
|
|
3083
|
+
async takeScreenshot(name) {
|
|
3084
|
+
return writeSootsimScreenshot(name);
|
|
3085
|
+
},
|
|
3086
|
+
// single-shot variant for multi-stage animation captures. skips the
|
|
3087
|
+
// frame-stability poll so callers can sample mid-transition.
|
|
3088
|
+
async takeScreenshotFast(name) {
|
|
3089
|
+
ensureScreenshotDir();
|
|
3090
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
|
|
3091
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPngFast());
|
|
3092
|
+
return screenshotPath;
|
|
3093
|
+
},
|
|
3094
|
+
// record the SootSim canvas via the engine's headless recorder
|
|
3095
|
+
// (window.__sootsimRecorder — @rnx/plugin-recording). this is the
|
|
3096
|
+
// SAME code path `rnx record video` drives, NOT the rail-button
|
|
3097
|
+
// path (SootSim.bridges.startRecording wires the dialog state machine
|
|
3098
|
+
// and never stashes the resulting blob into __sootsimRecorder.lastBlob,
|
|
3099
|
+
// which is what getBlobBase64 streams from).
|
|
3100
|
+
//
|
|
3101
|
+
// unlike playwright's page-level recordVideo, the headless recorder
|
|
3102
|
+
// captures ONLY the device canvas (composited shell + tenant surfaces)
|
|
3103
|
+
// — no browser chrome, no menu bar, no device frame. uses the EXISTING
|
|
3104
|
+
// page/context so per-test state is preserved across capture.
|
|
3105
|
+
async startBridgeRecording(opts) {
|
|
3106
|
+
const page = getPage();
|
|
3107
|
+
const startOpts = {
|
|
3108
|
+
format: "webm",
|
|
3109
|
+
fps: opts?.fps ?? 60,
|
|
3110
|
+
layers: opts?.layers ?? "tenant",
|
|
3111
|
+
durationMs: opts?.durationMs ?? 5e3
|
|
3112
|
+
};
|
|
3113
|
+
const result = await page.evaluate(async (startOpts2) => {
|
|
3114
|
+
const rec = window.__sootsimRecorder;
|
|
3115
|
+
const start = rec?.start;
|
|
3116
|
+
if (typeof start !== "function") {
|
|
3117
|
+
return { ok: false, error: "__sootsimRecorder.start not installed" };
|
|
3118
|
+
}
|
|
3119
|
+
return start(startOpts2);
|
|
3120
|
+
}, startOpts);
|
|
3121
|
+
if (!result.ok) {
|
|
3122
|
+
throw new Error(`startBridgeRecording failed: ${result.error ?? "unknown"}`);
|
|
3123
|
+
}
|
|
3124
|
+
},
|
|
3125
|
+
// stop the headless recorder, stream the resulting blob in chunks (same
|
|
3126
|
+
// protocol as `rnx record`), and write the webm to outPath.
|
|
3127
|
+
async stopAndSaveBridgeRecording(outPath) {
|
|
3128
|
+
const page = getPage();
|
|
3129
|
+
const stopResult = await page.evaluate(async () => {
|
|
3130
|
+
const rec = window.__sootsimRecorder;
|
|
3131
|
+
const stop = rec?.stop;
|
|
3132
|
+
if (typeof stop !== "function") {
|
|
3133
|
+
return { ok: false, error: "__sootsimRecorder.stop not installed" };
|
|
3134
|
+
}
|
|
3135
|
+
return stop();
|
|
3136
|
+
});
|
|
3137
|
+
if (!stopResult.ok) {
|
|
3138
|
+
throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? "unknown"}`);
|
|
3139
|
+
}
|
|
3140
|
+
if (!stopResult.size) {
|
|
3141
|
+
throw new Error("stopBridgeRecording: recorder returned empty blob");
|
|
3142
|
+
}
|
|
3143
|
+
const chunks = [];
|
|
3144
|
+
let offset = 0;
|
|
3145
|
+
const CHUNK = 2 * 1024 * 1024;
|
|
3146
|
+
while (true) {
|
|
3147
|
+
const result = await page.evaluate(
|
|
3148
|
+
({ offset: offset2, chunk }) => {
|
|
3149
|
+
const rec = window.__sootsimRecorder;
|
|
3150
|
+
const get = rec?.getBlobBase64;
|
|
3151
|
+
if (typeof get !== "function") return null;
|
|
3152
|
+
return get({ offset: offset2, chunk });
|
|
3153
|
+
},
|
|
3154
|
+
{ offset, chunk: CHUNK }
|
|
3155
|
+
);
|
|
3156
|
+
if (!result) throw new Error("rnx recorder produced no blob");
|
|
3157
|
+
chunks.push(Buffer.from(result.data, "base64"));
|
|
3158
|
+
offset = result.offset;
|
|
3159
|
+
if (result.done) break;
|
|
3160
|
+
}
|
|
3161
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3162
|
+
fs.writeFileSync(outPath, Buffer.concat(chunks));
|
|
3163
|
+
this._lastRecordingPath = outPath;
|
|
3164
|
+
return outPath;
|
|
3165
|
+
},
|
|
3166
|
+
_lastRecordingPath: "",
|
|
3167
|
+
// park every render clock before the interaction, detect the first changed
|
|
3168
|
+
// stepped frame inside the named component, then advance exact 60fps frames
|
|
3169
|
+
// to each requested offset. native extraction also chooses the first frame
|
|
3170
|
+
// at or after an offset, so both sides use the same rounding convention.
|
|
3171
|
+
async takeScreenshotStagesFromVisibleChangeFast(namePrefix, stages, options) {
|
|
3172
|
+
const page = getPage();
|
|
3173
|
+
const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`;
|
|
3174
|
+
const settleMs = options.settleMs ?? 0;
|
|
3175
|
+
if (!Number.isFinite(settleMs) || settleMs < 0) {
|
|
3176
|
+
throw new Error("rnx motion capture needs a non-negative settleMs");
|
|
3177
|
+
}
|
|
3178
|
+
await page.evaluate(import_motion_change.measureMotionChange, {
|
|
3179
|
+
definition: import_motion_change.MOTION_CHANGE_DEFINITION,
|
|
3180
|
+
installGlobal: true
|
|
3181
|
+
});
|
|
3182
|
+
const capturePromise = page.evaluate(
|
|
3183
|
+
async (input) => {
|
|
3184
|
+
const captureWindow = window;
|
|
3185
|
+
const live = captureWindow.SootSim?.bridges?.liveComposite;
|
|
3186
|
+
const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture;
|
|
3187
|
+
if (!live || typeof live.captureFresh !== "function") {
|
|
3188
|
+
throw new Error("rnx live composite bridge is not installed");
|
|
3189
|
+
}
|
|
3190
|
+
if (!lockstep || typeof lockstep.begin !== "function") {
|
|
3191
|
+
throw new Error("SootSim lockstep capture bridge is not installed");
|
|
3192
|
+
}
|
|
3193
|
+
const registry = captureWindow.__sootsimVisibleChangeCaptures ?? (captureWindow.__sootsimVisibleChangeCaptures = {});
|
|
3194
|
+
const state = {
|
|
3195
|
+
cancelled: false,
|
|
3196
|
+
ready: false,
|
|
3197
|
+
triggerCompleted: false
|
|
3198
|
+
};
|
|
3199
|
+
registry[input.token] = state;
|
|
3200
|
+
const measureChange = captureWindow.__sootsimMeasureMotionChange;
|
|
3201
|
+
if (!measureChange) {
|
|
3202
|
+
throw new Error("rnx motion change definition is not installed");
|
|
3203
|
+
}
|
|
3204
|
+
const captureBitmap = async () => {
|
|
3205
|
+
const { canvas } = await live.captureFresh();
|
|
3206
|
+
if (!canvas) throw new Error("rnx live composite produced no canvas");
|
|
3207
|
+
return createImageBitmap(canvas);
|
|
3208
|
+
};
|
|
3209
|
+
const boundsFor = (bitmap) => {
|
|
3210
|
+
const scale = input.rootFrame && input.rootFrame.width > 0 ? bitmap.width / input.rootFrame.width : 1;
|
|
3211
|
+
return {
|
|
3212
|
+
height: input.frame ? Math.max(
|
|
3213
|
+
1,
|
|
3214
|
+
Math.min(
|
|
3215
|
+
bitmap.height,
|
|
3216
|
+
Math.ceil((input.frame.y + input.frame.height) * scale)
|
|
3217
|
+
) - Math.max(0, Math.floor(input.frame.y * scale))
|
|
3218
|
+
) : bitmap.height,
|
|
3219
|
+
left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
|
|
3220
|
+
top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
|
|
3221
|
+
width: input.frame ? Math.max(
|
|
3222
|
+
1,
|
|
3223
|
+
Math.min(
|
|
3224
|
+
bitmap.width,
|
|
3225
|
+
Math.ceil((input.frame.x + input.frame.width) * scale)
|
|
3226
|
+
) - Math.max(0, Math.floor(input.frame.x * scale))
|
|
3227
|
+
) : bitmap.width
|
|
3228
|
+
};
|
|
3229
|
+
};
|
|
3230
|
+
const regionPixels = (bitmap) => {
|
|
3231
|
+
const bounds = boundsFor(bitmap);
|
|
3232
|
+
const canvas = document.createElement("canvas");
|
|
3233
|
+
canvas.width = bounds.width;
|
|
3234
|
+
canvas.height = bounds.height;
|
|
3235
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
3236
|
+
if (!context) throw new Error("rnx motion detector needs a 2d canvas");
|
|
3237
|
+
context.drawImage(
|
|
3238
|
+
bitmap,
|
|
3239
|
+
bounds.left,
|
|
3240
|
+
bounds.top,
|
|
3241
|
+
bounds.width,
|
|
3242
|
+
bounds.height,
|
|
3243
|
+
0,
|
|
3244
|
+
0,
|
|
3245
|
+
bounds.width,
|
|
3246
|
+
bounds.height
|
|
3247
|
+
);
|
|
3248
|
+
return context.getImageData(0, 0, bounds.width, bounds.height).data;
|
|
3249
|
+
};
|
|
3250
|
+
const encode = (snapshot) => {
|
|
3251
|
+
const canvas = document.createElement("canvas");
|
|
3252
|
+
canvas.width = snapshot.bitmap.width;
|
|
3253
|
+
canvas.height = snapshot.bitmap.height;
|
|
3254
|
+
const context = canvas.getContext("2d");
|
|
3255
|
+
if (!context) throw new Error("rnx motion capture needs a 2d canvas");
|
|
3256
|
+
context.drawImage(snapshot.bitmap, 0, 0);
|
|
3257
|
+
snapshot.bitmap.close();
|
|
3258
|
+
return {
|
|
3259
|
+
actualMs: snapshot.actualMs,
|
|
3260
|
+
dataUrl: canvas.toDataURL("image/png"),
|
|
3261
|
+
label: snapshot.label,
|
|
3262
|
+
offsetMs: snapshot.offsetMs,
|
|
3263
|
+
frameIndex: snapshot.frameIndex,
|
|
3264
|
+
virtualTimestampMs: snapshot.virtualTimestampMs
|
|
3265
|
+
};
|
|
3266
|
+
};
|
|
3267
|
+
const interact = captureWindow.SootSim?.bridges?.interact;
|
|
3268
|
+
if (input.hold && (!interact || typeof interact.touchDown !== "function")) {
|
|
3269
|
+
throw new Error("rnx interact bridge is not installed");
|
|
3270
|
+
}
|
|
3271
|
+
const frameMs = 1e3 / 60;
|
|
3272
|
+
const controller = await lockstep.begin(60);
|
|
3273
|
+
const moves = input.hold?.moves ?? [];
|
|
3274
|
+
const holdState = {
|
|
3275
|
+
nextMove: 0,
|
|
3276
|
+
pressFrameIndex: null,
|
|
3277
|
+
released: false,
|
|
3278
|
+
x: input.hold?.x ?? 0,
|
|
3279
|
+
y: input.hold?.y ?? 0
|
|
3280
|
+
};
|
|
3281
|
+
const holdEventFrameIndex = (afterMs) => (holdState.pressFrameIndex ?? 0) + Math.ceil(afterMs / frameMs);
|
|
3282
|
+
const nextHoldEventFrameIndex = () => {
|
|
3283
|
+
if (!input.hold || holdState.released || holdState.pressFrameIndex === null) {
|
|
3284
|
+
return null;
|
|
3285
|
+
}
|
|
3286
|
+
const move = moves[holdState.nextMove];
|
|
3287
|
+
return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs);
|
|
3288
|
+
};
|
|
3289
|
+
const advanceHold = async (receipt) => {
|
|
3290
|
+
if (!input.hold || !interact || holdState.released) return;
|
|
3291
|
+
if (holdState.pressFrameIndex === null) {
|
|
3292
|
+
holdState.pressFrameIndex = receipt.frameIndex - 1;
|
|
3293
|
+
}
|
|
3294
|
+
for (let move = moves[holdState.nextMove]; move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs); move = moves[holdState.nextMove]) {
|
|
3295
|
+
holdState.nextMove++;
|
|
3296
|
+
holdState.x = move.x;
|
|
3297
|
+
holdState.y = move.y;
|
|
3298
|
+
await interact.touchMove(move.x, move.y);
|
|
3299
|
+
}
|
|
3300
|
+
if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return;
|
|
3301
|
+
holdState.released = true;
|
|
3302
|
+
await interact.touchUp(holdState.x, holdState.y);
|
|
3303
|
+
};
|
|
3304
|
+
try {
|
|
3305
|
+
const baselineBitmap = await captureBitmap();
|
|
3306
|
+
const baseline = regionPixels(baselineBitmap);
|
|
3307
|
+
baselineBitmap.close();
|
|
3308
|
+
state.ready = true;
|
|
3309
|
+
if (input.hold && interact) {
|
|
3310
|
+
const hit = await interact.touchDown(input.hold.x, input.hold.y);
|
|
3311
|
+
if (!hit) {
|
|
3312
|
+
throw new Error(`rnx motion hold missed at ${input.hold.x},${input.hold.y}`);
|
|
3313
|
+
}
|
|
3314
|
+
} else {
|
|
3315
|
+
while (!state.triggerCompleted) {
|
|
3316
|
+
if (state.cancelled) throw new Error("rnx motion capture was cancelled");
|
|
3317
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
let visibleStart = null;
|
|
3321
|
+
let detectedMeasurement = null;
|
|
3322
|
+
const detectionFrameLimit = Math.ceil(input.detectionTimeoutMs * 60 / 1e3);
|
|
3323
|
+
for (let frame = 0; frame < detectionFrameLimit; frame++) {
|
|
3324
|
+
if (state.cancelled) throw new Error("rnx motion capture was cancelled");
|
|
3325
|
+
const receipt = await controller.stepFrame();
|
|
3326
|
+
await advanceHold(receipt);
|
|
3327
|
+
const candidateBitmap = await captureBitmap();
|
|
3328
|
+
const bounds = boundsFor(candidateBitmap);
|
|
3329
|
+
const measurement = measureChange({
|
|
3330
|
+
baseline,
|
|
3331
|
+
candidate: regionPixels(candidateBitmap),
|
|
3332
|
+
definition: input.motionChangeDefinition,
|
|
3333
|
+
height: bounds.height,
|
|
3334
|
+
label: "SootSim visible-change detector",
|
|
3335
|
+
width: bounds.width
|
|
3336
|
+
});
|
|
3337
|
+
candidateBitmap.close();
|
|
3338
|
+
if (measurement.passedMinimum) {
|
|
3339
|
+
visibleStart = receipt;
|
|
3340
|
+
detectedMeasurement = measurement;
|
|
3341
|
+
break;
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
if (!visibleStart || !detectedMeasurement) {
|
|
3345
|
+
throw new Error(
|
|
3346
|
+
`rnx motion had no visible change before ${input.detectionTimeoutMs}ms`
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
const snapshots = [];
|
|
3350
|
+
for (const stage of input.stages) {
|
|
3351
|
+
if (state.cancelled) throw new Error("rnx motion capture was cancelled");
|
|
3352
|
+
const stageFrameIndex = visibleStart.frameIndex + Math.ceil(stage.offsetMs / frameMs);
|
|
3353
|
+
for (let eventFrameIndex = nextHoldEventFrameIndex(); eventFrameIndex !== null && stageFrameIndex > eventFrameIndex; eventFrameIndex = nextHoldEventFrameIndex()) {
|
|
3354
|
+
await advanceHold(await controller.stepFrame());
|
|
3355
|
+
}
|
|
3356
|
+
const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs);
|
|
3357
|
+
await advanceHold(receipt);
|
|
3358
|
+
snapshots.push({
|
|
3359
|
+
actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
|
|
3360
|
+
bitmap: await captureBitmap(),
|
|
3361
|
+
frameIndex: receipt.frameIndex,
|
|
3362
|
+
label: stage.label,
|
|
3363
|
+
offsetMs: stage.offsetMs,
|
|
3364
|
+
virtualTimestampMs: receipt.virtualTimestampMs
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
if (input.settleMs > 0) {
|
|
3368
|
+
const maximumOffsetMs = input.stages.reduce(
|
|
3369
|
+
(maximum, stage) => Math.max(maximum, stage.offsetMs),
|
|
3370
|
+
0
|
|
3371
|
+
);
|
|
3372
|
+
await controller.stepToOffset(visibleStart, maximumOffsetMs + input.settleMs);
|
|
3373
|
+
}
|
|
3374
|
+
return {
|
|
3375
|
+
frames: snapshots.map(encode),
|
|
3376
|
+
visibleChangeDetection: {
|
|
3377
|
+
actualMs: visibleStart.frameIndex * 1e3 / 60,
|
|
3378
|
+
changedPixelPercent: detectedMeasurement.changePercent,
|
|
3379
|
+
changedPixels: detectedMeasurement.changePixels,
|
|
3380
|
+
channelThreshold: input.motionChangeDefinition.channelThreshold,
|
|
3381
|
+
minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
|
|
3382
|
+
totalPixels: detectedMeasurement.totalPixels,
|
|
3383
|
+
frameIndex: visibleStart.frameIndex,
|
|
3384
|
+
virtualTimestampMs: visibleStart.virtualTimestampMs
|
|
3385
|
+
}
|
|
3386
|
+
};
|
|
3387
|
+
} finally {
|
|
3388
|
+
controller.end();
|
|
3389
|
+
delete registry[input.token];
|
|
3390
|
+
}
|
|
3391
|
+
},
|
|
3392
|
+
{
|
|
3393
|
+
detectionTimeoutMs: options.detectionTimeoutMs ?? 3e3,
|
|
3394
|
+
frame: options.frame ?? null,
|
|
3395
|
+
hold: options.hold ?? null,
|
|
3396
|
+
motionChangeDefinition: import_motion_change.MOTION_CHANGE_DEFINITION,
|
|
3397
|
+
rootFrame: options.rootFrame ?? null,
|
|
3398
|
+
settleMs,
|
|
3399
|
+
stages,
|
|
3400
|
+
token
|
|
3401
|
+
}
|
|
3402
|
+
);
|
|
3403
|
+
await page.waitForFunction(
|
|
3404
|
+
(captureToken) => {
|
|
3405
|
+
const registry = window.__sootsimVisibleChangeCaptures;
|
|
3406
|
+
return registry?.[captureToken]?.ready === true;
|
|
3407
|
+
},
|
|
3408
|
+
token,
|
|
3409
|
+
{ timeout: 5e3 }
|
|
3410
|
+
);
|
|
3411
|
+
const triggerStartMs = Date.now();
|
|
3412
|
+
let triggerCompletedMs = 0;
|
|
3413
|
+
try {
|
|
3414
|
+
if (!options.hold) {
|
|
3415
|
+
if (typeof options.trigger !== "function") {
|
|
3416
|
+
throw new Error("rnx motion capture needs a trigger or a hold");
|
|
3417
|
+
}
|
|
3418
|
+
await Promise.resolve(options.trigger());
|
|
3419
|
+
}
|
|
3420
|
+
triggerCompletedMs = Date.now();
|
|
3421
|
+
await page.evaluate((captureToken) => {
|
|
3422
|
+
const registry = window.__sootsimVisibleChangeCaptures;
|
|
3423
|
+
const capture2 = registry?.[captureToken];
|
|
3424
|
+
if (!capture2) throw new Error("rnx motion capture disarmed before trigger");
|
|
3425
|
+
capture2.triggerCompleted = true;
|
|
3426
|
+
}, token);
|
|
3427
|
+
const capture = await capturePromise;
|
|
3428
|
+
return {
|
|
3429
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
3430
|
+
triggerCompletedMs,
|
|
3431
|
+
triggerStartMs,
|
|
3432
|
+
visibleChangeDetection: {
|
|
3433
|
+
...capture.visibleChangeDetection,
|
|
3434
|
+
frame: options.frame ?? null,
|
|
3435
|
+
rootFrame: options.rootFrame ?? null
|
|
3436
|
+
}
|
|
3437
|
+
};
|
|
3438
|
+
} catch (error) {
|
|
3439
|
+
await page.evaluate((captureToken) => {
|
|
3440
|
+
const registry = window.__sootsimVisibleChangeCaptures;
|
|
3441
|
+
const capture = registry?.[captureToken];
|
|
3442
|
+
if (capture) capture.cancelled = true;
|
|
3443
|
+
}, token).catch(() => {
|
|
3444
|
+
});
|
|
3445
|
+
await capturePromise.catch(() => {
|
|
3446
|
+
});
|
|
3447
|
+
throw error;
|
|
3448
|
+
}
|
|
3449
|
+
},
|
|
3450
|
+
// FAST multi-stage capture for sub-second animation diffs. each stage
|
|
3451
|
+
// schedules a setTimeout at its requested offsetMs and at that moment
|
|
3452
|
+
// SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
|
|
3453
|
+
// (drawImage from the worker-transferred home + overlay canvases). PNG
|
|
3454
|
+
// encoding is queued after all snapshots land — encoding stalls don't
|
|
3455
|
+
// contaminate the animation window.
|
|
3456
|
+
//
|
|
3457
|
+
// unlike `takeScreenshotStages`, this does NOT go through screenshot()
|
|
3458
|
+
// (which calls forceRenderAll + composite + toDataURL serially at
|
|
3459
|
+
// 300-1300ms per call and blows the spring-open timing). the worker's
|
|
3460
|
+
// vsync-pumped rAF keeps the home canvas fresh; we just sample it.
|
|
3461
|
+
//
|
|
3462
|
+
// returns paths in stage order; actualMs reports when the snapshot
|
|
3463
|
+
// actually fired (typically within a few ms of offsetMs).
|
|
3464
|
+
async takeScreenshotStagesFast(namePrefix, stages, options) {
|
|
3465
|
+
const page = getPage();
|
|
3466
|
+
const token = `timed-${process.pid}-${Date.now()}-${Math.random()}`;
|
|
3467
|
+
const capturePromise = page.evaluate(
|
|
3468
|
+
async (input) => {
|
|
3469
|
+
const captureWindow = window;
|
|
3470
|
+
const screenshot = captureWindow.SootSim?.bridges?.screenshot;
|
|
3471
|
+
const live = captureWindow.SootSim?.bridges?.liveComposite;
|
|
3472
|
+
if (typeof screenshot !== "function") {
|
|
3473
|
+
throw new Error("rnx screenshot bridge is not installed");
|
|
3474
|
+
}
|
|
3475
|
+
const registry = captureWindow.__sootsimTimedCaptures ?? (captureWindow.__sootsimTimedCaptures = {});
|
|
3476
|
+
const state = {
|
|
3477
|
+
anchorPerformanceMs: null,
|
|
3478
|
+
anchorWallMs: null,
|
|
3479
|
+
cancelled: false,
|
|
3480
|
+
ready: false
|
|
3481
|
+
};
|
|
3482
|
+
registry[input.token] = state;
|
|
3483
|
+
if (!input.skipWarmup) {
|
|
3484
|
+
await screenshot({ format: "png" });
|
|
3485
|
+
}
|
|
3486
|
+
state.ready = true;
|
|
3487
|
+
while (state.anchorPerformanceMs === null || state.anchorWallMs === null) {
|
|
3488
|
+
if (state.cancelled) throw new Error("rnx timed capture was cancelled");
|
|
3489
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3490
|
+
}
|
|
3491
|
+
const captureAnchorMs = state.anchorWallMs;
|
|
3492
|
+
const captureAnchorPerformanceMs = state.anchorPerformanceMs;
|
|
3493
|
+
const snaps = await Promise.all(
|
|
3494
|
+
input.stages.map(
|
|
3495
|
+
(stage) => new Promise((resolve) => {
|
|
3496
|
+
const fire = async () => {
|
|
3497
|
+
const actualMs = performance.now() - captureAnchorPerformanceMs;
|
|
3498
|
+
const captureRequestedAtMs = Date.now();
|
|
3499
|
+
try {
|
|
3500
|
+
let dataUrl = null;
|
|
3501
|
+
if (live && typeof live.captureFresh === "function") {
|
|
3502
|
+
const { canvas } = await live.captureFresh();
|
|
3503
|
+
if (canvas) {
|
|
3504
|
+
const bmp = await createImageBitmap(canvas);
|
|
3505
|
+
const enc = document.createElement("canvas");
|
|
3506
|
+
enc.width = bmp.width;
|
|
3507
|
+
enc.height = bmp.height;
|
|
3508
|
+
const ctx = enc.getContext("2d");
|
|
3509
|
+
if (ctx) {
|
|
3510
|
+
ctx.drawImage(bmp, 0, 0);
|
|
3511
|
+
dataUrl = enc.toDataURL("image/png");
|
|
3512
|
+
}
|
|
3513
|
+
bmp.close();
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
if (!dataUrl) {
|
|
3517
|
+
dataUrl = await screenshot({ format: "png" });
|
|
3518
|
+
}
|
|
3519
|
+
resolve({
|
|
3520
|
+
label: stage.label,
|
|
3521
|
+
offsetMs: stage.offsetMs,
|
|
3522
|
+
actualMs,
|
|
3523
|
+
captureRequestedAtMs,
|
|
3524
|
+
dataUrl
|
|
3525
|
+
});
|
|
3526
|
+
} catch {
|
|
3527
|
+
resolve({
|
|
3528
|
+
label: stage.label,
|
|
3529
|
+
offsetMs: stage.offsetMs,
|
|
3530
|
+
actualMs,
|
|
3531
|
+
captureRequestedAtMs,
|
|
3532
|
+
dataUrl: null
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
};
|
|
3536
|
+
setTimeout(
|
|
3537
|
+
() => {
|
|
3538
|
+
void fire();
|
|
3539
|
+
},
|
|
3540
|
+
Math.max(
|
|
3541
|
+
0,
|
|
3542
|
+
captureAnchorPerformanceMs + stage.offsetMs - performance.now()
|
|
3543
|
+
)
|
|
3544
|
+
);
|
|
3545
|
+
})
|
|
3546
|
+
)
|
|
3547
|
+
);
|
|
3548
|
+
const out = [];
|
|
3549
|
+
for (const snap of snaps) {
|
|
3550
|
+
if (!snap.dataUrl) continue;
|
|
3551
|
+
out.push({
|
|
3552
|
+
label: snap.label,
|
|
3553
|
+
offsetMs: snap.offsetMs,
|
|
3554
|
+
actualMs: snap.actualMs,
|
|
3555
|
+
captureRequestedAtMs: snap.captureRequestedAtMs,
|
|
3556
|
+
dataUrl: snap.dataUrl
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
delete registry[input.token];
|
|
3560
|
+
return { captureAnchorMs, frames: out };
|
|
3561
|
+
},
|
|
3562
|
+
{ stages, skipWarmup: options.skipWarmup === true, token }
|
|
3563
|
+
);
|
|
3564
|
+
await page.waitForFunction(
|
|
3565
|
+
(captureToken) => {
|
|
3566
|
+
const registry = window.__sootsimTimedCaptures;
|
|
3567
|
+
return registry?.[captureToken]?.ready === true;
|
|
3568
|
+
},
|
|
3569
|
+
token,
|
|
3570
|
+
{ timeout: 5e3 }
|
|
3571
|
+
);
|
|
3572
|
+
const armCapture = (anchorMs) => page.evaluate(
|
|
3573
|
+
({ anchorMs: anchorMs2, captureToken }) => {
|
|
3574
|
+
const registry = window.__sootsimTimedCaptures;
|
|
3575
|
+
const capture = registry?.[captureToken];
|
|
3576
|
+
if (!capture?.ready) throw new Error("rnx timed capture was not armed");
|
|
3577
|
+
const nowMs = Date.now();
|
|
3578
|
+
capture.anchorWallMs = anchorMs2 ?? nowMs;
|
|
3579
|
+
capture.anchorPerformanceMs = performance.now() - (nowMs - capture.anchorWallMs);
|
|
3580
|
+
return capture.anchorWallMs;
|
|
3581
|
+
},
|
|
3582
|
+
{ anchorMs, captureToken: token }
|
|
3583
|
+
);
|
|
3584
|
+
let triggerStartMs = 0;
|
|
3585
|
+
let triggerCompletedMs = 0;
|
|
3586
|
+
try {
|
|
3587
|
+
let captureAnchorMs;
|
|
3588
|
+
if (options.anchorAfterTrigger === false) {
|
|
3589
|
+
captureAnchorMs = await armCapture();
|
|
3590
|
+
triggerStartMs = captureAnchorMs;
|
|
3591
|
+
const triggerPromise = Promise.resolve().then(options.trigger).then(() => {
|
|
3592
|
+
triggerCompletedMs = Date.now();
|
|
3593
|
+
});
|
|
3594
|
+
const [capture2] = await Promise.all([capturePromise, triggerPromise]);
|
|
3595
|
+
return {
|
|
3596
|
+
captureAnchorMs,
|
|
3597
|
+
frames: writeFastScreenshotFrames(namePrefix, capture2.frames),
|
|
3598
|
+
triggerCompletedMs,
|
|
3599
|
+
triggerStartMs
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
triggerStartMs = Date.now();
|
|
3603
|
+
await Promise.resolve(options.trigger());
|
|
3604
|
+
triggerCompletedMs = Date.now();
|
|
3605
|
+
captureAnchorMs = await armCapture(triggerCompletedMs);
|
|
3606
|
+
const capture = await capturePromise;
|
|
3607
|
+
return {
|
|
3608
|
+
captureAnchorMs,
|
|
3609
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
3610
|
+
triggerCompletedMs,
|
|
3611
|
+
triggerStartMs
|
|
3612
|
+
};
|
|
3613
|
+
} catch (error) {
|
|
3614
|
+
await page.evaluate((captureToken) => {
|
|
3615
|
+
const registry = window.__sootsimTimedCaptures;
|
|
3616
|
+
const capture = registry?.[captureToken];
|
|
3617
|
+
if (capture) capture.cancelled = true;
|
|
3618
|
+
}, token).catch(() => {
|
|
3619
|
+
});
|
|
3620
|
+
await capturePromise.catch(() => {
|
|
3621
|
+
});
|
|
3622
|
+
throw error;
|
|
3623
|
+
}
|
|
3624
|
+
},
|
|
3625
|
+
// batched multi-stage capture for animation diffs. all N captures happen
|
|
3626
|
+
// inside one page.evaluate round trip so browser-side timing controls the
|
|
3627
|
+
// offsets — sidesteps the ~300ms per-call bridge overhead that blows
|
|
3628
|
+
// sub-second animation windows. each stage gets a screenshot at the
|
|
3629
|
+
// requested offsetMs (from when the evaluate starts, i.e. right after
|
|
3630
|
+
// trigger() returns). returns absolute paths in stage order.
|
|
3631
|
+
async takeScreenshotStages(namePrefix, stages) {
|
|
3632
|
+
const page = getPage();
|
|
3633
|
+
const frames = await page.evaluate(
|
|
3634
|
+
async (input) => {
|
|
3635
|
+
const screenshot = window.SootSim?.bridges?.screenshot;
|
|
3636
|
+
if (typeof screenshot !== "function") {
|
|
3637
|
+
throw new Error("rnx screenshot bridge is not installed");
|
|
3638
|
+
}
|
|
3639
|
+
const start = Date.now();
|
|
3640
|
+
const out = [];
|
|
3641
|
+
for (const stage of input.stages) {
|
|
3642
|
+
const elapsed = Date.now() - start;
|
|
3643
|
+
const wait = Math.max(0, stage.offsetMs - elapsed);
|
|
3644
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
3645
|
+
const actualMs = Date.now() - start;
|
|
3646
|
+
const dataUrl = await screenshot({ format: "png" });
|
|
3647
|
+
out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl });
|
|
3648
|
+
}
|
|
3649
|
+
return out;
|
|
3650
|
+
},
|
|
3651
|
+
{ stages }
|
|
3652
|
+
);
|
|
3653
|
+
return writeFastScreenshotFrames(namePrefix, frames);
|
|
3654
|
+
},
|
|
3655
|
+
async shake() {
|
|
3656
|
+
},
|
|
3657
|
+
async setLocation(lat, lon) {
|
|
3658
|
+
},
|
|
3659
|
+
async setStatusBar(config) {
|
|
3660
|
+
await dispatchStatusBarOverride(config);
|
|
3661
|
+
},
|
|
3662
|
+
async setURLBlacklist(urls) {
|
|
3663
|
+
},
|
|
3664
|
+
async enableSynchronization() {
|
|
3665
|
+
_synchronizationEnabled = true;
|
|
3666
|
+
},
|
|
3667
|
+
async disableSynchronization() {
|
|
3668
|
+
_synchronizationEnabled = false;
|
|
3669
|
+
},
|
|
3670
|
+
getPlatform() {
|
|
3671
|
+
return device._platform;
|
|
3672
|
+
},
|
|
3673
|
+
async pressBack() {
|
|
3674
|
+
if (device._platform !== "android") return;
|
|
3675
|
+
const page = getPage();
|
|
3676
|
+
const dispatched = await page.evaluate(async () => {
|
|
3677
|
+
const goBack = window.SootSim?.bridges?.mainShell?.goBack;
|
|
3678
|
+
if (typeof goBack !== "function") return false;
|
|
3679
|
+
return await goBack() !== false;
|
|
3680
|
+
});
|
|
3681
|
+
if (!dispatched) throw new Error("sootsim Android back bridge is not installed");
|
|
3682
|
+
if (_synchronizationEnabled) {
|
|
3683
|
+
await waitForSootsimIdle(page);
|
|
3684
|
+
}
|
|
3685
|
+
},
|
|
3686
|
+
// a real browser key press. host.ts turns window keydown into the
|
|
3687
|
+
// `sootsim:hostKey` shell event that overlays listen to, so this drives the
|
|
3688
|
+
// same path a desktop user does for keys no device button covers (Escape).
|
|
3689
|
+
async pressKey(key) {
|
|
3690
|
+
const page = getPage();
|
|
3691
|
+
await page.keyboard.press(key);
|
|
3692
|
+
if (_synchronizationEnabled) {
|
|
3693
|
+
await waitForSootsimIdle(page);
|
|
3694
|
+
}
|
|
3695
|
+
},
|
|
3696
|
+
// raw coordinate tap, bypassing the element-based visibility/hit-test path.
|
|
3697
|
+
// mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
|
|
3698
|
+
// target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
|
|
3699
|
+
// `element(...).tap({x,y})` would fail Detox's visibility threshold.
|
|
3700
|
+
async tap(point) {
|
|
3701
|
+
if (point == null || typeof point === "boolean") return;
|
|
3702
|
+
const page = getPage();
|
|
3703
|
+
const { x, y } = point;
|
|
3704
|
+
await page.evaluate(
|
|
3705
|
+
async ({ x: tx, y: ty }) => {
|
|
3706
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap;
|
|
3707
|
+
if (typeof interactTap === "function") {
|
|
3708
|
+
const result = await interactTap(tx, ty);
|
|
3709
|
+
if (result && (typeof result !== "object" || result.hit !== false)) return;
|
|
3710
|
+
}
|
|
3711
|
+
const tap = window.__sootsimTest?.tap;
|
|
3712
|
+
if (typeof tap !== "function") {
|
|
3713
|
+
throw new Error("sootsim tap bridge is not installed");
|
|
3714
|
+
}
|
|
3715
|
+
await tap(tx, ty);
|
|
3716
|
+
},
|
|
3717
|
+
{ x, y }
|
|
3718
|
+
);
|
|
3719
|
+
await page.waitForTimeout(50);
|
|
3720
|
+
if (_synchronizationEnabled) {
|
|
3721
|
+
await waitForSootsimIdle(page);
|
|
3722
|
+
}
|
|
3723
|
+
},
|
|
3724
|
+
// window-point counterpart to element(...).longPress, for a target detox can
|
|
3725
|
+
// only address by coordinate (a native tab cell, a shell chrome control).
|
|
3726
|
+
async longPress(point, durationMs = 1e3) {
|
|
3727
|
+
const page = getPage();
|
|
3728
|
+
const { x, y } = point;
|
|
3729
|
+
await page.evaluate(
|
|
3730
|
+
async ({ x: px, y: py, ms }) => {
|
|
3731
|
+
const longPress = window.SootSim?.bridges?.interact?.longPress;
|
|
3732
|
+
if (typeof longPress !== "function") {
|
|
3733
|
+
throw new Error("sootsim longPress bridge is not installed");
|
|
3734
|
+
}
|
|
3735
|
+
const hit = await longPress(px, py, ms);
|
|
3736
|
+
if (!hit) {
|
|
3737
|
+
throw new Error(`sootsim longPress missed at ${px},${py}`);
|
|
3738
|
+
}
|
|
3739
|
+
},
|
|
3740
|
+
{ x, y, ms: durationMs }
|
|
3741
|
+
);
|
|
3742
|
+
await page.waitForTimeout(50);
|
|
3743
|
+
if (_synchronizationEnabled) {
|
|
3744
|
+
await waitForSootsimIdle(page);
|
|
3745
|
+
}
|
|
3746
|
+
},
|
|
3747
|
+
async sendToHome() {
|
|
3748
|
+
const page = getPage();
|
|
3749
|
+
_suspendedAppId = await page.evaluate(async () => {
|
|
3750
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.();
|
|
3751
|
+
const appId = state?.activeApp;
|
|
3752
|
+
if (typeof appId !== "string") {
|
|
3753
|
+
throw new Error(`cannot send an inactive SootSim app home`);
|
|
3754
|
+
}
|
|
3755
|
+
window.SootSim?.bridges?.mainShell?.goHome?.();
|
|
3756
|
+
return appId;
|
|
3757
|
+
});
|
|
3758
|
+
await pollUntil(
|
|
3759
|
+
() => page.evaluate(async () => {
|
|
3760
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.();
|
|
3761
|
+
return state?.state === "home" && state.switcherPhase === "idle";
|
|
3762
|
+
}),
|
|
3763
|
+
Boolean,
|
|
3764
|
+
{
|
|
3765
|
+
timeoutMs: 1e4,
|
|
3766
|
+
label: "SootSim home transition"
|
|
3767
|
+
}
|
|
3768
|
+
);
|
|
3769
|
+
},
|
|
3770
|
+
// get direct access to the playwright page for advanced use
|
|
3771
|
+
getPage() {
|
|
3772
|
+
return getPage();
|
|
3773
|
+
}
|
|
3774
|
+
};
|
|
3775
|
+
var sootsimExpect = createExpect(findNodeByMatcher, getPage);
|
|
3776
|
+
var sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage);
|
|
3777
|
+
async function cleanup() {
|
|
3778
|
+
await closeBrowser();
|
|
3779
|
+
}
|
|
3780
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3781
|
+
0 && (module.exports = {
|
|
3782
|
+
by,
|
|
3783
|
+
cleanup,
|
|
3784
|
+
device,
|
|
3785
|
+
element,
|
|
3786
|
+
expect,
|
|
3787
|
+
waitFor,
|
|
3788
|
+
waitForSootsimIdle
|
|
3789
|
+
});
|