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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// parse detox configuration from various locations
|
|
2
|
+
// maps detox device config to sootsim settings
|
|
3
|
+
|
|
4
|
+
import * as fs from 'fs'
|
|
5
|
+
import * as path from 'path'
|
|
6
|
+
|
|
7
|
+
export interface DetoxConfig {
|
|
8
|
+
testRunner?: {
|
|
9
|
+
args?: Record<string, any>
|
|
10
|
+
jest?: { setupTimeout?: number; teardownTimeout?: number }
|
|
11
|
+
}
|
|
12
|
+
apps?: Record<string, { type: string; binaryPath?: string; build?: string }>
|
|
13
|
+
devices?: Record<string, { type: string; device?: { type?: string } }>
|
|
14
|
+
configurations?: Record<string, { device?: string; app?: string }>
|
|
15
|
+
testMatch?: string[]
|
|
16
|
+
testDir?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SootSimDetoxConfig {
|
|
20
|
+
testFiles: string[]
|
|
21
|
+
testTimeout: number
|
|
22
|
+
sootsimUrl: string
|
|
23
|
+
deviceModel: string
|
|
24
|
+
maxWorkers: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// search order: .detoxrc.js, .detoxrc.json, detox.config.js, detox.config.json, package.json#detox
|
|
28
|
+
export function loadDetoxConfig(projectDir: string): DetoxConfig | null {
|
|
29
|
+
const candidates = [
|
|
30
|
+
'.detoxrc.js',
|
|
31
|
+
'.detoxrc.json',
|
|
32
|
+
'detox.config.js',
|
|
33
|
+
'detox.config.json',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
for (const file of candidates) {
|
|
37
|
+
const filePath = path.join(projectDir, file)
|
|
38
|
+
if (fs.existsSync(filePath)) {
|
|
39
|
+
try {
|
|
40
|
+
if (file.endsWith('.json')) {
|
|
41
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
42
|
+
}
|
|
43
|
+
// for JS files, we can't easily require them without running them
|
|
44
|
+
// return a basic config indicating file was found
|
|
45
|
+
return { testDir: 'e2e' }
|
|
46
|
+
} catch {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// check package.json
|
|
53
|
+
const pkgPath = path.join(projectDir, 'package.json')
|
|
54
|
+
if (fs.existsSync(pkgPath)) {
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
57
|
+
if (pkg.detox) return pkg.detox
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// find test files matching detox patterns
|
|
65
|
+
export function findDetoxTestFiles(
|
|
66
|
+
projectDir: string,
|
|
67
|
+
config: DetoxConfig | null,
|
|
68
|
+
): string[] {
|
|
69
|
+
const testDirs = ['e2e', 'test/e2e', 'detox', '__tests__/e2e']
|
|
70
|
+
const patterns = [
|
|
71
|
+
'.test.ts',
|
|
72
|
+
'.test.js',
|
|
73
|
+
'.test.tsx',
|
|
74
|
+
'.test.jsx',
|
|
75
|
+
'.spec.ts',
|
|
76
|
+
'.spec.js',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
// if config specifies a test dir, prioritize it
|
|
80
|
+
if (config?.testDir) {
|
|
81
|
+
testDirs.unshift(config.testDir)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const files: string[] = []
|
|
85
|
+
|
|
86
|
+
for (const dir of testDirs) {
|
|
87
|
+
const fullDir = path.join(projectDir, dir)
|
|
88
|
+
if (!fs.existsSync(fullDir)) continue
|
|
89
|
+
|
|
90
|
+
const entries = fs.readdirSync(fullDir, { recursive: true })
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const entryStr = String(entry)
|
|
93
|
+
if (patterns.some((p) => entryStr.endsWith(p))) {
|
|
94
|
+
files.push(path.join(fullDir, entryStr))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return files
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// map detox device type to sootsim device model
|
|
103
|
+
export function mapDetoxDevice(config: DetoxConfig | null): string {
|
|
104
|
+
if (!config?.devices) return 'iphone-15-pro'
|
|
105
|
+
|
|
106
|
+
const devices = Object.values(config.devices)
|
|
107
|
+
for (const dev of devices) {
|
|
108
|
+
const type = dev.device?.type?.toLowerCase() || ''
|
|
109
|
+
if (type.includes('se')) return 'iphone-se'
|
|
110
|
+
if (type.includes('16 pro max') || type.includes('16-pro-max'))
|
|
111
|
+
return 'iphone-16-pro-max'
|
|
112
|
+
if (type.includes('16 pro') || type.includes('16-pro')) return 'iphone-16-pro'
|
|
113
|
+
if (type.includes('16')) return 'iphone-16'
|
|
114
|
+
if (type.includes('15 pro max') || type.includes('15-pro-max'))
|
|
115
|
+
return 'iphone-15-pro-max'
|
|
116
|
+
if (type.includes('15 pro') || type.includes('15-pro')) return 'iphone-15-pro'
|
|
117
|
+
if (type.includes('15')) return 'iphone-15'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return 'iphone-15-pro'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// generate a sootsim-compatible config from detox config
|
|
124
|
+
export function toSootSimConfig(
|
|
125
|
+
projectDir: string,
|
|
126
|
+
config: DetoxConfig | null,
|
|
127
|
+
): SootSimDetoxConfig {
|
|
128
|
+
return {
|
|
129
|
+
testFiles: findDetoxTestFiles(projectDir, config),
|
|
130
|
+
testTimeout: config?.testRunner?.jest?.setupTimeout || 120000,
|
|
131
|
+
sootsimUrl: process.env.SOOTSIM_URL || 'http://localhost:5173',
|
|
132
|
+
deviceModel: mapDetoxDevice(config),
|
|
133
|
+
maxWorkers: 1,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Matcher } from './matchers'
|
|
2
|
+
|
|
3
|
+
// element interaction object returned by element(matcher)
|
|
4
|
+
export interface SootSimElement {
|
|
5
|
+
// reference to the matcher for lazy evaluation
|
|
6
|
+
_matcher: Matcher
|
|
7
|
+
|
|
8
|
+
tap(point?: { x?: number; y?: number }): Promise<void>
|
|
9
|
+
multiTap(times: number): Promise<void>
|
|
10
|
+
longPress(duration?: number): Promise<void>
|
|
11
|
+
longPressAndDrag(
|
|
12
|
+
duration: number,
|
|
13
|
+
normalizedStartX: number,
|
|
14
|
+
normalizedStartY: number,
|
|
15
|
+
targetElement: SootSimElement,
|
|
16
|
+
normalizedEndX: number,
|
|
17
|
+
normalizedEndY: number,
|
|
18
|
+
speed?: string,
|
|
19
|
+
holdDuration?: number,
|
|
20
|
+
): Promise<void>
|
|
21
|
+
typeText(text: string): Promise<void>
|
|
22
|
+
replaceText(text: string): Promise<void>
|
|
23
|
+
clearText(): Promise<void>
|
|
24
|
+
scroll(pixels: number, direction: 'up' | 'down' | 'left' | 'right'): Promise<void>
|
|
25
|
+
scrollTo(edge: 'top' | 'bottom' | 'left' | 'right'): Promise<void>
|
|
26
|
+
swipe(
|
|
27
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
28
|
+
speed?: 'fast' | 'slow',
|
|
29
|
+
percentage?: number,
|
|
30
|
+
normalizedStartingPointX?: number,
|
|
31
|
+
normalizedStartingPointY?: number,
|
|
32
|
+
): Promise<void>
|
|
33
|
+
setColumnToValue(column: number, value: string): Promise<void>
|
|
34
|
+
pinch(scale: number, speed?: 'fast' | 'slow', angle?: number): Promise<void>
|
|
35
|
+
rotate(radians: number, speed?: 'fast' | 'slow'): Promise<void>
|
|
36
|
+
takeScreenshot(name: string): Promise<string>
|
|
37
|
+
getAttributes(): Promise<any>
|
|
38
|
+
atIndex(index: number): SootSimElement
|
|
39
|
+
}
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// detox-compatible expectations for sootsim
|
|
2
|
+
// expect(element).toExist(), .toBeVisible(), .toHaveText(), etc.
|
|
3
|
+
// waitFor(element).toExist().withTimeout(ms)
|
|
4
|
+
|
|
5
|
+
import { dragScrollNode } from './gestures'
|
|
6
|
+
import type { SootSimElement } from './element-types'
|
|
7
|
+
import type { Matcher } from './matchers'
|
|
8
|
+
import type { SootSimNodeInfo } from '@sootsim/globals'
|
|
9
|
+
import type { Page } from 'playwright'
|
|
10
|
+
|
|
11
|
+
type FindFn = (matcher: Matcher) => Promise<any>
|
|
12
|
+
type GetPageFn = () => Page
|
|
13
|
+
const DEFAULT_VISIBILITY_THRESHOLD = 0.75
|
|
14
|
+
|
|
15
|
+
// one scroll step on the container, reporting how far it actually travelled.
|
|
16
|
+
// the caller ends the search when the container stops moving, which is how
|
|
17
|
+
// detox ends a `whileElement(...).scroll()` on a device: the native scroll
|
|
18
|
+
// throws once it reaches the end of the content.
|
|
19
|
+
async function scrollMatcherElement(
|
|
20
|
+
page: Page,
|
|
21
|
+
findNode: FindFn,
|
|
22
|
+
matcher: Matcher,
|
|
23
|
+
pixels: number,
|
|
24
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
25
|
+
): Promise<number> {
|
|
26
|
+
const node = await findNode(matcher)
|
|
27
|
+
if (!node) {
|
|
28
|
+
throw new Error(`scroll container not found: ${JSON.stringify(matcher)}`)
|
|
29
|
+
}
|
|
30
|
+
const before = scrollOffsetOf(node)
|
|
31
|
+
await dragScrollNode(page, node, pixels, direction)
|
|
32
|
+
const after = scrollOffsetOf(await findNode(matcher))
|
|
33
|
+
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function scrollOffsetOf(node: any): { x: number; y: number } {
|
|
37
|
+
return { x: node?.scrollOffsetX ?? 0, y: node?.scrollOffsetY ?? 0 }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// how much of the node is actually on screen, as a fraction of its own area.
|
|
41
|
+
// the engine already intersects every node with each clipping ancestor and
|
|
42
|
+
// with the device surface when it builds `visibleFrame`, so that rect is the
|
|
43
|
+
// on-screen area and needs no further clipping here. intersecting it a second
|
|
44
|
+
// time against the interactive viewport (screen minus the home indicator band)
|
|
45
|
+
// used to fail views that were 84% and 93% on screen, because iOS draws the
|
|
46
|
+
// home indicator over app content rather than hiding it, and Detox's own
|
|
47
|
+
// visibility percentage on iOS is computed against the app window.
|
|
48
|
+
function visibleFraction(node: SootSimNodeInfo | null | undefined): number {
|
|
49
|
+
const totalArea = (node?.layout?.width ?? 0) * (node?.layout?.height ?? 0)
|
|
50
|
+
const frame = node?.visibleFrame
|
|
51
|
+
if (totalArea <= 0 || !frame) return 0
|
|
52
|
+
const visibleArea = Math.max(0, frame.width) * Math.max(0, frame.height)
|
|
53
|
+
return visibleArea / totalArea
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isNodeVisible(node: SootSimNodeInfo | null | undefined): boolean {
|
|
57
|
+
return visibleFraction(node) >= DEFAULT_VISIBILITY_THRESHOLD
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// the engine tags each rendered node with its id, so DOM focus is the source of
|
|
61
|
+
// truth for both the `expect` and `waitFor` forms of toBeFocused.
|
|
62
|
+
async function isNodeFocused(node: any, page: Page): Promise<boolean> {
|
|
63
|
+
const nodeId =
|
|
64
|
+
typeof node?.nodeId === 'number'
|
|
65
|
+
? node.nodeId
|
|
66
|
+
: typeof node?.id === 'number'
|
|
67
|
+
? node.id
|
|
68
|
+
: null
|
|
69
|
+
if (nodeId === null) return false
|
|
70
|
+
return page.evaluate((id) => {
|
|
71
|
+
const active = document.activeElement
|
|
72
|
+
return active?.getAttribute('data-sootsim-id') === String(id)
|
|
73
|
+
}, nodeId)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function describeNodeVisibility(node: SootSimNodeInfo | null | undefined): string {
|
|
77
|
+
if (!node) return 'not found'
|
|
78
|
+
const absX = node.absolutePosition?.x ?? node.layout?.x ?? 0
|
|
79
|
+
const absY = node.absolutePosition?.y ?? node.layout?.y ?? 0
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
layout: node.layout,
|
|
82
|
+
absolutePosition: node.absolutePosition ?? null,
|
|
83
|
+
frame: {
|
|
84
|
+
x: absX,
|
|
85
|
+
y: absY,
|
|
86
|
+
right: absX + (node.layout?.width ?? 0),
|
|
87
|
+
bottom: absY + (node.layout?.height ?? 0),
|
|
88
|
+
},
|
|
89
|
+
visibleFrame: node.visibleFrame ?? null,
|
|
90
|
+
visibleFraction: visibleFraction(node),
|
|
91
|
+
threshold: DEFAULT_VISIBILITY_THRESHOLD,
|
|
92
|
+
style: node.style ?? null,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// expect(element) returns an object with assertion methods
|
|
97
|
+
export function createExpect(findNode: FindFn, getPage: GetPageFn) {
|
|
98
|
+
return function sootsimExpect(el: SootSimElement): SootSimExpectation {
|
|
99
|
+
return new SootSimExpectation(el._matcher, findNode, getPage, false)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class SootSimExpectation {
|
|
104
|
+
private matcher: Matcher
|
|
105
|
+
private findNode: FindFn
|
|
106
|
+
private getPage: GetPageFn
|
|
107
|
+
private negated: boolean
|
|
108
|
+
|
|
109
|
+
constructor(matcher: Matcher, findNode: FindFn, getPage: GetPageFn, negated: boolean) {
|
|
110
|
+
this.matcher = matcher
|
|
111
|
+
this.findNode = findNode
|
|
112
|
+
this.getPage = getPage
|
|
113
|
+
this.negated = negated
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get not(): SootSimExpectation {
|
|
117
|
+
return new SootSimExpectation(
|
|
118
|
+
this.matcher,
|
|
119
|
+
this.findNode,
|
|
120
|
+
this.getPage,
|
|
121
|
+
!this.negated,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async toExist(): Promise<void> {
|
|
126
|
+
const node = await this.findNode(this.matcher)
|
|
127
|
+
const exists = node !== null && node !== undefined
|
|
128
|
+
if (this.negated) {
|
|
129
|
+
if (exists) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`expected element NOT to exist but it does: ${JSON.stringify(this.matcher)}`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
if (!exists) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`expected element to exist but it was not found: ${JSON.stringify(this.matcher)}`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async toBeVisible(): Promise<void> {
|
|
144
|
+
const node = await this.findNode(this.matcher)
|
|
145
|
+
const visible = isNodeVisible(node)
|
|
146
|
+
if (this.negated) {
|
|
147
|
+
if (visible) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`expected element NOT to be visible: ${JSON.stringify(this.matcher)}`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
if (!visible) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`expected element to be visible but it is ${describeNodeVisibility(node)}: ${JSON.stringify(this.matcher)}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async toBeNotVisible(): Promise<void> {
|
|
162
|
+
const node = await this.findNode(this.matcher)
|
|
163
|
+
const visible = isNodeVisible(node)
|
|
164
|
+
if (visible) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`expected element NOT to be visible: ${JSON.stringify(this.matcher)}`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async toHaveText(expectedText: string): Promise<void> {
|
|
172
|
+
const node = await this.findNode(this.matcher)
|
|
173
|
+
if (!node) {
|
|
174
|
+
throw new Error(`element not found for toHaveText: ${JSON.stringify(this.matcher)}`)
|
|
175
|
+
}
|
|
176
|
+
const actualText = node.text || ''
|
|
177
|
+
const matches = actualText === expectedText || actualText.includes(expectedText)
|
|
178
|
+
|
|
179
|
+
if (this.negated) {
|
|
180
|
+
if (matches) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`expected text NOT to be "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
if (!matches) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`expected text "${expectedText}" but got "${actualText}": ${JSON.stringify(this.matcher)}`,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async toHaveId(expectedId: string): Promise<void> {
|
|
195
|
+
const node = await this.findNode(this.matcher)
|
|
196
|
+
if (!node) {
|
|
197
|
+
throw new Error(`element not found for toHaveId: ${JSON.stringify(this.matcher)}`)
|
|
198
|
+
}
|
|
199
|
+
const hasId = node.testID === expectedId || node.id === expectedId
|
|
200
|
+
if (this.negated) {
|
|
201
|
+
if (hasId) throw new Error(`expected element NOT to have id "${expectedId}"`)
|
|
202
|
+
} else {
|
|
203
|
+
if (!hasId)
|
|
204
|
+
throw new Error(
|
|
205
|
+
`expected element to have id "${expectedId}" but got "${node.testID || node.id}"`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async toHaveLabel(expectedLabel: string): Promise<void> {
|
|
211
|
+
const node = await this.findNode(this.matcher)
|
|
212
|
+
if (!node) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`element not found for toHaveLabel: ${JSON.stringify(this.matcher)}`,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
// check explicit accessibilityLabel, then derived text content
|
|
218
|
+
const actual = node.accessibilityLabel || node.text || ''
|
|
219
|
+
const matches = actual === expectedLabel
|
|
220
|
+
if (this.negated) {
|
|
221
|
+
if (matches)
|
|
222
|
+
throw new Error(`expected label NOT to be "${expectedLabel}" but it was`)
|
|
223
|
+
} else {
|
|
224
|
+
if (!matches)
|
|
225
|
+
throw new Error(
|
|
226
|
+
`expected label "${expectedLabel}" but got "${actual}": ${JSON.stringify(this.matcher)}`,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async toHaveAccessibilityRole(expectedRole: string): Promise<void> {
|
|
232
|
+
const node = await this.findNode(this.matcher)
|
|
233
|
+
if (!node) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`element not found for toHaveAccessibilityRole: ${JSON.stringify(this.matcher)}`,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
const actual = node.accessibilityRole || ''
|
|
239
|
+
const matches = actual === expectedRole
|
|
240
|
+
if (this.negated) {
|
|
241
|
+
if (matches) throw new Error(`expected role NOT to be "${expectedRole}" but it was`)
|
|
242
|
+
} else {
|
|
243
|
+
if (!matches)
|
|
244
|
+
throw new Error(
|
|
245
|
+
`expected role "${expectedRole}" but got "${actual}": ${JSON.stringify(this.matcher)}`,
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async toBeEnabled(): Promise<void> {
|
|
251
|
+
const node = await this.findNode(this.matcher)
|
|
252
|
+
if (!node) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`element not found for toBeEnabled: ${JSON.stringify(this.matcher)}`,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
const disabled = node.accessibilityState?.disabled ?? false
|
|
258
|
+
if (this.negated) {
|
|
259
|
+
if (!disabled) throw new Error(`expected element to be disabled but it is enabled`)
|
|
260
|
+
} else {
|
|
261
|
+
if (disabled) throw new Error(`expected element to be enabled but it is disabled`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async toBeFocused(): Promise<void> {
|
|
266
|
+
const node = await this.findNode(this.matcher)
|
|
267
|
+
if (!node) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`element not found for toBeFocused: ${JSON.stringify(this.matcher)}`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const focused = await isNodeFocused(node, this.getPage())
|
|
273
|
+
|
|
274
|
+
if (this.negated) {
|
|
275
|
+
if (focused) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`expected element NOT to be focused: ${JSON.stringify(this.matcher)}`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
} else if (!focused) {
|
|
281
|
+
throw new Error(`expected element to be focused: ${JSON.stringify(this.matcher)}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async toHaveValue(value: string): Promise<void> {
|
|
286
|
+
const node = await this.findNode(this.matcher)
|
|
287
|
+
if (!node) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`element not found for toHaveValue: ${JSON.stringify(this.matcher)}`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
// value could be in text or props
|
|
293
|
+
const actual = node.text || ''
|
|
294
|
+
if (this.negated) {
|
|
295
|
+
if (actual.includes(value)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`expected element NOT to have value "${value}" but got "${actual}"`,
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
if (!actual.includes(value)) {
|
|
302
|
+
throw new Error(`expected element to have value "${value}" but got "${actual}"`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async toHaveSliderPosition(
|
|
308
|
+
normalizedPosition: number,
|
|
309
|
+
tolerance?: number,
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
// stub for slider tests -- sootsim doesn't have native sliders yet
|
|
312
|
+
await this.toExist()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async toHaveToggleValue(value: boolean): Promise<void> {
|
|
316
|
+
await this.toExist()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// waitFor(element) returns a chainable object that polls until the assertion passes
|
|
321
|
+
export function createWaitFor(findNode: FindFn, getPage: GetPageFn) {
|
|
322
|
+
return function sootsimWaitFor(el: SootSimElement): SootSimWaitForChain {
|
|
323
|
+
return new SootSimWaitForChain(el._matcher, findNode, getPage)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
class SootSimWaitForChain {
|
|
328
|
+
private matcher: Matcher
|
|
329
|
+
private findNode: FindFn
|
|
330
|
+
private getPage: GetPageFn
|
|
331
|
+
private assertionFn: (() => Promise<void>) | null = null
|
|
332
|
+
private _negated = false
|
|
333
|
+
|
|
334
|
+
constructor(matcher: Matcher, findNode: FindFn, getPage: GetPageFn) {
|
|
335
|
+
this.matcher = matcher
|
|
336
|
+
this.findNode = findNode
|
|
337
|
+
this.getPage = getPage
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
get not(): SootSimWaitForChain {
|
|
341
|
+
this._negated = true
|
|
342
|
+
return this
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
toExist(): SootSimWaitForAction {
|
|
346
|
+
this.assertionFn = async () => {
|
|
347
|
+
const node = await this.findNode(this.matcher)
|
|
348
|
+
const exists = node !== null && node !== undefined
|
|
349
|
+
if (this._negated ? exists : !exists) {
|
|
350
|
+
throw new Error(`waitFor toExist failed: ${JSON.stringify(this.matcher)}`)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
toBeVisible(): SootSimWaitForAction {
|
|
357
|
+
this.assertionFn = async () => {
|
|
358
|
+
const node = await this.findNode(this.matcher)
|
|
359
|
+
const visible = isNodeVisible(node)
|
|
360
|
+
if (this._negated ? visible : !visible) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`waitFor toBeVisible failed (${describeNodeVisibility(node)}): ${JSON.stringify(this.matcher)}`,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
toBeNotVisible(): SootSimWaitForAction {
|
|
370
|
+
this.assertionFn = async () => {
|
|
371
|
+
const node = await this.findNode(this.matcher)
|
|
372
|
+
const visible = isNodeVisible(node)
|
|
373
|
+
if (visible) {
|
|
374
|
+
throw new Error(`waitFor toBeNotVisible failed: ${JSON.stringify(this.matcher)}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
toHaveText(expectedText: string): SootSimWaitForAction {
|
|
381
|
+
this.assertionFn = async () => {
|
|
382
|
+
const node = await this.findNode(this.matcher)
|
|
383
|
+
if (!node)
|
|
384
|
+
throw new Error(
|
|
385
|
+
`waitFor toHaveText: element not found: ${JSON.stringify(this.matcher)}`,
|
|
386
|
+
)
|
|
387
|
+
const actual = node.text || ''
|
|
388
|
+
const matches = actual === expectedText || actual.includes(expectedText)
|
|
389
|
+
if (this._negated ? matches : !matches) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`waitFor toHaveText: expected "${expectedText}" but got "${actual}" ${JSON.stringify(this.matcher)}`,
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
toHaveValue(value: string): SootSimWaitForAction {
|
|
399
|
+
this.assertionFn = async () => {
|
|
400
|
+
const node = await this.findNode(this.matcher)
|
|
401
|
+
if (!node) throw new Error(`waitFor toHaveValue: element not found`)
|
|
402
|
+
const actual = node.text || ''
|
|
403
|
+
if (!actual.includes(value)) {
|
|
404
|
+
throw new Error(`waitFor toHaveValue: expected "${value}" in "${actual}"`)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
toBeFocused(): SootSimWaitForAction {
|
|
411
|
+
this.assertionFn = async () => {
|
|
412
|
+
const node = await this.findNode(this.matcher)
|
|
413
|
+
if (!node)
|
|
414
|
+
throw new Error(
|
|
415
|
+
`waitFor toBeFocused: element not found: ${JSON.stringify(this.matcher)}`,
|
|
416
|
+
)
|
|
417
|
+
const focused = await isNodeFocused(node, this.getPage())
|
|
418
|
+
if (this._negated ? focused : !focused) {
|
|
419
|
+
throw new Error(`waitFor toBeFocused failed: ${JSON.stringify(this.matcher)}`)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
class SootSimWaitForAction {
|
|
427
|
+
private assertionFn: () => Promise<void>
|
|
428
|
+
private findNode: FindFn
|
|
429
|
+
private getPage: GetPageFn
|
|
430
|
+
|
|
431
|
+
constructor(assertionFn: () => Promise<void>, findNode: FindFn, getPage: GetPageFn) {
|
|
432
|
+
this.assertionFn = assertionFn
|
|
433
|
+
this.findNode = findNode
|
|
434
|
+
this.getPage = getPage
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async withTimeout(ms: number): Promise<void> {
|
|
438
|
+
const start = Date.now()
|
|
439
|
+
const pollInterval = 100
|
|
440
|
+
let lastError: Error | null = null
|
|
441
|
+
|
|
442
|
+
while (Date.now() - start < ms) {
|
|
443
|
+
try {
|
|
444
|
+
await this.assertionFn()
|
|
445
|
+
return // assertion passed
|
|
446
|
+
} catch (e) {
|
|
447
|
+
lastError = e as Error
|
|
448
|
+
await new Promise((r) => setTimeout(r, pollInterval))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
throw lastError || new Error(`waitFor timed out after ${ms}ms`)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// if no withTimeout is called, just run the assertion once
|
|
456
|
+
async then(resolve: (v: void) => void, reject: (e: any) => void): Promise<void> {
|
|
457
|
+
try {
|
|
458
|
+
// default 5 second timeout
|
|
459
|
+
await this.withTimeout(5000)
|
|
460
|
+
resolve(undefined)
|
|
461
|
+
} catch (e) {
|
|
462
|
+
reject(e)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
whileElement(matcher: Matcher): SootSimWaitForScrollAction {
|
|
467
|
+
return new SootSimWaitForScrollAction(
|
|
468
|
+
this.assertionFn,
|
|
469
|
+
this.findNode,
|
|
470
|
+
this.getPage,
|
|
471
|
+
matcher,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
class SootSimWaitForScrollAction {
|
|
477
|
+
private assertionFn: () => Promise<void>
|
|
478
|
+
private findNode: FindFn
|
|
479
|
+
private getPage: GetPageFn
|
|
480
|
+
private matcher: Matcher
|
|
481
|
+
|
|
482
|
+
constructor(
|
|
483
|
+
assertionFn: () => Promise<void>,
|
|
484
|
+
findNode: FindFn,
|
|
485
|
+
getPage: GetPageFn,
|
|
486
|
+
matcher: Matcher,
|
|
487
|
+
) {
|
|
488
|
+
this.assertionFn = assertionFn
|
|
489
|
+
this.findNode = findNode
|
|
490
|
+
this.getPage = getPage
|
|
491
|
+
this.matcher = matcher
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// detox scrolls the container until the expectation passes or the container
|
|
495
|
+
// can no longer move. bounding this by wall clock instead made the result
|
|
496
|
+
// depend on how fast the host dispatches pointer events: the same fixture and
|
|
497
|
+
// the same assertion pass on an idle machine and fail on a loaded one,
|
|
498
|
+
// because each 200px drag is a real pointer stream and fewer of them fit in
|
|
499
|
+
// the budget. end on scroll exhaustion, which is a property of the content.
|
|
500
|
+
async scroll(
|
|
501
|
+
pixels: number,
|
|
502
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
503
|
+
_startPositionX?: number,
|
|
504
|
+
_startPositionY?: number,
|
|
505
|
+
): Promise<void> {
|
|
506
|
+
const page = this.getPage()
|
|
507
|
+
let lastError: Error | null = null
|
|
508
|
+
let stalledSteps = 0
|
|
509
|
+
|
|
510
|
+
// two consecutive steps, so one settling frame that reports the same offset
|
|
511
|
+
// does not read as the end of the content.
|
|
512
|
+
while (stalledSteps < 2) {
|
|
513
|
+
try {
|
|
514
|
+
await this.assertionFn()
|
|
515
|
+
return
|
|
516
|
+
} catch (error) {
|
|
517
|
+
lastError = error as Error
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const travelled = await scrollMatcherElement(
|
|
521
|
+
page,
|
|
522
|
+
this.findNode,
|
|
523
|
+
this.matcher,
|
|
524
|
+
pixels,
|
|
525
|
+
direction,
|
|
526
|
+
)
|
|
527
|
+
stalledSteps = travelled < 0.5 ? stalledSteps + 1 : 0
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
throw (
|
|
531
|
+
lastError ||
|
|
532
|
+
new Error(
|
|
533
|
+
`waitFor whileElement(...).scroll() reached the end of ${JSON.stringify(this.matcher)}`,
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
}
|