sootsim 0.1.82 → 0.1.84
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/README.md +0 -1
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/expectations.ts +477 -0
- package/detox/gestures.ts +442 -0
- package/detox/index.ts +1436 -0
- package/detox/jest-environment.ts +86 -0
- package/detox/jest-preset.cjs +50 -0
- package/detox/matchers.ts +29 -0
- package/detox/navigation.ts +43 -0
- package/detox/run-test.ts +113 -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 +3 -3
- package/dist-cli/chunks/{agent-3C6Z6YXA.js → agent-2CWD6W6P.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-7Z4UFACX.js → agent-wrapper-5W3LOX6S.js} +2 -2
- package/dist-cli/chunks/{assert-XYBIZRDK.js → assert-ZOMAMKRT.js} +2 -2
- package/dist-cli/chunks/auto-bootstrap-NYYSMTIM.js +2 -0
- package/dist-cli/chunks/beta-4K2SQACK.js +2 -0
- package/dist-cli/chunks/chunk-3HXQ7MJK.js +79 -0
- package/dist-cli/chunks/{chunk-EJGEDUOC.js → chunk-4K7BH2D4.js} +3 -3
- package/dist-cli/chunks/{chunk-2EFQQWEC.js → chunk-4OPRODFA.js} +2 -2
- package/dist-cli/chunks/{chunk-Z6G5SDG7.js → chunk-4OWVPRZV.js} +2 -2
- package/dist-cli/chunks/{chunk-DCFGNIJC.js → chunk-5XCXOLG2.js} +2 -2
- package/dist-cli/chunks/chunk-67ZZ2CM5.js +1 -0
- package/dist-cli/chunks/{chunk-M3OULYY3.js → chunk-73UZXB4B.js} +2 -2
- package/dist-cli/chunks/{chunk-QPDWMYCA.js → chunk-7NWNTUJF.js} +1 -1
- package/dist-cli/chunks/chunk-7YHDJLO2.js +119 -0
- package/dist-cli/chunks/{chunk-EX6IOT23.js → chunk-AJVTY6KY.js} +2 -2
- package/dist-cli/chunks/chunk-AWSQUOAS.js +67 -0
- package/dist-cli/chunks/{chunk-JVNGH5S7.js → chunk-BCBNVJVG.js} +1 -1
- package/dist-cli/chunks/{chunk-WZLKUS54.js → chunk-BKBL6K2G.js} +1 -1
- package/dist-cli/chunks/{chunk-DSYW2NOW.js → chunk-C3DPQZ4J.js} +2 -2
- package/dist-cli/chunks/chunk-D3ZSBIIY.js +2 -0
- package/dist-cli/chunks/{chunk-PYDAVGCZ.js → chunk-D4HUVLZR.js} +1 -1
- package/dist-cli/chunks/{chunk-H6NBDJIO.js → chunk-DUUSJDES.js} +1 -1
- package/dist-cli/chunks/{chunk-BVXP2GDN.js → chunk-ELJLF4SG.js} +3 -3
- package/dist-cli/chunks/{chunk-TR7NIFSL.js → chunk-EQ7TFQ2F.js} +1 -1
- package/dist-cli/chunks/{chunk-UOWBKSSI.js → chunk-EQCKGC4B.js} +1 -1
- package/dist-cli/chunks/chunk-FUCGLWNN.js +1 -0
- package/dist-cli/chunks/{chunk-BISEHRNE.js → chunk-HYPJW65U.js} +2 -2
- package/dist-cli/chunks/chunk-IILJQCZA.js +2 -0
- package/dist-cli/chunks/{chunk-2XULSYS6.js → chunk-KU6MSPAH.js} +2 -2
- package/dist-cli/chunks/{chunk-QTJJHBCI.js → chunk-OOOR7NT2.js} +1 -1
- package/dist-cli/chunks/{chunk-U3XCDQRH.js → chunk-P7WDNKOS.js} +3 -3
- package/dist-cli/chunks/{chunk-C7JOLDDQ.js → chunk-PPKKA5VW.js} +2 -2
- package/dist-cli/chunks/{chunk-JUCV3VHM.js → chunk-PS2G44GT.js} +2 -2
- package/dist-cli/chunks/{chunk-PO64TMRT.js → chunk-QMSJR5R2.js} +2 -2
- package/dist-cli/chunks/{chunk-4QUAOBUB.js → chunk-RF4R2U46.js} +2 -2
- package/dist-cli/chunks/{chunk-D3SM2JYB.js → chunk-RIXUH3NK.js} +2 -2
- package/dist-cli/chunks/{chunk-2JQIKL3B.js → chunk-SFGUPL2X.js} +2 -2
- package/dist-cli/chunks/{chunk-GI5MF6LP.js → chunk-SQX5CAYG.js} +1 -1
- package/dist-cli/chunks/{chunk-Q4JNA5VO.js → chunk-SQZAC7C4.js} +1 -1
- package/dist-cli/chunks/{chunk-M4ERVRM4.js → chunk-SV7FOGJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-ZN2C7V5R.js → chunk-TK3OJSEO.js} +2 -2
- package/dist-cli/chunks/{chunk-7SCQEPXK.js → chunk-TL7SIZ7S.js} +1 -1
- package/dist-cli/chunks/{chunk-IZ2OO47Y.js → chunk-V2GQ4WXJ.js} +2 -2
- package/dist-cli/chunks/{chunk-JUDJXJSE.js → chunk-VH7F45CN.js} +1 -1
- package/dist-cli/chunks/chunk-WNVNU2OW.js +4 -0
- package/dist-cli/chunks/{chunk-O3AOQP3V.js → chunk-XQ2OBHBE.js} +2 -2
- package/dist-cli/chunks/{chunk-MQXYJTXM.js → chunk-YCIA4BHJ.js} +2 -2
- package/dist-cli/chunks/chunk-ZSMMJMPA.js +1 -0
- package/dist-cli/chunks/cli-version-QB4VH24H.js +2 -0
- package/dist-cli/chunks/{compat-2DVSCCR7.js → compat-FWSEEGEH.js} +3 -3
- package/dist-cli/chunks/{config-YDX4Q4XM.js → config-CYI2WAGP.js} +2 -2
- package/dist-cli/chunks/control-UXY7YQVX.js +2 -0
- package/dist-cli/chunks/{cpu-profile-GU62WVZZ.js → cpu-profile-IKAE3KTY.js} +2 -2
- package/dist-cli/chunks/{daemon-V5NLDTSB.js → daemon-ZUMF53YB.js} +2 -2
- package/dist-cli/chunks/{debug-HAOCONNB.js → debug-P6KULKKS.js} +3 -3
- package/dist-cli/chunks/{detox-YLC4DLXB.js → detox-SPWAZCYG.js} +2 -2
- package/dist-cli/chunks/{device-ZQ4DN4H6.js → device-JWEPK6I2.js} +2 -2
- package/dist-cli/chunks/{diagnose-HNUO3Z5F.js → diagnose-IZODTXV2.js} +2 -2
- package/dist-cli/chunks/drivers-MK6WJKBC.js +2 -0
- package/dist-cli/chunks/{electron-ZAASAHSW.js → electron-R5GP6RVB.js} +3 -3
- package/dist-cli/chunks/flow-6O4GEOPJ.js +2 -0
- package/dist-cli/chunks/{hints-BA3GE5W5.js → hints-DYDNYX7N.js} +2 -2
- package/dist-cli/chunks/{home-paths-MQXRHBTW.js → home-paths-GLMX5OKL.js} +2 -2
- package/dist-cli/chunks/{inspect-T4RMS5KX.js → inspect-FJOPCTY2.js} +3 -3
- package/dist-cli/chunks/install-A3TUGGHN.js +2 -0
- package/dist-cli/chunks/{install-desktop-MH26VONS.js → install-desktop-YPJZMZM5.js} +3 -3
- package/dist-cli/chunks/{keys-IELIDRGB.js → keys-GSYPHWNY.js} +2 -2
- package/dist-cli/chunks/{launch-VMT3OWOB.js → launch-4G2PKW5X.js} +3 -3
- package/dist-cli/chunks/{login-VZBANVLU.js → login-KJQGHA64.js} +4 -4
- package/dist-cli/chunks/{logout-GWXBTQ4H.js → logout-XM2SYH5C.js} +2 -2
- package/dist-cli/chunks/{maestro-JYHR4HFR.js → maestro-EOWGI7DG.js} +2 -2
- package/dist-cli/chunks/{preview-RPZ4UQ2B.js → preview-F73TKK37.js} +2 -2
- package/dist-cli/chunks/{profile-7FLDF2AP.js → profile-22FDKBUO.js} +2 -2
- package/dist-cli/chunks/{react-3RC4CNDZ.js → react-5L6VPFUP.js} +2 -2
- package/dist-cli/chunks/record-JZXCQ4IN.js +70 -0
- package/dist-cli/chunks/runtime-EEBX7CFV.js +2 -0
- package/dist-cli/chunks/{runtime-delivery-Z7I2KIRB.js → runtime-delivery-LXUM3R4A.js} +2 -2
- package/dist-cli/chunks/{screenshot-GRCZ6AM4.js → screenshot-HDRRG33Q.js} +2 -2
- package/dist-cli/chunks/{screenshot-mode-E4YHXHH5.js → screenshot-mode-WY63LZIX.js} +2 -2
- package/dist-cli/chunks/{screenshots-7SXMX2AY.js → screenshots-MPV2ENL5.js} +2 -2
- package/dist-cli/chunks/{server-GDJ2TCRV.js → server-5LBMCJ3G.js} +2 -2
- package/dist-cli/chunks/setup-repo-SZSYNKNI.js +2 -0
- package/dist-cli/chunks/{skills-62E7NDRC.js → skills-BQ73YOBF.js} +2 -2
- package/dist-cli/chunks/{start-Y7KR5ZQ3.js → start-2WU4W6ZU.js} +4 -4
- package/dist-cli/chunks/store-RE45SUBF.js +2 -0
- package/dist-cli/chunks/telemetry-DG6GJLCP.js +2 -0
- package/dist-cli/chunks/{test-IYMSUPVC.js → test-OVO4CQTG.js} +3 -3
- package/dist-cli/chunks/{three-mode-QKKXCCC2.js → three-mode-BKM3KFM7.js} +2 -2
- package/dist-cli/chunks/{timeline-PF6NQ7RT.js → timeline-MDXGEDQL.js} +2 -2
- package/dist-cli/chunks/{upgrade-CE2Y3TAN.js → upgrade-JGQABWVF.js} +2 -2
- package/dist-cli/chunks/upload-UJNUA4ZV.js +2 -0
- package/dist-cli/chunks/{web-XEO3ZCPF.js → web-WYFAYQ72.js} +2 -2
- package/dist-cli/chunks/{what-happened-372J7YF7.js → what-happened-PZW2KW6A.js} +2 -2
- package/dist-cli/chunks/{whoami-B4E7KCT5.js → whoami-7ATWJQS6.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +44 -0
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1770 -0
- package/dist-lib/detox/jest-preset.cjs +50 -0
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/index.cjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/demo-app-registry.cjs +809 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
- package/dist-lib/skills.cjs +8322 -0
- package/dist-lib/vite-base.cjs +3 -3
- package/dist-lib/vite.cjs +1 -1
- package/package.json +39 -10
- package/scripts/demo-app-registry.ts +989 -0
- package/scripts/dev-server-scanner.ts +674 -0
- package/src/agent-daemon-client.ts +390 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +572 -0
- package/src/attached-projects.ts +536 -0
- package/src/auth/shared-session.ts +199 -0
- package/src/backend-origin.ts +49 -0
- package/src/beta.ts +21 -0
- package/src/bridge-constants.ts +10 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +30 -0
- package/src/codex-client.ts +215 -0
- package/src/config.ts +110 -0
- package/src/dev-bundle-resolution.ts +180 -0
- package/src/home-paths.ts +382 -0
- package/src/host/agent-host.ts +576 -0
- package/src/host/bridge-host.ts +2293 -0
- package/src/host/fetch-proxy-handler.ts +288 -0
- package/src/host/fetch-proxy-overrides.ts +39 -0
- package/src/host/open-url.ts +234 -0
- package/src/index.ts +9 -0
- package/src/metro-plugin.ts +207 -0
- package/src/native-dev-bundle-url.ts +62 -0
- package/src/native-seam-manifest.ts +313 -0
- package/src/profiles.ts +179 -0
- package/src/render-mode.ts +27 -0
- package/src/runtime-delivery.ts +334 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +438 -0
- package/src/screenshots/orchestrate.ts +244 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/skills/builtin/a11y-review.ts +126 -0
- package/src/skills/builtin/compat-check.ts +104 -0
- package/src/skills/builtin/perf-profile.ts +84 -0
- package/src/skills/builtin/screenshot-all.ts +46 -0
- package/src/skills/builtin/test-flow.ts +118 -0
- package/src/skills/builtin/visual-diff.ts +94 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/vite-plugin-one.ts +187 -0
- package/src/vite-plugin.ts +1381 -0
- package/src/worklets-babel.ts +132 -0
- package/dist-cli/chunks/auto-bootstrap-D2EQVL7R.js +0 -2
- package/dist-cli/chunks/beta-VHPXECZY.js +0 -2
- package/dist-cli/chunks/chunk-27HBWBE6.js +0 -4
- package/dist-cli/chunks/chunk-2W5C5J4O.js +0 -64
- package/dist-cli/chunks/chunk-3OH4VCJA.js +0 -1
- package/dist-cli/chunks/chunk-45HLFQRI.js +0 -2
- package/dist-cli/chunks/chunk-7YLCK5HG.js +0 -5
- package/dist-cli/chunks/chunk-BRDUKIZI.js +0 -119
- package/dist-cli/chunks/chunk-GADW2Q5S.js +0 -1
- package/dist-cli/chunks/chunk-HST43CVE.js +0 -2
- package/dist-cli/chunks/chunk-QJBQOGTK.js +0 -73
- package/dist-cli/chunks/chunk-V26REV7G.js +0 -1
- package/dist-cli/chunks/cli-version-WF7T6IKI.js +0 -2
- package/dist-cli/chunks/control-EAK2OPGB.js +0 -2
- package/dist-cli/chunks/demo-app-registry-52A2MI72.js +0 -2
- package/dist-cli/chunks/drivers-B4QPIZ4B.js +0 -2
- package/dist-cli/chunks/flow-PFLHFNVM.js +0 -2
- package/dist-cli/chunks/install-ZCPEMK6U.js +0 -2
- package/dist-cli/chunks/record-CZ33G5FT.js +0 -70
- package/dist-cli/chunks/runtime-AZKHZHJ4.js +0 -2
- package/dist-cli/chunks/setup-repo-3Y2QAZRK.js +0 -2
- package/dist-cli/chunks/store-TDTFZMGA.js +0 -2
- package/dist-cli/chunks/telemetry-G3NIU5NP.js +0 -2
- package/dist-cli/chunks/upload-CLWFS7IL.js +0 -2
package/detox/index.ts
ADDED
|
@@ -0,0 +1,1436 @@
|
|
|
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 { createExpect, createWaitFor } from './expectations'
|
|
9
|
+
import {
|
|
10
|
+
dispatchTwoFingerGesture,
|
|
11
|
+
dragScrollNode,
|
|
12
|
+
readSootsimInteractiveViewport,
|
|
13
|
+
sootsimToPage,
|
|
14
|
+
waitForSootsimGestureHandler,
|
|
15
|
+
} from './gestures'
|
|
16
|
+
import { by, type Matcher } from './matchers'
|
|
17
|
+
import type { Browser, BrowserContext, Page } from 'playwright'
|
|
18
|
+
import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
|
|
19
|
+
|
|
20
|
+
export { by }
|
|
21
|
+
|
|
22
|
+
const BASE_URL = process.env.SOOTSIM_URL || 'http://localhost:5173'
|
|
23
|
+
const SCREENSHOT_DIR =
|
|
24
|
+
process.env.SOOTSIM_SCREENSHOT_DIR ||
|
|
25
|
+
path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
|
|
26
|
+
const DETOX_PLATFORM =
|
|
27
|
+
process.env.SOOTSIM_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
|
|
28
|
+
|
|
29
|
+
// shared state -- playwright page and browser
|
|
30
|
+
let _browser: Browser | null = null
|
|
31
|
+
let _context: BrowserContext | null = null
|
|
32
|
+
let _page: Page | null = null
|
|
33
|
+
let _synchronizationEnabled = false
|
|
34
|
+
const pageDiagnostics = new WeakMap<Page, string[]>()
|
|
35
|
+
|
|
36
|
+
const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
|
|
37
|
+
|
|
38
|
+
type StatusBarConfig = {
|
|
39
|
+
time?: string
|
|
40
|
+
dataNetwork?: string
|
|
41
|
+
wifiMode?: string
|
|
42
|
+
wifiBars?: string
|
|
43
|
+
cellularMode?: string
|
|
44
|
+
cellularBars?: string
|
|
45
|
+
operatorName?: string
|
|
46
|
+
batteryState?: string
|
|
47
|
+
batteryLevel?: string | number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getPage(): Page {
|
|
51
|
+
if (!_page)
|
|
52
|
+
throw new Error('sootsim driver not initialized -- call device.launchApp() first')
|
|
53
|
+
return _page
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recordPageDiagnostic(page: Page, message: string) {
|
|
57
|
+
const entries = pageDiagnostics.get(page)
|
|
58
|
+
if (!entries) return
|
|
59
|
+
entries.push(message)
|
|
60
|
+
if (entries.length > 40) entries.shift()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function attachPageDiagnostics(page: Page) {
|
|
64
|
+
if (pageDiagnostics.has(page)) return
|
|
65
|
+
pageDiagnostics.set(page, [])
|
|
66
|
+
page.on('console', (message) => {
|
|
67
|
+
recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`)
|
|
68
|
+
})
|
|
69
|
+
page.on('pageerror', (error) => {
|
|
70
|
+
recordPageDiagnostic(page, `[pageerror] ${error.message}`)
|
|
71
|
+
})
|
|
72
|
+
page.on('requestfailed', (request) => {
|
|
73
|
+
const failure = request.failure()
|
|
74
|
+
recordPageDiagnostic(
|
|
75
|
+
page,
|
|
76
|
+
`[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ''}`,
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function describeSootsimBridgeFailure(page: Page, cause: unknown) {
|
|
82
|
+
let pageState: unknown = null
|
|
83
|
+
try {
|
|
84
|
+
pageState = await page.evaluate(() => ({
|
|
85
|
+
bodyText: document.body?.innerText?.slice(0, 500) ?? '',
|
|
86
|
+
globals: Object.keys(window)
|
|
87
|
+
.filter((key) => key.startsWith('__sootsim') || key === 'SootSim')
|
|
88
|
+
.sort(),
|
|
89
|
+
readyState: document.readyState,
|
|
90
|
+
title: document.title,
|
|
91
|
+
url: window.location.href,
|
|
92
|
+
}))
|
|
93
|
+
} catch (error) {
|
|
94
|
+
pageState = {
|
|
95
|
+
evaluateError: error instanceof Error ? error.message : String(error),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return new Error(
|
|
100
|
+
[
|
|
101
|
+
`timed out waiting for sootsim test bridge`,
|
|
102
|
+
`cause: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
103
|
+
`page: ${JSON.stringify(pageState)}`,
|
|
104
|
+
`recent page diagnostics:\n${(pageDiagnostics.get(page) ?? []).join('\n') || '(none)'}`,
|
|
105
|
+
].join('\n'),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function waitForSootsimTree(page: Page, timeout = 30000): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
|
|
112
|
+
timeout,
|
|
113
|
+
})
|
|
114
|
+
await page.evaluate(async (timeoutMs) => {
|
|
115
|
+
await Promise.race([
|
|
116
|
+
window.__sootsimTest!.waitForTree(),
|
|
117
|
+
new Promise((_, reject) => {
|
|
118
|
+
setTimeout(
|
|
119
|
+
() => reject(new Error(`sootsim waitForTree timed out after ${timeoutMs}ms`)),
|
|
120
|
+
timeoutMs,
|
|
121
|
+
)
|
|
122
|
+
}),
|
|
123
|
+
])
|
|
124
|
+
}, timeout)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw await describeSootsimBridgeFailure(page, error)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function closeContext() {
|
|
131
|
+
const context = _context
|
|
132
|
+
_context = null
|
|
133
|
+
_page = null
|
|
134
|
+
_synchronizationEnabled = false
|
|
135
|
+
if (context) await context.close()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function closeBrowser() {
|
|
139
|
+
if (!_browser) return
|
|
140
|
+
const browser = _browser
|
|
141
|
+
_browser = null
|
|
142
|
+
await closeContext()
|
|
143
|
+
await browser.close()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function waitForSootsimIdle(page: Page, maxMs = 3000): Promise<void> {
|
|
147
|
+
const result = await page.evaluate(
|
|
148
|
+
async ({ maxMs }) => {
|
|
149
|
+
let transitionError: string | null = null
|
|
150
|
+
try {
|
|
151
|
+
const waitForScreenTransitions = (window as any).__sootsimTest
|
|
152
|
+
?.waitForScreenTransitions
|
|
153
|
+
if (typeof waitForScreenTransitions === 'function') {
|
|
154
|
+
const transitionResult = await waitForScreenTransitions({
|
|
155
|
+
timeoutMs: Math.min(maxMs, 1800),
|
|
156
|
+
settleMs: 48,
|
|
157
|
+
startWindowMs: 600,
|
|
158
|
+
})
|
|
159
|
+
if (
|
|
160
|
+
transitionResult?.started === true &&
|
|
161
|
+
transitionResult.settled === true &&
|
|
162
|
+
transitionResult.timedOut !== true
|
|
163
|
+
) {
|
|
164
|
+
return { settled: true, elapsed: transitionResult.waitedMs ?? 0 }
|
|
165
|
+
}
|
|
166
|
+
if (transitionResult?.timedOut) {
|
|
167
|
+
return {
|
|
168
|
+
settled: false,
|
|
169
|
+
elapsed: transitionResult.waitedMs,
|
|
170
|
+
reason: 'screen transition',
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
transitionError = error instanceof Error ? error.message : String(error)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const start = Date.now()
|
|
179
|
+
const deadline = start + maxMs
|
|
180
|
+
const pollMs = 50
|
|
181
|
+
const requiredStablePolls = 3
|
|
182
|
+
const layoutTolerance = 1
|
|
183
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
184
|
+
const isLayoutStable = (a: number[] | null, b: number[]) => {
|
|
185
|
+
if (!a || a.length !== b.length) return false
|
|
186
|
+
for (let i = 0; i < b.length; i++) {
|
|
187
|
+
if (Math.abs(a[i] - b[i]) > layoutTolerance) return false
|
|
188
|
+
}
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const readSnapshot = async () => {
|
|
193
|
+
const root = (window as any).__sootsimRoot
|
|
194
|
+
const layout: number[] = []
|
|
195
|
+
if (root) {
|
|
196
|
+
const walk = (node: any) => {
|
|
197
|
+
if (node.layout && node.layout.width > 0) {
|
|
198
|
+
layout.push(
|
|
199
|
+
Math.round(node.layout.x),
|
|
200
|
+
Math.round(node.layout.y),
|
|
201
|
+
Math.round(node.layout.width),
|
|
202
|
+
Math.round(node.layout.height),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
for (const child of node.children || []) walk(child)
|
|
206
|
+
}
|
|
207
|
+
walk(root)
|
|
208
|
+
}
|
|
209
|
+
return layout
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let stableLayout: number[] | null = null
|
|
213
|
+
let stable = 0
|
|
214
|
+
while (Date.now() < deadline) {
|
|
215
|
+
const layout = await readSnapshot()
|
|
216
|
+
if (isLayoutStable(stableLayout, layout)) {
|
|
217
|
+
stable++
|
|
218
|
+
if (stable >= requiredStablePolls) {
|
|
219
|
+
return { settled: true, elapsed: Date.now() - start }
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
stableLayout = layout
|
|
223
|
+
stable = 0
|
|
224
|
+
}
|
|
225
|
+
await sleep(pollMs)
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
settled: false,
|
|
229
|
+
elapsed: Date.now() - start,
|
|
230
|
+
reason: transitionError
|
|
231
|
+
? `layout fallback after screen transition wait failed: ${transitionError}`
|
|
232
|
+
: 'layout fallback',
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{ maxMs },
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if (!result?.settled) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`sootsim synchronization timed out after ${result?.elapsed ?? maxMs}ms${result?.reason ? ` (${result.reason})` : ''}`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// find a node in the sootsim tree matching a matcher descriptor
|
|
246
|
+
async function findNodeByMatcher(matcher: Matcher): Promise<any> {
|
|
247
|
+
const page = getPage()
|
|
248
|
+
if (matcher.type === 'id') {
|
|
249
|
+
return page.evaluate(
|
|
250
|
+
(id: string) => window.__sootsimTest!.findByTestId(id),
|
|
251
|
+
matcher.value,
|
|
252
|
+
)
|
|
253
|
+
} else if (matcher.type === 'text') {
|
|
254
|
+
return page.evaluate(
|
|
255
|
+
(text: string) => window.__sootsimTest!.findByText(text),
|
|
256
|
+
matcher.value,
|
|
257
|
+
)
|
|
258
|
+
} else if (matcher.type === 'label') {
|
|
259
|
+
return page.evaluate(
|
|
260
|
+
(label: string) => window.__sootsimTest!.findByLabel(label),
|
|
261
|
+
matcher.value,
|
|
262
|
+
)
|
|
263
|
+
} else if (matcher.type === 'role') {
|
|
264
|
+
return page.evaluate(
|
|
265
|
+
(role: string) => window.__sootsimTest!.findByRole(role),
|
|
266
|
+
matcher.value,
|
|
267
|
+
)
|
|
268
|
+
} else if (matcher.type === 'type') {
|
|
269
|
+
return page.evaluate(async (type: string) => {
|
|
270
|
+
const results = await window.__sootsimTest!.queryAll({ type })
|
|
271
|
+
return results[0] || null
|
|
272
|
+
}, matcher.value)
|
|
273
|
+
}
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// get the absolute center coordinates of a node in sootsim coordinate space
|
|
278
|
+
function getNodeCenter(nodeInfo: any): { x: number; y: number } {
|
|
279
|
+
return {
|
|
280
|
+
x: nodeInfo.absolutePosition.x + nodeInfo.layout.width / 2,
|
|
281
|
+
y: nodeInfo.absolutePosition.y + nodeInfo.layout.height / 2,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function getDefaultTapPoint(
|
|
286
|
+
page: Page,
|
|
287
|
+
nodeInfo: any,
|
|
288
|
+
): Promise<{ x: number; y: number }> {
|
|
289
|
+
const center = getNodeCenter(nodeInfo)
|
|
290
|
+
const viewport = await readSootsimInteractiveViewport(page)
|
|
291
|
+
const frame = nodeInfo.visibleFrame ?? {
|
|
292
|
+
x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
|
|
293
|
+
y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
|
|
294
|
+
width: nodeInfo.layout?.width ?? 0,
|
|
295
|
+
height: nodeInfo.layout?.height ?? 0,
|
|
296
|
+
}
|
|
297
|
+
const left = Math.max(0, frame.x)
|
|
298
|
+
const top = Math.max(0, frame.y)
|
|
299
|
+
const right = Math.min(viewport.width, frame.x + frame.width)
|
|
300
|
+
const bottom = Math.min(viewport.height, frame.y + frame.height)
|
|
301
|
+
|
|
302
|
+
if (right <= left || bottom <= top) {
|
|
303
|
+
return center
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
x: left + (right - left) / 2,
|
|
308
|
+
y: top + (bottom - top) / 2,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
|
|
313
|
+
const focused = await page.evaluate(
|
|
314
|
+
() => window.__sootsimTest!.getFocusedNode?.() ?? null,
|
|
315
|
+
)
|
|
316
|
+
return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function focusElementForTextEntry(el: SootElement): Promise<void> {
|
|
320
|
+
const page = getPage()
|
|
321
|
+
const node = await findNodeByMatcher(el._matcher)
|
|
322
|
+
if (!node) {
|
|
323
|
+
throw new Error(`element not found for text entry: ${JSON.stringify(el._matcher)}`)
|
|
324
|
+
}
|
|
325
|
+
if (await isFocusedTextInputNode(page, node)) return
|
|
326
|
+
await el.tap()
|
|
327
|
+
await page.waitForTimeout(100)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ensureScreenshotDir() {
|
|
331
|
+
if (!fs.existsSync(SCREENSHOT_DIR)) {
|
|
332
|
+
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true })
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function decodePngDataUrl(dataUrl: string): Buffer {
|
|
337
|
+
const match = /^data:image\/png;base64,(.+)$/.exec(dataUrl)
|
|
338
|
+
if (!match) {
|
|
339
|
+
throw new Error('sootsim screenshot bridge returned a non-png payload')
|
|
340
|
+
}
|
|
341
|
+
return Buffer.from(match[1], 'base64')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// single-shot canvas capture — no frame-stability poll. used for multi-stage
|
|
345
|
+
// animation captures where we explicitly want to sample mid-transition. the
|
|
346
|
+
// usual captureSootsimPng polls until two consecutive frames are byte-equal
|
|
347
|
+
// which is ideal for settled screenshots but blocks for the full timeout
|
|
348
|
+
// while an animation is running.
|
|
349
|
+
async function captureSootsimPngFast(opts?: {
|
|
350
|
+
crop?: { h: number; w: number; x: number; y: number }
|
|
351
|
+
}) {
|
|
352
|
+
const page = getPage()
|
|
353
|
+
const dataUrl = await page.evaluate(
|
|
354
|
+
async (captureOpts) => {
|
|
355
|
+
const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
|
|
356
|
+
.SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
|
|
357
|
+
if (typeof screenshot !== 'function') {
|
|
358
|
+
throw new Error('sootsim screenshot bridge is not installed')
|
|
359
|
+
}
|
|
360
|
+
return screenshot(captureOpts)
|
|
361
|
+
},
|
|
362
|
+
{ crop: opts?.crop },
|
|
363
|
+
)
|
|
364
|
+
if (!dataUrl) {
|
|
365
|
+
throw new Error('sootsim screenshot bridge returned an empty image')
|
|
366
|
+
}
|
|
367
|
+
return decodePngDataUrl(dataUrl)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function captureSootsimPng(opts?: {
|
|
371
|
+
crop?: { h: number; w: number; x: number; y: number }
|
|
372
|
+
}) {
|
|
373
|
+
const page = getPage()
|
|
374
|
+
// frame-stability poll: capture twice with a gap > one tenant live-blur
|
|
375
|
+
// push interval (~67ms) and accept only when both PNGs are byte-equal.
|
|
376
|
+
// PNGs from canvas.toDataURL are deterministic for stable RGBA, so byte-
|
|
377
|
+
// equal = pixel-equal. without this, the keyboard's blur stream and the
|
|
378
|
+
// text-input cursor blink produce 1-12pp frame-to-frame variance in
|
|
379
|
+
// captures fired blindly after a settle, which then drifts the published
|
|
380
|
+
// proof manifest. matches the same poll in scripts/conformance-fast-iter.ts
|
|
381
|
+
// so cron and publish lock onto the same stable frame.
|
|
382
|
+
const result: {
|
|
383
|
+
dataUrl: string
|
|
384
|
+
stable: boolean
|
|
385
|
+
attempts: number
|
|
386
|
+
elapsedMs: number
|
|
387
|
+
} = await page.evaluate(
|
|
388
|
+
async (captureOpts) => {
|
|
389
|
+
const screenshot = (window as { SootSim?: { bridges?: { screenshot?: unknown } } })
|
|
390
|
+
.SootSim?.bridges?.screenshot as ((opts?: unknown) => Promise<string>) | undefined
|
|
391
|
+
if (typeof screenshot !== 'function') {
|
|
392
|
+
throw new Error('sootsim screenshot bridge is not installed')
|
|
393
|
+
}
|
|
394
|
+
const gapMs = 100
|
|
395
|
+
const maxWaitMs = 8000
|
|
396
|
+
const minAttempts = 3
|
|
397
|
+
const t0 = Date.now()
|
|
398
|
+
let prev = await screenshot(captureOpts)
|
|
399
|
+
let attempts = 1
|
|
400
|
+
while (attempts < minAttempts || Date.now() - t0 < maxWaitMs) {
|
|
401
|
+
await new Promise((r) => setTimeout(r, gapMs))
|
|
402
|
+
const next = await screenshot(captureOpts)
|
|
403
|
+
attempts++
|
|
404
|
+
if (next === prev) {
|
|
405
|
+
return {
|
|
406
|
+
dataUrl: next,
|
|
407
|
+
stable: true,
|
|
408
|
+
attempts,
|
|
409
|
+
elapsedMs: Date.now() - t0,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
prev = next
|
|
413
|
+
}
|
|
414
|
+
return { dataUrl: prev, stable: false, attempts, elapsedMs: Date.now() - t0 }
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
crop: opts?.crop,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
if (!result.dataUrl) {
|
|
421
|
+
throw new Error('sootsim screenshot bridge returned an empty image')
|
|
422
|
+
}
|
|
423
|
+
if (!result.stable) {
|
|
424
|
+
console.warn(
|
|
425
|
+
`[sootsim-detox] screenshot did not stabilize after ${result.elapsedMs}ms (${result.attempts} captures); using last frame`,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
return decodePngDataUrl(result.dataUrl)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function writeSootsimScreenshot(
|
|
432
|
+
name: string,
|
|
433
|
+
opts?: { crop?: { h: number; w: number; x: number; y: number } },
|
|
434
|
+
) {
|
|
435
|
+
ensureScreenshotDir()
|
|
436
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
|
|
437
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPng(opts))
|
|
438
|
+
return screenshotPath
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function dispatchStatusBarOverride(config: StatusBarConfig) {
|
|
442
|
+
const page = getPage()
|
|
443
|
+
const time =
|
|
444
|
+
typeof config.time === 'string' && config.time.length > 0 ? config.time : null
|
|
445
|
+
await page.evaluate(
|
|
446
|
+
({ eventType, time }) => {
|
|
447
|
+
window.dispatchEvent(
|
|
448
|
+
new CustomEvent(eventType, {
|
|
449
|
+
detail: { time },
|
|
450
|
+
}),
|
|
451
|
+
)
|
|
452
|
+
},
|
|
453
|
+
{ eventType: STATUS_BAR_OVERRIDE_EVENT, time },
|
|
454
|
+
)
|
|
455
|
+
await page.waitForTimeout(50)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function dispatchSootsimEventInPage<K extends SootsimEventName>(
|
|
459
|
+
type: K,
|
|
460
|
+
detail: SootsimEventMap[K],
|
|
461
|
+
) {
|
|
462
|
+
const page = getPage()
|
|
463
|
+
await page.evaluate(
|
|
464
|
+
({ detail, type }) => {
|
|
465
|
+
window.dispatchEvent(new CustomEvent(type, { detail }))
|
|
466
|
+
},
|
|
467
|
+
{ detail, type },
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// element interaction object returned by element(matcher)
|
|
472
|
+
export interface SootElement {
|
|
473
|
+
// reference to the matcher for lazy evaluation
|
|
474
|
+
_matcher: Matcher
|
|
475
|
+
|
|
476
|
+
tap(point?: { x?: number; y?: number }): Promise<void>
|
|
477
|
+
multiTap(times: number): Promise<void>
|
|
478
|
+
longPress(duration?: number): Promise<void>
|
|
479
|
+
longPressAndDrag(
|
|
480
|
+
duration: number,
|
|
481
|
+
normalizedStartX: number,
|
|
482
|
+
normalizedStartY: number,
|
|
483
|
+
targetElement: SootElement,
|
|
484
|
+
normalizedEndX: number,
|
|
485
|
+
normalizedEndY: number,
|
|
486
|
+
speed?: string,
|
|
487
|
+
holdDuration?: number,
|
|
488
|
+
): Promise<void>
|
|
489
|
+
typeText(text: string): Promise<void>
|
|
490
|
+
replaceText(text: string): Promise<void>
|
|
491
|
+
clearText(): Promise<void>
|
|
492
|
+
scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right'): Promise<void>
|
|
493
|
+
scrollTo(edge: 'top' | 'bottom' | 'left' | 'right'): Promise<void>
|
|
494
|
+
swipe(
|
|
495
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
496
|
+
speed?: string,
|
|
497
|
+
percentage?: number,
|
|
498
|
+
): Promise<void>
|
|
499
|
+
pinch(scale: number, speed?: 'fast' | 'slow', angle?: number): Promise<void>
|
|
500
|
+
rotate(radians: number, speed?: 'fast' | 'slow'): Promise<void>
|
|
501
|
+
takeScreenshot(name: string): Promise<string>
|
|
502
|
+
getAttributes(): Promise<any>
|
|
503
|
+
atIndex(index: number): SootElement
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function createSootElement(matcher: Matcher): SootElement {
|
|
507
|
+
const el: SootElement = {
|
|
508
|
+
_matcher: matcher,
|
|
509
|
+
|
|
510
|
+
async tap(point) {
|
|
511
|
+
const page = getPage()
|
|
512
|
+
const node = await findNodeByMatcher(matcher)
|
|
513
|
+
if (!node) throw new Error(`element not found for tap: ${JSON.stringify(matcher)}`)
|
|
514
|
+
const defaultPoint = point ? null : await getDefaultTapPoint(page, node)
|
|
515
|
+
const targetSootX =
|
|
516
|
+
typeof point?.x === 'number'
|
|
517
|
+
? node.absolutePosition.x + point.x
|
|
518
|
+
: (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
|
|
519
|
+
const targetSootY =
|
|
520
|
+
typeof point?.y === 'number'
|
|
521
|
+
? node.absolutePosition.y + point.y
|
|
522
|
+
: (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
|
|
523
|
+
await page.evaluate(
|
|
524
|
+
async ({ x, y }) => {
|
|
525
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
526
|
+
if (typeof interactTap === 'function') {
|
|
527
|
+
const result = await interactTap(x, y)
|
|
528
|
+
if (result && (typeof result !== 'object' || result.hit !== false)) return
|
|
529
|
+
}
|
|
530
|
+
const tap = window.__sootsimTest?.tap
|
|
531
|
+
if (typeof tap !== 'function') {
|
|
532
|
+
throw new Error('sootsim tap bridge is not installed')
|
|
533
|
+
}
|
|
534
|
+
await tap(x, y)
|
|
535
|
+
},
|
|
536
|
+
{ x: targetSootX, y: targetSootY },
|
|
537
|
+
)
|
|
538
|
+
// small settling time for react state updates
|
|
539
|
+
await page.waitForTimeout(50)
|
|
540
|
+
if (_synchronizationEnabled) {
|
|
541
|
+
await waitForSootsimIdle(page)
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
async multiTap(times: number) {
|
|
546
|
+
// detox iOS multiTap performs N taps with the system's natural double-
|
|
547
|
+
// tap pause. UITapGestureRecognizer treats taps within ~250ms as a
|
|
548
|
+
// multi-tap. emit each tap as a discrete down/up at the same node
|
|
549
|
+
// center with a small inter-tap pause that stays inside RNGH's
|
|
550
|
+
// default maxDelay window of 200ms.
|
|
551
|
+
const page = getPage()
|
|
552
|
+
const node = await findNodeByMatcher(matcher)
|
|
553
|
+
if (!node)
|
|
554
|
+
throw new Error(`element not found for multiTap: ${JSON.stringify(matcher)}`)
|
|
555
|
+
const center = getNodeCenter(node)
|
|
556
|
+
const pageCoords = await sootsimToPage(page, center.x, center.y)
|
|
557
|
+
|
|
558
|
+
for (let i = 0; i < times; i++) {
|
|
559
|
+
await page.mouse.move(pageCoords.x, pageCoords.y)
|
|
560
|
+
await page.mouse.down()
|
|
561
|
+
await page.waitForTimeout(50)
|
|
562
|
+
await page.mouse.up()
|
|
563
|
+
if (i < times - 1) {
|
|
564
|
+
await page.waitForTimeout(80)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
await page.waitForTimeout(80)
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
async longPress(duration = 1000) {
|
|
571
|
+
const page = getPage()
|
|
572
|
+
const node = await findNodeByMatcher(matcher)
|
|
573
|
+
if (!node)
|
|
574
|
+
throw new Error(`element not found for longPress: ${JSON.stringify(matcher)}`)
|
|
575
|
+
const center = getNodeCenter(node)
|
|
576
|
+
const pageCoords = await sootsimToPage(page, center.x, center.y)
|
|
577
|
+
|
|
578
|
+
await page.mouse.move(pageCoords.x, pageCoords.y)
|
|
579
|
+
await page.mouse.down()
|
|
580
|
+
await page.waitForTimeout(duration)
|
|
581
|
+
await page.mouse.up()
|
|
582
|
+
await page.waitForTimeout(50)
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
async longPressAndDrag(
|
|
586
|
+
duration: number,
|
|
587
|
+
normalizedStartX: number,
|
|
588
|
+
normalizedStartY: number,
|
|
589
|
+
targetElement: SootElement,
|
|
590
|
+
normalizedEndX: number,
|
|
591
|
+
normalizedEndY: number,
|
|
592
|
+
speed = 'fast',
|
|
593
|
+
holdDuration = 0,
|
|
594
|
+
) {
|
|
595
|
+
const page = getPage()
|
|
596
|
+
// resolve source element
|
|
597
|
+
const srcNode = await findNodeByMatcher(matcher)
|
|
598
|
+
if (!srcNode)
|
|
599
|
+
throw new Error(`source element not found: ${JSON.stringify(matcher)}`)
|
|
600
|
+
|
|
601
|
+
// resolve target element
|
|
602
|
+
const tgtNode = await findNodeByMatcher(targetElement._matcher)
|
|
603
|
+
if (!tgtNode)
|
|
604
|
+
throw new Error(
|
|
605
|
+
`target element not found: ${JSON.stringify(targetElement._matcher)}`,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
// compute start and end in sootsim coords
|
|
609
|
+
const startSootX =
|
|
610
|
+
srcNode.absolutePosition.x + srcNode.layout.width * normalizedStartX
|
|
611
|
+
const startSootY =
|
|
612
|
+
srcNode.absolutePosition.y + srcNode.layout.height * normalizedStartY
|
|
613
|
+
const endSootX = tgtNode.absolutePosition.x + tgtNode.layout.width * normalizedEndX
|
|
614
|
+
const endSootY = tgtNode.absolutePosition.y + tgtNode.layout.height * normalizedEndY
|
|
615
|
+
|
|
616
|
+
const startPage = await sootsimToPage(page, startSootX, startSootY)
|
|
617
|
+
const endPage = await sootsimToPage(page, endSootX, endSootY)
|
|
618
|
+
const steps = speed === 'slow' ? 20 : 10
|
|
619
|
+
|
|
620
|
+
// press at start position
|
|
621
|
+
await page.mouse.move(startPage.x, startPage.y)
|
|
622
|
+
await page.mouse.down()
|
|
623
|
+
await page.waitForTimeout(Math.max(duration, holdDuration))
|
|
624
|
+
|
|
625
|
+
// drag to end position
|
|
626
|
+
const dx = (endPage.x - startPage.x) / steps
|
|
627
|
+
const dy = (endPage.y - startPage.y) / steps
|
|
628
|
+
for (let i = 1; i <= steps; i++) {
|
|
629
|
+
await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
|
|
630
|
+
await page.waitForTimeout(speed === 'slow' ? 30 : 10)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
await page.mouse.up()
|
|
634
|
+
await page.waitForTimeout(50)
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
async typeText(text: string) {
|
|
638
|
+
const page = getPage()
|
|
639
|
+
await focusElementForTextEntry(el)
|
|
640
|
+
await page.keyboard.type(text)
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
async replaceText(text: string) {
|
|
644
|
+
const page = getPage()
|
|
645
|
+
await focusElementForTextEntry(el)
|
|
646
|
+
const node = await findNodeByMatcher(matcher)
|
|
647
|
+
const value = typeof node?.text === 'string' ? node.text : ''
|
|
648
|
+
for (let i = 0; i < value.length; i++) {
|
|
649
|
+
await page.keyboard.press('Backspace')
|
|
650
|
+
}
|
|
651
|
+
await page.keyboard.type(text)
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
async clearText() {
|
|
655
|
+
const page = getPage()
|
|
656
|
+
await focusElementForTextEntry(el)
|
|
657
|
+
const node = await findNodeByMatcher(matcher)
|
|
658
|
+
const value = typeof node?.text === 'string' ? node.text : ''
|
|
659
|
+
for (let i = 0; i < value.length; i++) {
|
|
660
|
+
await page.keyboard.press('Backspace')
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
|
|
664
|
+
async scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right') {
|
|
665
|
+
const node = await findNodeByMatcher(matcher)
|
|
666
|
+
if (!node)
|
|
667
|
+
throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
|
|
668
|
+
const page = getPage()
|
|
669
|
+
await dragScrollNode(page, node, pixels, direction)
|
|
670
|
+
},
|
|
671
|
+
|
|
672
|
+
async scrollTo(edge: 'top' | 'bottom' | 'left' | 'right') {
|
|
673
|
+
const node = await findNodeByMatcher(matcher)
|
|
674
|
+
if (!node)
|
|
675
|
+
throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
|
|
676
|
+
|
|
677
|
+
const targetId =
|
|
678
|
+
typeof node.testID === 'string' && node.testID
|
|
679
|
+
? node.testID
|
|
680
|
+
: typeof node.id === 'string' && node.id
|
|
681
|
+
? node.id
|
|
682
|
+
: null
|
|
683
|
+
if (!targetId) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
`scrollTo requires an id-backed scroll view: ${JSON.stringify(matcher)}`,
|
|
686
|
+
)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const hitX = node.absolutePosition.x + node.layout.width / 2
|
|
690
|
+
const hitY = node.absolutePosition.y + node.layout.height / 2
|
|
691
|
+
const page = getPage()
|
|
692
|
+
const result = await page.evaluate(
|
|
693
|
+
async ({ edge, hitX, hitY, targetId }) => {
|
|
694
|
+
const bridge = window.__sootsimTest
|
|
695
|
+
if (!bridge?.getScrollStateAt || !bridge.scrollTo) {
|
|
696
|
+
return { ok: false, reason: 'scroll bridge unavailable' }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const state = await bridge.getScrollStateAt(hitX, hitY)
|
|
700
|
+
if (!state) return { ok: false, reason: 'scroll state not found' }
|
|
701
|
+
|
|
702
|
+
const maxOffset =
|
|
703
|
+
state.maxOffset && typeof state.maxOffset === 'object'
|
|
704
|
+
? (state.maxOffset as { x?: number; y?: number })
|
|
705
|
+
: null
|
|
706
|
+
const offset =
|
|
707
|
+
state.offset && typeof state.offset === 'object'
|
|
708
|
+
? (state.offset as { x?: number; y?: number })
|
|
709
|
+
: null
|
|
710
|
+
const maxX =
|
|
711
|
+
typeof state.maxOffsetX === 'number' ? state.maxOffsetX : (maxOffset?.x ?? 0)
|
|
712
|
+
const maxY =
|
|
713
|
+
typeof state.maxOffsetY === 'number' ? state.maxOffsetY : (maxOffset?.y ?? 0)
|
|
714
|
+
const currentX =
|
|
715
|
+
typeof state.offsetX === 'number' ? state.offsetX : (offset?.x ?? 0)
|
|
716
|
+
const currentY =
|
|
717
|
+
typeof state.offsetY === 'number' ? state.offsetY : (offset?.y ?? 0)
|
|
718
|
+
|
|
719
|
+
const x = edge === 'left' ? 0 : edge === 'right' ? maxX : currentX
|
|
720
|
+
const y = edge === 'top' ? 0 : edge === 'bottom' ? maxY : currentY
|
|
721
|
+
return bridge.scrollTo(targetId, x, y, false)
|
|
722
|
+
},
|
|
723
|
+
{ edge, hitX, hitY, targetId },
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
if (!result?.ok) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
`scrollTo(${edge}) failed for ${targetId}: ${result?.reason ?? 'unknown error'}`,
|
|
729
|
+
)
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
await page.evaluate(
|
|
733
|
+
() =>
|
|
734
|
+
new Promise<void>((resolve) => {
|
|
735
|
+
requestAnimationFrame(() => resolve())
|
|
736
|
+
}),
|
|
737
|
+
)
|
|
738
|
+
if (_synchronizationEnabled) {
|
|
739
|
+
await waitForSootsimIdle(page)
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
|
|
743
|
+
async swipe(
|
|
744
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
745
|
+
speed = 'fast',
|
|
746
|
+
percentage = 0.75,
|
|
747
|
+
) {
|
|
748
|
+
const node = await findNodeByMatcher(matcher)
|
|
749
|
+
if (!node)
|
|
750
|
+
throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
|
|
751
|
+
|
|
752
|
+
const page = getPage()
|
|
753
|
+
const { absolutePosition, layout } = node
|
|
754
|
+
|
|
755
|
+
// start from center
|
|
756
|
+
const startSootX = absolutePosition.x + layout.width * 0.5
|
|
757
|
+
const startSootY = absolutePosition.y + layout.height * 0.5
|
|
758
|
+
|
|
759
|
+
// compute distance
|
|
760
|
+
let endSootX = startSootX
|
|
761
|
+
let endSootY = startSootY
|
|
762
|
+
const dist =
|
|
763
|
+
direction === 'up' || direction === 'down'
|
|
764
|
+
? layout.height * percentage
|
|
765
|
+
: layout.width * percentage
|
|
766
|
+
|
|
767
|
+
switch (direction) {
|
|
768
|
+
case 'up':
|
|
769
|
+
endSootY -= dist
|
|
770
|
+
break
|
|
771
|
+
case 'down':
|
|
772
|
+
endSootY += dist
|
|
773
|
+
break
|
|
774
|
+
case 'left':
|
|
775
|
+
endSootX -= dist
|
|
776
|
+
break
|
|
777
|
+
case 'right':
|
|
778
|
+
endSootX += dist
|
|
779
|
+
break
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const steps = speed === 'slow' ? 20 : speed === 'fast' ? 8 : 12
|
|
783
|
+
const stepDelay = speed === 'slow' ? 25 : speed === 'fast' ? 5 : 15
|
|
784
|
+
|
|
785
|
+
await page.evaluate(
|
|
786
|
+
async ({ fromX, fromY, toX, toY, steps, stepMs }) => {
|
|
787
|
+
const drag = window.__sootsimTest?.drag
|
|
788
|
+
if (typeof drag !== 'function') {
|
|
789
|
+
throw new Error('sootsim drag bridge is not installed')
|
|
790
|
+
}
|
|
791
|
+
await drag(fromX, fromY, toX, toY, steps, stepMs)
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
fromX: startSootX,
|
|
795
|
+
fromY: startSootY,
|
|
796
|
+
toX: endSootX,
|
|
797
|
+
toY: endSootY,
|
|
798
|
+
steps,
|
|
799
|
+
stepMs: stepDelay,
|
|
800
|
+
},
|
|
801
|
+
)
|
|
802
|
+
await page.waitForTimeout(100)
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
async pinch(scale: number, speed: 'fast' | 'slow' = 'fast', angle: number = 0) {
|
|
806
|
+
// detox iOS pinch: scale > 1 spreads fingers (zoom in), scale < 1
|
|
807
|
+
// brings them together (zoom out). speed maps to step delay so RNGH's
|
|
808
|
+
// velocity tracking sees a realistic delta. angle (radians) tilts the
|
|
809
|
+
// pinch axis off horizontal — matches Detox's third arg.
|
|
810
|
+
const page = getPage()
|
|
811
|
+
const node = await findNodeByMatcher(matcher)
|
|
812
|
+
if (!node)
|
|
813
|
+
throw new Error(`element not found for pinch: ${JSON.stringify(matcher)}`)
|
|
814
|
+
await waitForSootsimGestureHandler(page, node.nodeId, 'PinchGestureHandler')
|
|
815
|
+
const center = getNodeCenter(node)
|
|
816
|
+
const centerPage = await sootsimToPage(page, center.x, center.y)
|
|
817
|
+
const startSpread = 60
|
|
818
|
+
const endSpread = startSpread * Math.max(scale, 0.05)
|
|
819
|
+
const cos = Math.cos(angle)
|
|
820
|
+
const sin = Math.sin(angle)
|
|
821
|
+
const offset = (d: number) => ({ dx: d * cos, dy: d * sin })
|
|
822
|
+
const a0 = offset(-startSpread)
|
|
823
|
+
const b0 = offset(startSpread)
|
|
824
|
+
const a1 = offset(-endSpread)
|
|
825
|
+
const b1 = offset(endSpread)
|
|
826
|
+
await dispatchTwoFingerGesture(
|
|
827
|
+
page,
|
|
828
|
+
{ x: centerPage.x + a0.dx, y: centerPage.y + a0.dy },
|
|
829
|
+
{ x: centerPage.x + b0.dx, y: centerPage.y + b0.dy },
|
|
830
|
+
{ x: centerPage.x + a1.dx, y: centerPage.y + a1.dy },
|
|
831
|
+
{ x: centerPage.x + b1.dx, y: centerPage.y + b1.dy },
|
|
832
|
+
{ steps: speed === 'fast' ? 4 : 12, stepDelayMs: speed === 'fast' ? 18 : 24 },
|
|
833
|
+
)
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
|
|
837
|
+
// two fingers held on a horizontal axis around the node center, then
|
|
838
|
+
// both rotated to `radians` around the same center. positive radians
|
|
839
|
+
// rotate counter-clockwise to match RNGH RotationGesture's reported
|
|
840
|
+
// sign (its 'rotation' delta is positive for a counter-clockwise
|
|
841
|
+
// motion of the rear finger relative to the front finger).
|
|
842
|
+
const page = getPage()
|
|
843
|
+
const node = await findNodeByMatcher(matcher)
|
|
844
|
+
if (!node)
|
|
845
|
+
throw new Error(`element not found for rotate: ${JSON.stringify(matcher)}`)
|
|
846
|
+
await waitForSootsimGestureHandler(page, node.nodeId, 'RotationGestureHandler')
|
|
847
|
+
const center = getNodeCenter(node)
|
|
848
|
+
const centerPage = await sootsimToPage(page, center.x, center.y)
|
|
849
|
+
const radius = 60
|
|
850
|
+
const startA = { x: centerPage.x - radius, y: centerPage.y }
|
|
851
|
+
const startB = { x: centerPage.x + radius, y: centerPage.y }
|
|
852
|
+
const cos = Math.cos(radians)
|
|
853
|
+
const sin = Math.sin(radians)
|
|
854
|
+
const endA = {
|
|
855
|
+
x: centerPage.x - radius * cos,
|
|
856
|
+
y: centerPage.y - radius * sin,
|
|
857
|
+
}
|
|
858
|
+
const endB = {
|
|
859
|
+
x: centerPage.x + radius * cos,
|
|
860
|
+
y: centerPage.y + radius * sin,
|
|
861
|
+
}
|
|
862
|
+
await dispatchTwoFingerGesture(page, startA, startB, endA, endB, {
|
|
863
|
+
steps: speed === 'fast' ? 10 : 20,
|
|
864
|
+
stepDelayMs: speed === 'fast' ? 14 : 24,
|
|
865
|
+
})
|
|
866
|
+
},
|
|
867
|
+
|
|
868
|
+
async takeScreenshot(name: string): Promise<string> {
|
|
869
|
+
const node = await findNodeByMatcher(matcher)
|
|
870
|
+
if (!node)
|
|
871
|
+
throw new Error(
|
|
872
|
+
`element not found for takeScreenshot: ${JSON.stringify(matcher)}`,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
return writeSootsimScreenshot(name, {
|
|
876
|
+
crop: {
|
|
877
|
+
x: node.absolutePosition.x,
|
|
878
|
+
y: node.absolutePosition.y,
|
|
879
|
+
w: node.layout.width,
|
|
880
|
+
h: node.layout.height,
|
|
881
|
+
},
|
|
882
|
+
})
|
|
883
|
+
},
|
|
884
|
+
|
|
885
|
+
async getAttributes(): Promise<any> {
|
|
886
|
+
const node = await findNodeByMatcher(matcher)
|
|
887
|
+
if (!node)
|
|
888
|
+
throw new Error(`element not found for getAttributes: ${JSON.stringify(matcher)}`)
|
|
889
|
+
return {
|
|
890
|
+
text: node.text || '',
|
|
891
|
+
label: node.accessibilityLabel || node.text || '',
|
|
892
|
+
identifier: node.testID || node.id || '',
|
|
893
|
+
visible: node.layout.width > 0 && node.layout.height > 0,
|
|
894
|
+
enabled: !node.accessibilityState?.disabled,
|
|
895
|
+
...node,
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
atIndex(_index: number) {
|
|
900
|
+
// sootsim resolves a testID to exactly one SootSimNode, so the
|
|
901
|
+
// detox-style atIndex is a passthrough. supplied for parity with
|
|
902
|
+
// real Detox so library tests can wrap atIndex around matchers
|
|
903
|
+
// that surface multiple native views on iOS (e.g. RNGH RectButton)
|
|
904
|
+
// without diverging code paths between the two lanes.
|
|
905
|
+
return el
|
|
906
|
+
},
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return el
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// element() factory -- takes a matcher and returns an element interaction object
|
|
913
|
+
export function element(matcher: Matcher): SootElement {
|
|
914
|
+
return createSootElement(matcher)
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// device object -- app lifecycle
|
|
918
|
+
export const device = {
|
|
919
|
+
_currentUrl: '',
|
|
920
|
+
_platform: DETOX_PLATFORM as 'ios' | 'android',
|
|
921
|
+
|
|
922
|
+
async launchApp(opts?: {
|
|
923
|
+
delete?: boolean
|
|
924
|
+
newInstance?: boolean
|
|
925
|
+
url?: string
|
|
926
|
+
launchArgs?: Record<string, any>
|
|
927
|
+
// when set, the new BrowserContext records video at this dir. used by
|
|
928
|
+
// captureProofMenuStages for the multi-stage animation diff path. must
|
|
929
|
+
// be set at launchApp time because playwright recordVideo can't be
|
|
930
|
+
// toggled on an existing context.
|
|
931
|
+
recordVideoDir?: string
|
|
932
|
+
}) {
|
|
933
|
+
if (!_browser) {
|
|
934
|
+
_browser = await chromium.launch({ headless: true })
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
|
|
938
|
+
await closeContext()
|
|
939
|
+
const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
|
|
940
|
+
hasTouch: true,
|
|
941
|
+
viewport: { width: 500, height: 900 },
|
|
942
|
+
}
|
|
943
|
+
if (opts?.recordVideoDir) {
|
|
944
|
+
fs.mkdirSync(opts.recordVideoDir, { recursive: true })
|
|
945
|
+
contextOpts.recordVideo = {
|
|
946
|
+
dir: opts.recordVideoDir,
|
|
947
|
+
size: { width: 500, height: 900 },
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
_context = await _browser.newContext(contextOpts)
|
|
951
|
+
_page = await _context.newPage()
|
|
952
|
+
attachPageDiagnostics(_page)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const url = opts?.url || BASE_URL
|
|
956
|
+
device._currentUrl = url
|
|
957
|
+
await _page!.goto(url, { waitUntil: 'load', timeout: 30000 })
|
|
958
|
+
|
|
959
|
+
await waitForSootsimTree(_page!, 30000)
|
|
960
|
+
// record the wall-clock time the page is ready — captureProofMenuStages
|
|
961
|
+
// uses this to compute trigger-in-video offsets
|
|
962
|
+
device._lastReadyAtMs = Date.now()
|
|
963
|
+
await _page!.waitForTimeout(500)
|
|
964
|
+
},
|
|
965
|
+
|
|
966
|
+
_lastReadyAtMs: 0,
|
|
967
|
+
|
|
968
|
+
async reloadReactNative() {
|
|
969
|
+
const page = getPage()
|
|
970
|
+
await page.reload({ waitUntil: 'load', timeout: 30000 })
|
|
971
|
+
await waitForSootsimTree(page, 30000)
|
|
972
|
+
await page.waitForTimeout(500)
|
|
973
|
+
},
|
|
974
|
+
|
|
975
|
+
async terminateApp() {
|
|
976
|
+
await closeBrowser()
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
async installApp() {
|
|
980
|
+
// no-op for sootsim -- app runs in browser
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
async uninstallApp() {
|
|
984
|
+
// no-op
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
async openURL(url: { url: string; sourceApp?: string }) {
|
|
988
|
+
const page = getPage()
|
|
989
|
+
await page.goto(url.url, { waitUntil: 'load', timeout: 30000 })
|
|
990
|
+
await waitForSootsimTree(page, 10000)
|
|
991
|
+
},
|
|
992
|
+
|
|
993
|
+
async takeScreenshot(name: string): Promise<string> {
|
|
994
|
+
return writeSootsimScreenshot(name)
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
// single-shot variant for multi-stage animation captures. skips the
|
|
998
|
+
// frame-stability poll so callers can sample mid-transition.
|
|
999
|
+
async takeScreenshotFast(name: string): Promise<string> {
|
|
1000
|
+
ensureScreenshotDir()
|
|
1001
|
+
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`)
|
|
1002
|
+
fs.writeFileSync(screenshotPath, await captureSootsimPngFast())
|
|
1003
|
+
return screenshotPath
|
|
1004
|
+
},
|
|
1005
|
+
|
|
1006
|
+
// record the SootSim canvas via the engine's headless recorder
|
|
1007
|
+
// (window.__sootsimRecorder — @sootsim/plugin-recording). this is the
|
|
1008
|
+
// SAME code path `bun sootsim record video` drives, NOT the rail-button
|
|
1009
|
+
// path (SootSim.bridges.startRecording wires the dialog state machine
|
|
1010
|
+
// and never stashes the resulting blob into __sootsimRecorder.lastBlob,
|
|
1011
|
+
// which is what getBlobBase64 streams from).
|
|
1012
|
+
//
|
|
1013
|
+
// unlike playwright's page-level recordVideo, the headless recorder
|
|
1014
|
+
// captures ONLY the device canvas (composited shell + tenant surfaces)
|
|
1015
|
+
// — no browser chrome, no menu bar, no device frame. uses the EXISTING
|
|
1016
|
+
// page/context so per-test state is preserved across capture.
|
|
1017
|
+
async startBridgeRecording(opts?: {
|
|
1018
|
+
durationMs?: number
|
|
1019
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
1020
|
+
fps?: number
|
|
1021
|
+
}): Promise<void> {
|
|
1022
|
+
const page = getPage()
|
|
1023
|
+
const startOpts = {
|
|
1024
|
+
format: 'webm' as const,
|
|
1025
|
+
fps: opts?.fps ?? 60,
|
|
1026
|
+
layers: opts?.layers ?? 'tenant',
|
|
1027
|
+
durationMs: opts?.durationMs ?? 5000,
|
|
1028
|
+
}
|
|
1029
|
+
const result = await page.evaluate(async (startOpts) => {
|
|
1030
|
+
const rec = (
|
|
1031
|
+
window as {
|
|
1032
|
+
__sootsimRecorder?: {
|
|
1033
|
+
start?: (
|
|
1034
|
+
o: unknown,
|
|
1035
|
+
) => Promise<{ ok: boolean; error?: string; format?: string }>
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
).__sootsimRecorder
|
|
1039
|
+
const start = rec?.start
|
|
1040
|
+
if (typeof start !== 'function') {
|
|
1041
|
+
return { ok: false, error: '__sootsimRecorder.start not installed' }
|
|
1042
|
+
}
|
|
1043
|
+
return start(startOpts)
|
|
1044
|
+
}, startOpts)
|
|
1045
|
+
if (!result.ok) {
|
|
1046
|
+
throw new Error(`startBridgeRecording failed: ${result.error ?? 'unknown'}`)
|
|
1047
|
+
}
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
// stop the headless recorder, stream the resulting blob in chunks (same
|
|
1051
|
+
// protocol as `bun sootsim record`), and write the webm to outPath.
|
|
1052
|
+
async stopAndSaveBridgeRecording(outPath: string): Promise<string> {
|
|
1053
|
+
const page = getPage()
|
|
1054
|
+
const stopResult = await page.evaluate(async () => {
|
|
1055
|
+
const rec = (
|
|
1056
|
+
window as {
|
|
1057
|
+
__sootsimRecorder?: {
|
|
1058
|
+
stop?: () => Promise<{
|
|
1059
|
+
ok: boolean
|
|
1060
|
+
error?: string
|
|
1061
|
+
size?: number
|
|
1062
|
+
mime?: string
|
|
1063
|
+
durationMs?: number
|
|
1064
|
+
}>
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
).__sootsimRecorder
|
|
1068
|
+
const stop = rec?.stop
|
|
1069
|
+
if (typeof stop !== 'function') {
|
|
1070
|
+
return { ok: false, error: '__sootsimRecorder.stop not installed' }
|
|
1071
|
+
}
|
|
1072
|
+
return stop()
|
|
1073
|
+
})
|
|
1074
|
+
if (!stopResult.ok) {
|
|
1075
|
+
throw new Error(`stopBridgeRecording failed: ${stopResult.error ?? 'unknown'}`)
|
|
1076
|
+
}
|
|
1077
|
+
if (!stopResult.size) {
|
|
1078
|
+
throw new Error('stopBridgeRecording: recorder returned empty blob')
|
|
1079
|
+
}
|
|
1080
|
+
const chunks: Buffer[] = []
|
|
1081
|
+
let offset = 0
|
|
1082
|
+
const CHUNK = 2 * 1024 * 1024
|
|
1083
|
+
while (true) {
|
|
1084
|
+
const result: {
|
|
1085
|
+
data: string
|
|
1086
|
+
size: number
|
|
1087
|
+
offset: number
|
|
1088
|
+
done: boolean
|
|
1089
|
+
mime: string
|
|
1090
|
+
} | null = await page.evaluate(
|
|
1091
|
+
({ offset, chunk }) => {
|
|
1092
|
+
const rec = (window as { __sootsimRecorder?: { getBlobBase64?: unknown } })
|
|
1093
|
+
.__sootsimRecorder
|
|
1094
|
+
const get = rec?.getBlobBase64 as
|
|
1095
|
+
| ((args: { offset: number; chunk: number }) => Promise<{
|
|
1096
|
+
data: string
|
|
1097
|
+
size: number
|
|
1098
|
+
offset: number
|
|
1099
|
+
done: boolean
|
|
1100
|
+
mime: string
|
|
1101
|
+
} | null>)
|
|
1102
|
+
| undefined
|
|
1103
|
+
if (typeof get !== 'function') return null
|
|
1104
|
+
return get({ offset, chunk })
|
|
1105
|
+
},
|
|
1106
|
+
{ offset, chunk: CHUNK },
|
|
1107
|
+
)
|
|
1108
|
+
if (!result) throw new Error('SootSim recorder produced no blob')
|
|
1109
|
+
chunks.push(Buffer.from(result.data, 'base64'))
|
|
1110
|
+
offset = result.offset
|
|
1111
|
+
if (result.done) break
|
|
1112
|
+
}
|
|
1113
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true })
|
|
1114
|
+
fs.writeFileSync(outPath, Buffer.concat(chunks))
|
|
1115
|
+
;(this as { _lastRecordingPath?: string })._lastRecordingPath = outPath
|
|
1116
|
+
return outPath
|
|
1117
|
+
},
|
|
1118
|
+
|
|
1119
|
+
_lastRecordingPath: '' as string,
|
|
1120
|
+
|
|
1121
|
+
// set the engine's ContextMenu animation-progress override. visual-proof
|
|
1122
|
+
// harnesses use this to drive each capture stage to a known animProgress
|
|
1123
|
+
// (matched against the iOS oracle's curve) instead of fighting capture
|
|
1124
|
+
// latency vs. sub-second spring timing. pass null to clear.
|
|
1125
|
+
//
|
|
1126
|
+
// dispatches a host CustomEvent that the engine forwards to the tenant
|
|
1127
|
+
// worker (see shell-event-registry.ts + input-bridge.ts handleShellEvent).
|
|
1128
|
+
// the worker writes globalThis.__sootsimMenuAnimOverride which the engine
|
|
1129
|
+
// ContextMenu reads each rAF tick.
|
|
1130
|
+
async setMenuAnimOverride(progress: number | null): Promise<void> {
|
|
1131
|
+
// dispatched from the playwright host; the typed `dispatchSootsimEvent`
|
|
1132
|
+
// helper lives in the engine bundle and isn't reachable from outside
|
|
1133
|
+
// the page. keep this host-side wrapper typed against SootsimEventMap so
|
|
1134
|
+
// the event names and payload shapes still come from the central registry.
|
|
1135
|
+
await dispatchSootsimEventInPage('sootsim:menuAnimOverride', { value: progress })
|
|
1136
|
+
},
|
|
1137
|
+
|
|
1138
|
+
async setBottomTabAnimOverride(
|
|
1139
|
+
value: { fromIndex: number; progress: number; toIndex: number } | null,
|
|
1140
|
+
): Promise<void> {
|
|
1141
|
+
await dispatchSootsimEventInPage('sootsim:bottomTabAnimOverride', { value })
|
|
1142
|
+
},
|
|
1143
|
+
|
|
1144
|
+
// FAST multi-stage capture for sub-second animation diffs. each stage
|
|
1145
|
+
// schedules a setTimeout at its requested offsetMs and at that moment
|
|
1146
|
+
// SYNCHRONOUSLY snapshots the engine's `liveComposite` host-side canvas
|
|
1147
|
+
// (drawImage from the worker-transferred home + overlay canvases). PNG
|
|
1148
|
+
// encoding is queued after all snapshots land — encoding stalls don't
|
|
1149
|
+
// contaminate the animation window.
|
|
1150
|
+
//
|
|
1151
|
+
// unlike `takeScreenshotStages`, this does NOT go through screenshot()
|
|
1152
|
+
// (which calls forceRenderAll + composite + toDataURL serially at
|
|
1153
|
+
// 300-1300ms per call and blows the spring-open timing). the worker's
|
|
1154
|
+
// vsync-pumped rAF keeps the home canvas fresh; we just sample it.
|
|
1155
|
+
//
|
|
1156
|
+
// returns paths in stage order; actualMs reports when the snapshot
|
|
1157
|
+
// actually fired (typically within a few ms of offsetMs).
|
|
1158
|
+
async takeScreenshotStagesFast(
|
|
1159
|
+
namePrefix: string,
|
|
1160
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1161
|
+
): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
|
|
1162
|
+
const page = getPage()
|
|
1163
|
+
const frames: Array<{
|
|
1164
|
+
label: string
|
|
1165
|
+
offsetMs: number
|
|
1166
|
+
actualMs: number
|
|
1167
|
+
dataUrl: string
|
|
1168
|
+
}> = await page.evaluate(
|
|
1169
|
+
async (input) => {
|
|
1170
|
+
const sim = window as {
|
|
1171
|
+
SootSim?: {
|
|
1172
|
+
bridges?: {
|
|
1173
|
+
screenshot?: (opts?: unknown) => Promise<string>
|
|
1174
|
+
liveComposite?: {
|
|
1175
|
+
captureFresh: () => Promise<{
|
|
1176
|
+
canvas: HTMLCanvasElement | null
|
|
1177
|
+
frameToken: number
|
|
1178
|
+
}>
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const screenshot = sim.SootSim?.bridges?.screenshot
|
|
1184
|
+
const live = sim.SootSim?.bridges?.liveComposite
|
|
1185
|
+
if (typeof screenshot !== 'function') {
|
|
1186
|
+
throw new Error('SootSim screenshot bridge is not installed')
|
|
1187
|
+
}
|
|
1188
|
+
// initial warm-up: ensure the worker has produced at least one
|
|
1189
|
+
// frame after the menu's portal mount before we start sampling.
|
|
1190
|
+
// (a pump at higher cadence was tried and starved tap propagation —
|
|
1191
|
+
// the tenant React commit for setOpen never landed between
|
|
1192
|
+
// forceRender calls.)
|
|
1193
|
+
await screenshot()
|
|
1194
|
+
const start = performance.now()
|
|
1195
|
+
type Snapshot = {
|
|
1196
|
+
label: string
|
|
1197
|
+
offsetMs: number
|
|
1198
|
+
actualMs: number
|
|
1199
|
+
dataUrl: string | null
|
|
1200
|
+
}
|
|
1201
|
+
// each stage fires at its requested offset and SYNCHRONOUSLY
|
|
1202
|
+
// snapshots the engine's host-side composite canvas (drawImage
|
|
1203
|
+
// from the worker-transferred home canvas + overlay). encoding
|
|
1204
|
+
// happens after — encoding stalls don't disturb the next stage's
|
|
1205
|
+
// capture moment.
|
|
1206
|
+
const snaps: Snapshot[] = await Promise.all(
|
|
1207
|
+
input.stages.map(
|
|
1208
|
+
(stage) =>
|
|
1209
|
+
new Promise<Snapshot>((resolve) => {
|
|
1210
|
+
const fire = async () => {
|
|
1211
|
+
const actualMs = performance.now() - start
|
|
1212
|
+
try {
|
|
1213
|
+
let dataUrl: string | null = null
|
|
1214
|
+
if (live && typeof live.captureFresh === 'function') {
|
|
1215
|
+
// synchronous paint of current canvas state — no
|
|
1216
|
+
// worker round-trip. the background pump keeps the
|
|
1217
|
+
// canvas fresh.
|
|
1218
|
+
const { canvas } = await live.captureFresh()
|
|
1219
|
+
if (canvas) {
|
|
1220
|
+
const bmp = await createImageBitmap(canvas)
|
|
1221
|
+
const enc = document.createElement('canvas')
|
|
1222
|
+
enc.width = bmp.width
|
|
1223
|
+
enc.height = bmp.height
|
|
1224
|
+
const ctx = enc.getContext('2d')
|
|
1225
|
+
if (ctx) {
|
|
1226
|
+
ctx.drawImage(bmp, 0, 0)
|
|
1227
|
+
dataUrl = enc.toDataURL('image/png')
|
|
1228
|
+
}
|
|
1229
|
+
bmp.close()
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
if (!dataUrl) {
|
|
1233
|
+
dataUrl = await screenshot()
|
|
1234
|
+
}
|
|
1235
|
+
resolve({
|
|
1236
|
+
label: stage.label,
|
|
1237
|
+
offsetMs: stage.offsetMs,
|
|
1238
|
+
actualMs,
|
|
1239
|
+
dataUrl,
|
|
1240
|
+
})
|
|
1241
|
+
} catch {
|
|
1242
|
+
resolve({
|
|
1243
|
+
label: stage.label,
|
|
1244
|
+
offsetMs: stage.offsetMs,
|
|
1245
|
+
actualMs,
|
|
1246
|
+
dataUrl: null,
|
|
1247
|
+
})
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
setTimeout(
|
|
1251
|
+
() => {
|
|
1252
|
+
void fire()
|
|
1253
|
+
},
|
|
1254
|
+
Math.max(0, stage.offsetMs),
|
|
1255
|
+
)
|
|
1256
|
+
}),
|
|
1257
|
+
),
|
|
1258
|
+
)
|
|
1259
|
+
const out: Array<{
|
|
1260
|
+
label: string
|
|
1261
|
+
offsetMs: number
|
|
1262
|
+
actualMs: number
|
|
1263
|
+
dataUrl: string
|
|
1264
|
+
}> = []
|
|
1265
|
+
for (const snap of snaps) {
|
|
1266
|
+
if (!snap.dataUrl) continue
|
|
1267
|
+
out.push({
|
|
1268
|
+
label: snap.label,
|
|
1269
|
+
offsetMs: snap.offsetMs,
|
|
1270
|
+
actualMs: snap.actualMs,
|
|
1271
|
+
dataUrl: snap.dataUrl,
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
return out
|
|
1275
|
+
},
|
|
1276
|
+
{ stages },
|
|
1277
|
+
)
|
|
1278
|
+
ensureScreenshotDir()
|
|
1279
|
+
return frames.map((f) => {
|
|
1280
|
+
const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
1281
|
+
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
1282
|
+
fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
|
|
1283
|
+
return {
|
|
1284
|
+
label: f.label,
|
|
1285
|
+
offsetMs: f.offsetMs,
|
|
1286
|
+
actualMs: f.actualMs,
|
|
1287
|
+
path: filePath,
|
|
1288
|
+
}
|
|
1289
|
+
})
|
|
1290
|
+
},
|
|
1291
|
+
|
|
1292
|
+
// batched multi-stage capture for animation diffs. all N captures happen
|
|
1293
|
+
// inside one page.evaluate round trip so browser-side timing controls the
|
|
1294
|
+
// offsets — sidesteps the ~300ms per-call bridge overhead that blows
|
|
1295
|
+
// sub-second animation windows. each stage gets a screenshot at the
|
|
1296
|
+
// requested offsetMs (from when the evaluate starts, i.e. right after
|
|
1297
|
+
// trigger() returns). returns absolute paths in stage order.
|
|
1298
|
+
async takeScreenshotStages(
|
|
1299
|
+
namePrefix: string,
|
|
1300
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1301
|
+
): Promise<Array<{ label: string; offsetMs: number; actualMs: number; path: string }>> {
|
|
1302
|
+
const page = getPage()
|
|
1303
|
+
const frames: Array<{
|
|
1304
|
+
label: string
|
|
1305
|
+
offsetMs: number
|
|
1306
|
+
actualMs: number
|
|
1307
|
+
dataUrl: string
|
|
1308
|
+
}> = await page.evaluate(
|
|
1309
|
+
async (input) => {
|
|
1310
|
+
const screenshot = (
|
|
1311
|
+
window as { SootSim?: { bridges?: { screenshot?: unknown } } }
|
|
1312
|
+
).SootSim?.bridges?.screenshot as
|
|
1313
|
+
| ((opts?: unknown) => Promise<string>)
|
|
1314
|
+
| undefined
|
|
1315
|
+
if (typeof screenshot !== 'function') {
|
|
1316
|
+
throw new Error('sootsim screenshot bridge is not installed')
|
|
1317
|
+
}
|
|
1318
|
+
const start = Date.now()
|
|
1319
|
+
const out: Array<{
|
|
1320
|
+
label: string
|
|
1321
|
+
offsetMs: number
|
|
1322
|
+
actualMs: number
|
|
1323
|
+
dataUrl: string
|
|
1324
|
+
}> = []
|
|
1325
|
+
for (const stage of input.stages) {
|
|
1326
|
+
const elapsed = Date.now() - start
|
|
1327
|
+
const wait = Math.max(0, stage.offsetMs - elapsed)
|
|
1328
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
|
1329
|
+
const actualMs = Date.now() - start
|
|
1330
|
+
const dataUrl = await screenshot()
|
|
1331
|
+
out.push({ label: stage.label, offsetMs: stage.offsetMs, actualMs, dataUrl })
|
|
1332
|
+
}
|
|
1333
|
+
return out
|
|
1334
|
+
},
|
|
1335
|
+
{ stages },
|
|
1336
|
+
)
|
|
1337
|
+
ensureScreenshotDir()
|
|
1338
|
+
return frames.map((f) => {
|
|
1339
|
+
const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
1340
|
+
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
1341
|
+
fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
|
|
1342
|
+
return {
|
|
1343
|
+
label: f.label,
|
|
1344
|
+
offsetMs: f.offsetMs,
|
|
1345
|
+
actualMs: f.actualMs,
|
|
1346
|
+
path: filePath,
|
|
1347
|
+
}
|
|
1348
|
+
})
|
|
1349
|
+
},
|
|
1350
|
+
|
|
1351
|
+
async shake() {
|
|
1352
|
+
// no-op for sootsim
|
|
1353
|
+
},
|
|
1354
|
+
|
|
1355
|
+
async setLocation(lat: number, lon: number) {
|
|
1356
|
+
// no-op
|
|
1357
|
+
},
|
|
1358
|
+
|
|
1359
|
+
async setStatusBar(config: StatusBarConfig) {
|
|
1360
|
+
await dispatchStatusBarOverride(config)
|
|
1361
|
+
},
|
|
1362
|
+
|
|
1363
|
+
async setURLBlacklist(urls: string[]) {
|
|
1364
|
+
// no-op
|
|
1365
|
+
},
|
|
1366
|
+
|
|
1367
|
+
async enableSynchronization() {
|
|
1368
|
+
_synchronizationEnabled = true
|
|
1369
|
+
},
|
|
1370
|
+
|
|
1371
|
+
async disableSynchronization() {
|
|
1372
|
+
_synchronizationEnabled = false
|
|
1373
|
+
},
|
|
1374
|
+
|
|
1375
|
+
getPlatform(): 'ios' | 'android' {
|
|
1376
|
+
return device._platform
|
|
1377
|
+
},
|
|
1378
|
+
|
|
1379
|
+
async pressBack() {
|
|
1380
|
+
if (device._platform !== 'android') return
|
|
1381
|
+
await element(by.id('android-nav-back')).tap()
|
|
1382
|
+
},
|
|
1383
|
+
|
|
1384
|
+
// raw coordinate tap, bypassing the element-based visibility/hit-test path.
|
|
1385
|
+
// mirrors detox 20.x device.tap({ x, y }) on real iOS — used when the
|
|
1386
|
+
// target is occluded by a native UIWindow (e.g. UIMenu's dim layer) so
|
|
1387
|
+
// `element(...).tap({x,y})` would fail Detox's visibility threshold.
|
|
1388
|
+
async tap(point?: { x: number; y: number } | boolean) {
|
|
1389
|
+
if (point == null || typeof point === 'boolean') return
|
|
1390
|
+
const page = getPage()
|
|
1391
|
+
const { x, y } = point
|
|
1392
|
+
await page.evaluate(
|
|
1393
|
+
async ({ x: tx, y: ty }) => {
|
|
1394
|
+
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
1395
|
+
if (typeof interactTap === 'function') {
|
|
1396
|
+
const result = await interactTap(tx, ty)
|
|
1397
|
+
if (result && (typeof result !== 'object' || result.hit !== false)) return
|
|
1398
|
+
}
|
|
1399
|
+
const tap = window.__sootsimTest?.tap
|
|
1400
|
+
if (typeof tap !== 'function') {
|
|
1401
|
+
throw new Error('sootsim tap bridge is not installed')
|
|
1402
|
+
}
|
|
1403
|
+
await tap(tx, ty)
|
|
1404
|
+
},
|
|
1405
|
+
{ x, y },
|
|
1406
|
+
)
|
|
1407
|
+
await page.waitForTimeout(50)
|
|
1408
|
+
if (_synchronizationEnabled) {
|
|
1409
|
+
await waitForSootsimIdle(page)
|
|
1410
|
+
}
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1413
|
+
async sendToHome() {
|
|
1414
|
+
// no-op
|
|
1415
|
+
},
|
|
1416
|
+
|
|
1417
|
+
// get direct access to the playwright page for advanced use
|
|
1418
|
+
getPage(): Page {
|
|
1419
|
+
return getPage()
|
|
1420
|
+
},
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// exports matching detox API
|
|
1424
|
+
const sootExpect = createExpect(findNodeByMatcher, getPage)
|
|
1425
|
+
const sootWaitFor = createWaitFor(findNodeByMatcher, getPage)
|
|
1426
|
+
|
|
1427
|
+
export { sootExpect as expect, sootWaitFor as waitFor }
|
|
1428
|
+
|
|
1429
|
+
// cleanup -- call this in afterAll
|
|
1430
|
+
export async function cleanup() {
|
|
1431
|
+
if (_browser) {
|
|
1432
|
+
await _browser.close()
|
|
1433
|
+
_browser = null
|
|
1434
|
+
_page = null
|
|
1435
|
+
}
|
|
1436
|
+
}
|