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
package/detox/index.ts
ADDED
|
@@ -0,0 +1,2694 @@
|
|
|
1
|
+
// detox-compatible test driver for sootsim
|
|
2
|
+
// drop-in replacement for `import { by, device, element, expect, waitFor } from 'detox'`
|
|
3
|
+
// uses playwright to control a headless browser running sootsim
|
|
4
|
+
|
|
5
|
+
import * as fs from 'fs'
|
|
6
|
+
import * as path from 'path'
|
|
7
|
+
import { chromium } from 'playwright'
|
|
8
|
+
import { launchReapedChromeScoped } from '../../../scripts/lib/reap-browser'
|
|
9
|
+
import {
|
|
10
|
+
readBrowserCompositeCaptureRequest,
|
|
11
|
+
resolveBrowserCompositeCdpClip,
|
|
12
|
+
} from '../../sootsim-engine/src/capture/browser-composite'
|
|
13
|
+
import { pollUntil } from '../src/poll-until'
|
|
14
|
+
import {
|
|
15
|
+
createExpect,
|
|
16
|
+
createWaitFor,
|
|
17
|
+
describeNodeVisibility,
|
|
18
|
+
isNodeVisible,
|
|
19
|
+
} from './expectations'
|
|
20
|
+
import {
|
|
21
|
+
dispatchTwoFingerGesture,
|
|
22
|
+
dragScrollNode,
|
|
23
|
+
readSootsimInteractiveViewport,
|
|
24
|
+
readSootsimViewport,
|
|
25
|
+
resolveScrollStartPosition,
|
|
26
|
+
sootsimToPage,
|
|
27
|
+
waitForSootsimGestureHandler,
|
|
28
|
+
} from './gestures'
|
|
29
|
+
import { by, type Matcher } from './matchers'
|
|
30
|
+
import {
|
|
31
|
+
MOTION_CHANGE_DEFINITION,
|
|
32
|
+
measureMotionChange,
|
|
33
|
+
type MotionChangeDefinition,
|
|
34
|
+
type MotionChangeMeasurement,
|
|
35
|
+
} from './motion-change.cjs'
|
|
36
|
+
import { describeSootsimBridgeFailure, navigateSootsimPage } from './navigation-lifecycle'
|
|
37
|
+
import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs'
|
|
38
|
+
import type { SootSimElement } from './element-types'
|
|
39
|
+
import type { Browser, BrowserContext, Page } from 'playwright'
|
|
40
|
+
|
|
41
|
+
export { by }
|
|
42
|
+
export type { SootSimElement } from './element-types'
|
|
43
|
+
|
|
44
|
+
const BASE_URL = process.env.RNX_URL || 'http://localhost:5173'
|
|
45
|
+
const SCREENSHOT_DIR =
|
|
46
|
+
process.env.RNX_SCREENSHOT_DIR ||
|
|
47
|
+
path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
|
|
48
|
+
const DETOX_PLATFORM =
|
|
49
|
+
process.env.RNX_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
|
|
50
|
+
|
|
51
|
+
// shared state -- playwright page and browser
|
|
52
|
+
let _browser: Browser | null = null
|
|
53
|
+
let _reapBrowserNow: (() => void) | null = null
|
|
54
|
+
let _context: BrowserContext | null = null
|
|
55
|
+
let _page: Page | null = null
|
|
56
|
+
let _synchronizationEnabled = false
|
|
57
|
+
let _suspendedAppId: string | null = null
|
|
58
|
+
let _browserCompromised: string | null = null
|
|
59
|
+
let browserIdentity = 0
|
|
60
|
+
let contextIdentity = 0
|
|
61
|
+
let nextBrowserIdentity = 0
|
|
62
|
+
let nextContextIdentity = 0
|
|
63
|
+
|
|
64
|
+
// maximum duration for browser/context lifecycle operations (closeContext,
|
|
65
|
+
// newContext, newPage). playwright does not bound context.close() or context
|
|
66
|
+
// creation; this bound converts an opaque 180s Jest hook timeout into a
|
|
67
|
+
// named failure that identifies which lifecycle step stalled.
|
|
68
|
+
const BROWSER_LIFECYCLE_TIMEOUT_MS = 30_000
|
|
69
|
+
|
|
70
|
+
async function withLifecycleTimeout<T>(
|
|
71
|
+
actionName: string,
|
|
72
|
+
targetContextId: number,
|
|
73
|
+
fn: () => Promise<T>,
|
|
74
|
+
timeoutMs = BROWSER_LIFECYCLE_TIMEOUT_MS,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const start = Date.now()
|
|
77
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([
|
|
80
|
+
fn(),
|
|
81
|
+
new Promise<never>((_, reject) => {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
const elapsed = Date.now() - start
|
|
84
|
+
reject(
|
|
85
|
+
new Error(
|
|
86
|
+
`sootsim browser lifecycle error: ${actionName} did not settle after ${elapsed}ms (context=${targetContextId})`,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
}, timeoutMs)
|
|
90
|
+
}),
|
|
91
|
+
])
|
|
92
|
+
} finally {
|
|
93
|
+
if (timer) clearTimeout(timer)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
|
|
98
|
+
|
|
99
|
+
type StatusBarConfig = {
|
|
100
|
+
time?: string
|
|
101
|
+
dataNetwork?: string
|
|
102
|
+
wifiMode?: string
|
|
103
|
+
wifiBars?: string
|
|
104
|
+
cellularMode?: string
|
|
105
|
+
cellularBars?: string
|
|
106
|
+
operatorName?: string
|
|
107
|
+
batteryState?: string
|
|
108
|
+
batteryLevel?: string | number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getPage(): Page {
|
|
112
|
+
if (_browserCompromised) {
|
|
113
|
+
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
|
|
114
|
+
}
|
|
115
|
+
if (!_page)
|
|
116
|
+
throw new Error('sootsim driver not initialized -- call device.launchApp() first')
|
|
117
|
+
return _page
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function waitForSootsimTree(page: Page, timeout = 30000): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
|
|
123
|
+
timeout,
|
|
124
|
+
})
|
|
125
|
+
await page.evaluate(async (timeoutMs) => {
|
|
126
|
+
await Promise.race([
|
|
127
|
+
window.__sootsimTest!.waitForTree(),
|
|
128
|
+
new Promise((_, reject) => {
|
|
129
|
+
setTimeout(
|
|
130
|
+
() => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)),
|
|
131
|
+
timeoutMs,
|
|
132
|
+
)
|
|
133
|
+
}),
|
|
134
|
+
])
|
|
135
|
+
}, timeout)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw await describeSootsimBridgeFailure(page, error)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function waitForSootsimSurfaceMetrics(page: Page, timeout = 30000): Promise<void> {
|
|
142
|
+
const deadline = Date.now() + timeout
|
|
143
|
+
let lastSnapshot: unknown = null
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
lastSnapshot = await page.evaluate(async () => {
|
|
146
|
+
const metricsWindow = (value: unknown): Record<string, unknown> | null => {
|
|
147
|
+
if (!value || typeof value !== 'object') return null
|
|
148
|
+
const windowValue = (value as Record<string, unknown>).window
|
|
149
|
+
if (!windowValue || typeof windowValue !== 'object') return null
|
|
150
|
+
return windowValue as Record<string, unknown>
|
|
151
|
+
}
|
|
152
|
+
const snapshot = await window.__sootsimTest?.getSurfaceMetricsSnapshot?.()
|
|
153
|
+
const windowMetrics = metricsWindow(snapshot)
|
|
154
|
+
const width = windowMetrics?.width
|
|
155
|
+
const height = windowMetrics?.height
|
|
156
|
+
if (
|
|
157
|
+
typeof width === 'number' &&
|
|
158
|
+
typeof height === 'number' &&
|
|
159
|
+
width > 0 &&
|
|
160
|
+
height > 0
|
|
161
|
+
) {
|
|
162
|
+
return { ready: true }
|
|
163
|
+
}
|
|
164
|
+
return { ready: false, snapshot }
|
|
165
|
+
})
|
|
166
|
+
if (
|
|
167
|
+
lastSnapshot &&
|
|
168
|
+
typeof lastSnapshot === 'object' &&
|
|
169
|
+
(lastSnapshot as { ready?: boolean }).ready
|
|
170
|
+
) {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
await page.waitForTimeout(50)
|
|
174
|
+
}
|
|
175
|
+
throw new Error(
|
|
176
|
+
`timed out waiting for sootsim surface metrics: ${JSON.stringify(lastSnapshot)}`,
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function waitForSootsimShellReady(page: Page, timeout = 30000): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
await page.evaluate(async (timeoutMs) => {
|
|
183
|
+
type ShellHostReadyBridge = {
|
|
184
|
+
ready?: Promise<unknown>
|
|
185
|
+
firstContentPainted?: Promise<unknown>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const start = Date.now()
|
|
189
|
+
const deadline = start + timeoutMs
|
|
190
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
191
|
+
const remaining = () => Math.max(1, deadline - Date.now())
|
|
192
|
+
const withTimeout = async <T>(label: string, promise: Promise<T>) => {
|
|
193
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
194
|
+
try {
|
|
195
|
+
await Promise.race([
|
|
196
|
+
promise,
|
|
197
|
+
new Promise((_, reject) => {
|
|
198
|
+
timer = setTimeout(
|
|
199
|
+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
|
200
|
+
remaining(),
|
|
201
|
+
)
|
|
202
|
+
}),
|
|
203
|
+
])
|
|
204
|
+
} finally {
|
|
205
|
+
if (timer) clearTimeout(timer)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const isTenantOnly =
|
|
210
|
+
new URL(window.location.href).searchParams.get('renderMode') === 'tenant-only'
|
|
211
|
+
const currentUrl = new URL(window.location.href)
|
|
212
|
+
const expectsInitialAppPaint =
|
|
213
|
+
currentUrl.searchParams.has('test') || currentUrl.pathname.includes('/app/')
|
|
214
|
+
let shellHost: ShellHostReadyBridge | null = null
|
|
215
|
+
while (Date.now() < deadline) {
|
|
216
|
+
const candidate = (window as any).SootSim?.bridges?.shellHost
|
|
217
|
+
if (candidate || isTenantOnly) {
|
|
218
|
+
shellHost = candidate ?? null
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
await sleep(25)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!shellHost) {
|
|
225
|
+
if (isTenantOnly) return
|
|
226
|
+
throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (shellHost.ready && typeof shellHost.ready.then === 'function') {
|
|
230
|
+
await withTimeout('sootsim shell ready', shellHost.ready)
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
!expectsInitialAppPaint &&
|
|
234
|
+
shellHost.firstContentPainted &&
|
|
235
|
+
typeof shellHost.firstContentPainted.then === 'function'
|
|
236
|
+
) {
|
|
237
|
+
await withTimeout(
|
|
238
|
+
'sootsim shell first content paint',
|
|
239
|
+
shellHost.firstContentPainted,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const waitForScreenTransitions = (window as any).__sootsimTest
|
|
244
|
+
?.waitForScreenTransitions
|
|
245
|
+
if (typeof waitForScreenTransitions === 'function') {
|
|
246
|
+
const transitionResult = await waitForScreenTransitions({
|
|
247
|
+
timeoutMs: Math.min(remaining(), 3000),
|
|
248
|
+
settleMs: 64,
|
|
249
|
+
startWindowMs: 700,
|
|
250
|
+
})
|
|
251
|
+
if (transitionResult?.timedOut === true) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}, timeout)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw await describeSootsimBridgeFailure(page, error)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<void> {
|
|
264
|
+
const result = await page.evaluate(
|
|
265
|
+
async ({ timeout }) => {
|
|
266
|
+
const getPeerStats = (window as any).__sootsimTest?.getPeerStats
|
|
267
|
+
if (typeof getPeerStats !== 'function') {
|
|
268
|
+
return { settled: true, skipped: true }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
272
|
+
const readStats = () =>
|
|
273
|
+
Promise.race([
|
|
274
|
+
getPeerStats(false),
|
|
275
|
+
new Promise((resolve) => setTimeout(() => resolve(null), 250)),
|
|
276
|
+
])
|
|
277
|
+
const start = Date.now()
|
|
278
|
+
const deadline = start + timeout
|
|
279
|
+
const requiredStableMs = 120
|
|
280
|
+
let stableSince: number | null = null
|
|
281
|
+
let lastPending = 0
|
|
282
|
+
await sleep(0)
|
|
283
|
+
|
|
284
|
+
while (Date.now() < deadline) {
|
|
285
|
+
const stats = await readStats()
|
|
286
|
+
const pending =
|
|
287
|
+
stats &&
|
|
288
|
+
typeof stats === 'object' &&
|
|
289
|
+
typeof (stats as { pendingRunWorklets?: unknown }).pendingRunWorklets ===
|
|
290
|
+
'number'
|
|
291
|
+
? stats.pendingRunWorklets
|
|
292
|
+
: 0
|
|
293
|
+
lastPending = pending
|
|
294
|
+
if (pending === 0) {
|
|
295
|
+
stableSince ??= Date.now()
|
|
296
|
+
if (Date.now() - stableSince >= requiredStableMs) {
|
|
297
|
+
return { settled: true, elapsed: Date.now() - start, pending }
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
stableSince = null
|
|
301
|
+
}
|
|
302
|
+
await sleep(25)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
settled: false,
|
|
307
|
+
elapsed: Date.now() - start,
|
|
308
|
+
pending: lastPending,
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
{ timeout },
|
|
312
|
+
)
|
|
313
|
+
if (!result?.settled) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${result?.pending ?? 'unknown'})`,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function reapOwnedBrowser(): void {
|
|
321
|
+
const reap = _reapBrowserNow
|
|
322
|
+
_reapBrowserNow = null
|
|
323
|
+
_browser = null
|
|
324
|
+
try {
|
|
325
|
+
reap?.()
|
|
326
|
+
} catch {}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function closeContext() {
|
|
330
|
+
if (_browserCompromised) {
|
|
331
|
+
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
|
|
332
|
+
}
|
|
333
|
+
if (!_context) return
|
|
334
|
+
const context = _context
|
|
335
|
+
const closingContextId = contextIdentity
|
|
336
|
+
try {
|
|
337
|
+
await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
|
|
338
|
+
_context = null
|
|
339
|
+
_page = null
|
|
340
|
+
contextIdentity = 0
|
|
341
|
+
_synchronizationEnabled = false
|
|
342
|
+
} catch (error) {
|
|
343
|
+
_browserCompromised = error instanceof Error ? error.message : String(error)
|
|
344
|
+
reapOwnedBrowser()
|
|
345
|
+
throw error
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function closeBrowser() {
|
|
350
|
+
if (!_browser) return
|
|
351
|
+
const browser = _browser
|
|
352
|
+
const reapBrowserNow = _reapBrowserNow
|
|
353
|
+
_browser = null
|
|
354
|
+
_reapBrowserNow = null
|
|
355
|
+
browserIdentity = 0
|
|
356
|
+
|
|
357
|
+
let closeError: Error | null = null
|
|
358
|
+
try {
|
|
359
|
+
await closeContext()
|
|
360
|
+
} catch (error) {
|
|
361
|
+
closeError = error instanceof Error ? error : new Error(String(error))
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// reap Chrome directly without waiting on an unbounded browser.close()
|
|
365
|
+
try {
|
|
366
|
+
reapBrowserNow?.()
|
|
367
|
+
} catch {}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
await browser.close()
|
|
371
|
+
} catch {}
|
|
372
|
+
|
|
373
|
+
if (closeError) {
|
|
374
|
+
throw closeError
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export async function waitForSootsimIdle(
|
|
379
|
+
page: Page,
|
|
380
|
+
maxMs = 3000,
|
|
381
|
+
strict = false,
|
|
382
|
+
): Promise<void> {
|
|
383
|
+
const result = await page.evaluate(
|
|
384
|
+
async ({ maxMs, strict }) => {
|
|
385
|
+
const start = Date.now()
|
|
386
|
+
let transitionError: string | null = null
|
|
387
|
+
try {
|
|
388
|
+
const waitForScreenTransitions = (window as any).__sootsimTest
|
|
389
|
+
?.waitForScreenTransitions
|
|
390
|
+
if (typeof waitForScreenTransitions === 'function') {
|
|
391
|
+
const transitionResult = await waitForScreenTransitions({
|
|
392
|
+
timeoutMs: Math.min(maxMs, 1800),
|
|
393
|
+
settleMs: 48,
|
|
394
|
+
startWindowMs: 600,
|
|
395
|
+
})
|
|
396
|
+
if (transitionResult?.timedOut) {
|
|
397
|
+
return {
|
|
398
|
+
settled: false,
|
|
399
|
+
elapsed: transitionResult.waitedMs,
|
|
400
|
+
reason: 'screen transition',
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
transitionError = error instanceof Error ? error.message : String(error)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const deadline = start + maxMs
|
|
409
|
+
const pollMs = 50
|
|
410
|
+
const requiredStablePolls = 3
|
|
411
|
+
const layoutTolerance = 1
|
|
412
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
413
|
+
const isLayoutStable = (a: number[] | null, b: number[]) => {
|
|
414
|
+
if (!a || a.length !== b.length) return false
|
|
415
|
+
for (let i = 0; i < b.length; i++) {
|
|
416
|
+
if (Math.abs(a[i] - b[i]) > layoutTolerance) return false
|
|
417
|
+
}
|
|
418
|
+
return true
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const readSnapshot = async () => {
|
|
422
|
+
const root = (window as any).__sootsimRoot
|
|
423
|
+
const layout: number[] = []
|
|
424
|
+
let animating = false
|
|
425
|
+
let auxSurfaceDebug: unknown = null
|
|
426
|
+
let layoutDirty = false
|
|
427
|
+
let renderStatsAvailable = false
|
|
428
|
+
if (strict) {
|
|
429
|
+
try {
|
|
430
|
+
const renderHost: unknown = Reflect.get(window, '__sootsimRenderHost')
|
|
431
|
+
const queryStats =
|
|
432
|
+
typeof renderHost === 'object' && renderHost !== null
|
|
433
|
+
? Reflect.get(renderHost, 'queryStats')
|
|
434
|
+
: null
|
|
435
|
+
const stats =
|
|
436
|
+
typeof queryStats === 'function' ? await queryStats.call(renderHost) : null
|
|
437
|
+
renderStatsAvailable = !!stats
|
|
438
|
+
auxSurfaceDebug = stats?.auxSurfaceDebug ?? null
|
|
439
|
+
animating =
|
|
440
|
+
stats?.hasActiveAnims === true ||
|
|
441
|
+
stats?.hasActiveNativeAnimations === true ||
|
|
442
|
+
stats?.hasPendingAnimationFrames === true
|
|
443
|
+
layoutDirty = stats?.layoutDirty === true && stats?.renderRequested === true
|
|
444
|
+
} catch {}
|
|
445
|
+
}
|
|
446
|
+
if (root) {
|
|
447
|
+
const walk = (node: any) => {
|
|
448
|
+
if (node.layout && node.layout.width > 0) {
|
|
449
|
+
layout.push(
|
|
450
|
+
Math.round(node.layout.x),
|
|
451
|
+
Math.round(node.layout.y),
|
|
452
|
+
Math.round(node.layout.width),
|
|
453
|
+
Math.round(node.layout.height),
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
for (const child of node.children || []) walk(child)
|
|
457
|
+
}
|
|
458
|
+
walk(root)
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
animating,
|
|
462
|
+
auxSurfaceDebug,
|
|
463
|
+
layout,
|
|
464
|
+
layoutDirty,
|
|
465
|
+
renderStatsAvailable,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let stableLayout: number[] | null = null
|
|
470
|
+
let stable = 0
|
|
471
|
+
let lastSnapshot: Awaited<ReturnType<typeof readSnapshot>> | null = null
|
|
472
|
+
while (Date.now() < deadline) {
|
|
473
|
+
const snapshot = await readSnapshot()
|
|
474
|
+
lastSnapshot = snapshot
|
|
475
|
+
if (
|
|
476
|
+
(!strict || snapshot.renderStatsAvailable) &&
|
|
477
|
+
!snapshot.animating &&
|
|
478
|
+
!snapshot.layoutDirty &&
|
|
479
|
+
isLayoutStable(stableLayout, snapshot.layout)
|
|
480
|
+
) {
|
|
481
|
+
stable++
|
|
482
|
+
if (stable >= requiredStablePolls) {
|
|
483
|
+
return { settled: true, elapsed: Date.now() - start }
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
stableLayout = snapshot.layout
|
|
487
|
+
stable = 0
|
|
488
|
+
}
|
|
489
|
+
await sleep(pollMs)
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
settled: false,
|
|
493
|
+
elapsed: Date.now() - start,
|
|
494
|
+
reason: transitionError
|
|
495
|
+
? `layout fallback after screen transition wait failed: ${transitionError}`
|
|
496
|
+
: strict
|
|
497
|
+
? `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)}`
|
|
498
|
+
: 'layout fallback',
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
{ maxMs, strict },
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if (!result?.settled) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`,
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// find a node in the sootsim tree matching a matcher descriptor
|
|
512
|
+
async function findNodeByMatcher(matcher: Matcher): Promise<any> {
|
|
513
|
+
const page = getPage()
|
|
514
|
+
if (matcher.index !== undefined) {
|
|
515
|
+
return page.evaluate(async (indexedMatcher: Matcher) => {
|
|
516
|
+
const filter =
|
|
517
|
+
indexedMatcher.type === 'id'
|
|
518
|
+
? { hasId: indexedMatcher.value }
|
|
519
|
+
: indexedMatcher.type === 'text'
|
|
520
|
+
? { hasText: indexedMatcher.value }
|
|
521
|
+
: indexedMatcher.type === 'label'
|
|
522
|
+
? { hasLabel: indexedMatcher.value }
|
|
523
|
+
: indexedMatcher.type === 'role'
|
|
524
|
+
? { hasRole: indexedMatcher.value }
|
|
525
|
+
: { type: indexedMatcher.value }
|
|
526
|
+
const results = await window.__sootsimTest!.queryAll(filter)
|
|
527
|
+
// detox indexes by.text over the text elements that read exactly that
|
|
528
|
+
// text. hasText also matches every ancestor, so index 0 would be the root.
|
|
529
|
+
const indexed =
|
|
530
|
+
indexedMatcher.type === 'text'
|
|
531
|
+
? results.filter(
|
|
532
|
+
(node) => node.type === 'text' && node.text === indexedMatcher.value,
|
|
533
|
+
)
|
|
534
|
+
: results
|
|
535
|
+
return indexed[indexedMatcher.index ?? 0] ?? null
|
|
536
|
+
}, matcher)
|
|
537
|
+
}
|
|
538
|
+
if (matcher.type === 'id') {
|
|
539
|
+
return page.evaluate(
|
|
540
|
+
(id: string) => window.__sootsimTest!.findByTestId(id),
|
|
541
|
+
matcher.value,
|
|
542
|
+
)
|
|
543
|
+
} else if (matcher.type === 'text') {
|
|
544
|
+
return page.evaluate(
|
|
545
|
+
(text: string) => window.__sootsimTest!.findByText(text),
|
|
546
|
+
matcher.value,
|
|
547
|
+
)
|
|
548
|
+
} else if (matcher.type === 'label') {
|
|
549
|
+
return page.evaluate(
|
|
550
|
+
(label: string) => window.__sootsimTest!.findByLabel(label),
|
|
551
|
+
matcher.value,
|
|
552
|
+
)
|
|
553
|
+
} else if (matcher.type === 'role') {
|
|
554
|
+
return page.evaluate(
|
|
555
|
+
(role: string) => window.__sootsimTest!.findByRole(role),
|
|
556
|
+
matcher.value,
|
|
557
|
+
)
|
|
558
|
+
} else if (matcher.type === 'type') {
|
|
559
|
+
return page.evaluate(async (type: string) => {
|
|
560
|
+
const results = await window.__sootsimTest!.queryAll({ type })
|
|
561
|
+
return results[0] || null
|
|
562
|
+
}, matcher.value)
|
|
563
|
+
}
|
|
564
|
+
return null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// get the absolute center coordinates of a node in sootsim coordinate space
|
|
568
|
+
function getNodeCenter(nodeInfo: any): { x: number; y: number } {
|
|
569
|
+
return {
|
|
570
|
+
x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
|
|
571
|
+
y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2,
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function nodeOffsetToPage(
|
|
576
|
+
page: Page,
|
|
577
|
+
nodeInfo: any,
|
|
578
|
+
dx: number,
|
|
579
|
+
dy: number,
|
|
580
|
+
): Promise<{ x: number; y: number }> {
|
|
581
|
+
const center = getNodeCenter(nodeInfo)
|
|
582
|
+
return sootsimToPage(page, center.x + dx, center.y + dy)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function getDefaultTapPoint(
|
|
586
|
+
page: Page,
|
|
587
|
+
nodeInfo: any,
|
|
588
|
+
): Promise<{ x: number; y: number }> {
|
|
589
|
+
const resolvedTarget = await page.evaluate(
|
|
590
|
+
(nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null,
|
|
591
|
+
nodeInfo.nodeId,
|
|
592
|
+
)
|
|
593
|
+
const center =
|
|
594
|
+
resolvedTarget &&
|
|
595
|
+
typeof resolvedTarget.cx === 'number' &&
|
|
596
|
+
typeof resolvedTarget.cy === 'number'
|
|
597
|
+
? { x: resolvedTarget.cx, y: resolvedTarget.cy }
|
|
598
|
+
: getNodeCenter(nodeInfo)
|
|
599
|
+
const frame = nodeInfo.visibleFrame ?? {
|
|
600
|
+
x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
|
|
601
|
+
y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
|
|
602
|
+
width: nodeInfo.layout?.width ?? 0,
|
|
603
|
+
height: nodeInfo.layout?.height ?? 0,
|
|
604
|
+
}
|
|
605
|
+
const viewport = await readSootsimInteractiveViewport(page)
|
|
606
|
+
const fullFrameTolerance = 0.5
|
|
607
|
+
// the resolved center may belong to a descendant of the matched frame.
|
|
608
|
+
const canTapCenter =
|
|
609
|
+
typeof nodeInfo.absolutePosition?.x === 'number' &&
|
|
610
|
+
typeof nodeInfo.absolutePosition?.y === 'number' &&
|
|
611
|
+
typeof nodeInfo.layout?.width === 'number' &&
|
|
612
|
+
typeof nodeInfo.layout?.height === 'number' &&
|
|
613
|
+
Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance &&
|
|
614
|
+
Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance &&
|
|
615
|
+
frame.width >= nodeInfo.layout.width - fullFrameTolerance &&
|
|
616
|
+
frame.height >= nodeInfo.layout.height - fullFrameTolerance &&
|
|
617
|
+
center.x >= 0 &&
|
|
618
|
+
center.y >= 0 &&
|
|
619
|
+
center.x < viewport.width &&
|
|
620
|
+
center.y < viewport.height
|
|
621
|
+
if (canTapCenter) return center
|
|
622
|
+
|
|
623
|
+
const left = Math.max(0, frame.x)
|
|
624
|
+
const top = Math.max(0, frame.y)
|
|
625
|
+
const right = Math.min(viewport.width, frame.x + frame.width)
|
|
626
|
+
const bottom = Math.min(viewport.height, frame.y + frame.height)
|
|
627
|
+
|
|
628
|
+
if (right <= left || bottom <= top) {
|
|
629
|
+
return center
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
x: left + (right - left) / 2,
|
|
634
|
+
y: top + (bottom - top) / 2,
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) {
|
|
639
|
+
await page.evaluate(
|
|
640
|
+
async ({ x, y }) => {
|
|
641
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
642
|
+
if (typeof interactTap === 'function') {
|
|
643
|
+
const result = await interactTap(x, y)
|
|
644
|
+
if (result && (typeof result !== 'object' || result.hit !== false)) return
|
|
645
|
+
}
|
|
646
|
+
const tap = window.__sootsimTest?.tap
|
|
647
|
+
if (typeof tap !== 'function') {
|
|
648
|
+
throw new Error('sootsim tap bridge is not installed')
|
|
649
|
+
}
|
|
650
|
+
await tap(x, y)
|
|
651
|
+
},
|
|
652
|
+
{ x, y },
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
|
|
657
|
+
const focused = await page.evaluate(
|
|
658
|
+
() => window.__sootsimTest!.getFocusedNode?.() ?? null,
|
|
659
|
+
)
|
|
660
|
+
return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
|
|
664
|
+
const page = getPage()
|
|
665
|
+
const node = await findNodeByMatcher(el._matcher)
|
|
666
|
+
if (!node) {
|
|
667
|
+
throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`)
|
|
668
|
+
}
|
|
669
|
+
if (await isFocusedTextInputNode(page, node)) return
|
|
670
|
+
await el.tap()
|
|
671
|
+
await page.waitForTimeout(100)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function ensureScreenshotDir() {
|
|
675
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
676
|
+
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function decodePngDataUrl(dataUrl: string): Buffer {
|
|
681
|
+
const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl)
|
|
682
|
+
if (!match) {
|
|
683
|
+
throw new Error(
|
|
684
|
+
`rnx screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`,
|
|
685
|
+
)
|
|
686
|
+
}
|
|
687
|
+
return Buffer.from(match[1], 'base64')
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
type FastScreenshotData = {
|
|
691
|
+
label: string
|
|
692
|
+
offsetMs: number
|
|
693
|
+
actualMs: number
|
|
694
|
+
captureRequestedAtMs?: number
|
|
695
|
+
dataUrl: string
|
|
696
|
+
frameIndex?: number
|
|
697
|
+
virtualTimestampMs?: number
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function writeFastScreenshotFrames<T extends FastScreenshotData>(
|
|
701
|
+
namePrefix: string,
|
|
702
|
+
frames: T[],
|
|
703
|
+
): Array<Omit<T, 'dataUrl'> & { path: string }> {
|
|
704
|
+
ensureScreenshotDir()
|
|
705
|
+
return frames.map((frame) => {
|
|
706
|
+
const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
707
|
+
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
708
|
+
fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl))
|
|
709
|
+
const { dataUrl: _dataUrl, ...metadata } = frame
|
|
710
|
+
return {
|
|
711
|
+
...metadata,
|
|
712
|
+
path: filePath,
|
|
713
|
+
}
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// single-shot canvas capture — no frame-stability poll. used for multi-stage
|
|
718
|
+
// animation captures where we explicitly want to sample mid-transition. the
|
|
719
|
+
// usual captureSootsimPng requires three consecutive idle frames to be byte-equal
|
|
720
|
+
// which is ideal for settled screenshots but blocks for the full timeout
|
|
721
|
+
// while an animation is running.
|
|
722
|
+
async function captureSootsimPngFast(opts?: {
|
|
723
|
+
crop?: { h: number; w: number; x: number; y: number }
|
|
724
|
+
}) {
|
|
725
|
+
const page = getPage()
|
|
726
|
+
const dataUrl = await page.evaluate(
|
|
727
|
+
async (captureOpts) => {
|
|
728
|
+
const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
|
|
729
|
+
.SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
|
|
730
|
+
if (typeof screenshot !== 'function') {
|
|
731
|
+
throw new Error('rnx screenshot bridge is not installed')
|
|
732
|
+
}
|
|
733
|
+
return screenshot(captureOpts)
|
|
734
|
+
},
|
|
735
|
+
{ crop: opts?.crop, format: 'png' },
|
|
736
|
+
)
|
|
737
|
+
if (!dataUrl) {
|
|
738
|
+
throw new Error('rnx screenshot bridge returned an empty image')
|
|
739
|
+
}
|
|
740
|
+
return decodePngDataUrl(dataUrl)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function setSootsimCursorCaptureActive(page: Page, active: boolean) {
|
|
744
|
+
await page.evaluate(async (captureActive) => {
|
|
745
|
+
const bridge = window.__sootsimTest
|
|
746
|
+
if (typeof bridge?.setCursorCaptureActive !== 'function') {
|
|
747
|
+
throw new Error('sootsim cursor capture control is not installed')
|
|
748
|
+
}
|
|
749
|
+
await bridge.setCursorCaptureActive(captureActive)
|
|
750
|
+
}, active)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function captureSootsimPng(opts?: {
|
|
754
|
+
crop?: { h: number; w: number; x: number; y: number }
|
|
755
|
+
}) {
|
|
756
|
+
const page = getPage()
|
|
757
|
+
// a focused TextInput caret changes on a timer. pin it visible for the
|
|
758
|
+
// capture transaction so forced frames represent one settled visual state.
|
|
759
|
+
await setSootsimCursorCaptureActive(page, true)
|
|
760
|
+
try {
|
|
761
|
+
const deadline = Date.now() + 8000
|
|
762
|
+
let previous: Buffer | null = null
|
|
763
|
+
let identicalFrames = 1
|
|
764
|
+
let attempts = 0
|
|
765
|
+
while (Date.now() < deadline) {
|
|
766
|
+
await waitForSootsimIdle(page, Math.max(1, deadline - Date.now()), true)
|
|
767
|
+
const current = await captureSootsimPngFast(opts)
|
|
768
|
+
attempts++
|
|
769
|
+
if (framesIdentical(previous, current)) {
|
|
770
|
+
identicalFrames++
|
|
771
|
+
if (identicalFrames >= REQUIRED_IDENTICAL_PROOF_FRAMES) return current
|
|
772
|
+
} else {
|
|
773
|
+
identicalFrames = 1
|
|
774
|
+
}
|
|
775
|
+
previous = current
|
|
776
|
+
}
|
|
777
|
+
throw new Error(
|
|
778
|
+
`rnx screenshot did not reach ${REQUIRED_IDENTICAL_PROOF_FRAMES} identical idle frames in ${attempts} captures`,
|
|
779
|
+
)
|
|
780
|
+
} finally {
|
|
781
|
+
await setSootsimCursorCaptureActive(page, false)
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function writeSootsimScreenshot(
|
|
786
|
+
name: string,
|
|
787
|
+
opts?: { crop?: { h: number; w: number; x: number; y: number } },
|
|
788
|
+
) {
|
|
789
|
+
ensureScreenshotDir()
|
|
790
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
|
|
791
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPng(opts))
|
|
792
|
+
return screenshotPath
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function dispatchStatusBarOverride(config: StatusBarConfig) {
|
|
796
|
+
const page = getPage()
|
|
797
|
+
const time =
|
|
798
|
+
typeof config.time === 'string' && config.time.length > 0 ? config.time : null
|
|
799
|
+
await page.evaluate(
|
|
800
|
+
({ eventType, time }) => {
|
|
801
|
+
window.dispatchEvent(
|
|
802
|
+
new CustomEvent(eventType, {
|
|
803
|
+
detail: { time },
|
|
804
|
+
}),
|
|
805
|
+
)
|
|
806
|
+
},
|
|
807
|
+
{ eventType: STATUS_BAR_OVERRIDE_EVENT, time },
|
|
808
|
+
)
|
|
809
|
+
await page.waitForTimeout(50)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const MAX_SETTLED_SPEED_PT_PER_SEC = 2.0
|
|
813
|
+
|
|
814
|
+
async function waitForFrameClock(page: Page, minMs = 25): Promise<void> {
|
|
815
|
+
const start = performance.now()
|
|
816
|
+
await page.evaluate(
|
|
817
|
+
() =>
|
|
818
|
+
new Promise<void>((resolve) => {
|
|
819
|
+
const timeoutId = setTimeout(() => resolve(), 50)
|
|
820
|
+
requestAnimationFrame(() => {
|
|
821
|
+
clearTimeout(timeoutId)
|
|
822
|
+
resolve()
|
|
823
|
+
})
|
|
824
|
+
}),
|
|
825
|
+
)
|
|
826
|
+
const elapsed = performance.now() - start
|
|
827
|
+
if (elapsed < minMs) {
|
|
828
|
+
await page.waitForTimeout(minMs - elapsed)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function findStableNodeByMatcher(
|
|
833
|
+
matcher: Matcher,
|
|
834
|
+
action = 'tap',
|
|
835
|
+
maxWaitMs = 1500,
|
|
836
|
+
): Promise<any> {
|
|
837
|
+
const page = getPage()
|
|
838
|
+
const start = performance.now()
|
|
839
|
+
let lastPos: { x: number; y: number } | null = null
|
|
840
|
+
let lastTime: number | null = null
|
|
841
|
+
let lastRate: { vx: number; vy: number; speed: number } | null = null
|
|
842
|
+
let sampleCount = 0
|
|
843
|
+
let stablePolls = 0
|
|
844
|
+
const requiredStable = 2
|
|
845
|
+
let hadNumericLayout = false
|
|
846
|
+
let disappearedMidWait = false
|
|
847
|
+
|
|
848
|
+
while (performance.now() - start < maxWaitMs) {
|
|
849
|
+
const node = await findNodeByMatcher(matcher)
|
|
850
|
+
if (!node) {
|
|
851
|
+
if (hadNumericLayout) {
|
|
852
|
+
disappearedMidWait = true
|
|
853
|
+
} else {
|
|
854
|
+
return null
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
disappearedMidWait = false
|
|
858
|
+
const pos = node.absolutePosition ?? node.layout
|
|
859
|
+
if (pos && typeof pos.x === 'number' && typeof pos.y === 'number') {
|
|
860
|
+
hadNumericLayout = true
|
|
861
|
+
sampleCount++
|
|
862
|
+
const now = performance.now()
|
|
863
|
+
if (lastPos && lastTime !== null) {
|
|
864
|
+
const dtSec = (now - lastTime) / 1000
|
|
865
|
+
if (dtSec > 0.005) {
|
|
866
|
+
const vx = Math.abs(pos.x - lastPos.x) / dtSec
|
|
867
|
+
const vy = Math.abs(pos.y - lastPos.y) / dtSec
|
|
868
|
+
const speed = Math.sqrt(vx * vx + vy * vy)
|
|
869
|
+
lastRate = { vx, vy, speed }
|
|
870
|
+
if (speed < MAX_SETTLED_SPEED_PT_PER_SEC) {
|
|
871
|
+
stablePolls++
|
|
872
|
+
if (stablePolls >= requiredStable) {
|
|
873
|
+
return node
|
|
874
|
+
}
|
|
875
|
+
} else {
|
|
876
|
+
stablePolls = 0
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
lastPos = { x: pos.x, y: pos.y }
|
|
881
|
+
lastTime = now
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
await waitForFrameClock(page, 25)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const elapsed = Math.round(performance.now() - start)
|
|
888
|
+
if (disappearedMidWait) {
|
|
889
|
+
throw new Error(
|
|
890
|
+
`element disappeared while waiting to come to rest for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`,
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
if (!hadNumericLayout) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`element layout is not available for ${action}: ${JSON.stringify(matcher)} after ${elapsed}ms`,
|
|
896
|
+
)
|
|
897
|
+
}
|
|
898
|
+
if (sampleCount < 2 || lastRate === null) {
|
|
899
|
+
throw new Error(
|
|
900
|
+
`element did not come to rest for ${action}: ${JSON.stringify(matcher)}; only one sample obtained after ${elapsed}ms`,
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
throw new Error(
|
|
904
|
+
`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`,
|
|
905
|
+
)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
909
|
+
const el: SootSimElement = {
|
|
910
|
+
_matcher: matcher,
|
|
911
|
+
|
|
912
|
+
async tap(point) {
|
|
913
|
+
const page = getPage()
|
|
914
|
+
await waitForSootsimWorkletsIdle(page, 3000)
|
|
915
|
+
const node = await findStableNodeByMatcher(matcher, 'tap')
|
|
916
|
+
if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`)
|
|
917
|
+
if (!isNodeVisible(node)) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
|
|
920
|
+
)
|
|
921
|
+
}
|
|
922
|
+
const defaultPoint = point ? null : await getDefaultTapPoint(page, node)
|
|
923
|
+
const targetSootSimX =
|
|
924
|
+
typeof point?.x === 'number'
|
|
925
|
+
? node.absolutePosition.x + point.x
|
|
926
|
+
: (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
|
|
927
|
+
const targetSootSimY =
|
|
928
|
+
typeof point?.y === 'number'
|
|
929
|
+
? node.absolutePosition.y + point.y
|
|
930
|
+
: (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
|
|
931
|
+
await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY)
|
|
932
|
+
if (_synchronizationEnabled) {
|
|
933
|
+
await waitForSootsimIdle(page)
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
|
|
937
|
+
async multiTap(times: number) {
|
|
938
|
+
// detox iOS multiTap performs N taps with the system's natural double-
|
|
939
|
+
// tap pause. UITapGestureRecognizer treats taps within ~250ms as a
|
|
940
|
+
// multi-tap. emit each tap as a discrete down/up at the same node
|
|
941
|
+
// center with a small inter-tap pause that stays inside RNGH's
|
|
942
|
+
// default maxDelay window of 200ms.
|
|
943
|
+
const page = getPage()
|
|
944
|
+
const node = await findStableNodeByMatcher(matcher, 'multiTap')
|
|
945
|
+
if (!node)
|
|
946
|
+
throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`)
|
|
947
|
+
if (!isNodeVisible(node)) {
|
|
948
|
+
throw new Error(
|
|
949
|
+
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
const center = getNodeCenter(node)
|
|
953
|
+
const pageCoords = await sootsimToPage(page, center.x, center.y)
|
|
954
|
+
|
|
955
|
+
for (let i = 0; i < times; i++) {
|
|
956
|
+
await page.mouse.move(pageCoords.x, pageCoords.y)
|
|
957
|
+
await page.mouse.down()
|
|
958
|
+
await page.waitForTimeout(50)
|
|
959
|
+
await page.mouse.up()
|
|
960
|
+
if (i < times - 1) {
|
|
961
|
+
await page.waitForTimeout(80)
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
await page.waitForTimeout(80)
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
// mirrors detox element.longPress(duration) and longPress(point, duration),
|
|
968
|
+
// where the point is relative to the element's origin
|
|
969
|
+
async longPress(
|
|
970
|
+
pointOrDuration?: { x: number; y: number } | number,
|
|
971
|
+
durationArg = 1000,
|
|
972
|
+
) {
|
|
973
|
+
const page = getPage()
|
|
974
|
+
const node = await findStableNodeByMatcher(matcher, 'longPress')
|
|
975
|
+
if (!node)
|
|
976
|
+
throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`)
|
|
977
|
+
if (!isNodeVisible(node)) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
|
|
980
|
+
)
|
|
981
|
+
}
|
|
982
|
+
const duration = typeof pointOrDuration === 'number' ? pointOrDuration : durationArg
|
|
983
|
+
const target =
|
|
984
|
+
typeof pointOrDuration === 'object'
|
|
985
|
+
? {
|
|
986
|
+
x: node.absolutePosition.x + pointOrDuration.x,
|
|
987
|
+
y: node.absolutePosition.y + pointOrDuration.y,
|
|
988
|
+
}
|
|
989
|
+
: getNodeCenter(node)
|
|
990
|
+
await page.evaluate(
|
|
991
|
+
async ({ duration, x, y }) => {
|
|
992
|
+
const longPress = window.SootSim?.bridges?.interact?.longPress
|
|
993
|
+
if (typeof longPress !== 'function') {
|
|
994
|
+
throw new Error('sootsim longPress bridge is not installed')
|
|
995
|
+
}
|
|
996
|
+
const hit = await longPress(x, y, duration)
|
|
997
|
+
if (!hit) {
|
|
998
|
+
throw new Error(`sootsim longPress missed at ${x},${y}`)
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
{ duration, x: target.x, y: target.y },
|
|
1002
|
+
)
|
|
1003
|
+
await page.waitForTimeout(50)
|
|
1004
|
+
},
|
|
1005
|
+
|
|
1006
|
+
async longPressAndDrag(
|
|
1007
|
+
duration: number,
|
|
1008
|
+
normalizedStartX: number,
|
|
1009
|
+
normalizedStartY: number,
|
|
1010
|
+
targetElement: SootSimElement,
|
|
1011
|
+
normalizedEndX: number,
|
|
1012
|
+
normalizedEndY: number,
|
|
1013
|
+
speed = 'fast',
|
|
1014
|
+
holdDuration = 0,
|
|
1015
|
+
) {
|
|
1016
|
+
const page = getPage()
|
|
1017
|
+
// resolve source element
|
|
1018
|
+
const srcNode = await findStableNodeByMatcher(matcher, 'longPressAndDrag')
|
|
1019
|
+
if (!srcNode)
|
|
1020
|
+
throw new Error(`source element not found: ${JSON.stringify(matcher)}`)
|
|
1021
|
+
|
|
1022
|
+
// resolve target element
|
|
1023
|
+
const tgtNode = await findStableNodeByMatcher(
|
|
1024
|
+
targetElement._matcher,
|
|
1025
|
+
'longPressAndDrag',
|
|
1026
|
+
)
|
|
1027
|
+
if (!tgtNode)
|
|
1028
|
+
throw new Error(
|
|
1029
|
+
`target element not found: ${JSON.stringify(targetElement._matcher)}`,
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
// compute start and end in sootsim coords
|
|
1033
|
+
const startSootSimX =
|
|
1034
|
+
srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX
|
|
1035
|
+
const startSootSimY =
|
|
1036
|
+
srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY
|
|
1037
|
+
const endSootSimX =
|
|
1038
|
+
tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX
|
|
1039
|
+
const endSootSimY =
|
|
1040
|
+
tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY
|
|
1041
|
+
|
|
1042
|
+
const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
|
|
1043
|
+
const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
|
|
1044
|
+
const steps = speed === 'slow' ? 20 : 10
|
|
1045
|
+
|
|
1046
|
+
// press at start position
|
|
1047
|
+
await page.mouse.move(startPage.x, startPage.y)
|
|
1048
|
+
await page.mouse.down()
|
|
1049
|
+
await page.waitForTimeout(Math.max(duration, holdDuration))
|
|
1050
|
+
|
|
1051
|
+
// drag to end position
|
|
1052
|
+
const dx = (endPage.x - startPage.x) / steps
|
|
1053
|
+
const dy = (endPage.y - startPage.y) / steps
|
|
1054
|
+
for (let i = 1; i <= steps; i++) {
|
|
1055
|
+
await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
|
|
1056
|
+
await page.waitForTimeout(speed === 'slow' ? 30 : 10)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
await page.mouse.up()
|
|
1060
|
+
await page.waitForTimeout(50)
|
|
1061
|
+
},
|
|
1062
|
+
|
|
1063
|
+
async typeText(text: string) {
|
|
1064
|
+
const page = getPage()
|
|
1065
|
+
await focusElementForTextEntry(el)
|
|
1066
|
+
await page.keyboard.type(text)
|
|
1067
|
+
},
|
|
1068
|
+
|
|
1069
|
+
async replaceText(text: string) {
|
|
1070
|
+
const page = getPage()
|
|
1071
|
+
const node = await findNodeByMatcher(matcher)
|
|
1072
|
+
if (
|
|
1073
|
+
matcher.type === 'id' &&
|
|
1074
|
+
typeof node?.nodeId === 'number' &&
|
|
1075
|
+
node.nodeId >= 0x40000000
|
|
1076
|
+
) {
|
|
1077
|
+
const replaced = await page.evaluate(
|
|
1078
|
+
async ({ id, value }) => {
|
|
1079
|
+
const mainShell = window.SootSim?.bridges?.mainShell
|
|
1080
|
+
if (typeof mainShell?.callTestBridge !== 'function') return false
|
|
1081
|
+
return mainShell.callTestBridge('replaceTextByTestId', id, value)
|
|
1082
|
+
},
|
|
1083
|
+
{ id: matcher.value, value: text },
|
|
1084
|
+
)
|
|
1085
|
+
if (replaced === true) return
|
|
1086
|
+
}
|
|
1087
|
+
await focusElementForTextEntry(el)
|
|
1088
|
+
const value = typeof node?.text === 'string' ? node.text : ''
|
|
1089
|
+
for (let i = 0; i < value.length; i++) {
|
|
1090
|
+
await page.keyboard.press('Backspace')
|
|
1091
|
+
}
|
|
1092
|
+
await page.keyboard.type(text)
|
|
1093
|
+
},
|
|
1094
|
+
|
|
1095
|
+
async clearText() {
|
|
1096
|
+
const page = getPage()
|
|
1097
|
+
await focusElementForTextEntry(el)
|
|
1098
|
+
const node = await findNodeByMatcher(matcher)
|
|
1099
|
+
const value = typeof node?.text === 'string' ? node.text : ''
|
|
1100
|
+
for (let i = 0; i < value.length; i++) {
|
|
1101
|
+
await page.keyboard.press('Backspace')
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
|
|
1105
|
+
async scroll(
|
|
1106
|
+
pixels: number,
|
|
1107
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
1108
|
+
startPositionX?: number,
|
|
1109
|
+
startPositionY?: number,
|
|
1110
|
+
) {
|
|
1111
|
+
const node = await findNodeByMatcher(matcher)
|
|
1112
|
+
if (!node)
|
|
1113
|
+
throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
|
|
1114
|
+
const page = getPage()
|
|
1115
|
+
await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY)
|
|
1116
|
+
},
|
|
1117
|
+
|
|
1118
|
+
async scrollTo(
|
|
1119
|
+
edge: 'top' | 'bottom' | 'left' | 'right',
|
|
1120
|
+
startPositionX?: number,
|
|
1121
|
+
startPositionY?: number,
|
|
1122
|
+
) {
|
|
1123
|
+
const node = await findNodeByMatcher(matcher)
|
|
1124
|
+
if (!node)
|
|
1125
|
+
throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
|
|
1126
|
+
|
|
1127
|
+
const targetId =
|
|
1128
|
+
typeof node.testID === 'string' && node.testID
|
|
1129
|
+
? node.testID
|
|
1130
|
+
: typeof node.id === 'string' && node.id
|
|
1131
|
+
? node.id
|
|
1132
|
+
: null
|
|
1133
|
+
if (!targetId) {
|
|
1134
|
+
throw new Error(
|
|
1135
|
+
`scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`,
|
|
1136
|
+
)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const normalizedStartX = resolveScrollStartPosition(
|
|
1140
|
+
startPositionX,
|
|
1141
|
+
'scrollTo startPositionX',
|
|
1142
|
+
)
|
|
1143
|
+
const normalizedStartY = resolveScrollStartPosition(
|
|
1144
|
+
startPositionY,
|
|
1145
|
+
'scrollTo startPositionY',
|
|
1146
|
+
)
|
|
1147
|
+
const hitX = node.absolutePosition.x + node.layout.width * normalizedStartX
|
|
1148
|
+
const hitY = node.absolutePosition.y + node.layout.height * normalizedStartY
|
|
1149
|
+
const page = getPage()
|
|
1150
|
+
const result = await page.evaluate(
|
|
1151
|
+
async ({ edge, hitX, hitY, targetId }) => {
|
|
1152
|
+
const bridge = window.__sootsimTest
|
|
1153
|
+
if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
|
|
1154
|
+
return { ok: false, reason: 'scroll bridge unavailable' }
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const state = await bridge.getScrollStateAt(hitX, hitY)
|
|
1158
|
+
if (!state) return { ok: false, reason: 'scroll state not found' }
|
|
1159
|
+
|
|
1160
|
+
const maxOffset =
|
|
1161
|
+
state.maxOffset && typeof state.maxOffset === 'object'
|
|
1162
|
+
? (state.maxOffset as { x?: number; y?: number })
|
|
1163
|
+
: null
|
|
1164
|
+
const offset =
|
|
1165
|
+
state.offset && typeof state.offset === 'object'
|
|
1166
|
+
? (state.offset as { x?: number; y?: number })
|
|
1167
|
+
: null
|
|
1168
|
+
const maxX =
|
|
1169
|
+
typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0)
|
|
1170
|
+
const maxY =
|
|
1171
|
+
typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0)
|
|
1172
|
+
const currentX =
|
|
1173
|
+
typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0)
|
|
1174
|
+
const currentY =
|
|
1175
|
+
typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0)
|
|
1176
|
+
|
|
1177
|
+
const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX
|
|
1178
|
+
const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY
|
|
1179
|
+
return bridge.scrollTo(targetId, x, y, false)
|
|
1180
|
+
},
|
|
1181
|
+
{ edge, hitX, hitY, targetId },
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
if (!result?.ok) {
|
|
1185
|
+
throw new Error(
|
|
1186
|
+
`scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`,
|
|
1187
|
+
)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
await page.evaluate(
|
|
1191
|
+
() =>
|
|
1192
|
+
new Promise<void>((resolve) => {
|
|
1193
|
+
requestAnimationFrame(() => resolve())
|
|
1194
|
+
}),
|
|
1195
|
+
)
|
|
1196
|
+
if (_synchronizationEnabled) {
|
|
1197
|
+
await waitForSootsimIdle(page)
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
async swipe(
|
|
1202
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
1203
|
+
speed: 'fast' | 'slow' = 'fast',
|
|
1204
|
+
percentage = 0.75,
|
|
1205
|
+
normalizedStartingPointX = 0.5,
|
|
1206
|
+
normalizedStartingPointY = 0.5,
|
|
1207
|
+
) {
|
|
1208
|
+
const node = await findStableNodeByMatcher(matcher, 'swipe')
|
|
1209
|
+
if (!node)
|
|
1210
|
+
throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
|
|
1211
|
+
|
|
1212
|
+
const page = getPage()
|
|
1213
|
+
const { absolutePosition, layout } = node
|
|
1214
|
+
|
|
1215
|
+
const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
|
|
1216
|
+
const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY
|
|
1217
|
+
|
|
1218
|
+
let endSootSimX = startSootSimX
|
|
1219
|
+
let endSootSimY = startSootSimY
|
|
1220
|
+
const viewport = await readSootsimViewport(page)
|
|
1221
|
+
const dist =
|
|
1222
|
+
direction === 'up' || direction === 'down'
|
|
1223
|
+
? viewport.height * percentage
|
|
1224
|
+
: viewport.width * percentage
|
|
1225
|
+
|
|
1226
|
+
switch (direction) {
|
|
1227
|
+
case 'up':
|
|
1228
|
+
endSootSimY -= dist
|
|
1229
|
+
break
|
|
1230
|
+
case 'down':
|
|
1231
|
+
endSootSimY += dist
|
|
1232
|
+
break
|
|
1233
|
+
case 'left':
|
|
1234
|
+
endSootSimX -= dist
|
|
1235
|
+
break
|
|
1236
|
+
case 'right':
|
|
1237
|
+
endSootSimX += dist
|
|
1238
|
+
break
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// detox injects one point per display-link tick. its fast path contains
|
|
1242
|
+
// 21 points and its slow path 41, including the endpoints.
|
|
1243
|
+
const steps = speed === 'slow' ? 40 : 20
|
|
1244
|
+
const stepDelay = 1000 / 60
|
|
1245
|
+
|
|
1246
|
+
await page.evaluate(
|
|
1247
|
+
async ({ fromX, fromY, toX, toY, steps, stepMs }) => {
|
|
1248
|
+
// the interact bridge, not __sootsimTest.drag. the test bridge's drag
|
|
1249
|
+
// runs the whole down/move/up loop through the tenant's own
|
|
1250
|
+
// dispatchPointer and never fans to the shell, where gesture-handler's
|
|
1251
|
+
// recognizers live, so a swipe reported exact-zero activations for
|
|
1252
|
+
// every pan, fling, and manual gesture. taps were unaffected because
|
|
1253
|
+
// the responder path is tenant-local. the interact bridge resolves the
|
|
1254
|
+
// owner and feeds both, which is why the cases that call it directly
|
|
1255
|
+
// pass.
|
|
1256
|
+
const drag = window.__sootsimInteract?.drag
|
|
1257
|
+
if (typeof drag !== 'function') {
|
|
1258
|
+
throw new Error('sootsim interact bridge is not installed')
|
|
1259
|
+
}
|
|
1260
|
+
await drag(fromX, fromY, toX, toY, steps, stepMs)
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
fromX: startSootSimX,
|
|
1264
|
+
fromY: startSootSimY,
|
|
1265
|
+
toX: endSootSimX,
|
|
1266
|
+
toY: endSootSimY,
|
|
1267
|
+
steps,
|
|
1268
|
+
stepMs: stepDelay,
|
|
1269
|
+
},
|
|
1270
|
+
)
|
|
1271
|
+
await page.waitForTimeout(100)
|
|
1272
|
+
},
|
|
1273
|
+
|
|
1274
|
+
async setColumnToValue(column: number, value: string) {
|
|
1275
|
+
if (!Number.isInteger(column) || column < 0) {
|
|
1276
|
+
throw new Error(`picker column must be a non-negative integer, got ${column}`)
|
|
1277
|
+
}
|
|
1278
|
+
if (typeof value !== 'string') {
|
|
1279
|
+
throw new Error(`picker value must be a string, got ${typeof value}`)
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const picker = await findStableNodeByMatcher(matcher, 'setColumnToValue')
|
|
1283
|
+
if (!picker) {
|
|
1284
|
+
throw new Error(
|
|
1285
|
+
`element not found for setColumnToValue: ${JSON.stringify(matcher)}`,
|
|
1286
|
+
)
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const page = getPage()
|
|
1290
|
+
const candidates = await page.evaluate(
|
|
1291
|
+
async ({ picker, column, value }) => {
|
|
1292
|
+
const result = await window.__sootsimTest?.queryTextCandidates?.({
|
|
1293
|
+
query: value,
|
|
1294
|
+
exact: true,
|
|
1295
|
+
})
|
|
1296
|
+
if (!result || !Array.isArray(result.candidates)) return []
|
|
1297
|
+
const left = picker.absolutePosition.x
|
|
1298
|
+
const top = picker.absolutePosition.y
|
|
1299
|
+
const right = left + picker.layout.width
|
|
1300
|
+
const bottom = top + picker.layout.height
|
|
1301
|
+
return result.candidates
|
|
1302
|
+
.filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column))
|
|
1303
|
+
.map((candidate) => candidate.info)
|
|
1304
|
+
.filter((candidate) => {
|
|
1305
|
+
const x = candidate.absolutePosition?.x
|
|
1306
|
+
const y = candidate.absolutePosition?.y
|
|
1307
|
+
const width = candidate.layout?.width
|
|
1308
|
+
const height = candidate.layout?.height
|
|
1309
|
+
if (
|
|
1310
|
+
typeof x !== 'number' ||
|
|
1311
|
+
typeof y !== 'number' ||
|
|
1312
|
+
typeof width !== 'number' ||
|
|
1313
|
+
typeof height !== 'number'
|
|
1314
|
+
) {
|
|
1315
|
+
return false
|
|
1316
|
+
}
|
|
1317
|
+
const centerX = x + width / 2
|
|
1318
|
+
const centerY = y + height / 2
|
|
1319
|
+
return (
|
|
1320
|
+
centerX >= left && centerX <= right && centerY >= top && centerY <= bottom
|
|
1321
|
+
)
|
|
1322
|
+
})
|
|
1323
|
+
},
|
|
1324
|
+
{ picker, column, value },
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
if (candidates.length !== 1) {
|
|
1328
|
+
throw new Error(
|
|
1329
|
+
`setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`,
|
|
1330
|
+
)
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const target = candidates[0]
|
|
1334
|
+
// detox selects the picker row, whose hit rectangle stays flat. the
|
|
1335
|
+
// label is visually projected around the wheel and can overlap a later
|
|
1336
|
+
// label near the cylinder edge.
|
|
1337
|
+
const tapTarget = await page.evaluate(
|
|
1338
|
+
async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId),
|
|
1339
|
+
target.nodeId,
|
|
1340
|
+
)
|
|
1341
|
+
if (!tapTarget) {
|
|
1342
|
+
throw new Error(
|
|
1343
|
+
`setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`,
|
|
1344
|
+
)
|
|
1345
|
+
}
|
|
1346
|
+
await dispatchTapAtSootsimPoint(
|
|
1347
|
+
page,
|
|
1348
|
+
tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
|
|
1349
|
+
tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2,
|
|
1350
|
+
)
|
|
1351
|
+
},
|
|
1352
|
+
|
|
1353
|
+
async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) {
|
|
1354
|
+
// detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1
|
|
1355
|
+
// brings them together (zoom out). speed maps to step delay so RNGH's
|
|
1356
|
+
// velocity tracking sees a realistic delta. angle (radians) tilts the
|
|
1357
|
+
// pinch axis off horizontal — matches Detox's third arg.
|
|
1358
|
+
const page = getPage()
|
|
1359
|
+
const node = await findStableNodeByMatcher(matcher, 'pinch')
|
|
1360
|
+
if (!node)
|
|
1361
|
+
throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`)
|
|
1362
|
+
if (!node.nativePinchEnabled) {
|
|
1363
|
+
await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler')
|
|
1364
|
+
}
|
|
1365
|
+
const startSpread = Math.min(
|
|
1366
|
+
60,
|
|
1367
|
+
Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
|
|
1368
|
+
)
|
|
1369
|
+
const endSpread = startSpread * Math.max(scale, 0.05)
|
|
1370
|
+
const cos = Math.cos(angle)
|
|
1371
|
+
const sin = Math.sin(angle)
|
|
1372
|
+
const offset = (d: number) => ({ dx: d * cos, dy: d * sin })
|
|
1373
|
+
const a0 = offset(-startSpread)
|
|
1374
|
+
const b0 = offset(startSpread)
|
|
1375
|
+
const a1 = offset(-endSpread)
|
|
1376
|
+
const b1 = offset(endSpread)
|
|
1377
|
+
await dispatchTwoFingerGesture(
|
|
1378
|
+
page,
|
|
1379
|
+
await nodeOffsetToPage(page, node, a0.dx, a0.dy),
|
|
1380
|
+
await nodeOffsetToPage(page, node, b0.dx, b0.dy),
|
|
1381
|
+
await nodeOffsetToPage(page, node, a1.dx, a1.dy),
|
|
1382
|
+
await nodeOffsetToPage(page, node, b1.dx, b1.dy),
|
|
1383
|
+
{ steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 },
|
|
1384
|
+
)
|
|
1385
|
+
},
|
|
1386
|
+
|
|
1387
|
+
async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
|
|
1388
|
+
// two fingers held on a horizontal axis around the node center, then
|
|
1389
|
+
// both rotated to `radians` around the same center. positive radians
|
|
1390
|
+
// rotate counter-clockwise to match RNGH RotationGesture's reported
|
|
1391
|
+
// sign (its 'rotation' delta is positive for a counter-clockwise
|
|
1392
|
+
// motion of the rear finger relative to the front finger).
|
|
1393
|
+
const page = getPage()
|
|
1394
|
+
const node = await findStableNodeByMatcher(matcher, 'rotate')
|
|
1395
|
+
if (!node)
|
|
1396
|
+
throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`)
|
|
1397
|
+
await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler')
|
|
1398
|
+
const radius = Math.min(
|
|
1399
|
+
60,
|
|
1400
|
+
Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
|
|
1401
|
+
)
|
|
1402
|
+
const startA = await nodeOffsetToPage(page, node, -radius, 0)
|
|
1403
|
+
const startB = await nodeOffsetToPage(page, node, radius, 0)
|
|
1404
|
+
const cos = Math.cos(radians)
|
|
1405
|
+
const sin = Math.sin(radians)
|
|
1406
|
+
const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin)
|
|
1407
|
+
const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin)
|
|
1408
|
+
await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
|
|
1409
|
+
steps: speed === 'fast' ? 10 : 20,
|
|
1410
|
+
stepDelayMs: speed === 'fast' ? 14 : 24,
|
|
1411
|
+
})
|
|
1412
|
+
},
|
|
1413
|
+
|
|
1414
|
+
async takeScreenshot(name: string): Promise<string> {
|
|
1415
|
+
const node = await findNodeByMatcher(matcher)
|
|
1416
|
+
if (!node)
|
|
1417
|
+
throw new Error(
|
|
1418
|
+
`element not found for takeScreenshot: ${JSON.stringify(matcher)}`,
|
|
1419
|
+
)
|
|
1420
|
+
|
|
1421
|
+
const box = node.frame ?? {
|
|
1422
|
+
x: node.absolutePosition.x,
|
|
1423
|
+
y: node.absolutePosition.y,
|
|
1424
|
+
width: node.layout.width,
|
|
1425
|
+
height: node.layout.height,
|
|
1426
|
+
}
|
|
1427
|
+
return writeSootsimScreenshot(name, {
|
|
1428
|
+
crop: {
|
|
1429
|
+
x: box.x,
|
|
1430
|
+
y: box.y,
|
|
1431
|
+
w: box.width,
|
|
1432
|
+
h: box.height,
|
|
1433
|
+
},
|
|
1434
|
+
})
|
|
1435
|
+
},
|
|
1436
|
+
|
|
1437
|
+
async getAttributes(): Promise<any> {
|
|
1438
|
+
const node = await findNodeByMatcher(matcher)
|
|
1439
|
+
if (!node)
|
|
1440
|
+
throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`)
|
|
1441
|
+
return {
|
|
1442
|
+
text: node.text || '',
|
|
1443
|
+
label: node.accessibilityLabel || node.text || '',
|
|
1444
|
+
identifier: node.testID || node.id || '',
|
|
1445
|
+
visible: node.layout.width > 0 && node.layout.height > 0,
|
|
1446
|
+
enabled: !node.accessibilityState?.disabled,
|
|
1447
|
+
...node,
|
|
1448
|
+
}
|
|
1449
|
+
},
|
|
1450
|
+
|
|
1451
|
+
atIndex(index: number) {
|
|
1452
|
+
return createSootSimElement({ ...matcher, index })
|
|
1453
|
+
},
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return el
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// element() factory -- takes a matcher and returns an element interaction object
|
|
1460
|
+
export function element(matcher: Matcher): SootSimElement {
|
|
1461
|
+
return createSootSimElement(matcher)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// device object -- app lifecycle
|
|
1465
|
+
export const device = {
|
|
1466
|
+
_currentUrl: '',
|
|
1467
|
+
_platform: DETOX_PLATFORM as 'ios' | 'android',
|
|
1468
|
+
|
|
1469
|
+
async launchApp(opts?: {
|
|
1470
|
+
delete?: boolean
|
|
1471
|
+
newInstance?: boolean
|
|
1472
|
+
url?: string
|
|
1473
|
+
launchArgs?: Record<string, any>
|
|
1474
|
+
// when set, the new BrowserContext records video at this dir. used by
|
|
1475
|
+
// captureProofMenuStages for the multi-stage animation diff path. must
|
|
1476
|
+
// be set at launchApp time because playwright recordVideo can't be
|
|
1477
|
+
// toggled on an existing context.
|
|
1478
|
+
recordVideoDir?: string
|
|
1479
|
+
}) {
|
|
1480
|
+
if (_browserCompromised) {
|
|
1481
|
+
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
|
|
1482
|
+
}
|
|
1483
|
+
if (
|
|
1484
|
+
_page &&
|
|
1485
|
+
opts?.newInstance === false &&
|
|
1486
|
+
!opts.delete &&
|
|
1487
|
+
!opts.recordVideoDir &&
|
|
1488
|
+
_suspendedAppId
|
|
1489
|
+
) {
|
|
1490
|
+
const page = _page
|
|
1491
|
+
const appId = _suspendedAppId
|
|
1492
|
+
await page.evaluate(
|
|
1493
|
+
(id) => window.SootSim?.bridges?.mainShell?.launchApp?.(id),
|
|
1494
|
+
appId,
|
|
1495
|
+
)
|
|
1496
|
+
await pollUntil(
|
|
1497
|
+
() =>
|
|
1498
|
+
page.evaluate(async (id) => {
|
|
1499
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.()
|
|
1500
|
+
return (
|
|
1501
|
+
state?.state === 'app' &&
|
|
1502
|
+
state.activeApp === id &&
|
|
1503
|
+
state.switcherPhase === 'idle'
|
|
1504
|
+
)
|
|
1505
|
+
}, appId),
|
|
1506
|
+
Boolean,
|
|
1507
|
+
{
|
|
1508
|
+
timeoutMs: 10000,
|
|
1509
|
+
label: `SootSim app ${appId} to resume`,
|
|
1510
|
+
},
|
|
1511
|
+
)
|
|
1512
|
+
_suspendedAppId = null
|
|
1513
|
+
await waitForSootsimTree(page, 10000)
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (!_browser) {
|
|
1518
|
+
// Conformance renders CanvasKit and must use the real platform GPU. The
|
|
1519
|
+
// bundled chromium_headless_shell has no GPU and silently falls back to
|
|
1520
|
+
// SwiftShader, invalidating visual/perf results while pegging the box.
|
|
1521
|
+
// The canonical GPU args force Metal/Vulkan and stripping Playwright's
|
|
1522
|
+
// --enable-unsafe-swiftshader default makes GPU init fail loudly. The
|
|
1523
|
+
// scoped reaper owns the whole Chrome process tree if Detox is interrupted.
|
|
1524
|
+
const launched = await launchReapedChromeScoped(
|
|
1525
|
+
(options) => chromium.launch(options),
|
|
1526
|
+
{ headless: true, ignoreDefaultArgs: ['--disable-popup-blocking'] },
|
|
1527
|
+
)
|
|
1528
|
+
_browser = launched.browser
|
|
1529
|
+
_reapBrowserNow = launched.reapNow
|
|
1530
|
+
browserIdentity = ++nextBrowserIdentity
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
|
|
1534
|
+
await closeContext()
|
|
1535
|
+
const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
|
|
1536
|
+
hasTouch: true,
|
|
1537
|
+
viewport: { width: 500, height: 900 },
|
|
1538
|
+
}
|
|
1539
|
+
if (opts?.recordVideoDir) {
|
|
1540
|
+
fs.mkdirSync(opts.recordVideoDir, { recursive: true })
|
|
1541
|
+
contextOpts.recordVideo = {
|
|
1542
|
+
dir: opts.recordVideoDir,
|
|
1543
|
+
size: { width: 500, height: 900 },
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const newContextIdentity = ++nextContextIdentity
|
|
1547
|
+
try {
|
|
1548
|
+
_context = await withLifecycleTimeout(
|
|
1549
|
+
'_browser.newContext()',
|
|
1550
|
+
newContextIdentity,
|
|
1551
|
+
() => _browser!.newContext(contextOpts),
|
|
1552
|
+
)
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
_browserCompromised = error instanceof Error ? error.message : String(error)
|
|
1555
|
+
reapOwnedBrowser()
|
|
1556
|
+
throw error
|
|
1557
|
+
}
|
|
1558
|
+
await _context.exposeBinding('__sootsimHostCapture', async (source, value) => {
|
|
1559
|
+
const request = readBrowserCompositeCaptureRequest(value)
|
|
1560
|
+
const session = await source.page.context().newCDPSession(source.page)
|
|
1561
|
+
try {
|
|
1562
|
+
const result = await session.send('Page.captureScreenshot', {
|
|
1563
|
+
format: 'png',
|
|
1564
|
+
fromSurface: true,
|
|
1565
|
+
captureBeyondViewport: true,
|
|
1566
|
+
clip: resolveBrowserCompositeCdpClip(
|
|
1567
|
+
request,
|
|
1568
|
+
await session.send('Page.getLayoutMetrics'),
|
|
1569
|
+
),
|
|
1570
|
+
})
|
|
1571
|
+
return 'data:image/png;base64,' + result.data
|
|
1572
|
+
} finally {
|
|
1573
|
+
await session.detach()
|
|
1574
|
+
}
|
|
1575
|
+
})
|
|
1576
|
+
try {
|
|
1577
|
+
_page = await withLifecycleTimeout('_context.newPage()', newContextIdentity, () =>
|
|
1578
|
+
_context!.newPage(),
|
|
1579
|
+
)
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
_browserCompromised = error instanceof Error ? error.message : String(error)
|
|
1582
|
+
reapOwnedBrowser()
|
|
1583
|
+
throw error
|
|
1584
|
+
}
|
|
1585
|
+
contextIdentity = newContextIdentity
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const url = opts?.url || BASE_URL
|
|
1589
|
+
device._currentUrl = url
|
|
1590
|
+
await navigateSootsimPage(_page!, url, {
|
|
1591
|
+
browserId: browserIdentity,
|
|
1592
|
+
contextId: contextIdentity,
|
|
1593
|
+
operation: 'launchApp',
|
|
1594
|
+
})
|
|
1595
|
+
|
|
1596
|
+
await waitForSootsimTree(_page!, 30000)
|
|
1597
|
+
await waitForSootsimSurfaceMetrics(_page!, 30000)
|
|
1598
|
+
await waitForSootsimShellReady(_page!, 30000)
|
|
1599
|
+
await waitForSootsimWorkletsIdle(_page!, 3000)
|
|
1600
|
+
// record the wall-clock time the page is ready — captureProofMenuStages
|
|
1601
|
+
// uses this to compute trigger-in-video offsets
|
|
1602
|
+
device._lastReadyAtMs = Date.now()
|
|
1603
|
+
},
|
|
1604
|
+
|
|
1605
|
+
_lastReadyAtMs: 0,
|
|
1606
|
+
|
|
1607
|
+
async reloadReactNative() {
|
|
1608
|
+
const page = getPage()
|
|
1609
|
+
await page.reload({ waitUntil: 'load', timeout: 30000 })
|
|
1610
|
+
await waitForSootsimTree(page, 30000)
|
|
1611
|
+
await waitForSootsimSurfaceMetrics(page, 30000)
|
|
1612
|
+
await waitForSootsimShellReady(page, 30000)
|
|
1613
|
+
await waitForSootsimWorkletsIdle(page, 3000)
|
|
1614
|
+
},
|
|
1615
|
+
|
|
1616
|
+
async terminateApp() {
|
|
1617
|
+
await closeBrowser()
|
|
1618
|
+
},
|
|
1619
|
+
|
|
1620
|
+
async installApp() {
|
|
1621
|
+
// no-op for sootsim -- app runs in browser
|
|
1622
|
+
},
|
|
1623
|
+
|
|
1624
|
+
async uninstallApp() {
|
|
1625
|
+
// no-op
|
|
1626
|
+
},
|
|
1627
|
+
|
|
1628
|
+
async openURL(url: { url: string; sourceApp?: string }) {
|
|
1629
|
+
const page = getPage()
|
|
1630
|
+
await navigateSootsimPage(page, url.url, {
|
|
1631
|
+
browserId: browserIdentity,
|
|
1632
|
+
contextId: contextIdentity,
|
|
1633
|
+
operation: 'openURL',
|
|
1634
|
+
})
|
|
1635
|
+
await waitForSootsimTree(page, 10000)
|
|
1636
|
+
await waitForSootsimSurfaceMetrics(page, 10000)
|
|
1637
|
+
await waitForSootsimShellReady(page, 10000)
|
|
1638
|
+
await waitForSootsimWorkletsIdle(page, 3000)
|
|
1639
|
+
},
|
|
1640
|
+
|
|
1641
|
+
async takeScreenshot(name: string): Promise<string> {
|
|
1642
|
+
return writeSootsimScreenshot(name)
|
|
1643
|
+
},
|
|
1644
|
+
|
|
1645
|
+
// single-shot variant for multi-stage animation captures. skips the
|
|
1646
|
+
// frame-stability poll so callers can sample mid-transition.
|
|
1647
|
+
async takeScreenshotFast(name: string): Promise<string> {
|
|
1648
|
+
ensureScreenshotDir()
|
|
1649
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
|
|
1650
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPngFast())
|
|
1651
|
+
return screenshotPath
|
|
1652
|
+
},
|
|
1653
|
+
|
|
1654
|
+
// record the SootSim canvas via the engine's headless recorder
|
|
1655
|
+
// (window.__sootsimRecorder — @rnx/plugin-recording). this is the
|
|
1656
|
+
// SAME code path `rnx record video` drives, NOT the rail-button
|
|
1657
|
+
// path (SootSim.bridges.startRecording wires the dialog state machine
|
|
1658
|
+
// and never stashes the resulting blob into __sootsimRecorder.lastBlob,
|
|
1659
|
+
// which is what getBlobBase64 streams from).
|
|
1660
|
+
//
|
|
1661
|
+
// unlike playwright's page-level recordVideo, the headless recorder
|
|
1662
|
+
// captures ONLY the device canvas (composited shell + tenant surfaces)
|
|
1663
|
+
// — no browser chrome, no menu bar, no device frame. uses the EXISTING
|
|
1664
|
+
// page/context so per-test state is preserved across capture.
|
|
1665
|
+
async startBridgeRecording(opts?: {
|
|
1666
|
+
durationMs?: number
|
|
1667
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
1668
|
+
fps?: number
|
|
1669
|
+
}): Promise<void> {
|
|
1670
|
+
const page = getPage()
|
|
1671
|
+
const startOpts = {
|
|
1672
|
+
format: 'webm' as const,
|
|
1673
|
+
fps: opts?.fps ?? 60,
|
|
1674
|
+
layers: opts?.layers ?? 'tenant',
|
|
1675
|
+
durationMs: opts?.durationMs ?? 5000,
|
|
1676
|
+
}
|
|
1677
|
+
const result = await page.evaluate(async (startOpts) => {
|
|
1678
|
+
const rec = (
|
|
1679
|
+
window as {
|
|
1680
|
+
__sootsimRecorder?: {
|
|
1681
|
+
start?: (
|
|
1682
|
+
o: unknown,
|
|
1683
|
+
) => Promise<{ ok: boolean; error?: string; format?: string }>
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
).__sootsimRecorder
|
|
1687
|
+
const start = rec?.start
|
|
1688
|
+
if (typeof start !== 'function') {
|
|
1689
|
+
return { ok: false, error: '__sootsimRecorder.start not installed' }
|
|
1690
|
+
}
|
|
1691
|
+
return start(startOpts)
|
|
1692
|
+
}, startOpts)
|
|
1693
|
+
if (!result.ok) {
|
|
1694
|
+
throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`)
|
|
1695
|
+
}
|
|
1696
|
+
},
|
|
1697
|
+
|
|
1698
|
+
// stop the headless recorder, stream the resulting blob in chunks (same
|
|
1699
|
+
// protocol as `rnx record`), and write the webm to outPath.
|
|
1700
|
+
async stopAndSaveBridgeRecording(outPath: string): Promise<string> {
|
|
1701
|
+
const page = getPage()
|
|
1702
|
+
const stopResult = await page.evaluate(async () => {
|
|
1703
|
+
const rec = (
|
|
1704
|
+
window as {
|
|
1705
|
+
__sootsimRecorder?: {
|
|
1706
|
+
stop?: () => Promise<{
|
|
1707
|
+
ok: boolean
|
|
1708
|
+
error?: string
|
|
1709
|
+
size?: number
|
|
1710
|
+
mime?: string
|
|
1711
|
+
durationMs?: number
|
|
1712
|
+
}>
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
).__sootsimRecorder
|
|
1716
|
+
const stop = rec?.stop
|
|
1717
|
+
if (typeof stop !== 'function') {
|
|
1718
|
+
return { ok: false, error: '__sootsimRecorder.stop not installed' }
|
|
1719
|
+
}
|
|
1720
|
+
return stop()
|
|
1721
|
+
})
|
|
1722
|
+
if (!stopResult.ok) {
|
|
1723
|
+
throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`)
|
|
1724
|
+
}
|
|
1725
|
+
if (!stopResult.size) {
|
|
1726
|
+
throw new Error('stopBridgeRecording: recorder returned empty blob')
|
|
1727
|
+
}
|
|
1728
|
+
const chunks: Buffer[] = []
|
|
1729
|
+
let offset = 0
|
|
1730
|
+
const CHUNK = 2 * 1024 * 1024
|
|
1731
|
+
while (true) {
|
|
1732
|
+
const result: {
|
|
1733
|
+
data: string
|
|
1734
|
+
size: number
|
|
1735
|
+
offset: number
|
|
1736
|
+
done: boolean
|
|
1737
|
+
mime: string
|
|
1738
|
+
} | null = await page.evaluate(
|
|
1739
|
+
({ offset, chunk }) => {
|
|
1740
|
+
const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } })
|
|
1741
|
+
.__sootsimRecorder
|
|
1742
|
+
const get = rec?.getBlobBase64 as
|
|
1743
|
+
| ((args: { offset: number; chunk: number }) => Promise<{
|
|
1744
|
+
data: string
|
|
1745
|
+
size: number
|
|
1746
|
+
offset: number
|
|
1747
|
+
done: boolean
|
|
1748
|
+
mime: string
|
|
1749
|
+
} | null>)
|
|
1750
|
+
| undefined
|
|
1751
|
+
if (typeof get !== 'function') return null
|
|
1752
|
+
return get({ offset, chunk })
|
|
1753
|
+
},
|
|
1754
|
+
{ offset, chunk: CHUNK },
|
|
1755
|
+
)
|
|
1756
|
+
if (!result) throw new Error('rnx recorder produced no blob')
|
|
1757
|
+
chunks.push(Buffer.from(result.data, 'base64'))
|
|
1758
|
+
offset = result.offset
|
|
1759
|
+
if (result.done) break
|
|
1760
|
+
}
|
|
1761
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
1762
|
+
fs.writeFileSync(outPath, Buffer.concat(chunks))
|
|
1763
|
+
;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath
|
|
1764
|
+
return outPath
|
|
1765
|
+
},
|
|
1766
|
+
|
|
1767
|
+
_lastRecordingPath: '' as string,
|
|
1768
|
+
|
|
1769
|
+
// park every render clock before the interaction, detect the first changed
|
|
1770
|
+
// stepped frame inside the named component, then advance exact 60fps frames
|
|
1771
|
+
// to each requested offset. native extraction also chooses the first frame
|
|
1772
|
+
// at or after an offset, so both sides use the same rounding convention.
|
|
1773
|
+
async takeScreenshotStagesFromVisibleChangeFast(
|
|
1774
|
+
namePrefix: string,
|
|
1775
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1776
|
+
options: {
|
|
1777
|
+
detectionTimeoutMs?: number
|
|
1778
|
+
frame?: { height: number; width: number; x: number; y: number } | null
|
|
1779
|
+
// a pointer the capture itself presses at the trigger point and releases
|
|
1780
|
+
// once durationMs of lockstep time has elapsed, so held states (a lifted
|
|
1781
|
+
// control thumb, a pressed row) are painted mid-hold. an external trigger
|
|
1782
|
+
// that holds and releases before returning never paints a frame in
|
|
1783
|
+
// between, because no lockstep frame steps while it runs. moves drag the
|
|
1784
|
+
// held pointer to each point once afterMs of lockstep time has elapsed
|
|
1785
|
+
// since the press, and the release lands at the last point reached.
|
|
1786
|
+
hold?: {
|
|
1787
|
+
durationMs: number
|
|
1788
|
+
moves?: Array<{ afterMs: number; x: number; y: number }>
|
|
1789
|
+
x: number
|
|
1790
|
+
y: number
|
|
1791
|
+
} | null
|
|
1792
|
+
rootFrame?: { height: number; width: number; x: number; y: number } | null
|
|
1793
|
+
settleMs?: number
|
|
1794
|
+
trigger?: () => unknown
|
|
1795
|
+
},
|
|
1796
|
+
): Promise<{
|
|
1797
|
+
frames: Array<{
|
|
1798
|
+
label: string
|
|
1799
|
+
offsetMs: number
|
|
1800
|
+
actualMs: number
|
|
1801
|
+
path: string
|
|
1802
|
+
frameIndex: number
|
|
1803
|
+
virtualTimestampMs: number
|
|
1804
|
+
}>
|
|
1805
|
+
triggerCompletedMs: number
|
|
1806
|
+
triggerStartMs: number
|
|
1807
|
+
visibleChangeDetection: {
|
|
1808
|
+
actualMs: number
|
|
1809
|
+
changedPixelPercent: number
|
|
1810
|
+
changedPixels: number
|
|
1811
|
+
channelThreshold: number
|
|
1812
|
+
frame: { height: number; width: number; x: number; y: number } | null
|
|
1813
|
+
minimumChangePercent: number
|
|
1814
|
+
rootFrame: { height: number; width: number; x: number; y: number } | null
|
|
1815
|
+
totalPixels: number
|
|
1816
|
+
frameIndex: number
|
|
1817
|
+
virtualTimestampMs: number
|
|
1818
|
+
}
|
|
1819
|
+
}> {
|
|
1820
|
+
const page = getPage()
|
|
1821
|
+
const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`
|
|
1822
|
+
const settleMs = options.settleMs ?? 0
|
|
1823
|
+
if (!Number.isFinite(settleMs) || settleMs < 0) {
|
|
1824
|
+
throw new Error('rnx motion capture needs a non-negative settleMs')
|
|
1825
|
+
}
|
|
1826
|
+
await page.evaluate(measureMotionChange, {
|
|
1827
|
+
definition: MOTION_CHANGE_DEFINITION,
|
|
1828
|
+
installGlobal: true,
|
|
1829
|
+
})
|
|
1830
|
+
const capturePromise: Promise<{
|
|
1831
|
+
frames: Array<
|
|
1832
|
+
FastScreenshotData & {
|
|
1833
|
+
frameIndex: number
|
|
1834
|
+
virtualTimestampMs: number
|
|
1835
|
+
}
|
|
1836
|
+
>
|
|
1837
|
+
visibleChangeDetection: {
|
|
1838
|
+
actualMs: number
|
|
1839
|
+
changedPixelPercent: number
|
|
1840
|
+
changedPixels: number
|
|
1841
|
+
channelThreshold: number
|
|
1842
|
+
frameIndex: number
|
|
1843
|
+
minimumChangePercent: number
|
|
1844
|
+
totalPixels: number
|
|
1845
|
+
virtualTimestampMs: number
|
|
1846
|
+
}
|
|
1847
|
+
}> = page.evaluate(
|
|
1848
|
+
async (input) => {
|
|
1849
|
+
type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
|
|
1850
|
+
type CaptureState = {
|
|
1851
|
+
cancelled: boolean
|
|
1852
|
+
ready: boolean
|
|
1853
|
+
triggerCompleted: boolean
|
|
1854
|
+
}
|
|
1855
|
+
type CaptureWindow = Window & {
|
|
1856
|
+
__sootsimMeasureMotionChange?: (input: {
|
|
1857
|
+
baseline: Uint8ClampedArray
|
|
1858
|
+
candidate: Uint8ClampedArray
|
|
1859
|
+
definition: MotionChangeDefinition
|
|
1860
|
+
height: number
|
|
1861
|
+
label: string
|
|
1862
|
+
width: number
|
|
1863
|
+
}) => MotionChangeMeasurement
|
|
1864
|
+
__sootsimVisibleChangeCaptures?: Record<string, CaptureState>
|
|
1865
|
+
SootSim?: {
|
|
1866
|
+
bridges?: {
|
|
1867
|
+
interact?: {
|
|
1868
|
+
touchDown: (x: number, y: number) => Promise<boolean>
|
|
1869
|
+
touchMove: (x: number, y: number) => Promise<boolean>
|
|
1870
|
+
touchUp: (x: number, y: number) => Promise<boolean>
|
|
1871
|
+
}
|
|
1872
|
+
liveComposite?: {
|
|
1873
|
+
captureFresh: () => Promise<{
|
|
1874
|
+
canvas: HTMLCanvasElement | null
|
|
1875
|
+
frameToken: number
|
|
1876
|
+
}>
|
|
1877
|
+
}
|
|
1878
|
+
lockstepCapture?: {
|
|
1879
|
+
begin: (fps: number) => Promise<{
|
|
1880
|
+
stepFrame: () => Promise<FrameReceipt>
|
|
1881
|
+
stepToOffset: (
|
|
1882
|
+
anchor: FrameReceipt,
|
|
1883
|
+
offsetMs: number,
|
|
1884
|
+
) => Promise<FrameReceipt>
|
|
1885
|
+
end: () => void
|
|
1886
|
+
}>
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
type Snapshot = {
|
|
1892
|
+
actualMs: number
|
|
1893
|
+
bitmap: ImageBitmap
|
|
1894
|
+
label: string
|
|
1895
|
+
offsetMs: number
|
|
1896
|
+
frameIndex: number
|
|
1897
|
+
virtualTimestampMs: number
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
const captureWindow = window as unknown as CaptureWindow
|
|
1901
|
+
const live = captureWindow.SootSim?.bridges?.liveComposite
|
|
1902
|
+
const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture
|
|
1903
|
+
if (!live || typeof live.captureFresh !== 'function') {
|
|
1904
|
+
throw new Error('rnx live composite bridge is not installed')
|
|
1905
|
+
}
|
|
1906
|
+
if (!lockstep || typeof lockstep.begin !== 'function') {
|
|
1907
|
+
throw new Error('SootSim lockstep capture bridge is not installed')
|
|
1908
|
+
}
|
|
1909
|
+
const registry =
|
|
1910
|
+
captureWindow.__sootsimVisibleChangeCaptures ??
|
|
1911
|
+
(captureWindow.__sootsimVisibleChangeCaptures = {})
|
|
1912
|
+
const state: CaptureState = {
|
|
1913
|
+
cancelled: false,
|
|
1914
|
+
ready: false,
|
|
1915
|
+
triggerCompleted: false,
|
|
1916
|
+
}
|
|
1917
|
+
registry[input.token] = state
|
|
1918
|
+
const measureChange = captureWindow.__sootsimMeasureMotionChange
|
|
1919
|
+
if (!measureChange) {
|
|
1920
|
+
throw new Error('rnx motion change definition is not installed')
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
const captureBitmap = async () => {
|
|
1924
|
+
const { canvas } = await live.captureFresh()
|
|
1925
|
+
if (!canvas) throw new Error('rnx live composite produced no canvas')
|
|
1926
|
+
return createImageBitmap(canvas)
|
|
1927
|
+
}
|
|
1928
|
+
const boundsFor = (bitmap: ImageBitmap) => {
|
|
1929
|
+
const scale =
|
|
1930
|
+
input.rootFrame && input.rootFrame.width > 0
|
|
1931
|
+
? bitmap.width / input.rootFrame.width
|
|
1932
|
+
: 1
|
|
1933
|
+
return {
|
|
1934
|
+
height: input.frame
|
|
1935
|
+
? Math.max(
|
|
1936
|
+
1,
|
|
1937
|
+
Math.min(
|
|
1938
|
+
bitmap.height,
|
|
1939
|
+
Math.ceil((input.frame.y + input.frame.height) * scale),
|
|
1940
|
+
) - Math.max(0, Math.floor(input.frame.y * scale)),
|
|
1941
|
+
)
|
|
1942
|
+
: bitmap.height,
|
|
1943
|
+
left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
|
|
1944
|
+
top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
|
|
1945
|
+
width: input.frame
|
|
1946
|
+
? Math.max(
|
|
1947
|
+
1,
|
|
1948
|
+
Math.min(
|
|
1949
|
+
bitmap.width,
|
|
1950
|
+
Math.ceil((input.frame.x + input.frame.width) * scale),
|
|
1951
|
+
) - Math.max(0, Math.floor(input.frame.x * scale)),
|
|
1952
|
+
)
|
|
1953
|
+
: bitmap.width,
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
const regionPixels = (bitmap: ImageBitmap) => {
|
|
1957
|
+
const bounds = boundsFor(bitmap)
|
|
1958
|
+
const canvas = document.createElement('canvas')
|
|
1959
|
+
canvas.width = bounds.width
|
|
1960
|
+
canvas.height = bounds.height
|
|
1961
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
1962
|
+
if (!context) throw new Error('rnx motion detector needs a 2d canvas')
|
|
1963
|
+
context.drawImage(
|
|
1964
|
+
bitmap,
|
|
1965
|
+
bounds.left,
|
|
1966
|
+
bounds.top,
|
|
1967
|
+
bounds.width,
|
|
1968
|
+
bounds.height,
|
|
1969
|
+
0,
|
|
1970
|
+
0,
|
|
1971
|
+
bounds.width,
|
|
1972
|
+
bounds.height,
|
|
1973
|
+
)
|
|
1974
|
+
return context.getImageData(0, 0, bounds.width, bounds.height).data
|
|
1975
|
+
}
|
|
1976
|
+
const encode = (snapshot: Snapshot) => {
|
|
1977
|
+
const canvas = document.createElement('canvas')
|
|
1978
|
+
canvas.width = snapshot.bitmap.width
|
|
1979
|
+
canvas.height = snapshot.bitmap.height
|
|
1980
|
+
const context = canvas.getContext('2d')
|
|
1981
|
+
if (!context) throw new Error('rnx motion capture needs a 2d canvas')
|
|
1982
|
+
context.drawImage(snapshot.bitmap, 0, 0)
|
|
1983
|
+
snapshot.bitmap.close()
|
|
1984
|
+
return {
|
|
1985
|
+
actualMs: snapshot.actualMs,
|
|
1986
|
+
dataUrl: canvas.toDataURL('image/png'),
|
|
1987
|
+
label: snapshot.label,
|
|
1988
|
+
offsetMs: snapshot.offsetMs,
|
|
1989
|
+
frameIndex: snapshot.frameIndex,
|
|
1990
|
+
virtualTimestampMs: snapshot.virtualTimestampMs,
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
const interact = captureWindow.SootSim?.bridges?.interact
|
|
1995
|
+
if (input.hold && (!interact || typeof interact.touchDown !== 'function')) {
|
|
1996
|
+
throw new Error('rnx interact bridge is not installed')
|
|
1997
|
+
}
|
|
1998
|
+
const frameMs = 1000 / 60
|
|
1999
|
+
const controller = await lockstep.begin(60)
|
|
2000
|
+
// the hold's pointer goes down before the first stepped frame, so its
|
|
2001
|
+
// start is one frame before the first receipt; each move and the release
|
|
2002
|
+
// land on the first stepped frame at or past their time from there. the
|
|
2003
|
+
// deadlines are frame indexes: comparing summed virtual timestamps
|
|
2004
|
+
// against a duration lets rounding push an event past its frame.
|
|
2005
|
+
const moves = input.hold?.moves ?? []
|
|
2006
|
+
const holdState = {
|
|
2007
|
+
nextMove: 0,
|
|
2008
|
+
pressFrameIndex: null as number | null,
|
|
2009
|
+
released: false,
|
|
2010
|
+
x: input.hold?.x ?? 0,
|
|
2011
|
+
y: input.hold?.y ?? 0,
|
|
2012
|
+
}
|
|
2013
|
+
const holdEventFrameIndex = (afterMs: number) =>
|
|
2014
|
+
(holdState.pressFrameIndex ?? 0) + Math.ceil(afterMs / frameMs)
|
|
2015
|
+
const nextHoldEventFrameIndex = () => {
|
|
2016
|
+
if (!input.hold || holdState.released || holdState.pressFrameIndex === null) {
|
|
2017
|
+
return null
|
|
2018
|
+
}
|
|
2019
|
+
const move = moves[holdState.nextMove]
|
|
2020
|
+
return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
|
|
2021
|
+
}
|
|
2022
|
+
const advanceHold = async (receipt: FrameReceipt) => {
|
|
2023
|
+
if (!input.hold || !interact || holdState.released) return
|
|
2024
|
+
if (holdState.pressFrameIndex === null) {
|
|
2025
|
+
holdState.pressFrameIndex = receipt.frameIndex - 1
|
|
2026
|
+
}
|
|
2027
|
+
for (
|
|
2028
|
+
let move = moves[holdState.nextMove];
|
|
2029
|
+
move && receipt.frameIndex >= holdEventFrameIndex(move.afterMs);
|
|
2030
|
+
move = moves[holdState.nextMove]
|
|
2031
|
+
) {
|
|
2032
|
+
holdState.nextMove++
|
|
2033
|
+
holdState.x = move.x
|
|
2034
|
+
holdState.y = move.y
|
|
2035
|
+
await interact.touchMove(move.x, move.y)
|
|
2036
|
+
}
|
|
2037
|
+
if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
|
|
2038
|
+
holdState.released = true
|
|
2039
|
+
await interact.touchUp(holdState.x, holdState.y)
|
|
2040
|
+
}
|
|
2041
|
+
try {
|
|
2042
|
+
const baselineBitmap = await captureBitmap()
|
|
2043
|
+
const baseline = regionPixels(baselineBitmap)
|
|
2044
|
+
baselineBitmap.close()
|
|
2045
|
+
state.ready = true
|
|
2046
|
+
if (input.hold && interact) {
|
|
2047
|
+
const hit = await interact.touchDown(input.hold.x, input.hold.y)
|
|
2048
|
+
if (!hit) {
|
|
2049
|
+
throw new Error(`rnx motion hold missed at ${input.hold.x},${input.hold.y}`)
|
|
2050
|
+
}
|
|
2051
|
+
} else {
|
|
2052
|
+
while (!state.triggerCompleted) {
|
|
2053
|
+
if (state.cancelled) throw new Error('rnx motion capture was cancelled')
|
|
2054
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
let visibleStart: FrameReceipt | null = null
|
|
2059
|
+
let detectedMeasurement: MotionChangeMeasurement | null = null
|
|
2060
|
+
const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
|
|
2061
|
+
for (let frame = 0; frame < detectionFrameLimit; frame++) {
|
|
2062
|
+
if (state.cancelled) throw new Error('rnx motion capture was cancelled')
|
|
2063
|
+
const receipt = await controller.stepFrame()
|
|
2064
|
+
await advanceHold(receipt)
|
|
2065
|
+
const candidateBitmap = await captureBitmap()
|
|
2066
|
+
const bounds = boundsFor(candidateBitmap)
|
|
2067
|
+
const measurement = measureChange({
|
|
2068
|
+
baseline,
|
|
2069
|
+
candidate: regionPixels(candidateBitmap),
|
|
2070
|
+
definition: input.motionChangeDefinition,
|
|
2071
|
+
height: bounds.height,
|
|
2072
|
+
label: 'SootSim visible-change detector',
|
|
2073
|
+
width: bounds.width,
|
|
2074
|
+
})
|
|
2075
|
+
candidateBitmap.close()
|
|
2076
|
+
if (measurement.passedMinimum) {
|
|
2077
|
+
visibleStart = receipt
|
|
2078
|
+
detectedMeasurement = measurement
|
|
2079
|
+
break
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
if (!visibleStart || !detectedMeasurement) {
|
|
2083
|
+
throw new Error(
|
|
2084
|
+
`rnx motion had no visible change before ${input.detectionTimeoutMs}ms`,
|
|
2085
|
+
)
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const snapshots: Snapshot[] = []
|
|
2089
|
+
for (const stage of input.stages) {
|
|
2090
|
+
if (state.cancelled) throw new Error('rnx motion capture was cancelled')
|
|
2091
|
+
// step through every hold move and the release that fall before
|
|
2092
|
+
// this stage one frame at a time, so the stage paints the pointer
|
|
2093
|
+
// where it belongs rather than a hold the jump skipped past.
|
|
2094
|
+
const stageFrameIndex =
|
|
2095
|
+
visibleStart.frameIndex + Math.ceil(stage.offsetMs / frameMs)
|
|
2096
|
+
for (
|
|
2097
|
+
let eventFrameIndex = nextHoldEventFrameIndex();
|
|
2098
|
+
eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
|
|
2099
|
+
eventFrameIndex = nextHoldEventFrameIndex()
|
|
2100
|
+
) {
|
|
2101
|
+
await advanceHold(await controller.stepFrame())
|
|
2102
|
+
}
|
|
2103
|
+
const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
|
|
2104
|
+
await advanceHold(receipt)
|
|
2105
|
+
snapshots.push({
|
|
2106
|
+
actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
|
|
2107
|
+
bitmap: await captureBitmap(),
|
|
2108
|
+
frameIndex: receipt.frameIndex,
|
|
2109
|
+
label: stage.label,
|
|
2110
|
+
offsetMs: stage.offsetMs,
|
|
2111
|
+
virtualTimestampMs: receipt.virtualTimestampMs,
|
|
2112
|
+
})
|
|
2113
|
+
}
|
|
2114
|
+
if (input.settleMs > 0) {
|
|
2115
|
+
const maximumOffsetMs = input.stages.reduce(
|
|
2116
|
+
(maximum, stage) => Math.max(maximum, stage.offsetMs),
|
|
2117
|
+
0,
|
|
2118
|
+
)
|
|
2119
|
+
await controller.stepToOffset(visibleStart, maximumOffsetMs + input.settleMs)
|
|
2120
|
+
}
|
|
2121
|
+
return {
|
|
2122
|
+
frames: snapshots.map(encode),
|
|
2123
|
+
visibleChangeDetection: {
|
|
2124
|
+
actualMs: (visibleStart.frameIndex * 1000) / 60,
|
|
2125
|
+
changedPixelPercent: detectedMeasurement.changePercent,
|
|
2126
|
+
changedPixels: detectedMeasurement.changePixels,
|
|
2127
|
+
channelThreshold: input.motionChangeDefinition.channelThreshold,
|
|
2128
|
+
minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
|
|
2129
|
+
totalPixels: detectedMeasurement.totalPixels,
|
|
2130
|
+
frameIndex: visibleStart.frameIndex,
|
|
2131
|
+
virtualTimestampMs: visibleStart.virtualTimestampMs,
|
|
2132
|
+
},
|
|
2133
|
+
}
|
|
2134
|
+
} finally {
|
|
2135
|
+
controller.end()
|
|
2136
|
+
delete registry[input.token]
|
|
2137
|
+
}
|
|
2138
|
+
},
|
|
2139
|
+
{
|
|
2140
|
+
detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
|
|
2141
|
+
frame: options.frame ?? null,
|
|
2142
|
+
hold: options.hold ?? null,
|
|
2143
|
+
motionChangeDefinition: MOTION_CHANGE_DEFINITION,
|
|
2144
|
+
rootFrame: options.rootFrame ?? null,
|
|
2145
|
+
settleMs,
|
|
2146
|
+
stages,
|
|
2147
|
+
token,
|
|
2148
|
+
},
|
|
2149
|
+
)
|
|
2150
|
+
|
|
2151
|
+
await page.waitForFunction(
|
|
2152
|
+
(captureToken) => {
|
|
2153
|
+
const registry = (
|
|
2154
|
+
window as Window & {
|
|
2155
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
2156
|
+
string,
|
|
2157
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
2158
|
+
>
|
|
2159
|
+
}
|
|
2160
|
+
).__sootsimVisibleChangeCaptures
|
|
2161
|
+
return registry?.[captureToken]?.ready === true
|
|
2162
|
+
},
|
|
2163
|
+
token,
|
|
2164
|
+
{ timeout: 5000 },
|
|
2165
|
+
)
|
|
2166
|
+
|
|
2167
|
+
const triggerStartMs = Date.now()
|
|
2168
|
+
let triggerCompletedMs = 0
|
|
2169
|
+
try {
|
|
2170
|
+
if (!options.hold) {
|
|
2171
|
+
if (typeof options.trigger !== 'function') {
|
|
2172
|
+
throw new Error('rnx motion capture needs a trigger or a hold')
|
|
2173
|
+
}
|
|
2174
|
+
await Promise.resolve(options.trigger())
|
|
2175
|
+
}
|
|
2176
|
+
triggerCompletedMs = Date.now()
|
|
2177
|
+
await page.evaluate((captureToken) => {
|
|
2178
|
+
const registry = (
|
|
2179
|
+
window as Window & {
|
|
2180
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
2181
|
+
string,
|
|
2182
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
2183
|
+
>
|
|
2184
|
+
}
|
|
2185
|
+
).__sootsimVisibleChangeCaptures
|
|
2186
|
+
const capture = registry?.[captureToken]
|
|
2187
|
+
if (!capture) throw new Error('rnx motion capture disarmed before trigger')
|
|
2188
|
+
capture.triggerCompleted = true
|
|
2189
|
+
}, token)
|
|
2190
|
+
const capture = await capturePromise
|
|
2191
|
+
return {
|
|
2192
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
2193
|
+
triggerCompletedMs,
|
|
2194
|
+
triggerStartMs,
|
|
2195
|
+
visibleChangeDetection: {
|
|
2196
|
+
...capture.visibleChangeDetection,
|
|
2197
|
+
frame: options.frame ?? null,
|
|
2198
|
+
rootFrame: options.rootFrame ?? null,
|
|
2199
|
+
},
|
|
2200
|
+
}
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
await page
|
|
2203
|
+
.evaluate((captureToken) => {
|
|
2204
|
+
const registry = (
|
|
2205
|
+
window as Window & {
|
|
2206
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
2207
|
+
string,
|
|
2208
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
2209
|
+
>
|
|
2210
|
+
}
|
|
2211
|
+
).__sootsimVisibleChangeCaptures
|
|
2212
|
+
const capture = registry?.[captureToken]
|
|
2213
|
+
if (capture) capture.cancelled = true
|
|
2214
|
+
}, token)
|
|
2215
|
+
.catch(() => {})
|
|
2216
|
+
await capturePromise.catch(() => {})
|
|
2217
|
+
throw error
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
|
|
2221
|
+
// FAST multi-stage capture for sub-second animation diffs. each stage
|
|
2222
|
+
// schedules a setTimeout at its requested offsetMs and at that moment
|
|
2223
|
+
// SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
|
|
2224
|
+
// (drawImage from the worker-transferred home + overlay canvases). PNG
|
|
2225
|
+
// encoding is queued after all snapshots land — encoding stalls don't
|
|
2226
|
+
// contaminate the animation window.
|
|
2227
|
+
//
|
|
2228
|
+
// unlike `takeScreenshotStages`, this does NOT go through screenshot()
|
|
2229
|
+
// (which calls forceRenderAll + composite + toDataURL serially at
|
|
2230
|
+
// 300-1300ms per call and blows the spring-open timing). the worker's
|
|
2231
|
+
// vsync-pumped rAF keeps the home canvas fresh; we just sample it.
|
|
2232
|
+
//
|
|
2233
|
+
// returns paths in stage order; actualMs reports when the snapshot
|
|
2234
|
+
// actually fired (typically within a few ms of offsetMs).
|
|
2235
|
+
async takeScreenshotStagesFast(
|
|
2236
|
+
namePrefix: string,
|
|
2237
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
2238
|
+
options: {
|
|
2239
|
+
anchorAfterTrigger?: boolean
|
|
2240
|
+
skipWarmup?: boolean
|
|
2241
|
+
trigger: () => unknown
|
|
2242
|
+
},
|
|
2243
|
+
): Promise<{
|
|
2244
|
+
captureAnchorMs: number
|
|
2245
|
+
frames: Array<{
|
|
2246
|
+
label: string
|
|
2247
|
+
offsetMs: number
|
|
2248
|
+
actualMs: number
|
|
2249
|
+
captureRequestedAtMs: number
|
|
2250
|
+
path: string
|
|
2251
|
+
}>
|
|
2252
|
+
triggerCompletedMs: number
|
|
2253
|
+
triggerStartMs: number
|
|
2254
|
+
}> {
|
|
2255
|
+
const page = getPage()
|
|
2256
|
+
const token = `timed-${process.pid}-${Date.now()}-${Math.random()}`
|
|
2257
|
+
const capturePromise: Promise<{
|
|
2258
|
+
captureAnchorMs: number
|
|
2259
|
+
frames: Array<{
|
|
2260
|
+
label: string
|
|
2261
|
+
offsetMs: number
|
|
2262
|
+
actualMs: number
|
|
2263
|
+
captureRequestedAtMs: number
|
|
2264
|
+
dataUrl: string
|
|
2265
|
+
}>
|
|
2266
|
+
}> = page.evaluate(
|
|
2267
|
+
async (input) => {
|
|
2268
|
+
type CaptureState = {
|
|
2269
|
+
anchorPerformanceMs: number | null
|
|
2270
|
+
anchorWallMs: number | null
|
|
2271
|
+
cancelled: boolean
|
|
2272
|
+
ready: boolean
|
|
2273
|
+
}
|
|
2274
|
+
type CaptureWindow = Window & {
|
|
2275
|
+
__sootsimTimedCaptures?: Record<string, CaptureState>
|
|
2276
|
+
SootSim?: {
|
|
2277
|
+
bridges?: {
|
|
2278
|
+
screenshot?: (opts?: unknown) => Promise<string>
|
|
2279
|
+
liveComposite?: {
|
|
2280
|
+
captureFresh: () => Promise<{
|
|
2281
|
+
canvas: HTMLCanvasElement | null
|
|
2282
|
+
frameToken: number
|
|
2283
|
+
}>
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
const captureWindow = window as unknown as CaptureWindow
|
|
2289
|
+
const screenshot = captureWindow.SootSim?.bridges?.screenshot
|
|
2290
|
+
const live = captureWindow.SootSim?.bridges?.liveComposite
|
|
2291
|
+
if (typeof screenshot !== 'function') {
|
|
2292
|
+
throw new Error('rnx screenshot bridge is not installed')
|
|
2293
|
+
}
|
|
2294
|
+
const registry =
|
|
2295
|
+
captureWindow.__sootsimTimedCaptures ??
|
|
2296
|
+
(captureWindow.__sootsimTimedCaptures = {})
|
|
2297
|
+
const state: CaptureState = {
|
|
2298
|
+
anchorPerformanceMs: null,
|
|
2299
|
+
anchorWallMs: null,
|
|
2300
|
+
cancelled: false,
|
|
2301
|
+
ready: false,
|
|
2302
|
+
}
|
|
2303
|
+
registry[input.token] = state
|
|
2304
|
+
// initial warm-up: ensure the worker has produced at least one
|
|
2305
|
+
// frame after the menu's portal mount before we start sampling.
|
|
2306
|
+
// (a pump at higher cadence was tried and starved tap propagation —
|
|
2307
|
+
// the tenant React commit for setOpen never landed between
|
|
2308
|
+
// forceRender calls.)
|
|
2309
|
+
if (!input.skipWarmup) {
|
|
2310
|
+
await screenshot({ format: 'png' })
|
|
2311
|
+
}
|
|
2312
|
+
state.ready = true
|
|
2313
|
+
while (state.anchorPerformanceMs === null || state.anchorWallMs === null) {
|
|
2314
|
+
if (state.cancelled) throw new Error('rnx timed capture was cancelled')
|
|
2315
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
2316
|
+
}
|
|
2317
|
+
const captureAnchorMs = state.anchorWallMs
|
|
2318
|
+
const captureAnchorPerformanceMs = state.anchorPerformanceMs
|
|
2319
|
+
type Snapshot = {
|
|
2320
|
+
label: string
|
|
2321
|
+
offsetMs: number
|
|
2322
|
+
actualMs: number
|
|
2323
|
+
captureRequestedAtMs: number
|
|
2324
|
+
dataUrl: string | null
|
|
2325
|
+
}
|
|
2326
|
+
// each stage fires at its requested offset and SYNCHRONOUSLY
|
|
2327
|
+
// snapshots the engine's host-side composite canvas (drawImage
|
|
2328
|
+
// from the worker-transferred home canvas + overlay). encoding
|
|
2329
|
+
// happens after — encoding stalls don't disturb the next stage's
|
|
2330
|
+
// capture moment.
|
|
2331
|
+
const snaps: Snapshot[] = await Promise.all(
|
|
2332
|
+
input.stages.map(
|
|
2333
|
+
(stage) =>
|
|
2334
|
+
new Promise<Snapshot>((resolve) => {
|
|
2335
|
+
const fire = async () => {
|
|
2336
|
+
const actualMs = performance.now() - captureAnchorPerformanceMs
|
|
2337
|
+
const captureRequestedAtMs = Date.now()
|
|
2338
|
+
try {
|
|
2339
|
+
let dataUrl: string | null = null
|
|
2340
|
+
if (live && typeof live.captureFresh === 'function') {
|
|
2341
|
+
// synchronous paint of current canvas state — no
|
|
2342
|
+
// worker round-trip. the background pump keeps the
|
|
2343
|
+
// canvas fresh.
|
|
2344
|
+
const { canvas } = await live.captureFresh()
|
|
2345
|
+
if (canvas) {
|
|
2346
|
+
const bmp = await createImageBitmap(canvas)
|
|
2347
|
+
const enc = document.createElement('canvas')
|
|
2348
|
+
enc.width = bmp.width
|
|
2349
|
+
enc.height = bmp.height
|
|
2350
|
+
const ctx = enc.getContext('2d')
|
|
2351
|
+
if (ctx) {
|
|
2352
|
+
ctx.drawImage(bmp, 0, 0)
|
|
2353
|
+
dataUrl = enc.toDataURL('image/png')
|
|
2354
|
+
}
|
|
2355
|
+
bmp.close()
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
if (!dataUrl) {
|
|
2359
|
+
dataUrl = await screenshot({ format: 'png' })
|
|
2360
|
+
}
|
|
2361
|
+
resolve({
|
|
2362
|
+
label: stage.label,
|
|
2363
|
+
offsetMs: stage.offsetMs,
|
|
2364
|
+
actualMs,
|
|
2365
|
+
captureRequestedAtMs,
|
|
2366
|
+
dataUrl,
|
|
2367
|
+
})
|
|
2368
|
+
} catch {
|
|
2369
|
+
resolve({
|
|
2370
|
+
label: stage.label,
|
|
2371
|
+
offsetMs: stage.offsetMs,
|
|
2372
|
+
actualMs,
|
|
2373
|
+
captureRequestedAtMs,
|
|
2374
|
+
dataUrl: null,
|
|
2375
|
+
})
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
setTimeout(
|
|
2379
|
+
() => {
|
|
2380
|
+
void fire()
|
|
2381
|
+
},
|
|
2382
|
+
Math.max(
|
|
2383
|
+
0,
|
|
2384
|
+
captureAnchorPerformanceMs + stage.offsetMs - performance.now(),
|
|
2385
|
+
),
|
|
2386
|
+
)
|
|
2387
|
+
}),
|
|
2388
|
+
),
|
|
2389
|
+
)
|
|
2390
|
+
const out: Array<{
|
|
2391
|
+
label: string
|
|
2392
|
+
offsetMs: number
|
|
2393
|
+
actualMs: number
|
|
2394
|
+
captureRequestedAtMs: number
|
|
2395
|
+
dataUrl: string
|
|
2396
|
+
}> = []
|
|
2397
|
+
for (const snap of snaps) {
|
|
2398
|
+
if (!snap.dataUrl) continue
|
|
2399
|
+
out.push({
|
|
2400
|
+
label: snap.label,
|
|
2401
|
+
offsetMs: snap.offsetMs,
|
|
2402
|
+
actualMs: snap.actualMs,
|
|
2403
|
+
captureRequestedAtMs: snap.captureRequestedAtMs,
|
|
2404
|
+
dataUrl: snap.dataUrl,
|
|
2405
|
+
})
|
|
2406
|
+
}
|
|
2407
|
+
delete registry[input.token]
|
|
2408
|
+
return { captureAnchorMs, frames: out }
|
|
2409
|
+
},
|
|
2410
|
+
{ stages, skipWarmup: options.skipWarmup === true, token },
|
|
2411
|
+
)
|
|
2412
|
+
|
|
2413
|
+
await page.waitForFunction(
|
|
2414
|
+
(captureToken) => {
|
|
2415
|
+
const registry = (
|
|
2416
|
+
window as Window & {
|
|
2417
|
+
__sootsimTimedCaptures?: Record<string, { ready: boolean }>
|
|
2418
|
+
}
|
|
2419
|
+
).__sootsimTimedCaptures
|
|
2420
|
+
return registry?.[captureToken]?.ready === true
|
|
2421
|
+
},
|
|
2422
|
+
token,
|
|
2423
|
+
{ timeout: 5000 },
|
|
2424
|
+
)
|
|
2425
|
+
|
|
2426
|
+
const armCapture = (anchorMs?: number) =>
|
|
2427
|
+
page.evaluate(
|
|
2428
|
+
({ anchorMs, captureToken }) => {
|
|
2429
|
+
const registry = (
|
|
2430
|
+
window as Window & {
|
|
2431
|
+
__sootsimTimedCaptures?: Record<
|
|
2432
|
+
string,
|
|
2433
|
+
{
|
|
2434
|
+
anchorPerformanceMs: number | null
|
|
2435
|
+
anchorWallMs: number | null
|
|
2436
|
+
ready: boolean
|
|
2437
|
+
}
|
|
2438
|
+
>
|
|
2439
|
+
}
|
|
2440
|
+
).__sootsimTimedCaptures
|
|
2441
|
+
const capture = registry?.[captureToken]
|
|
2442
|
+
if (!capture?.ready) throw new Error('rnx timed capture was not armed')
|
|
2443
|
+
const nowMs = Date.now()
|
|
2444
|
+
capture.anchorWallMs = anchorMs ?? nowMs
|
|
2445
|
+
capture.anchorPerformanceMs = performance.now() - (nowMs - capture.anchorWallMs)
|
|
2446
|
+
return capture.anchorWallMs
|
|
2447
|
+
},
|
|
2448
|
+
{ anchorMs, captureToken: token },
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
let triggerStartMs = 0
|
|
2452
|
+
let triggerCompletedMs = 0
|
|
2453
|
+
try {
|
|
2454
|
+
let captureAnchorMs: number
|
|
2455
|
+
if (options.anchorAfterTrigger === false) {
|
|
2456
|
+
captureAnchorMs = await armCapture()
|
|
2457
|
+
triggerStartMs = captureAnchorMs
|
|
2458
|
+
const triggerPromise = Promise.resolve()
|
|
2459
|
+
.then(options.trigger)
|
|
2460
|
+
.then(() => {
|
|
2461
|
+
triggerCompletedMs = Date.now()
|
|
2462
|
+
})
|
|
2463
|
+
const [capture] = await Promise.all([capturePromise, triggerPromise])
|
|
2464
|
+
return {
|
|
2465
|
+
captureAnchorMs,
|
|
2466
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
2467
|
+
triggerCompletedMs,
|
|
2468
|
+
triggerStartMs,
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
triggerStartMs = Date.now()
|
|
2473
|
+
await Promise.resolve(options.trigger())
|
|
2474
|
+
triggerCompletedMs = Date.now()
|
|
2475
|
+
captureAnchorMs = await armCapture(triggerCompletedMs)
|
|
2476
|
+
const capture = await capturePromise
|
|
2477
|
+
return {
|
|
2478
|
+
captureAnchorMs,
|
|
2479
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
2480
|
+
triggerCompletedMs,
|
|
2481
|
+
triggerStartMs,
|
|
2482
|
+
}
|
|
2483
|
+
} catch (error) {
|
|
2484
|
+
await page
|
|
2485
|
+
.evaluate((captureToken) => {
|
|
2486
|
+
const registry = (
|
|
2487
|
+
window as Window & {
|
|
2488
|
+
__sootsimTimedCaptures?: Record<string, { cancelled: boolean }>
|
|
2489
|
+
}
|
|
2490
|
+
).__sootsimTimedCaptures
|
|
2491
|
+
const capture = registry?.[captureToken]
|
|
2492
|
+
if (capture) capture.cancelled = true
|
|
2493
|
+
}, token)
|
|
2494
|
+
.catch(() => {})
|
|
2495
|
+
await capturePromise.catch(() => {})
|
|
2496
|
+
throw error
|
|
2497
|
+
}
|
|
2498
|
+
},
|
|
2499
|
+
|
|
2500
|
+
// batched multi-stage capture for animation diffs. all N captures happen
|
|
2501
|
+
// inside one page.evaluate round trip so browser-side timing controls the
|
|
2502
|
+
// offsets — sidesteps the ~300ms per-call bridge overhead that blows
|
|
2503
|
+
// sub-second animation windows. each stage gets a screenshot at the
|
|
2504
|
+
// requested offsetMs (from when the evaluate starts, i.e. right after
|
|
2505
|
+
// trigger() returns). returns absolute paths in stage order.
|
|
2506
|
+
async takeScreenshotStages(
|
|
2507
|
+
namePrefix: string,
|
|
2508
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
2509
|
+
): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
|
|
2510
|
+
const page = getPage()
|
|
2511
|
+
const frames: Array<{
|
|
2512
|
+
label: string
|
|
2513
|
+
offsetMs: number
|
|
2514
|
+
actualMs: number
|
|
2515
|
+
dataUrl: string
|
|
2516
|
+
}> = await page.evaluate(
|
|
2517
|
+
async (input) => {
|
|
2518
|
+
const screenshot = (
|
|
2519
|
+
window as { SootSim?: { bridges?: { screenshot?: unknown } } }
|
|
2520
|
+
).SootSim?.bridges?.screenshot as
|
|
2521
|
+
| ((opts?: unknown) => Promise<string>)
|
|
2522
|
+
| undefined
|
|
2523
|
+
if (typeof screenshot !== 'function') {
|
|
2524
|
+
throw new Error('rnx screenshot bridge is not installed')
|
|
2525
|
+
}
|
|
2526
|
+
const start = Date.now()
|
|
2527
|
+
const out: Array<{
|
|
2528
|
+
label: string
|
|
2529
|
+
offsetMs: number
|
|
2530
|
+
actualMs: number
|
|
2531
|
+
dataUrl: string
|
|
2532
|
+
}> = []
|
|
2533
|
+
for (const stage of input.stages) {
|
|
2534
|
+
const elapsed = Date.now() - start
|
|
2535
|
+
const wait = Math.max(0, stage.offsetMs - elapsed)
|
|
2536
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
|
2537
|
+
const actualMs = Date.now() - start
|
|
2538
|
+
const dataUrl = await screenshot({ format: 'png' })
|
|
2539
|
+
out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl })
|
|
2540
|
+
}
|
|
2541
|
+
return out
|
|
2542
|
+
},
|
|
2543
|
+
{ stages },
|
|
2544
|
+
)
|
|
2545
|
+
return writeFastScreenshotFrames(namePrefix, frames)
|
|
2546
|
+
},
|
|
2547
|
+
|
|
2548
|
+
async shake() {
|
|
2549
|
+
// no-op for sootsim
|
|
2550
|
+
},
|
|
2551
|
+
|
|
2552
|
+
async setLocation(lat: number, lon: number) {
|
|
2553
|
+
// no-op
|
|
2554
|
+
},
|
|
2555
|
+
|
|
2556
|
+
async setStatusBar(config: StatusBarConfig) {
|
|
2557
|
+
await dispatchStatusBarOverride(config)
|
|
2558
|
+
},
|
|
2559
|
+
|
|
2560
|
+
async setURLBlacklist(urls: string[]) {
|
|
2561
|
+
// no-op
|
|
2562
|
+
},
|
|
2563
|
+
|
|
2564
|
+
async enableSynchronization() {
|
|
2565
|
+
_synchronizationEnabled = true
|
|
2566
|
+
},
|
|
2567
|
+
|
|
2568
|
+
async disableSynchronization() {
|
|
2569
|
+
_synchronizationEnabled = false
|
|
2570
|
+
},
|
|
2571
|
+
|
|
2572
|
+
getPlatform(): 'ios' | 'android' {
|
|
2573
|
+
return device._platform
|
|
2574
|
+
},
|
|
2575
|
+
|
|
2576
|
+
async pressBack() {
|
|
2577
|
+
if (device._platform !== 'android') return
|
|
2578
|
+
const page = getPage()
|
|
2579
|
+
const dispatched = await page.evaluate(async () => {
|
|
2580
|
+
const goBack = window.SootSim?.bridges?.mainShell?.goBack
|
|
2581
|
+
if (typeof goBack !== 'function') return false
|
|
2582
|
+
return (await goBack()) !== false
|
|
2583
|
+
})
|
|
2584
|
+
if (!dispatched) throw new Error('sootsim Android back bridge is not installed')
|
|
2585
|
+
if (_synchronizationEnabled) {
|
|
2586
|
+
await waitForSootsimIdle(page)
|
|
2587
|
+
}
|
|
2588
|
+
},
|
|
2589
|
+
|
|
2590
|
+
// a real browser key press. host.ts turns window keydown into the
|
|
2591
|
+
// `sootsim:hostKey` shell event that overlays listen to, so this drives the
|
|
2592
|
+
// same path a desktop user does for keys no device button covers (Escape).
|
|
2593
|
+
async pressKey(key: string) {
|
|
2594
|
+
const page = getPage()
|
|
2595
|
+
await page.keyboard.press(key)
|
|
2596
|
+
if (_synchronizationEnabled) {
|
|
2597
|
+
await waitForSootsimIdle(page)
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
|
|
2601
|
+
// raw coordinate tap, bypassing the element-based visibility/hit-test path.
|
|
2602
|
+
// mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
|
|
2603
|
+
// target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
|
|
2604
|
+
// `element(...).tap({x,y})` would fail Detox's visibility threshold.
|
|
2605
|
+
async tap(point?: { x: number; y: number } | boolean) {
|
|
2606
|
+
if (point == null || typeof point === 'boolean') return
|
|
2607
|
+
const page = getPage()
|
|
2608
|
+
const { x, y } = point
|
|
2609
|
+
await page.evaluate(
|
|
2610
|
+
async ({ x: tx, y: ty }) => {
|
|
2611
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
2612
|
+
if (typeof interactTap === 'function') {
|
|
2613
|
+
const result = await interactTap(tx, ty)
|
|
2614
|
+
if (result && (typeof result !== 'object' || result.hit !== false)) return
|
|
2615
|
+
}
|
|
2616
|
+
const tap = window.__sootsimTest?.tap
|
|
2617
|
+
if (typeof tap !== 'function') {
|
|
2618
|
+
throw new Error('sootsim tap bridge is not installed')
|
|
2619
|
+
}
|
|
2620
|
+
await tap(tx, ty)
|
|
2621
|
+
},
|
|
2622
|
+
{ x, y },
|
|
2623
|
+
)
|
|
2624
|
+
await page.waitForTimeout(50)
|
|
2625
|
+
if (_synchronizationEnabled) {
|
|
2626
|
+
await waitForSootsimIdle(page)
|
|
2627
|
+
}
|
|
2628
|
+
},
|
|
2629
|
+
|
|
2630
|
+
// window-point counterpart to element(...).longPress, for a target detox can
|
|
2631
|
+
// only address by coordinate (a native tab cell, a shell chrome control).
|
|
2632
|
+
async longPress(point: { x: number; y: number }, durationMs = 1000) {
|
|
2633
|
+
const page = getPage()
|
|
2634
|
+
const { x, y } = point
|
|
2635
|
+
await page.evaluate(
|
|
2636
|
+
async ({ x: px, y: py, ms }) => {
|
|
2637
|
+
const longPress = window.SootSim?.bridges?.interact?.longPress
|
|
2638
|
+
if (typeof longPress !== 'function') {
|
|
2639
|
+
throw new Error('sootsim longPress bridge is not installed')
|
|
2640
|
+
}
|
|
2641
|
+
const hit = await longPress(px, py, ms)
|
|
2642
|
+
if (!hit) {
|
|
2643
|
+
throw new Error(`sootsim longPress missed at ${px},${py}`)
|
|
2644
|
+
}
|
|
2645
|
+
},
|
|
2646
|
+
{ x, y, ms: durationMs },
|
|
2647
|
+
)
|
|
2648
|
+
await page.waitForTimeout(50)
|
|
2649
|
+
if (_synchronizationEnabled) {
|
|
2650
|
+
await waitForSootsimIdle(page)
|
|
2651
|
+
}
|
|
2652
|
+
},
|
|
2653
|
+
|
|
2654
|
+
async sendToHome() {
|
|
2655
|
+
const page = getPage()
|
|
2656
|
+
_suspendedAppId = await page.evaluate(async () => {
|
|
2657
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.()
|
|
2658
|
+
const appId = state?.activeApp
|
|
2659
|
+
if (typeof appId !== 'string') {
|
|
2660
|
+
throw new Error(`cannot send an inactive SootSim app home`)
|
|
2661
|
+
}
|
|
2662
|
+
window.SootSim?.bridges?.mainShell?.goHome?.()
|
|
2663
|
+
return appId
|
|
2664
|
+
})
|
|
2665
|
+
await pollUntil(
|
|
2666
|
+
() =>
|
|
2667
|
+
page.evaluate(async () => {
|
|
2668
|
+
const state = await window.SootSim?.bridges?.mainShell?.getState?.()
|
|
2669
|
+
return state?.state === 'home' && state.switcherPhase === 'idle'
|
|
2670
|
+
}),
|
|
2671
|
+
Boolean,
|
|
2672
|
+
{
|
|
2673
|
+
timeoutMs: 10000,
|
|
2674
|
+
label: 'SootSim home transition',
|
|
2675
|
+
},
|
|
2676
|
+
)
|
|
2677
|
+
},
|
|
2678
|
+
|
|
2679
|
+
// get direct access to the playwright page for advanced use
|
|
2680
|
+
getPage(): Page {
|
|
2681
|
+
return getPage()
|
|
2682
|
+
},
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
// exports matching detox API
|
|
2686
|
+
const sootsimExpect = createExpect(findNodeByMatcher, getPage)
|
|
2687
|
+
const sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage)
|
|
2688
|
+
|
|
2689
|
+
export { sootsimExpect as expect, sootsimWaitFor as waitFor }
|
|
2690
|
+
|
|
2691
|
+
// cleanup -- call this in afterAll
|
|
2692
|
+
export async function cleanup() {
|
|
2693
|
+
await closeBrowser()
|
|
2694
|
+
}
|