rnxsim 0.0.0 → 0.1.313
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +512 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +39 -0
- package/detox/expectations.ts +537 -0
- package/detox/gestures.ts +480 -0
- package/detox/index.ts +2118 -0
- package/detox/jest-preset.cjs +51 -0
- package/detox/jest-setup-after-env.cjs +18 -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.ts +43 -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-cli/bin.js +15 -0
- package/dist-cli/chunks/agent-XZ2KTPCU.js +48 -0
- package/dist-cli/chunks/agent-wrapper-JJYYW2WH.js +17 -0
- package/dist-cli/chunks/app-fonts-IXRNQG6B.js +4 -0
- package/dist-cli/chunks/assert-54T5SK5F.js +49 -0
- package/dist-cli/chunks/auth-FI5UDI45.js +12 -0
- package/dist-cli/chunks/beta-JV6UKADW.js +4 -0
- package/dist-cli/chunks/camera-VL73YIKP.js +22 -0
- package/dist-cli/chunks/chunk-2D2UPBBR.js +4 -0
- package/dist-cli/chunks/chunk-2YR5BGA5.js +5 -0
- package/dist-cli/chunks/chunk-3NV2NCNX.js +4 -0
- package/dist-cli/chunks/chunk-46EUUFJ5.js +4 -0
- package/dist-cli/chunks/chunk-4NPPOV2N.js +5 -0
- package/dist-cli/chunks/chunk-5DHC6KHQ.js +7 -0
- package/dist-cli/chunks/chunk-5TEF3ET3.js +74 -0
- package/dist-cli/chunks/chunk-5TPRP5QT.js +4 -0
- package/dist-cli/chunks/chunk-5YJCOWCH.js +4 -0
- package/dist-cli/chunks/chunk-7GN3LVWB.js +4 -0
- package/dist-cli/chunks/chunk-BBULZ7CG.js +568 -0
- package/dist-cli/chunks/chunk-BTWORNNG.js +71 -0
- package/dist-cli/chunks/chunk-D4FFVGI5.js +5 -0
- package/dist-cli/chunks/chunk-DCEMHR2Y.js +4 -0
- package/dist-cli/chunks/chunk-DZS6WPUI.js +4 -0
- package/dist-cli/chunks/chunk-F5ZRSS3C.js +3 -0
- package/dist-cli/chunks/chunk-FSUYIVJ6.js +9 -0
- package/dist-cli/chunks/chunk-G2WW6L2C.js +23 -0
- package/dist-cli/chunks/chunk-GASE6UBA.js +4 -0
- package/dist-cli/chunks/chunk-GGRX24GF.js +142 -0
- package/dist-cli/chunks/chunk-HI5TFJWN.js +5 -0
- package/dist-cli/chunks/chunk-IJ5CAZZC.js +5 -0
- package/dist-cli/chunks/chunk-IJO63TDP.js +4 -0
- package/dist-cli/chunks/chunk-KTHV3RUS.js +26 -0
- package/dist-cli/chunks/chunk-LF2ZVT7O.js +6 -0
- package/dist-cli/chunks/chunk-MJRLLB4R.js +38 -0
- package/dist-cli/chunks/chunk-NFK7T35W.js +4 -0
- package/dist-cli/chunks/chunk-NMF2ZMZQ.js +15 -0
- package/dist-cli/chunks/chunk-OHAZNXLK.js +4 -0
- package/dist-cli/chunks/chunk-OVFJFXUD.js +101 -0
- package/dist-cli/chunks/chunk-OZSSI4WN.js +28 -0
- package/dist-cli/chunks/chunk-QKDWYITG.js +25 -0
- package/dist-cli/chunks/chunk-RSZWCKNT.js +30 -0
- package/dist-cli/chunks/chunk-RTN5C5RL.js +4 -0
- package/dist-cli/chunks/chunk-TIVZIMMW.js +4 -0
- package/dist-cli/chunks/chunk-TZFFR3SD.js +7 -0
- package/dist-cli/chunks/chunk-UC6U3MML.js +5 -0
- package/dist-cli/chunks/chunk-VFCMSYZK.js +6 -0
- package/dist-cli/chunks/chunk-VNQEB4L7.js +5 -0
- package/dist-cli/chunks/chunk-VZXWHRUZ.js +983 -0
- package/dist-cli/chunks/chunk-W6K4EFPH.js +107 -0
- package/dist-cli/chunks/chunk-WEXDAC74.js +4 -0
- package/dist-cli/chunks/chunk-WF3T4SVI.js +14 -0
- package/dist-cli/chunks/chunk-WINYQ44O.js +4 -0
- package/dist-cli/chunks/chunk-WMIIKMGK.js +10 -0
- package/dist-cli/chunks/chunk-WUSWBCWA.js +507 -0
- package/dist-cli/chunks/chunk-WWZIXIRD.js +4 -0
- package/dist-cli/chunks/chunk-XEVZYVIW.js +766 -0
- package/dist-cli/chunks/chunk-YDGQTMQL.js +4 -0
- package/dist-cli/chunks/chunk-YIFT42WN.js +4 -0
- package/dist-cli/chunks/chunk-ZMJD5GEC.js +125 -0
- package/dist-cli/chunks/cleanup-P27PA6JI.js +25 -0
- package/dist-cli/chunks/cli-version-WWLPBDQ7.js +4 -0
- package/dist-cli/chunks/compat-ZD65FED3.js +20 -0
- package/dist-cli/chunks/config-XMJRNM2A.js +13 -0
- package/dist-cli/chunks/control-KMIQT3QP.js +4 -0
- package/dist-cli/chunks/daemon-G2ME7NLB.js +4 -0
- package/dist-cli/chunks/debug-PT4HOP7N.js +200 -0
- package/dist-cli/chunks/desktop-S3FG72AK.js +31 -0
- package/dist-cli/chunks/detox-B3D4IFCN.js +52 -0
- package/dist-cli/chunks/device-XBNDSB2R.js +18 -0
- package/dist-cli/chunks/diagnose-HMQXJE5N.js +43 -0
- package/dist-cli/chunks/disk-cleanup-BLCZ5BSZ.js +4 -0
- package/dist-cli/chunks/drivers-LDECZGP2.js +4 -0
- package/dist-cli/chunks/film-BJGTBYZB.js +74 -0
- package/dist-cli/chunks/flow-UEQNVTU7.js +4 -0
- package/dist-cli/chunks/help-T5FYSVGB.js +4 -0
- package/dist-cli/chunks/hidden-runtime-alias-ANOYADHM.js +4 -0
- package/dist-cli/chunks/home-paths-GT3LFNOR.js +4 -0
- package/dist-cli/chunks/inspect-ZA6XF5LD.js +4 -0
- package/dist-cli/chunks/install-desktop-TIMUDHPL.js +4 -0
- package/dist-cli/chunks/login-FJ737MWG.js +28 -0
- package/dist-cli/chunks/logout-ZCNMMHMY.js +4 -0
- package/dist-cli/chunks/maestro-SZTNKLDF.js +104 -0
- package/dist-cli/chunks/maestro-generate-DCFAIZ4H.js +36 -0
- package/dist-cli/chunks/mode-GRMQCRXR.js +26 -0
- package/dist-cli/chunks/optional-demo-registry-W36EWFFB.js +4 -0
- package/dist-cli/chunks/perf-QYBAAUZG.js +48 -0
- package/dist-cli/chunks/permissions-3QCQ6VF4.js +13 -0
- package/dist-cli/chunks/record-QPPC2S4E.js +72 -0
- package/dist-cli/chunks/report-issue-7NMFP4HK.js +29 -0
- package/dist-cli/chunks/runtime-XOAXMSTU.js +4 -0
- package/dist-cli/chunks/screenshot-command-67AECJFB.js +159 -0
- package/dist-cli/chunks/screenshot-layers-ASWBYPJL.js +20 -0
- package/dist-cli/chunks/screenshots-capture-PXA3HFQK.js +42 -0
- package/dist-cli/chunks/serve-BI2NBAXG.js +44 -0
- package/dist-cli/chunks/setup-7DWPMRSB.js +32 -0
- package/dist-cli/chunks/skills-S3Y22TUA.js +50 -0
- package/dist-cli/chunks/storage-XUIMJWAJ.js +37 -0
- package/dist-cli/chunks/store-JTHEJLAZ.js +4 -0
- package/dist-cli/chunks/telemetry-ZYJGD2DB.js +4 -0
- package/dist-cli/chunks/timeline-TMPLQPSP.js +24 -0
- package/dist-cli/chunks/upgrade-7HDSIM7K.js +7 -0
- package/dist-cli/chunks/upload-GMSZPWM6.js +4 -0
- package/dist-cli/chunks/version-HOCHZ37L.js +6 -0
- package/dist-cli/chunks/web-DG3WBYD3.js +4 -0
- package/dist-cli/chunks/what-happened-XFVUTZR7.js +25 -0
- package/dist-lib/agent-daemon-client.cjs +468 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +725 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +195 -0
- package/dist-lib/backend-origin.cjs +70 -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/cli-constants.cjs +32 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +2863 -0
- package/dist-lib/detox/jest-preset.cjs +51 -0
- package/dist-lib/dev-bundle-resolution.cjs +223 -0
- package/dist-lib/home-paths.cjs +421 -0
- package/dist-lib/host/bridge-host.cjs +5600 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +373 -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/websocket-proxy.cjs +214 -0
- package/dist-lib/index.cjs +513 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/menu.cjs +620 -0
- package/dist-lib/menu.mjs +581 -0
- package/dist-lib/metro.cjs +297 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
- package/dist-lib/sdk.cjs +2333 -0
- package/dist-lib/sdk.mjs +2240 -0
- package/dist-lib/skills.cjs +23922 -0
- package/dist-lib/vite.cjs +358 -0
- package/package.json +189 -18
- package/scripts/dev-server-scanner.ts +941 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/scripts/postinstall.cjs +3 -0
- package/scripts/preuninstall.cjs +118 -0
- package/skills/contrast/SKILL.md +171 -0
- package/skills/rnx-debug/SKILL.md +425 -0
- package/skills/rnx-perf/SKILL.md +295 -0
- package/skills/rnx-setup/SKILL.md +160 -0
- package/skills/rnx-test/SKILL.md +292 -0
- package/skills/rnx-visual/SKILL.md +122 -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 +573 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +204 -0
- package/src/backend-origin.ts +49 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +171 -0
- package/src/browser-cache.ts +16 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +32 -0
- package/src/codex-client.ts +215 -0
- package/src/config.ts +134 -0
- package/src/dev-bundle-resolution.ts +236 -0
- package/src/disk-cleanup.ts +466 -0
- package/src/home-paths.ts +552 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2626 -0
- package/src/host/fetch-proxy-handler.ts +397 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/websocket-proxy.ts +210 -0
- package/src/index.ts +12 -705
- package/src/jump-to-source-babel.ts +135 -0
- package/src/menu.ts +734 -0
- package/src/metro-plugin.ts +139 -0
- package/src/native-dev-bundle-url.ts +96 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +92 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/render-mode.ts +27 -0
- package/src/runtime-assets.ts +228 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +454 -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 +7 -0
- package/src/settings-bundle.ts +418 -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/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +190 -0
- package/src/vite-plugin.ts +1750 -0
- package/src/worklets-babel.ts +134 -0
package/detox/index.ts
ADDED
|
@@ -0,0 +1,2118 @@
|
|
|
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 { createExpect, createWaitFor } from './expectations'
|
|
10
|
+
import {
|
|
11
|
+
dispatchTwoFingerGesture,
|
|
12
|
+
dragScrollNode,
|
|
13
|
+
readSootsimInteractiveViewport,
|
|
14
|
+
readSootsimViewport,
|
|
15
|
+
sootsimToPage,
|
|
16
|
+
waitForSootsimGestureHandler,
|
|
17
|
+
} from './gestures'
|
|
18
|
+
import { by, type Matcher } from './matchers'
|
|
19
|
+
import {
|
|
20
|
+
MOTION_CHANGE_DEFINITION,
|
|
21
|
+
measureMotionChange,
|
|
22
|
+
type MotionChangeDefinition,
|
|
23
|
+
type MotionChangeMeasurement,
|
|
24
|
+
} from './motion-change.cjs'
|
|
25
|
+
import type { SootSimElement } from './element-types'
|
|
26
|
+
import type { Browser, BrowserContext, Page } from 'playwright'
|
|
27
|
+
import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
|
|
28
|
+
|
|
29
|
+
export { by }
|
|
30
|
+
export type { SootSimElement } from './element-types'
|
|
31
|
+
|
|
32
|
+
const BASE_URL = process.env.SOOTSIM_URL || 'http://localhost:5173'
|
|
33
|
+
const SCREENSHOT_DIR =
|
|
34
|
+
process.env.SOOTSIM_SCREENSHOT_DIR ||
|
|
35
|
+
path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
|
|
36
|
+
const DETOX_PLATFORM =
|
|
37
|
+
process.env.SOOTSIM_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
|
|
38
|
+
|
|
39
|
+
// shared state -- playwright page and browser
|
|
40
|
+
let _browser: Browser | null = null
|
|
41
|
+
let _reapBrowserNow: (() => void) | null = null
|
|
42
|
+
let _context: BrowserContext | null = null
|
|
43
|
+
let _page: Page | null = null
|
|
44
|
+
let _synchronizationEnabled = false
|
|
45
|
+
const pageDiagnostics = new WeakMap<Page, string[]>()
|
|
46
|
+
|
|
47
|
+
const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
|
|
48
|
+
|
|
49
|
+
type StatusBarConfig = {
|
|
50
|
+
time?: string
|
|
51
|
+
dataNetwork?: string
|
|
52
|
+
wifiMode?: string
|
|
53
|
+
wifiBars?: string
|
|
54
|
+
cellularMode?: string
|
|
55
|
+
cellularBars?: string
|
|
56
|
+
operatorName?: string
|
|
57
|
+
batteryState?: string
|
|
58
|
+
batteryLevel?: string | number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getPage(): Page {
|
|
62
|
+
if (!_page)
|
|
63
|
+
throw new Error('sootsim driver not initialized -- call device.launchApp() first')
|
|
64
|
+
return _page
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function recordPageDiagnostic(page: Page, message: string) {
|
|
68
|
+
const entries = pageDiagnostics.get(page)
|
|
69
|
+
if (!entries) return
|
|
70
|
+
entries.push(message)
|
|
71
|
+
if (entries.length > 40) entries.shift()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function attachPageDiagnostics(page: Page) {
|
|
75
|
+
if (pageDiagnostics.has(page)) return
|
|
76
|
+
pageDiagnostics.set(page, [])
|
|
77
|
+
page.on('console', (message) => {
|
|
78
|
+
recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`)
|
|
79
|
+
})
|
|
80
|
+
page.on('pageerror', (error) => {
|
|
81
|
+
recordPageDiagnostic(page, `[pageerror] ${error.message}`)
|
|
82
|
+
})
|
|
83
|
+
page.on('requestfailed', (request) => {
|
|
84
|
+
const failure = request.failure()
|
|
85
|
+
recordPageDiagnostic(
|
|
86
|
+
page,
|
|
87
|
+
`[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ''}`,
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function describeSootsimBridgeFailure(page: Page, cause: unknown) {
|
|
93
|
+
let pageState: unknown = null
|
|
94
|
+
try {
|
|
95
|
+
pageState = await page.evaluate(() => ({
|
|
96
|
+
bodyText: document.body?.innerText?.slice(0, 500) ?? '',
|
|
97
|
+
globals: Object.keys(window)
|
|
98
|
+
.filter((key) => key.startsWith('__sootsim') || key === 'SootSim')
|
|
99
|
+
.sort(),
|
|
100
|
+
readyState: document.readyState,
|
|
101
|
+
title: document.title,
|
|
102
|
+
url: window.location.href,
|
|
103
|
+
}))
|
|
104
|
+
} catch (error) {
|
|
105
|
+
pageState = {
|
|
106
|
+
evaluateError: error instanceof Error ? error.message : String(error),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return new Error(
|
|
111
|
+
[
|
|
112
|
+
`timed out waiting for sootsim test bridge`,
|
|
113
|
+
`cause: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
114
|
+
`page: ${JSON.stringify(pageState)}`,
|
|
115
|
+
`recent page diagnostics:\n${(pageDiagnostics.get(page) ?? []).join('\n') || '(none)'}`,
|
|
116
|
+
].join('\n'),
|
|
117
|
+
)
|
|
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
|
+
async function closeContext() {
|
|
321
|
+
const context = _context
|
|
322
|
+
_context = null
|
|
323
|
+
_page = null
|
|
324
|
+
_synchronizationEnabled = false
|
|
325
|
+
if (context) await context.close()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function closeBrowser() {
|
|
329
|
+
if (!_browser) return
|
|
330
|
+
const browser = _browser
|
|
331
|
+
const reapBrowserNow = _reapBrowserNow
|
|
332
|
+
_browser = null
|
|
333
|
+
_reapBrowserNow = null
|
|
334
|
+
await closeContext()
|
|
335
|
+
try {
|
|
336
|
+
await browser.close()
|
|
337
|
+
} finally {
|
|
338
|
+
reapBrowserNow?.()
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void> {
|
|
343
|
+
const result = await page.evaluate(
|
|
344
|
+
async ({ maxMs }) => {
|
|
345
|
+
let transitionError: string | null = null
|
|
346
|
+
try {
|
|
347
|
+
const waitForScreenTransitions = (window as any).__sootsimTest
|
|
348
|
+
?.waitForScreenTransitions
|
|
349
|
+
if (typeof waitForScreenTransitions === 'function') {
|
|
350
|
+
const transitionResult = await waitForScreenTransitions({
|
|
351
|
+
timeoutMs: Math.min(maxMs, 1800),
|
|
352
|
+
settleMs: 48,
|
|
353
|
+
startWindowMs: 600,
|
|
354
|
+
})
|
|
355
|
+
if (
|
|
356
|
+
transitionResult?.started === true &&
|
|
357
|
+
transitionResult.settled === true &&
|
|
358
|
+
transitionResult.timedOut !== true
|
|
359
|
+
) {
|
|
360
|
+
return { settled: true, elapsed: transitionResult.waitedMs ?? 0 }
|
|
361
|
+
}
|
|
362
|
+
if (transitionResult?.timedOut) {
|
|
363
|
+
return {
|
|
364
|
+
settled: false,
|
|
365
|
+
elapsed: transitionResult.waitedMs,
|
|
366
|
+
reason: 'screen transition',
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
transitionError = error instanceof Error ? error.message : String(error)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const start = Date.now()
|
|
375
|
+
const deadline = start + maxMs
|
|
376
|
+
const pollMs = 50
|
|
377
|
+
const requiredStablePolls = 3
|
|
378
|
+
const layoutTolerance = 1
|
|
379
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
380
|
+
const isLayoutStable = (a: number[] | null, b: number[]) => {
|
|
381
|
+
if (!a || a.length !== b.length) return false
|
|
382
|
+
for (let i = 0; i < b.length; i++) {
|
|
383
|
+
if (Math.abs(a[i] - b[i]) > layoutTolerance) return false
|
|
384
|
+
}
|
|
385
|
+
return true
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const readSnapshot = async () => {
|
|
389
|
+
const root = (window as any).__sootsimRoot
|
|
390
|
+
const layout: number[] = []
|
|
391
|
+
if (root) {
|
|
392
|
+
const walk = (node: any) => {
|
|
393
|
+
if (node.layout && node.layout.width > 0) {
|
|
394
|
+
layout.push(
|
|
395
|
+
Math.round(node.layout.x),
|
|
396
|
+
Math.round(node.layout.y),
|
|
397
|
+
Math.round(node.layout.width),
|
|
398
|
+
Math.round(node.layout.height),
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
for (const child of node.children || []) walk(child)
|
|
402
|
+
}
|
|
403
|
+
walk(root)
|
|
404
|
+
}
|
|
405
|
+
return layout
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let stableLayout: number[] | null = null
|
|
409
|
+
let stable = 0
|
|
410
|
+
while (Date.now() < deadline) {
|
|
411
|
+
const layout = await readSnapshot()
|
|
412
|
+
if (isLayoutStable(stableLayout, layout)) {
|
|
413
|
+
stable++
|
|
414
|
+
if (stable >= requiredStablePolls) {
|
|
415
|
+
return { settled: true, elapsed: Date.now() - start }
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
stableLayout = layout
|
|
419
|
+
stable = 0
|
|
420
|
+
}
|
|
421
|
+
await sleep(pollMs)
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
settled: false,
|
|
425
|
+
elapsed: Date.now() - start,
|
|
426
|
+
reason: transitionError
|
|
427
|
+
? `layout fallback after screen transition wait failed: ${transitionError}`
|
|
428
|
+
: 'layout fallback',
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
{ maxMs },
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if (!result?.settled) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`,
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// find a node in the sootsim tree matching a matcher descriptor
|
|
442
|
+
async function findNodeByMatcher(matcher: Matcher): Promise<any> {
|
|
443
|
+
const page = getPage()
|
|
444
|
+
if (matcher.index !== undefined) {
|
|
445
|
+
return page.evaluate(async (indexedMatcher: Matcher) => {
|
|
446
|
+
const filter =
|
|
447
|
+
indexedMatcher.type === 'id'
|
|
448
|
+
? { hasId: indexedMatcher.value }
|
|
449
|
+
: indexedMatcher.type === 'text'
|
|
450
|
+
? { hasText: indexedMatcher.value }
|
|
451
|
+
: indexedMatcher.type === 'label'
|
|
452
|
+
? { hasLabel: indexedMatcher.value }
|
|
453
|
+
: indexedMatcher.type === 'role'
|
|
454
|
+
? { hasRole: indexedMatcher.value }
|
|
455
|
+
: { type: indexedMatcher.value }
|
|
456
|
+
const results = await window.__sootsimTest!.queryAll(filter)
|
|
457
|
+
return results[indexedMatcher.index ?? 0] ?? null
|
|
458
|
+
}, matcher)
|
|
459
|
+
}
|
|
460
|
+
if (matcher.type === 'id') {
|
|
461
|
+
return page.evaluate(
|
|
462
|
+
(id: string) => window.__sootsimTest!.findByTestId(id),
|
|
463
|
+
matcher.value,
|
|
464
|
+
)
|
|
465
|
+
} else if (matcher.type === 'text') {
|
|
466
|
+
return page.evaluate(
|
|
467
|
+
(text: string) => window.__sootsimTest!.findByText(text),
|
|
468
|
+
matcher.value,
|
|
469
|
+
)
|
|
470
|
+
} else if (matcher.type === 'label') {
|
|
471
|
+
return page.evaluate(
|
|
472
|
+
(label: string) => window.__sootsimTest!.findByLabel(label),
|
|
473
|
+
matcher.value,
|
|
474
|
+
)
|
|
475
|
+
} else if (matcher.type === 'role') {
|
|
476
|
+
return page.evaluate(
|
|
477
|
+
(role: string) => window.__sootsimTest!.findByRole(role),
|
|
478
|
+
matcher.value,
|
|
479
|
+
)
|
|
480
|
+
} else if (matcher.type === 'type') {
|
|
481
|
+
return page.evaluate(async (type: string) => {
|
|
482
|
+
const results = await window.__sootsimTest!.queryAll({ type })
|
|
483
|
+
return results[0] || null
|
|
484
|
+
}, matcher.value)
|
|
485
|
+
}
|
|
486
|
+
return null
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// get the absolute center coordinates of a node in sootsim coordinate space
|
|
490
|
+
function getNodeCenter(nodeInfo: any): { x: number; y: number } {
|
|
491
|
+
return {
|
|
492
|
+
x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
|
|
493
|
+
y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2,
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function nodeOffsetToPage(
|
|
498
|
+
page: Page,
|
|
499
|
+
nodeInfo: any,
|
|
500
|
+
dx: number,
|
|
501
|
+
dy: number,
|
|
502
|
+
): Promise<{ x: number; y: number }> {
|
|
503
|
+
const center = getNodeCenter(nodeInfo)
|
|
504
|
+
return sootsimToPage(page, center.x + dx, center.y + dy)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function getDefaultTapPoint(
|
|
508
|
+
page: Page,
|
|
509
|
+
nodeInfo: any,
|
|
510
|
+
): Promise<{ x: number; y: number }> {
|
|
511
|
+
const resolvedTarget = await page.evaluate(
|
|
512
|
+
(nodeId) => window.__sootsimTest?.resolveTapTarget?.(nodeId) ?? null,
|
|
513
|
+
nodeInfo.nodeId,
|
|
514
|
+
)
|
|
515
|
+
const center =
|
|
516
|
+
resolvedTarget &&
|
|
517
|
+
typeof resolvedTarget.cx === 'number' &&
|
|
518
|
+
typeof resolvedTarget.cy === 'number'
|
|
519
|
+
? { x: resolvedTarget.cx, y: resolvedTarget.cy }
|
|
520
|
+
: getNodeCenter(nodeInfo)
|
|
521
|
+
const frame = nodeInfo.visibleFrame ?? {
|
|
522
|
+
x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
|
|
523
|
+
y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
|
|
524
|
+
width: nodeInfo.layout?.width ?? 0,
|
|
525
|
+
height: nodeInfo.layout?.height ?? 0,
|
|
526
|
+
}
|
|
527
|
+
const fullFrameTolerance = 0.5
|
|
528
|
+
const fullyVisible =
|
|
529
|
+
typeof nodeInfo.absolutePosition?.x === 'number' &&
|
|
530
|
+
typeof nodeInfo.absolutePosition?.y === 'number' &&
|
|
531
|
+
typeof nodeInfo.layout?.width === 'number' &&
|
|
532
|
+
typeof nodeInfo.layout?.height === 'number' &&
|
|
533
|
+
Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance &&
|
|
534
|
+
Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance &&
|
|
535
|
+
frame.width >= nodeInfo.layout.width - fullFrameTolerance &&
|
|
536
|
+
frame.height >= nodeInfo.layout.height - fullFrameTolerance
|
|
537
|
+
if (fullyVisible) return center
|
|
538
|
+
|
|
539
|
+
const viewport = await readSootsimInteractiveViewport(page)
|
|
540
|
+
const left = Math.max(0, frame.x)
|
|
541
|
+
const top = Math.max(0, frame.y)
|
|
542
|
+
const right = Math.min(viewport.width, frame.x + frame.width)
|
|
543
|
+
const bottom = Math.min(viewport.height, frame.y + frame.height)
|
|
544
|
+
|
|
545
|
+
if (right <= left || bottom <= top) {
|
|
546
|
+
return center
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
x: left + (right - left) / 2,
|
|
551
|
+
y: top + (bottom - top) / 2,
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) {
|
|
556
|
+
await page.evaluate(
|
|
557
|
+
async ({ x, y }) => {
|
|
558
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
559
|
+
if (typeof interactTap === 'function') {
|
|
560
|
+
const result = await interactTap(x, y)
|
|
561
|
+
if (result && (typeof result !== 'object' || result.hit !== false)) return
|
|
562
|
+
}
|
|
563
|
+
const tap = window.__sootsimTest?.tap
|
|
564
|
+
if (typeof tap !== 'function') {
|
|
565
|
+
throw new Error('sootsim tap bridge is not installed')
|
|
566
|
+
}
|
|
567
|
+
await tap(x, y)
|
|
568
|
+
},
|
|
569
|
+
{ x, y },
|
|
570
|
+
)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
|
|
574
|
+
const focused = await page.evaluate(
|
|
575
|
+
() => window.__sootsimTest!.getFocusedNode?.() ?? null,
|
|
576
|
+
)
|
|
577
|
+
return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
|
|
581
|
+
const page = getPage()
|
|
582
|
+
const node = await findNodeByMatcher(el._matcher)
|
|
583
|
+
if (!node) {
|
|
584
|
+
throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`)
|
|
585
|
+
}
|
|
586
|
+
if (await isFocusedTextInputNode(page, node)) return
|
|
587
|
+
await el.tap()
|
|
588
|
+
await page.waitForTimeout(100)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function ensureScreenshotDir() {
|
|
592
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
593
|
+
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function decodePngDataUrl(dataUrl: string): Buffer {
|
|
598
|
+
const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl)
|
|
599
|
+
if (!match) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
`sootsim screenshot bridge returned a non-png payload: ${JSON.stringify(dataUrl.slice(0, 120))}`,
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
return Buffer.from(match[1], 'base64')
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
type FastScreenshotData = {
|
|
608
|
+
label: string
|
|
609
|
+
offsetMs: number
|
|
610
|
+
actualMs: number
|
|
611
|
+
dataUrl: string
|
|
612
|
+
frameIndex?: number
|
|
613
|
+
virtualTimestampMs?: number
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function writeFastScreenshotFrames<T extends FastScreenshotData>(
|
|
617
|
+
namePrefix: string,
|
|
618
|
+
frames: T[],
|
|
619
|
+
): Array<Omit<T, 'dataUrl'> & { path: string }> {
|
|
620
|
+
ensureScreenshotDir()
|
|
621
|
+
return frames.map((frame) => {
|
|
622
|
+
const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
623
|
+
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
624
|
+
fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl))
|
|
625
|
+
const { dataUrl: _dataUrl, ...metadata } = frame
|
|
626
|
+
return {
|
|
627
|
+
...metadata,
|
|
628
|
+
path: filePath,
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// single-shot canvas capture — no frame-stability poll. used for multi-stage
|
|
634
|
+
// animation captures where we explicitly want to sample mid-transition. the
|
|
635
|
+
// usual captureSootsimPng polls until two consecutive frames are byte-equal
|
|
636
|
+
// which is ideal for settled screenshots but blocks for the full timeout
|
|
637
|
+
// while an animation is running.
|
|
638
|
+
async function captureSootsimPngFast(opts?: {
|
|
639
|
+
crop?: { h: number; w: number; x: number; y: number }
|
|
640
|
+
}) {
|
|
641
|
+
const page = getPage()
|
|
642
|
+
const dataUrl = await page.evaluate(
|
|
643
|
+
async (captureOpts) => {
|
|
644
|
+
const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
|
|
645
|
+
.SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
|
|
646
|
+
if (typeof screenshot !== 'function') {
|
|
647
|
+
throw new Error('sootsim screenshot bridge is not installed')
|
|
648
|
+
}
|
|
649
|
+
return screenshot(captureOpts)
|
|
650
|
+
},
|
|
651
|
+
{ crop: opts?.crop, format: 'png' },
|
|
652
|
+
)
|
|
653
|
+
if (!dataUrl) {
|
|
654
|
+
throw new Error('sootsim screenshot bridge returned an empty image')
|
|
655
|
+
}
|
|
656
|
+
return decodePngDataUrl(dataUrl)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function captureSootsimPng(opts?: {
|
|
660
|
+
crop?: { h: number; w: number; x: number; y: number }
|
|
661
|
+
}) {
|
|
662
|
+
const page = getPage()
|
|
663
|
+
// frame-stability poll: capture twice with a gap > one tenant live-blur
|
|
664
|
+
// push interval (~67ms) and accept only when both PNGs are byte-equal.
|
|
665
|
+
// PNGs from canvas.toDataURL are deterministic for stable RGBA, so byte-
|
|
666
|
+
// equal = pixel-equal. without this, the keyboard's blur stream and the
|
|
667
|
+
// text-input cursor blink produce 1-12pp frame-to-frame variance in
|
|
668
|
+
// captures fired blindly after a settle, which then drifts the published
|
|
669
|
+
// proof manifest. matches the same poll in scripts/conformance-fast-iter.ts
|
|
670
|
+
// so cron and publish lock onto the same stable frame.
|
|
671
|
+
const result: {
|
|
672
|
+
dataUrl: string
|
|
673
|
+
stable: boolean
|
|
674
|
+
attempts: number
|
|
675
|
+
elapsedMs: number
|
|
676
|
+
} = await page.evaluate(
|
|
677
|
+
async (captureOpts) => {
|
|
678
|
+
const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
|
|
679
|
+
.SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
|
|
680
|
+
if (typeof screenshot !== 'function') {
|
|
681
|
+
throw new Error('sootsim screenshot bridge is not installed')
|
|
682
|
+
}
|
|
683
|
+
const gapMs = 100
|
|
684
|
+
const maxWaitMs = 8000
|
|
685
|
+
const minAttempts = 3
|
|
686
|
+
const t0 = Date.now()
|
|
687
|
+
let prev = await screenshot(captureOpts)
|
|
688
|
+
let attempts = 1
|
|
689
|
+
while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
|
|
690
|
+
await new Promise((r) => setTimeout(r, gapMs))
|
|
691
|
+
const next = await screenshot(captureOpts)
|
|
692
|
+
attempts++
|
|
693
|
+
if (next === prev) {
|
|
694
|
+
return {
|
|
695
|
+
dataUrl: next,
|
|
696
|
+
stable: true,
|
|
697
|
+
attempts,
|
|
698
|
+
elapsedMs: Date.now() - t0,
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
prev = next
|
|
702
|
+
}
|
|
703
|
+
return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 }
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
crop: opts?.crop,
|
|
707
|
+
format: 'png',
|
|
708
|
+
},
|
|
709
|
+
)
|
|
710
|
+
if (!result.dataUrl) {
|
|
711
|
+
throw new Error('sootsim screenshot bridge returned an empty image')
|
|
712
|
+
}
|
|
713
|
+
if (!result.stable) {
|
|
714
|
+
console.warn(
|
|
715
|
+
`[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`,
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
return decodePngDataUrl(result.dataUrl)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function writeSootsimScreenshot(
|
|
722
|
+
name: string,
|
|
723
|
+
opts?: { crop?: { h: number; w: number; x: number; y: number } },
|
|
724
|
+
) {
|
|
725
|
+
ensureScreenshotDir()
|
|
726
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
|
|
727
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPng(opts))
|
|
728
|
+
return screenshotPath
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function dispatchStatusBarOverride(config: StatusBarConfig) {
|
|
732
|
+
const page = getPage()
|
|
733
|
+
const time =
|
|
734
|
+
typeof config.time === 'string' && config.time.length > 0 ? config.time : null
|
|
735
|
+
await page.evaluate(
|
|
736
|
+
({ eventType, time }) => {
|
|
737
|
+
window.dispatchEvent(
|
|
738
|
+
new CustomEvent(eventType, {
|
|
739
|
+
detail: { time },
|
|
740
|
+
}),
|
|
741
|
+
)
|
|
742
|
+
},
|
|
743
|
+
{ eventType: STATUS_BAR_OVERRIDE_EVENT, time },
|
|
744
|
+
)
|
|
745
|
+
await page.waitForTimeout(50)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function dispatchSootsimEventInPage<K extends SootsimEventName>(
|
|
749
|
+
type: K,
|
|
750
|
+
detail: SootsimEventMap[K],
|
|
751
|
+
) {
|
|
752
|
+
const page = getPage()
|
|
753
|
+
await page.evaluate(
|
|
754
|
+
({ detail, type }) => {
|
|
755
|
+
window.dispatchEvent(new CustomEvent(type, { detail }))
|
|
756
|
+
},
|
|
757
|
+
{ detail, type },
|
|
758
|
+
)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
762
|
+
const el: SootSimElement = {
|
|
763
|
+
_matcher: matcher,
|
|
764
|
+
|
|
765
|
+
async tap(point) {
|
|
766
|
+
const page = getPage()
|
|
767
|
+
await waitForSootsimWorkletsIdle(page, 3000)
|
|
768
|
+
const node = await findNodeByMatcher(matcher)
|
|
769
|
+
if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`)
|
|
770
|
+
const defaultPoint = point ? null : await getDefaultTapPoint(page, node)
|
|
771
|
+
const targetSootSimX =
|
|
772
|
+
typeof point?.x === 'number'
|
|
773
|
+
? node.absolutePosition.x + point.x
|
|
774
|
+
: (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
|
|
775
|
+
const targetSootSimY =
|
|
776
|
+
typeof point?.y === 'number'
|
|
777
|
+
? node.absolutePosition.y + point.y
|
|
778
|
+
: (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
|
|
779
|
+
await dispatchTapAtSootsimPoint(page, targetSootSimX, targetSootSimY)
|
|
780
|
+
if (_synchronizationEnabled) {
|
|
781
|
+
await waitForSootsimIdle(page)
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
async multiTap(times: number) {
|
|
786
|
+
// detox iOS multiTap performs N taps with the system's natural double-
|
|
787
|
+
// tap pause. UITapGestureRecognizer treats taps within ~250ms as a
|
|
788
|
+
// multi-tap. emit each tap as a discrete down/up at the same node
|
|
789
|
+
// center with a small inter-tap pause that stays inside RNGH's
|
|
790
|
+
// default maxDelay window of 200ms.
|
|
791
|
+
const page = getPage()
|
|
792
|
+
const node = await findNodeByMatcher(matcher)
|
|
793
|
+
if (!node)
|
|
794
|
+
throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`)
|
|
795
|
+
const center = getNodeCenter(node)
|
|
796
|
+
const pageCoords = await sootsimToPage(page, center.x, center.y)
|
|
797
|
+
|
|
798
|
+
for (let i = 0; i < times; i++) {
|
|
799
|
+
await page.mouse.move(pageCoords.x, pageCoords.y)
|
|
800
|
+
await page.mouse.down()
|
|
801
|
+
await page.waitForTimeout(50)
|
|
802
|
+
await page.mouse.up()
|
|
803
|
+
if (i < times - 1) {
|
|
804
|
+
await page.waitForTimeout(80)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
await page.waitForTimeout(80)
|
|
808
|
+
},
|
|
809
|
+
|
|
810
|
+
async longPress(duration = 1000) {
|
|
811
|
+
const page = getPage()
|
|
812
|
+
const node = await findNodeByMatcher(matcher)
|
|
813
|
+
if (!node)
|
|
814
|
+
throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`)
|
|
815
|
+
const center = getNodeCenter(node)
|
|
816
|
+
await page.evaluate(
|
|
817
|
+
async ({ duration, x, y }) => {
|
|
818
|
+
const longPress = window.SootSim?.bridges?.interact?.longPress
|
|
819
|
+
if (typeof longPress !== 'function') {
|
|
820
|
+
throw new Error('sootsim longPress bridge is not installed')
|
|
821
|
+
}
|
|
822
|
+
const hit = await longPress(x, y, duration)
|
|
823
|
+
if (!hit) {
|
|
824
|
+
throw new Error(`sootsim longPress missed at ${x},${y}`)
|
|
825
|
+
}
|
|
826
|
+
},
|
|
827
|
+
{ duration, x: center.x, y: center.y },
|
|
828
|
+
)
|
|
829
|
+
await page.waitForTimeout(50)
|
|
830
|
+
},
|
|
831
|
+
|
|
832
|
+
async longPressAndDrag(
|
|
833
|
+
duration: number,
|
|
834
|
+
normalizedStartX: number,
|
|
835
|
+
normalizedStartY: number,
|
|
836
|
+
targetElement: SootSimElement,
|
|
837
|
+
normalizedEndX: number,
|
|
838
|
+
normalizedEndY: number,
|
|
839
|
+
speed = 'fast',
|
|
840
|
+
holdDuration = 0,
|
|
841
|
+
) {
|
|
842
|
+
const page = getPage()
|
|
843
|
+
// resolve source element
|
|
844
|
+
const srcNode = await findNodeByMatcher(matcher)
|
|
845
|
+
if (!srcNode)
|
|
846
|
+
throw new Error(`source element not found: ${JSON.stringify(matcher)}`)
|
|
847
|
+
|
|
848
|
+
// resolve target element
|
|
849
|
+
const tgtNode = await findNodeByMatcher(targetElement._matcher)
|
|
850
|
+
if (!tgtNode)
|
|
851
|
+
throw new Error(
|
|
852
|
+
`target element not found: ${JSON.stringify(targetElement._matcher)}`,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
// compute start and end in sootsim coords
|
|
856
|
+
const startSootSimX =
|
|
857
|
+
srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX
|
|
858
|
+
const startSootSimY =
|
|
859
|
+
srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY
|
|
860
|
+
const endSootSimX =
|
|
861
|
+
tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX
|
|
862
|
+
const endSootSimY =
|
|
863
|
+
tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY
|
|
864
|
+
|
|
865
|
+
const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
|
|
866
|
+
const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
|
|
867
|
+
const steps = speed === 'slow' ? 20 : 10
|
|
868
|
+
|
|
869
|
+
// press at start position
|
|
870
|
+
await page.mouse.move(startPage.x, startPage.y)
|
|
871
|
+
await page.mouse.down()
|
|
872
|
+
await page.waitForTimeout(Math.max(duration, holdDuration))
|
|
873
|
+
|
|
874
|
+
// drag to end position
|
|
875
|
+
const dx = (endPage.x - startPage.x) / steps
|
|
876
|
+
const dy = (endPage.y - startPage.y) / steps
|
|
877
|
+
for (let i = 1; i <= steps; i++) {
|
|
878
|
+
await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
|
|
879
|
+
await page.waitForTimeout(speed === 'slow' ? 30 : 10)
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
await page.mouse.up()
|
|
883
|
+
await page.waitForTimeout(50)
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
async typeText(text: string) {
|
|
887
|
+
const page = getPage()
|
|
888
|
+
await focusElementForTextEntry(el)
|
|
889
|
+
await page.keyboard.type(text)
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
async replaceText(text: string) {
|
|
893
|
+
const page = getPage()
|
|
894
|
+
await focusElementForTextEntry(el)
|
|
895
|
+
const node = await findNodeByMatcher(matcher)
|
|
896
|
+
const value = typeof node?.text === 'string' ? node.text : ''
|
|
897
|
+
for (let i = 0; i < value.length; i++) {
|
|
898
|
+
await page.keyboard.press('Backspace')
|
|
899
|
+
}
|
|
900
|
+
await page.keyboard.type(text)
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
async clearText() {
|
|
904
|
+
const page = getPage()
|
|
905
|
+
await focusElementForTextEntry(el)
|
|
906
|
+
const node = await findNodeByMatcher(matcher)
|
|
907
|
+
const value = typeof node?.text === 'string' ? node.text : ''
|
|
908
|
+
for (let i = 0; i < value.length; i++) {
|
|
909
|
+
await page.keyboard.press('Backspace')
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
async scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right') {
|
|
914
|
+
const node = await findNodeByMatcher(matcher)
|
|
915
|
+
if (!node)
|
|
916
|
+
throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
|
|
917
|
+
const page = getPage()
|
|
918
|
+
await dragScrollNode(page, node, pixels, direction)
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
async scrollTo(edge: 'top' | 'bottom' | 'left' | 'right') {
|
|
922
|
+
const node = await findNodeByMatcher(matcher)
|
|
923
|
+
if (!node)
|
|
924
|
+
throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
|
|
925
|
+
|
|
926
|
+
const targetId =
|
|
927
|
+
typeof node.testID === 'string' && node.testID
|
|
928
|
+
? node.testID
|
|
929
|
+
: typeof node.id === 'string' && node.id
|
|
930
|
+
? node.id
|
|
931
|
+
: null
|
|
932
|
+
if (!targetId) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`,
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const hitX = node.absolutePosition.x + node.layout.width / 2
|
|
939
|
+
const hitY = node.absolutePosition.y + node.layout.height / 2
|
|
940
|
+
const page = getPage()
|
|
941
|
+
const result = await page.evaluate(
|
|
942
|
+
async ({ edge, hitX, hitY, targetId }) => {
|
|
943
|
+
const bridge = window.__sootsimTest
|
|
944
|
+
if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
|
|
945
|
+
return { ok: false, reason: 'scroll bridge unavailable' }
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const state = await bridge.getScrollStateAt(hitX, hitY)
|
|
949
|
+
if (!state) return { ok: false, reason: 'scroll state not found' }
|
|
950
|
+
|
|
951
|
+
const maxOffset =
|
|
952
|
+
state.maxOffset && typeof state.maxOffset === 'object'
|
|
953
|
+
? (state.maxOffset as { x?: number; y?: number })
|
|
954
|
+
: null
|
|
955
|
+
const offset =
|
|
956
|
+
state.offset && typeof state.offset === 'object'
|
|
957
|
+
? (state.offset as { x?: number; y?: number })
|
|
958
|
+
: null
|
|
959
|
+
const maxX =
|
|
960
|
+
typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0)
|
|
961
|
+
const maxY =
|
|
962
|
+
typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0)
|
|
963
|
+
const currentX =
|
|
964
|
+
typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0)
|
|
965
|
+
const currentY =
|
|
966
|
+
typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0)
|
|
967
|
+
|
|
968
|
+
const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX
|
|
969
|
+
const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY
|
|
970
|
+
return bridge.scrollTo(targetId, x, y, false)
|
|
971
|
+
},
|
|
972
|
+
{ edge, hitX, hitY, targetId },
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
if (!result?.ok) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
`scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`,
|
|
978
|
+
)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
await page.evaluate(
|
|
982
|
+
() =>
|
|
983
|
+
new Promise<void>((resolve) => {
|
|
984
|
+
requestAnimationFrame(() => resolve())
|
|
985
|
+
}),
|
|
986
|
+
)
|
|
987
|
+
if (_synchronizationEnabled) {
|
|
988
|
+
await waitForSootsimIdle(page)
|
|
989
|
+
}
|
|
990
|
+
},
|
|
991
|
+
|
|
992
|
+
async swipe(
|
|
993
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
994
|
+
speed: 'fast' | 'slow' = 'fast',
|
|
995
|
+
percentage = 0.75,
|
|
996
|
+
normalizedStartingPointX = 0.5,
|
|
997
|
+
normalizedStartingPointY = 0.5,
|
|
998
|
+
) {
|
|
999
|
+
const node = await findNodeByMatcher(matcher)
|
|
1000
|
+
if (!node)
|
|
1001
|
+
throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
|
|
1002
|
+
|
|
1003
|
+
const page = getPage()
|
|
1004
|
+
const { absolutePosition, layout } = node
|
|
1005
|
+
|
|
1006
|
+
const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
|
|
1007
|
+
const startSootSimY = absolutePosition.y + layout.height * normalizedStartingPointY
|
|
1008
|
+
|
|
1009
|
+
let endSootSimX = startSootSimX
|
|
1010
|
+
let endSootSimY = startSootSimY
|
|
1011
|
+
const viewport = await readSootsimViewport(page)
|
|
1012
|
+
const dist =
|
|
1013
|
+
direction === 'up' || direction === 'down'
|
|
1014
|
+
? viewport.height * percentage
|
|
1015
|
+
: viewport.width * percentage
|
|
1016
|
+
|
|
1017
|
+
switch (direction) {
|
|
1018
|
+
case 'up':
|
|
1019
|
+
endSootSimY -= dist
|
|
1020
|
+
break
|
|
1021
|
+
case 'down':
|
|
1022
|
+
endSootSimY += dist
|
|
1023
|
+
break
|
|
1024
|
+
case 'left':
|
|
1025
|
+
endSootSimX -= dist
|
|
1026
|
+
break
|
|
1027
|
+
case 'right':
|
|
1028
|
+
endSootSimX += dist
|
|
1029
|
+
break
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// detox injects one point per display-link tick. its fast path contains
|
|
1033
|
+
// 21 points and its slow path 41, including the endpoints.
|
|
1034
|
+
const steps = speed === 'slow' ? 40 : 20
|
|
1035
|
+
const stepDelay = 1000 / 60
|
|
1036
|
+
|
|
1037
|
+
await page.evaluate(
|
|
1038
|
+
async ({ fromX, fromY, toX, toY, steps, stepMs }) => {
|
|
1039
|
+
// the interact bridge, not __sootsimTest.drag. the test bridge's drag
|
|
1040
|
+
// runs the whole down/move/up loop through the tenant's own
|
|
1041
|
+
// dispatchPointer and never fans to the shell, where gesture-handler's
|
|
1042
|
+
// recognizers live, so a swipe reported exact-zero activations for
|
|
1043
|
+
// every pan, fling, and manual gesture. taps were unaffected because
|
|
1044
|
+
// the responder path is tenant-local. the interact bridge resolves the
|
|
1045
|
+
// owner and feeds both, which is why the cases that call it directly
|
|
1046
|
+
// pass.
|
|
1047
|
+
const drag = window.__sootsimInteract?.drag
|
|
1048
|
+
if (typeof drag !== 'function') {
|
|
1049
|
+
throw new Error('sootsim interact bridge is not installed')
|
|
1050
|
+
}
|
|
1051
|
+
await drag(fromX, fromY, toX, toY, steps, stepMs)
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
fromX: startSootSimX,
|
|
1055
|
+
fromY: startSootSimY,
|
|
1056
|
+
toX: endSootSimX,
|
|
1057
|
+
toY: endSootSimY,
|
|
1058
|
+
steps,
|
|
1059
|
+
stepMs: stepDelay,
|
|
1060
|
+
},
|
|
1061
|
+
)
|
|
1062
|
+
await page.waitForTimeout(100)
|
|
1063
|
+
},
|
|
1064
|
+
|
|
1065
|
+
async setColumnToValue(column: number, value: string) {
|
|
1066
|
+
if (!Number.isInteger(column) || column < 0) {
|
|
1067
|
+
throw new Error(`picker column must be a non-negative integer, got ${column}`)
|
|
1068
|
+
}
|
|
1069
|
+
if (typeof value !== 'string') {
|
|
1070
|
+
throw new Error(`picker value must be a string, got ${typeof value}`)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const picker = await findNodeByMatcher(matcher)
|
|
1074
|
+
if (!picker) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
`element not found for setColumnToValue: ${JSON.stringify(matcher)}`,
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const page = getPage()
|
|
1081
|
+
const candidates = await page.evaluate(
|
|
1082
|
+
async ({ picker, column, value }) => {
|
|
1083
|
+
const result = await window.__sootsimTest?.queryTextCandidates?.({
|
|
1084
|
+
query: value,
|
|
1085
|
+
exact: true,
|
|
1086
|
+
})
|
|
1087
|
+
if (!result || !Array.isArray(result.candidates)) return []
|
|
1088
|
+
const left = picker.absolutePosition.x
|
|
1089
|
+
const top = picker.absolutePosition.y
|
|
1090
|
+
const right = left + picker.layout.width
|
|
1091
|
+
const bottom = top + picker.layout.height
|
|
1092
|
+
return result.candidates
|
|
1093
|
+
.filter((candidate) => candidate.ancestorPickerColumnIndices.includes(column))
|
|
1094
|
+
.map((candidate) => candidate.info)
|
|
1095
|
+
.filter((candidate) => {
|
|
1096
|
+
const x = candidate.absolutePosition?.x
|
|
1097
|
+
const y = candidate.absolutePosition?.y
|
|
1098
|
+
const width = candidate.layout?.width
|
|
1099
|
+
const height = candidate.layout?.height
|
|
1100
|
+
if (
|
|
1101
|
+
typeof x !== 'number' ||
|
|
1102
|
+
typeof y !== 'number' ||
|
|
1103
|
+
typeof width !== 'number' ||
|
|
1104
|
+
typeof height !== 'number'
|
|
1105
|
+
) {
|
|
1106
|
+
return false
|
|
1107
|
+
}
|
|
1108
|
+
const centerX = x + width / 2
|
|
1109
|
+
const centerY = y + height / 2
|
|
1110
|
+
return (
|
|
1111
|
+
centerX >= left && centerX <= right && centerY >= top && centerY <= bottom
|
|
1112
|
+
)
|
|
1113
|
+
})
|
|
1114
|
+
},
|
|
1115
|
+
{ picker, column, value },
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
if (candidates.length !== 1) {
|
|
1119
|
+
throw new Error(
|
|
1120
|
+
`setColumnToValue(${column}, ${JSON.stringify(value)}) found ${candidates.length} matching rows inside ${JSON.stringify(matcher)}`,
|
|
1121
|
+
)
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const target = candidates[0]
|
|
1125
|
+
// detox selects the picker row, whose hit rectangle stays flat. the
|
|
1126
|
+
// label is visually projected around the wheel and can overlap a later
|
|
1127
|
+
// label near the cylinder edge.
|
|
1128
|
+
const tapTarget = await page.evaluate(
|
|
1129
|
+
async (nodeId) => window.__sootsimTest?.resolveTapTarget(nodeId),
|
|
1130
|
+
target.nodeId,
|
|
1131
|
+
)
|
|
1132
|
+
if (!tapTarget) {
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
`setColumnToValue(${column}, ${JSON.stringify(value)}) could not resolve its picker row`,
|
|
1135
|
+
)
|
|
1136
|
+
}
|
|
1137
|
+
await dispatchTapAtSootsimPoint(
|
|
1138
|
+
page,
|
|
1139
|
+
tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
|
|
1140
|
+
tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2,
|
|
1141
|
+
)
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) {
|
|
1145
|
+
// detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1
|
|
1146
|
+
// brings them together (zoom out). speed maps to step delay so RNGH's
|
|
1147
|
+
// velocity tracking sees a realistic delta. angle (radians) tilts the
|
|
1148
|
+
// pinch axis off horizontal — matches Detox's third arg.
|
|
1149
|
+
const page = getPage()
|
|
1150
|
+
const node = await findNodeByMatcher(matcher)
|
|
1151
|
+
if (!node)
|
|
1152
|
+
throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`)
|
|
1153
|
+
await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler')
|
|
1154
|
+
const startSpread = Math.min(
|
|
1155
|
+
60,
|
|
1156
|
+
Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
|
|
1157
|
+
)
|
|
1158
|
+
const endSpread = startSpread * Math.max(scale, 0.05)
|
|
1159
|
+
const cos = Math.cos(angle)
|
|
1160
|
+
const sin = Math.sin(angle)
|
|
1161
|
+
const offset = (d: number) => ({ dx: d * cos, dy: d * sin })
|
|
1162
|
+
const a0 = offset(-startSpread)
|
|
1163
|
+
const b0 = offset(startSpread)
|
|
1164
|
+
const a1 = offset(-endSpread)
|
|
1165
|
+
const b1 = offset(endSpread)
|
|
1166
|
+
await dispatchTwoFingerGesture(
|
|
1167
|
+
page,
|
|
1168
|
+
await nodeOffsetToPage(page, node, a0.dx, a0.dy),
|
|
1169
|
+
await nodeOffsetToPage(page, node, b0.dx, b0.dy),
|
|
1170
|
+
await nodeOffsetToPage(page, node, a1.dx, a1.dy),
|
|
1171
|
+
await nodeOffsetToPage(page, node, b1.dx, b1.dy),
|
|
1172
|
+
{ steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 },
|
|
1173
|
+
)
|
|
1174
|
+
},
|
|
1175
|
+
|
|
1176
|
+
async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
|
|
1177
|
+
// two fingers held on a horizontal axis around the node center, then
|
|
1178
|
+
// both rotated to `radians` around the same center. positive radians
|
|
1179
|
+
// rotate counter-clockwise to match RNGH RotationGesture's reported
|
|
1180
|
+
// sign (its 'rotation' delta is positive for a counter-clockwise
|
|
1181
|
+
// motion of the rear finger relative to the front finger).
|
|
1182
|
+
const page = getPage()
|
|
1183
|
+
const node = await findNodeByMatcher(matcher)
|
|
1184
|
+
if (!node)
|
|
1185
|
+
throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`)
|
|
1186
|
+
await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler')
|
|
1187
|
+
const radius = Math.min(
|
|
1188
|
+
60,
|
|
1189
|
+
Math.max(12, Math.min(node.layout.width, node.layout.height) * 0.25),
|
|
1190
|
+
)
|
|
1191
|
+
const startA = await nodeOffsetToPage(page, node, -radius, 0)
|
|
1192
|
+
const startB = await nodeOffsetToPage(page, node, radius, 0)
|
|
1193
|
+
const cos = Math.cos(radians)
|
|
1194
|
+
const sin = Math.sin(radians)
|
|
1195
|
+
const endA = await nodeOffsetToPage(page, node, -radius * cos, -radius * sin)
|
|
1196
|
+
const endB = await nodeOffsetToPage(page, node, radius * cos, radius * sin)
|
|
1197
|
+
await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
|
|
1198
|
+
steps: speed === 'fast' ? 10 : 20,
|
|
1199
|
+
stepDelayMs: speed === 'fast' ? 14 : 24,
|
|
1200
|
+
})
|
|
1201
|
+
},
|
|
1202
|
+
|
|
1203
|
+
async takeScreenshot(name: string): Promise<string> {
|
|
1204
|
+
const node = await findNodeByMatcher(matcher)
|
|
1205
|
+
if (!node)
|
|
1206
|
+
throw new Error(
|
|
1207
|
+
`element not found for takeScreenshot: ${JSON.stringify(matcher)}`,
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
const box = node.frame ?? {
|
|
1211
|
+
x: node.absolutePosition.x,
|
|
1212
|
+
y: node.absolutePosition.y,
|
|
1213
|
+
width: node.layout.width,
|
|
1214
|
+
height: node.layout.height,
|
|
1215
|
+
}
|
|
1216
|
+
return writeSootsimScreenshot(name, {
|
|
1217
|
+
crop: {
|
|
1218
|
+
x: box.x,
|
|
1219
|
+
y: box.y,
|
|
1220
|
+
w: box.width,
|
|
1221
|
+
h: box.height,
|
|
1222
|
+
},
|
|
1223
|
+
})
|
|
1224
|
+
},
|
|
1225
|
+
|
|
1226
|
+
async getAttributes(): Promise<any> {
|
|
1227
|
+
const node = await findNodeByMatcher(matcher)
|
|
1228
|
+
if (!node)
|
|
1229
|
+
throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`)
|
|
1230
|
+
return {
|
|
1231
|
+
text: node.text || '',
|
|
1232
|
+
label: node.accessibilityLabel || node.text || '',
|
|
1233
|
+
identifier: node.testID || node.id || '',
|
|
1234
|
+
visible: node.layout.width > 0 && node.layout.height > 0,
|
|
1235
|
+
enabled: !node.accessibilityState?.disabled,
|
|
1236
|
+
...node,
|
|
1237
|
+
}
|
|
1238
|
+
},
|
|
1239
|
+
|
|
1240
|
+
atIndex(index: number) {
|
|
1241
|
+
return createSootSimElement({ ...matcher, index })
|
|
1242
|
+
},
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
return el
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// element() factory -- takes a matcher and returns an element interaction object
|
|
1249
|
+
export function element(matcher: Matcher): SootSimElement {
|
|
1250
|
+
return createSootSimElement(matcher)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// device object -- app lifecycle
|
|
1254
|
+
export const device = {
|
|
1255
|
+
_currentUrl: '',
|
|
1256
|
+
_platform: DETOX_PLATFORM as 'ios' | 'android',
|
|
1257
|
+
|
|
1258
|
+
async launchApp(opts?: {
|
|
1259
|
+
delete?: boolean
|
|
1260
|
+
newInstance?: boolean
|
|
1261
|
+
url?: string
|
|
1262
|
+
launchArgs?: Record<string, any>
|
|
1263
|
+
// when set, the new BrowserContext records video at this dir. used by
|
|
1264
|
+
// captureProofMenuStages for the multi-stage animation diff path. must
|
|
1265
|
+
// be set at launchApp time because playwright recordVideo can't be
|
|
1266
|
+
// toggled on an existing context.
|
|
1267
|
+
recordVideoDir?: string
|
|
1268
|
+
}) {
|
|
1269
|
+
if (!_browser) {
|
|
1270
|
+
// Conformance renders CanvasKit and must use the real platform GPU. The
|
|
1271
|
+
// bundled chromium_headless_shell has no GPU and silently falls back to
|
|
1272
|
+
// SwiftShader, invalidating visual/perf results while pegging the box.
|
|
1273
|
+
// The canonical GPU args force Metal/Vulkan and stripping Playwright's
|
|
1274
|
+
// --enable-unsafe-swiftshader default makes GPU init fail loudly. The
|
|
1275
|
+
// scoped reaper owns the whole Chrome process tree if Detox is interrupted.
|
|
1276
|
+
const launched = await launchReapedChromeScoped(
|
|
1277
|
+
(options) => chromium.launch(options),
|
|
1278
|
+
{ headless: true },
|
|
1279
|
+
)
|
|
1280
|
+
_browser = launched.browser
|
|
1281
|
+
_reapBrowserNow = launched.reapNow
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
|
|
1285
|
+
await closeContext()
|
|
1286
|
+
const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
|
|
1287
|
+
hasTouch: true,
|
|
1288
|
+
viewport: { width: 500, height: 900 },
|
|
1289
|
+
}
|
|
1290
|
+
if (opts?.recordVideoDir) {
|
|
1291
|
+
fs.mkdirSync(opts.recordVideoDir, { recursive: true })
|
|
1292
|
+
contextOpts.recordVideo = {
|
|
1293
|
+
dir: opts.recordVideoDir,
|
|
1294
|
+
size: { width: 500, height: 900 },
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
_context = await _browser.newContext(contextOpts)
|
|
1298
|
+
_page = await _context.newPage()
|
|
1299
|
+
attachPageDiagnostics(_page)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const url = opts?.url || BASE_URL
|
|
1303
|
+
device._currentUrl = url
|
|
1304
|
+
await _page!.goto(url, { waitUntil: 'load', timeout: 30000 })
|
|
1305
|
+
|
|
1306
|
+
await waitForSootsimTree(_page!, 30000)
|
|
1307
|
+
await waitForSootsimSurfaceMetrics(_page!, 30000)
|
|
1308
|
+
await waitForSootsimShellReady(_page!, 30000)
|
|
1309
|
+
await waitForSootsimWorkletsIdle(_page!, 3000)
|
|
1310
|
+
// record the wall-clock time the page is ready — captureProofMenuStages
|
|
1311
|
+
// uses this to compute trigger-in-video offsets
|
|
1312
|
+
device._lastReadyAtMs = Date.now()
|
|
1313
|
+
},
|
|
1314
|
+
|
|
1315
|
+
_lastReadyAtMs: 0,
|
|
1316
|
+
|
|
1317
|
+
async reloadReactNative() {
|
|
1318
|
+
const page = getPage()
|
|
1319
|
+
await page.reload({ waitUntil: 'load', timeout: 30000 })
|
|
1320
|
+
await waitForSootsimTree(page, 30000)
|
|
1321
|
+
await waitForSootsimSurfaceMetrics(page, 30000)
|
|
1322
|
+
await waitForSootsimShellReady(page, 30000)
|
|
1323
|
+
await waitForSootsimWorkletsIdle(page, 3000)
|
|
1324
|
+
},
|
|
1325
|
+
|
|
1326
|
+
async terminateApp() {
|
|
1327
|
+
await closeBrowser()
|
|
1328
|
+
},
|
|
1329
|
+
|
|
1330
|
+
async installApp() {
|
|
1331
|
+
// no-op for sootsim -- app runs in browser
|
|
1332
|
+
},
|
|
1333
|
+
|
|
1334
|
+
async uninstallApp() {
|
|
1335
|
+
// no-op
|
|
1336
|
+
},
|
|
1337
|
+
|
|
1338
|
+
async openURL(url: { url: string; sourceApp?: string }) {
|
|
1339
|
+
const page = getPage()
|
|
1340
|
+
await page.goto(url.url, { waitUntil: 'load', timeout: 30000 })
|
|
1341
|
+
await waitForSootsimTree(page, 10000)
|
|
1342
|
+
await waitForSootsimSurfaceMetrics(page, 10000)
|
|
1343
|
+
await waitForSootsimShellReady(page, 10000)
|
|
1344
|
+
await waitForSootsimWorkletsIdle(page, 3000)
|
|
1345
|
+
},
|
|
1346
|
+
|
|
1347
|
+
async takeScreenshot(name: string): Promise<string> {
|
|
1348
|
+
return writeSootsimScreenshot(name)
|
|
1349
|
+
},
|
|
1350
|
+
|
|
1351
|
+
// single-shot variant for multi-stage animation captures. skips the
|
|
1352
|
+
// frame-stability poll so callers can sample mid-transition.
|
|
1353
|
+
async takeScreenshotFast(name: string): Promise<string> {
|
|
1354
|
+
ensureScreenshotDir()
|
|
1355
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
|
|
1356
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPngFast())
|
|
1357
|
+
return screenshotPath
|
|
1358
|
+
},
|
|
1359
|
+
|
|
1360
|
+
// record the SootSim canvas via the engine's headless recorder
|
|
1361
|
+
// (window.__sootsimRecorder — @sootsim/plugin-recording). this is the
|
|
1362
|
+
// SAME code path `sootsim record video` drives, NOT the rail-button
|
|
1363
|
+
// path (SootSim.bridges.startRecording wires the dialog state machine
|
|
1364
|
+
// and never stashes the resulting blob into __sootsimRecorder.lastBlob,
|
|
1365
|
+
// which is what getBlobBase64 streams from).
|
|
1366
|
+
//
|
|
1367
|
+
// unlike playwright's page-level recordVideo, the headless recorder
|
|
1368
|
+
// captures ONLY the device canvas (composited shell + tenant surfaces)
|
|
1369
|
+
// — no browser chrome, no menu bar, no device frame. uses the EXISTING
|
|
1370
|
+
// page/context so per-test state is preserved across capture.
|
|
1371
|
+
async startBridgeRecording(opts?: {
|
|
1372
|
+
durationMs?: number
|
|
1373
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
1374
|
+
fps?: number
|
|
1375
|
+
}): Promise<void> {
|
|
1376
|
+
const page = getPage()
|
|
1377
|
+
const startOpts = {
|
|
1378
|
+
format: 'webm' as const,
|
|
1379
|
+
fps: opts?.fps ?? 60,
|
|
1380
|
+
layers: opts?.layers ?? 'tenant',
|
|
1381
|
+
durationMs: opts?.durationMs ?? 5000,
|
|
1382
|
+
}
|
|
1383
|
+
const result = await page.evaluate(async (startOpts) => {
|
|
1384
|
+
const rec = (
|
|
1385
|
+
window as {
|
|
1386
|
+
__sootsimRecorder?: {
|
|
1387
|
+
start?: (
|
|
1388
|
+
o: unknown,
|
|
1389
|
+
) => Promise<{ ok: boolean; error?: string; format?: string }>
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
).__sootsimRecorder
|
|
1393
|
+
const start = rec?.start
|
|
1394
|
+
if (typeof start !== 'function') {
|
|
1395
|
+
return { ok: false, error: '__sootsimRecorder.start not installed' }
|
|
1396
|
+
}
|
|
1397
|
+
return start(startOpts)
|
|
1398
|
+
}, startOpts)
|
|
1399
|
+
if (!result.ok) {
|
|
1400
|
+
throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`)
|
|
1401
|
+
}
|
|
1402
|
+
},
|
|
1403
|
+
|
|
1404
|
+
// stop the headless recorder, stream the resulting blob in chunks (same
|
|
1405
|
+
// protocol as `sootsim record`), and write the webm to outPath.
|
|
1406
|
+
async stopAndSaveBridgeRecording(outPath: string): Promise<string> {
|
|
1407
|
+
const page = getPage()
|
|
1408
|
+
const stopResult = await page.evaluate(async () => {
|
|
1409
|
+
const rec = (
|
|
1410
|
+
window as {
|
|
1411
|
+
__sootsimRecorder?: {
|
|
1412
|
+
stop?: () => Promise<{
|
|
1413
|
+
ok: boolean
|
|
1414
|
+
error?: string
|
|
1415
|
+
size?: number
|
|
1416
|
+
mime?: string
|
|
1417
|
+
durationMs?: number
|
|
1418
|
+
}>
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
).__sootsimRecorder
|
|
1422
|
+
const stop = rec?.stop
|
|
1423
|
+
if (typeof stop !== 'function') {
|
|
1424
|
+
return { ok: false, error: '__sootsimRecorder.stop not installed' }
|
|
1425
|
+
}
|
|
1426
|
+
return stop()
|
|
1427
|
+
})
|
|
1428
|
+
if (!stopResult.ok) {
|
|
1429
|
+
throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`)
|
|
1430
|
+
}
|
|
1431
|
+
if (!stopResult.size) {
|
|
1432
|
+
throw new Error('stopBridgeRecording: recorder returned empty blob')
|
|
1433
|
+
}
|
|
1434
|
+
const chunks: Buffer[] = []
|
|
1435
|
+
let offset = 0
|
|
1436
|
+
const CHUNK = 2 * 1024 * 1024
|
|
1437
|
+
while (true) {
|
|
1438
|
+
const result: {
|
|
1439
|
+
data: string
|
|
1440
|
+
size: number
|
|
1441
|
+
offset: number
|
|
1442
|
+
done: boolean
|
|
1443
|
+
mime: string
|
|
1444
|
+
} | null = await page.evaluate(
|
|
1445
|
+
({ offset, chunk }) => {
|
|
1446
|
+
const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } })
|
|
1447
|
+
.__sootsimRecorder
|
|
1448
|
+
const get = rec?.getBlobBase64 as
|
|
1449
|
+
| ((args: { offset: number; chunk: number }) => Promise<{
|
|
1450
|
+
data: string
|
|
1451
|
+
size: number
|
|
1452
|
+
offset: number
|
|
1453
|
+
done: boolean
|
|
1454
|
+
mime: string
|
|
1455
|
+
} | null>)
|
|
1456
|
+
| undefined
|
|
1457
|
+
if (typeof get !== 'function') return null
|
|
1458
|
+
return get({ offset, chunk })
|
|
1459
|
+
},
|
|
1460
|
+
{ offset, chunk: CHUNK },
|
|
1461
|
+
)
|
|
1462
|
+
if (!result) throw new Error('rnx recorder produced no blob')
|
|
1463
|
+
chunks.push(Buffer.from(result.data, 'base64'))
|
|
1464
|
+
offset = result.offset
|
|
1465
|
+
if (result.done) break
|
|
1466
|
+
}
|
|
1467
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
1468
|
+
fs.writeFileSync(outPath, Buffer.concat(chunks))
|
|
1469
|
+
;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath
|
|
1470
|
+
return outPath
|
|
1471
|
+
},
|
|
1472
|
+
|
|
1473
|
+
_lastRecordingPath: '' as string,
|
|
1474
|
+
|
|
1475
|
+
// set the engine's ContextMenu animation-progress override. visual-proof
|
|
1476
|
+
// harnesses use this to drive each capture stage to a known animProgress
|
|
1477
|
+
// (matched against the iOS oracle's curve) instead of fighting capture
|
|
1478
|
+
// latency vs. sub-second spring timing. pass null to clear.
|
|
1479
|
+
//
|
|
1480
|
+
// dispatches a host CustomEvent that the engine forwards to the tenant
|
|
1481
|
+
// worker (see shell-event-registry.ts + input-bridge.ts handleShellEvent).
|
|
1482
|
+
// the worker writes globalThis.__sootsimMenuAnimOverride which the engine
|
|
1483
|
+
// ContextMenu reads each rAF tick.
|
|
1484
|
+
async setMenuAnimOverride(progress: number | null): Promise<void> {
|
|
1485
|
+
// dispatched from the playwright host; the typed `dispatchSootsimEvent`
|
|
1486
|
+
// helper lives in the engine bundle and isn't reachable from outside
|
|
1487
|
+
// the page. keep this host-side wrapper typed against SootsimEventMap so
|
|
1488
|
+
// the event names and payload shapes still come from the central registry.
|
|
1489
|
+
await dispatchSootsimEventInPage('sootsim:menuAnimOverride', { value: progress })
|
|
1490
|
+
},
|
|
1491
|
+
|
|
1492
|
+
// park every render clock before the interaction, detect the first changed
|
|
1493
|
+
// stepped frame inside the named component, then advance exact 60fps frames
|
|
1494
|
+
// to each requested offset. native extraction also chooses the first frame
|
|
1495
|
+
// at or after an offset, so both sides use the same rounding convention.
|
|
1496
|
+
async takeScreenshotStagesFromVisibleChangeFast(
|
|
1497
|
+
namePrefix: string,
|
|
1498
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1499
|
+
options: {
|
|
1500
|
+
detectionTimeoutMs?: number
|
|
1501
|
+
frame?: { height: number; width: number; x: number; y: number } | null
|
|
1502
|
+
rootFrame?: { height: number; width: number; x: number; y: number } | null
|
|
1503
|
+
trigger: () => unknown
|
|
1504
|
+
},
|
|
1505
|
+
): Promise<{
|
|
1506
|
+
frames: Array<{
|
|
1507
|
+
label: string
|
|
1508
|
+
offsetMs: number
|
|
1509
|
+
actualMs: number
|
|
1510
|
+
path: string
|
|
1511
|
+
frameIndex: number
|
|
1512
|
+
virtualTimestampMs: number
|
|
1513
|
+
}>
|
|
1514
|
+
triggerCompletedMs: number
|
|
1515
|
+
triggerStartMs: number
|
|
1516
|
+
visibleChangeDetection: {
|
|
1517
|
+
actualMs: number
|
|
1518
|
+
changedPixelPercent: number
|
|
1519
|
+
changedPixels: number
|
|
1520
|
+
channelThreshold: number
|
|
1521
|
+
frame: { height: number; width: number; x: number; y: number } | null
|
|
1522
|
+
minimumChangePercent: number
|
|
1523
|
+
rootFrame: { height: number; width: number; x: number; y: number } | null
|
|
1524
|
+
totalPixels: number
|
|
1525
|
+
frameIndex: number
|
|
1526
|
+
virtualTimestampMs: number
|
|
1527
|
+
}
|
|
1528
|
+
}> {
|
|
1529
|
+
const page = getPage()
|
|
1530
|
+
const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`
|
|
1531
|
+
await page.evaluate(measureMotionChange, {
|
|
1532
|
+
definition: MOTION_CHANGE_DEFINITION,
|
|
1533
|
+
installGlobal: true,
|
|
1534
|
+
})
|
|
1535
|
+
const capturePromise: Promise<{
|
|
1536
|
+
frames: Array<
|
|
1537
|
+
FastScreenshotData & {
|
|
1538
|
+
frameIndex: number
|
|
1539
|
+
virtualTimestampMs: number
|
|
1540
|
+
}
|
|
1541
|
+
>
|
|
1542
|
+
visibleChangeDetection: {
|
|
1543
|
+
actualMs: number
|
|
1544
|
+
changedPixelPercent: number
|
|
1545
|
+
changedPixels: number
|
|
1546
|
+
channelThreshold: number
|
|
1547
|
+
frameIndex: number
|
|
1548
|
+
minimumChangePercent: number
|
|
1549
|
+
totalPixels: number
|
|
1550
|
+
virtualTimestampMs: number
|
|
1551
|
+
}
|
|
1552
|
+
}> = page.evaluate(
|
|
1553
|
+
async (input) => {
|
|
1554
|
+
type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
|
|
1555
|
+
type CaptureState = {
|
|
1556
|
+
cancelled: boolean
|
|
1557
|
+
ready: boolean
|
|
1558
|
+
triggerCompleted: boolean
|
|
1559
|
+
}
|
|
1560
|
+
type CaptureWindow = Window & {
|
|
1561
|
+
__sootsimMeasureMotionChange?: (input: {
|
|
1562
|
+
baseline: Uint8ClampedArray
|
|
1563
|
+
candidate: Uint8ClampedArray
|
|
1564
|
+
definition: MotionChangeDefinition
|
|
1565
|
+
height: number
|
|
1566
|
+
label: string
|
|
1567
|
+
width: number
|
|
1568
|
+
}) => MotionChangeMeasurement
|
|
1569
|
+
__sootsimVisibleChangeCaptures?: Record<string, CaptureState>
|
|
1570
|
+
SootSim?: {
|
|
1571
|
+
bridges?: {
|
|
1572
|
+
liveComposite?: {
|
|
1573
|
+
captureFresh: () => Promise<{
|
|
1574
|
+
canvas: HTMLCanvasElement | null
|
|
1575
|
+
frameToken: number
|
|
1576
|
+
}>
|
|
1577
|
+
}
|
|
1578
|
+
lockstepCapture?: {
|
|
1579
|
+
begin: (fps: number) => Promise<{
|
|
1580
|
+
stepFrame: () => Promise<FrameReceipt>
|
|
1581
|
+
stepToOffset: (
|
|
1582
|
+
anchor: FrameReceipt,
|
|
1583
|
+
offsetMs: number,
|
|
1584
|
+
) => Promise<FrameReceipt>
|
|
1585
|
+
end: () => void
|
|
1586
|
+
}>
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
type Snapshot = {
|
|
1592
|
+
actualMs: number
|
|
1593
|
+
bitmap: ImageBitmap
|
|
1594
|
+
label: string
|
|
1595
|
+
offsetMs: number
|
|
1596
|
+
frameIndex: number
|
|
1597
|
+
virtualTimestampMs: number
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const captureWindow = window as unknown as CaptureWindow
|
|
1601
|
+
const live = captureWindow.SootSim?.bridges?.liveComposite
|
|
1602
|
+
const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture
|
|
1603
|
+
if (!live || typeof live.captureFresh !== 'function') {
|
|
1604
|
+
throw new Error('rnx live composite bridge is not installed')
|
|
1605
|
+
}
|
|
1606
|
+
if (!lockstep || typeof lockstep.begin !== 'function') {
|
|
1607
|
+
throw new Error('SootSim lockstep capture bridge is not installed')
|
|
1608
|
+
}
|
|
1609
|
+
const registry =
|
|
1610
|
+
captureWindow.__sootsimVisibleChangeCaptures ??
|
|
1611
|
+
(captureWindow.__sootsimVisibleChangeCaptures = {})
|
|
1612
|
+
const state: CaptureState = {
|
|
1613
|
+
cancelled: false,
|
|
1614
|
+
ready: false,
|
|
1615
|
+
triggerCompleted: false,
|
|
1616
|
+
}
|
|
1617
|
+
registry[input.token] = state
|
|
1618
|
+
const measureChange = captureWindow.__sootsimMeasureMotionChange
|
|
1619
|
+
if (!measureChange) {
|
|
1620
|
+
throw new Error('rnx motion change definition is not installed')
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const captureBitmap = async () => {
|
|
1624
|
+
const { canvas } = await live.captureFresh()
|
|
1625
|
+
if (!canvas) throw new Error('rnx live composite produced no canvas')
|
|
1626
|
+
return createImageBitmap(canvas)
|
|
1627
|
+
}
|
|
1628
|
+
const boundsFor = (bitmap: ImageBitmap) => {
|
|
1629
|
+
const scale =
|
|
1630
|
+
input.rootFrame && input.rootFrame.width > 0
|
|
1631
|
+
? bitmap.width / input.rootFrame.width
|
|
1632
|
+
: 1
|
|
1633
|
+
return {
|
|
1634
|
+
height: input.frame
|
|
1635
|
+
? Math.max(
|
|
1636
|
+
1,
|
|
1637
|
+
Math.min(
|
|
1638
|
+
bitmap.height,
|
|
1639
|
+
Math.ceil((input.frame.y + input.frame.height) * scale),
|
|
1640
|
+
) - Math.max(0, Math.floor(input.frame.y * scale)),
|
|
1641
|
+
)
|
|
1642
|
+
: bitmap.height,
|
|
1643
|
+
left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
|
|
1644
|
+
top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
|
|
1645
|
+
width: input.frame
|
|
1646
|
+
? Math.max(
|
|
1647
|
+
1,
|
|
1648
|
+
Math.min(
|
|
1649
|
+
bitmap.width,
|
|
1650
|
+
Math.ceil((input.frame.x + input.frame.width) * scale),
|
|
1651
|
+
) - Math.max(0, Math.floor(input.frame.x * scale)),
|
|
1652
|
+
)
|
|
1653
|
+
: bitmap.width,
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const regionPixels = (bitmap: ImageBitmap) => {
|
|
1657
|
+
const bounds = boundsFor(bitmap)
|
|
1658
|
+
const canvas = document.createElement('canvas')
|
|
1659
|
+
canvas.width = bounds.width
|
|
1660
|
+
canvas.height = bounds.height
|
|
1661
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
1662
|
+
if (!context) throw new Error('rnx motion detector needs a 2d canvas')
|
|
1663
|
+
context.drawImage(
|
|
1664
|
+
bitmap,
|
|
1665
|
+
bounds.left,
|
|
1666
|
+
bounds.top,
|
|
1667
|
+
bounds.width,
|
|
1668
|
+
bounds.height,
|
|
1669
|
+
0,
|
|
1670
|
+
0,
|
|
1671
|
+
bounds.width,
|
|
1672
|
+
bounds.height,
|
|
1673
|
+
)
|
|
1674
|
+
return context.getImageData(0, 0, bounds.width, bounds.height).data
|
|
1675
|
+
}
|
|
1676
|
+
const encode = (snapshot: Snapshot) => {
|
|
1677
|
+
const canvas = document.createElement('canvas')
|
|
1678
|
+
canvas.width = snapshot.bitmap.width
|
|
1679
|
+
canvas.height = snapshot.bitmap.height
|
|
1680
|
+
const context = canvas.getContext('2d')
|
|
1681
|
+
if (!context) throw new Error('rnx motion capture needs a 2d canvas')
|
|
1682
|
+
context.drawImage(snapshot.bitmap, 0, 0)
|
|
1683
|
+
snapshot.bitmap.close()
|
|
1684
|
+
return {
|
|
1685
|
+
actualMs: snapshot.actualMs,
|
|
1686
|
+
dataUrl: canvas.toDataURL('image/png'),
|
|
1687
|
+
label: snapshot.label,
|
|
1688
|
+
offsetMs: snapshot.offsetMs,
|
|
1689
|
+
frameIndex: snapshot.frameIndex,
|
|
1690
|
+
virtualTimestampMs: snapshot.virtualTimestampMs,
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const controller = await lockstep.begin(60)
|
|
1695
|
+
try {
|
|
1696
|
+
const baselineBitmap = await captureBitmap()
|
|
1697
|
+
const baseline = regionPixels(baselineBitmap)
|
|
1698
|
+
baselineBitmap.close()
|
|
1699
|
+
state.ready = true
|
|
1700
|
+
while (!state.triggerCompleted) {
|
|
1701
|
+
if (state.cancelled) throw new Error('rnx motion capture was cancelled')
|
|
1702
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
let visibleStart: FrameReceipt | null = null
|
|
1706
|
+
let detectedMeasurement: MotionChangeMeasurement | null = null
|
|
1707
|
+
const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
|
|
1708
|
+
for (let frame = 0; frame < detectionFrameLimit; frame++) {
|
|
1709
|
+
if (state.cancelled) throw new Error('rnx motion capture was cancelled')
|
|
1710
|
+
const receipt = await controller.stepFrame()
|
|
1711
|
+
const candidateBitmap = await captureBitmap()
|
|
1712
|
+
const bounds = boundsFor(candidateBitmap)
|
|
1713
|
+
const measurement = measureChange({
|
|
1714
|
+
baseline,
|
|
1715
|
+
candidate: regionPixels(candidateBitmap),
|
|
1716
|
+
definition: input.motionChangeDefinition,
|
|
1717
|
+
height: bounds.height,
|
|
1718
|
+
label: 'SootSim visible-change detector',
|
|
1719
|
+
width: bounds.width,
|
|
1720
|
+
})
|
|
1721
|
+
candidateBitmap.close()
|
|
1722
|
+
if (measurement.passedMinimum) {
|
|
1723
|
+
visibleStart = receipt
|
|
1724
|
+
detectedMeasurement = measurement
|
|
1725
|
+
break
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
if (!visibleStart || !detectedMeasurement) {
|
|
1729
|
+
throw new Error(
|
|
1730
|
+
`rnx motion had no visible change before ${input.detectionTimeoutMs}ms`,
|
|
1731
|
+
)
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const snapshots: Snapshot[] = []
|
|
1735
|
+
for (const stage of input.stages) {
|
|
1736
|
+
if (state.cancelled) throw new Error('rnx motion capture was cancelled')
|
|
1737
|
+
const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
|
|
1738
|
+
snapshots.push({
|
|
1739
|
+
actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
|
|
1740
|
+
bitmap: await captureBitmap(),
|
|
1741
|
+
frameIndex: receipt.frameIndex,
|
|
1742
|
+
label: stage.label,
|
|
1743
|
+
offsetMs: stage.offsetMs,
|
|
1744
|
+
virtualTimestampMs: receipt.virtualTimestampMs,
|
|
1745
|
+
})
|
|
1746
|
+
}
|
|
1747
|
+
return {
|
|
1748
|
+
frames: snapshots.map(encode),
|
|
1749
|
+
visibleChangeDetection: {
|
|
1750
|
+
actualMs: (visibleStart.frameIndex * 1000) / 60,
|
|
1751
|
+
changedPixelPercent: detectedMeasurement.changePercent,
|
|
1752
|
+
changedPixels: detectedMeasurement.changePixels,
|
|
1753
|
+
channelThreshold: input.motionChangeDefinition.channelThreshold,
|
|
1754
|
+
minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
|
|
1755
|
+
totalPixels: detectedMeasurement.totalPixels,
|
|
1756
|
+
frameIndex: visibleStart.frameIndex,
|
|
1757
|
+
virtualTimestampMs: visibleStart.virtualTimestampMs,
|
|
1758
|
+
},
|
|
1759
|
+
}
|
|
1760
|
+
} finally {
|
|
1761
|
+
controller.end()
|
|
1762
|
+
delete registry[input.token]
|
|
1763
|
+
}
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
|
|
1767
|
+
frame: options.frame ?? null,
|
|
1768
|
+
motionChangeDefinition: MOTION_CHANGE_DEFINITION,
|
|
1769
|
+
rootFrame: options.rootFrame ?? null,
|
|
1770
|
+
stages,
|
|
1771
|
+
token,
|
|
1772
|
+
},
|
|
1773
|
+
)
|
|
1774
|
+
|
|
1775
|
+
await page.waitForFunction(
|
|
1776
|
+
(captureToken) => {
|
|
1777
|
+
const registry = (
|
|
1778
|
+
window as Window & {
|
|
1779
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
1780
|
+
string,
|
|
1781
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
1782
|
+
>
|
|
1783
|
+
}
|
|
1784
|
+
).__sootsimVisibleChangeCaptures
|
|
1785
|
+
return registry?.[captureToken]?.ready === true
|
|
1786
|
+
},
|
|
1787
|
+
token,
|
|
1788
|
+
{ timeout: 5000 },
|
|
1789
|
+
)
|
|
1790
|
+
|
|
1791
|
+
const triggerStartMs = Date.now()
|
|
1792
|
+
let triggerCompletedMs = 0
|
|
1793
|
+
try {
|
|
1794
|
+
await Promise.resolve(options.trigger())
|
|
1795
|
+
triggerCompletedMs = Date.now()
|
|
1796
|
+
await page.evaluate((captureToken) => {
|
|
1797
|
+
const registry = (
|
|
1798
|
+
window as Window & {
|
|
1799
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
1800
|
+
string,
|
|
1801
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
1802
|
+
>
|
|
1803
|
+
}
|
|
1804
|
+
).__sootsimVisibleChangeCaptures
|
|
1805
|
+
const capture = registry?.[captureToken]
|
|
1806
|
+
if (!capture) throw new Error('rnx motion capture disarmed before trigger')
|
|
1807
|
+
capture.triggerCompleted = true
|
|
1808
|
+
}, token)
|
|
1809
|
+
const capture = await capturePromise
|
|
1810
|
+
return {
|
|
1811
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
1812
|
+
triggerCompletedMs,
|
|
1813
|
+
triggerStartMs,
|
|
1814
|
+
visibleChangeDetection: {
|
|
1815
|
+
...capture.visibleChangeDetection,
|
|
1816
|
+
frame: options.frame ?? null,
|
|
1817
|
+
rootFrame: options.rootFrame ?? null,
|
|
1818
|
+
},
|
|
1819
|
+
}
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
await page
|
|
1822
|
+
.evaluate((captureToken) => {
|
|
1823
|
+
const registry = (
|
|
1824
|
+
window as Window & {
|
|
1825
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
1826
|
+
string,
|
|
1827
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
1828
|
+
>
|
|
1829
|
+
}
|
|
1830
|
+
).__sootsimVisibleChangeCaptures
|
|
1831
|
+
const capture = registry?.[captureToken]
|
|
1832
|
+
if (capture) capture.cancelled = true
|
|
1833
|
+
}, token)
|
|
1834
|
+
.catch(() => {})
|
|
1835
|
+
await capturePromise.catch(() => {})
|
|
1836
|
+
throw error
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
|
|
1840
|
+
// FAST multi-stage capture for sub-second animation diffs. each stage
|
|
1841
|
+
// schedules a setTimeout at its requested offsetMs and at that moment
|
|
1842
|
+
// SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
|
|
1843
|
+
// (drawImage from the worker-transferred home + overlay canvases). PNG
|
|
1844
|
+
// encoding is queued after all snapshots land — encoding stalls don't
|
|
1845
|
+
// contaminate the animation window.
|
|
1846
|
+
//
|
|
1847
|
+
// unlike `takeScreenshotStages`, this does NOT go through screenshot()
|
|
1848
|
+
// (which calls forceRenderAll + composite + toDataURL serially at
|
|
1849
|
+
// 300-1300ms per call and blows the spring-open timing). the worker's
|
|
1850
|
+
// vsync-pumped rAF keeps the home canvas fresh; we just sample it.
|
|
1851
|
+
//
|
|
1852
|
+
// returns paths in stage order; actualMs reports when the snapshot
|
|
1853
|
+
// actually fired (typically within a few ms of offsetMs).
|
|
1854
|
+
async takeScreenshotStagesFast(
|
|
1855
|
+
namePrefix: string,
|
|
1856
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1857
|
+
options?: { skipWarmup?: boolean },
|
|
1858
|
+
): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
|
|
1859
|
+
const page = getPage()
|
|
1860
|
+
const frames: Array<{
|
|
1861
|
+
label: string
|
|
1862
|
+
offsetMs: number
|
|
1863
|
+
actualMs: number
|
|
1864
|
+
dataUrl: string
|
|
1865
|
+
}> = await page.evaluate(
|
|
1866
|
+
async (input) => {
|
|
1867
|
+
const sim = window as {
|
|
1868
|
+
SootSim?: {
|
|
1869
|
+
bridges?: {
|
|
1870
|
+
screenshot?: (opts?: unknown) => Promise<string>
|
|
1871
|
+
liveComposite?: {
|
|
1872
|
+
captureFresh: () => Promise<{
|
|
1873
|
+
canvas: HTMLCanvasElement | null
|
|
1874
|
+
frameToken: number
|
|
1875
|
+
}>
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
const screenshot = sim.SootSim?.bridges?.screenshot
|
|
1881
|
+
const live = sim.SootSim?.bridges?.liveComposite
|
|
1882
|
+
if (typeof screenshot !== 'function') {
|
|
1883
|
+
throw new Error('rnx screenshot bridge is not installed')
|
|
1884
|
+
}
|
|
1885
|
+
// initial warm-up: ensure the worker has produced at least one
|
|
1886
|
+
// frame after the menu's portal mount before we start sampling.
|
|
1887
|
+
// (a pump at higher cadence was tried and starved tap propagation —
|
|
1888
|
+
// the tenant React commit for setOpen never landed between
|
|
1889
|
+
// forceRender calls.)
|
|
1890
|
+
if (!input.skipWarmup) {
|
|
1891
|
+
await screenshot({ format: 'png' })
|
|
1892
|
+
}
|
|
1893
|
+
const start = performance.now()
|
|
1894
|
+
type Snapshot = {
|
|
1895
|
+
label: string
|
|
1896
|
+
offsetMs: number
|
|
1897
|
+
actualMs: number
|
|
1898
|
+
dataUrl: string | null
|
|
1899
|
+
}
|
|
1900
|
+
// each stage fires at its requested offset and SYNCHRONOUSLY
|
|
1901
|
+
// snapshots the engine's host-side composite canvas (drawImage
|
|
1902
|
+
// from the worker-transferred home canvas + overlay). encoding
|
|
1903
|
+
// happens after — encoding stalls don't disturb the next stage's
|
|
1904
|
+
// capture moment.
|
|
1905
|
+
const snaps: Snapshot[] = await Promise.all(
|
|
1906
|
+
input.stages.map(
|
|
1907
|
+
(stage) =>
|
|
1908
|
+
new Promise<Snapshot>((resolve) => {
|
|
1909
|
+
const fire = async () => {
|
|
1910
|
+
const actualMs = performance.now() - start
|
|
1911
|
+
try {
|
|
1912
|
+
let dataUrl: string | null = null
|
|
1913
|
+
if (live && typeof live.captureFresh === 'function') {
|
|
1914
|
+
// synchronous paint of current canvas state — no
|
|
1915
|
+
// worker round-trip. the background pump keeps the
|
|
1916
|
+
// canvas fresh.
|
|
1917
|
+
const { canvas } = await live.captureFresh()
|
|
1918
|
+
if (canvas) {
|
|
1919
|
+
const bmp = await createImageBitmap(canvas)
|
|
1920
|
+
const enc = document.createElement('canvas')
|
|
1921
|
+
enc.width = bmp.width
|
|
1922
|
+
enc.height = bmp.height
|
|
1923
|
+
const ctx = enc.getContext('2d')
|
|
1924
|
+
if (ctx) {
|
|
1925
|
+
ctx.drawImage(bmp, 0, 0)
|
|
1926
|
+
dataUrl = enc.toDataURL('image/png')
|
|
1927
|
+
}
|
|
1928
|
+
bmp.close()
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
if (!dataUrl) {
|
|
1932
|
+
dataUrl = await screenshot({ format: 'png' })
|
|
1933
|
+
}
|
|
1934
|
+
resolve({
|
|
1935
|
+
label: stage.label,
|
|
1936
|
+
offsetMs: stage.offsetMs,
|
|
1937
|
+
actualMs,
|
|
1938
|
+
dataUrl,
|
|
1939
|
+
})
|
|
1940
|
+
} catch {
|
|
1941
|
+
resolve({
|
|
1942
|
+
label: stage.label,
|
|
1943
|
+
offsetMs: stage.offsetMs,
|
|
1944
|
+
actualMs,
|
|
1945
|
+
dataUrl: null,
|
|
1946
|
+
})
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
setTimeout(
|
|
1950
|
+
() => {
|
|
1951
|
+
void fire()
|
|
1952
|
+
},
|
|
1953
|
+
Math.max(0, stage.offsetMs),
|
|
1954
|
+
)
|
|
1955
|
+
}),
|
|
1956
|
+
),
|
|
1957
|
+
)
|
|
1958
|
+
const out: Array<{
|
|
1959
|
+
label: string
|
|
1960
|
+
offsetMs: number
|
|
1961
|
+
actualMs: number
|
|
1962
|
+
dataUrl: string
|
|
1963
|
+
}> = []
|
|
1964
|
+
for (const snap of snaps) {
|
|
1965
|
+
if (!snap.dataUrl) continue
|
|
1966
|
+
out.push({
|
|
1967
|
+
label: snap.label,
|
|
1968
|
+
offsetMs: snap.offsetMs,
|
|
1969
|
+
actualMs: snap.actualMs,
|
|
1970
|
+
dataUrl: snap.dataUrl,
|
|
1971
|
+
})
|
|
1972
|
+
}
|
|
1973
|
+
return out
|
|
1974
|
+
},
|
|
1975
|
+
{ stages, skipWarmup: options?.skipWarmup === true },
|
|
1976
|
+
)
|
|
1977
|
+
return writeFastScreenshotFrames(namePrefix, frames)
|
|
1978
|
+
},
|
|
1979
|
+
|
|
1980
|
+
// batched multi-stage capture for animation diffs. all N captures happen
|
|
1981
|
+
// inside one page.evaluate round trip so browser-side timing controls the
|
|
1982
|
+
// offsets — sidesteps the ~300ms per-call bridge overhead that blows
|
|
1983
|
+
// sub-second animation windows. each stage gets a screenshot at the
|
|
1984
|
+
// requested offsetMs (from when the evaluate starts, i.e. right after
|
|
1985
|
+
// trigger() returns). returns absolute paths in stage order.
|
|
1986
|
+
async takeScreenshotStages(
|
|
1987
|
+
namePrefix: string,
|
|
1988
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1989
|
+
): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
|
|
1990
|
+
const page = getPage()
|
|
1991
|
+
const frames: Array<{
|
|
1992
|
+
label: string
|
|
1993
|
+
offsetMs: number
|
|
1994
|
+
actualMs: number
|
|
1995
|
+
dataUrl: string
|
|
1996
|
+
}> = await page.evaluate(
|
|
1997
|
+
async (input) => {
|
|
1998
|
+
const screenshot = (
|
|
1999
|
+
window as { SootSim?: { bridges?: { screenshot?: unknown } } }
|
|
2000
|
+
).SootSim?.bridges?.screenshot as
|
|
2001
|
+
| ((opts?: unknown) => Promise<string>)
|
|
2002
|
+
| undefined
|
|
2003
|
+
if (typeof screenshot !== 'function') {
|
|
2004
|
+
throw new Error('sootsim screenshot bridge is not installed')
|
|
2005
|
+
}
|
|
2006
|
+
const start = Date.now()
|
|
2007
|
+
const out: Array<{
|
|
2008
|
+
label: string
|
|
2009
|
+
offsetMs: number
|
|
2010
|
+
actualMs: number
|
|
2011
|
+
dataUrl: string
|
|
2012
|
+
}> = []
|
|
2013
|
+
for (const stage of input.stages) {
|
|
2014
|
+
const elapsed = Date.now() - start
|
|
2015
|
+
const wait = Math.max(0, stage.offsetMs - elapsed)
|
|
2016
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
|
2017
|
+
const actualMs = Date.now() - start
|
|
2018
|
+
const dataUrl = await screenshot({ format: 'png' })
|
|
2019
|
+
out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl })
|
|
2020
|
+
}
|
|
2021
|
+
return out
|
|
2022
|
+
},
|
|
2023
|
+
{ stages },
|
|
2024
|
+
)
|
|
2025
|
+
return writeFastScreenshotFrames(namePrefix, frames)
|
|
2026
|
+
},
|
|
2027
|
+
|
|
2028
|
+
async shake() {
|
|
2029
|
+
// no-op for sootsim
|
|
2030
|
+
},
|
|
2031
|
+
|
|
2032
|
+
async setLocation(lat: number, lon: number) {
|
|
2033
|
+
// no-op
|
|
2034
|
+
},
|
|
2035
|
+
|
|
2036
|
+
async setStatusBar(config: StatusBarConfig) {
|
|
2037
|
+
await dispatchStatusBarOverride(config)
|
|
2038
|
+
},
|
|
2039
|
+
|
|
2040
|
+
async setURLBlacklist(urls: string[]) {
|
|
2041
|
+
// no-op
|
|
2042
|
+
},
|
|
2043
|
+
|
|
2044
|
+
async enableSynchronization() {
|
|
2045
|
+
_synchronizationEnabled = true
|
|
2046
|
+
},
|
|
2047
|
+
|
|
2048
|
+
async disableSynchronization() {
|
|
2049
|
+
_synchronizationEnabled = false
|
|
2050
|
+
},
|
|
2051
|
+
|
|
2052
|
+
getPlatform(): 'ios' | 'android' {
|
|
2053
|
+
return device._platform
|
|
2054
|
+
},
|
|
2055
|
+
|
|
2056
|
+
async pressBack() {
|
|
2057
|
+
if (device._platform !== 'android') return
|
|
2058
|
+
const page = getPage()
|
|
2059
|
+
const dispatched = await page.evaluate(async () => {
|
|
2060
|
+
const goBack = window.SootSim?.bridges?.mainShell?.goBack
|
|
2061
|
+
if (typeof goBack !== 'function') return false
|
|
2062
|
+
return (await goBack()) !== false
|
|
2063
|
+
})
|
|
2064
|
+
if (!dispatched) throw new Error('sootsim Android back bridge is not installed')
|
|
2065
|
+
if (_synchronizationEnabled) {
|
|
2066
|
+
await waitForSootsimIdle(page)
|
|
2067
|
+
}
|
|
2068
|
+
},
|
|
2069
|
+
|
|
2070
|
+
// raw coordinate tap, bypassing the element-based visibility/hit-test path.
|
|
2071
|
+
// mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
|
|
2072
|
+
// target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
|
|
2073
|
+
// `element(...).tap({x,y})` would fail Detox's visibility threshold.
|
|
2074
|
+
async tap(point?: { x: number; y: number } | boolean) {
|
|
2075
|
+
if (point == null || typeof point === 'boolean') return
|
|
2076
|
+
const page = getPage()
|
|
2077
|
+
const { x, y } = point
|
|
2078
|
+
await page.evaluate(
|
|
2079
|
+
async ({ x: tx, y: ty }) => {
|
|
2080
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
2081
|
+
if (typeof interactTap === 'function') {
|
|
2082
|
+
const result = await interactTap(tx, ty)
|
|
2083
|
+
if (result && (typeof result !== 'object' || result.hit !== false)) return
|
|
2084
|
+
}
|
|
2085
|
+
const tap = window.__sootsimTest?.tap
|
|
2086
|
+
if (typeof tap !== 'function') {
|
|
2087
|
+
throw new Error('sootsim tap bridge is not installed')
|
|
2088
|
+
}
|
|
2089
|
+
await tap(tx, ty)
|
|
2090
|
+
},
|
|
2091
|
+
{ x, y },
|
|
2092
|
+
)
|
|
2093
|
+
await page.waitForTimeout(50)
|
|
2094
|
+
if (_synchronizationEnabled) {
|
|
2095
|
+
await waitForSootsimIdle(page)
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
|
|
2099
|
+
async sendToHome() {
|
|
2100
|
+
// no-op
|
|
2101
|
+
},
|
|
2102
|
+
|
|
2103
|
+
// get direct access to the playwright page for advanced use
|
|
2104
|
+
getPage(): Page {
|
|
2105
|
+
return getPage()
|
|
2106
|
+
},
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// exports matching detox API
|
|
2110
|
+
const sootsimExpect = createExpect(findNodeByMatcher, getPage)
|
|
2111
|
+
const sootsimWaitFor = createWaitFor(findNodeByMatcher, getPage)
|
|
2112
|
+
|
|
2113
|
+
export { sootsimExpect as expect, sootsimWaitFor as waitFor }
|
|
2114
|
+
|
|
2115
|
+
// cleanup -- call this in afterAll
|
|
2116
|
+
export async function cleanup() {
|
|
2117
|
+
await closeBrowser()
|
|
2118
|
+
}
|