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.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-XZ2KTPCU.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-JJYYW2WH.js +17 -0
  25. package/dist-cli/chunks/app-fonts-IXRNQG6B.js +4 -0
  26. package/dist-cli/chunks/assert-54T5SK5F.js +49 -0
  27. package/dist-cli/chunks/auth-FI5UDI45.js +12 -0
  28. package/dist-cli/chunks/beta-JV6UKADW.js +4 -0
  29. package/dist-cli/chunks/camera-VL73YIKP.js +22 -0
  30. package/dist-cli/chunks/chunk-2D2UPBBR.js +4 -0
  31. package/dist-cli/chunks/chunk-2YR5BGA5.js +5 -0
  32. package/dist-cli/chunks/chunk-3NV2NCNX.js +4 -0
  33. package/dist-cli/chunks/chunk-46EUUFJ5.js +4 -0
  34. package/dist-cli/chunks/chunk-4NPPOV2N.js +5 -0
  35. package/dist-cli/chunks/chunk-5DHC6KHQ.js +7 -0
  36. package/dist-cli/chunks/chunk-5TEF3ET3.js +74 -0
  37. package/dist-cli/chunks/chunk-5TPRP5QT.js +4 -0
  38. package/dist-cli/chunks/chunk-5YJCOWCH.js +4 -0
  39. package/dist-cli/chunks/chunk-7GN3LVWB.js +4 -0
  40. package/dist-cli/chunks/chunk-BBULZ7CG.js +568 -0
  41. package/dist-cli/chunks/chunk-BTWORNNG.js +71 -0
  42. package/dist-cli/chunks/chunk-D4FFVGI5.js +5 -0
  43. package/dist-cli/chunks/chunk-DCEMHR2Y.js +4 -0
  44. package/dist-cli/chunks/chunk-DZS6WPUI.js +4 -0
  45. package/dist-cli/chunks/chunk-F5ZRSS3C.js +3 -0
  46. package/dist-cli/chunks/chunk-FSUYIVJ6.js +9 -0
  47. package/dist-cli/chunks/chunk-G2WW6L2C.js +23 -0
  48. package/dist-cli/chunks/chunk-GASE6UBA.js +4 -0
  49. package/dist-cli/chunks/chunk-GGRX24GF.js +142 -0
  50. package/dist-cli/chunks/chunk-HI5TFJWN.js +5 -0
  51. package/dist-cli/chunks/chunk-IJ5CAZZC.js +5 -0
  52. package/dist-cli/chunks/chunk-IJO63TDP.js +4 -0
  53. package/dist-cli/chunks/chunk-KTHV3RUS.js +26 -0
  54. package/dist-cli/chunks/chunk-LF2ZVT7O.js +6 -0
  55. package/dist-cli/chunks/chunk-MJRLLB4R.js +38 -0
  56. package/dist-cli/chunks/chunk-NFK7T35W.js +4 -0
  57. package/dist-cli/chunks/chunk-NMF2ZMZQ.js +15 -0
  58. package/dist-cli/chunks/chunk-OHAZNXLK.js +4 -0
  59. package/dist-cli/chunks/chunk-OVFJFXUD.js +101 -0
  60. package/dist-cli/chunks/chunk-OZSSI4WN.js +28 -0
  61. package/dist-cli/chunks/chunk-QKDWYITG.js +25 -0
  62. package/dist-cli/chunks/chunk-RSZWCKNT.js +30 -0
  63. package/dist-cli/chunks/chunk-RTN5C5RL.js +4 -0
  64. package/dist-cli/chunks/chunk-TIVZIMMW.js +4 -0
  65. package/dist-cli/chunks/chunk-TZFFR3SD.js +7 -0
  66. package/dist-cli/chunks/chunk-UC6U3MML.js +5 -0
  67. package/dist-cli/chunks/chunk-VFCMSYZK.js +6 -0
  68. package/dist-cli/chunks/chunk-VNQEB4L7.js +5 -0
  69. package/dist-cli/chunks/chunk-VZXWHRUZ.js +983 -0
  70. package/dist-cli/chunks/chunk-W6K4EFPH.js +107 -0
  71. package/dist-cli/chunks/chunk-WEXDAC74.js +4 -0
  72. package/dist-cli/chunks/chunk-WF3T4SVI.js +14 -0
  73. package/dist-cli/chunks/chunk-WINYQ44O.js +4 -0
  74. package/dist-cli/chunks/chunk-WMIIKMGK.js +10 -0
  75. package/dist-cli/chunks/chunk-WUSWBCWA.js +507 -0
  76. package/dist-cli/chunks/chunk-WWZIXIRD.js +4 -0
  77. package/dist-cli/chunks/chunk-XEVZYVIW.js +766 -0
  78. package/dist-cli/chunks/chunk-YDGQTMQL.js +4 -0
  79. package/dist-cli/chunks/chunk-YIFT42WN.js +4 -0
  80. package/dist-cli/chunks/chunk-ZMJD5GEC.js +125 -0
  81. package/dist-cli/chunks/cleanup-P27PA6JI.js +25 -0
  82. package/dist-cli/chunks/cli-version-WWLPBDQ7.js +4 -0
  83. package/dist-cli/chunks/compat-ZD65FED3.js +20 -0
  84. package/dist-cli/chunks/config-XMJRNM2A.js +13 -0
  85. package/dist-cli/chunks/control-KMIQT3QP.js +4 -0
  86. package/dist-cli/chunks/daemon-G2ME7NLB.js +4 -0
  87. package/dist-cli/chunks/debug-PT4HOP7N.js +200 -0
  88. package/dist-cli/chunks/desktop-S3FG72AK.js +31 -0
  89. package/dist-cli/chunks/detox-B3D4IFCN.js +52 -0
  90. package/dist-cli/chunks/device-XBNDSB2R.js +18 -0
  91. package/dist-cli/chunks/diagnose-HMQXJE5N.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-BLCZ5BSZ.js +4 -0
  93. package/dist-cli/chunks/drivers-LDECZGP2.js +4 -0
  94. package/dist-cli/chunks/film-BJGTBYZB.js +74 -0
  95. package/dist-cli/chunks/flow-UEQNVTU7.js +4 -0
  96. package/dist-cli/chunks/help-T5FYSVGB.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-ANOYADHM.js +4 -0
  98. package/dist-cli/chunks/home-paths-GT3LFNOR.js +4 -0
  99. package/dist-cli/chunks/inspect-ZA6XF5LD.js +4 -0
  100. package/dist-cli/chunks/install-desktop-TIMUDHPL.js +4 -0
  101. package/dist-cli/chunks/login-FJ737MWG.js +28 -0
  102. package/dist-cli/chunks/logout-ZCNMMHMY.js +4 -0
  103. package/dist-cli/chunks/maestro-SZTNKLDF.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-DCFAIZ4H.js +36 -0
  105. package/dist-cli/chunks/mode-GRMQCRXR.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-W36EWFFB.js +4 -0
  107. package/dist-cli/chunks/perf-QYBAAUZG.js +48 -0
  108. package/dist-cli/chunks/permissions-3QCQ6VF4.js +13 -0
  109. package/dist-cli/chunks/record-QPPC2S4E.js +72 -0
  110. package/dist-cli/chunks/report-issue-7NMFP4HK.js +29 -0
  111. package/dist-cli/chunks/runtime-XOAXMSTU.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-67AECJFB.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-ASWBYPJL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-PXA3HFQK.js +42 -0
  115. package/dist-cli/chunks/serve-BI2NBAXG.js +44 -0
  116. package/dist-cli/chunks/setup-7DWPMRSB.js +32 -0
  117. package/dist-cli/chunks/skills-S3Y22TUA.js +50 -0
  118. package/dist-cli/chunks/storage-XUIMJWAJ.js +37 -0
  119. package/dist-cli/chunks/store-JTHEJLAZ.js +4 -0
  120. package/dist-cli/chunks/telemetry-ZYJGD2DB.js +4 -0
  121. package/dist-cli/chunks/timeline-TMPLQPSP.js +24 -0
  122. package/dist-cli/chunks/upgrade-7HDSIM7K.js +7 -0
  123. package/dist-cli/chunks/upload-GMSZPWM6.js +4 -0
  124. package/dist-cli/chunks/version-HOCHZ37L.js +6 -0
  125. package/dist-cli/chunks/web-DG3WBYD3.js +4 -0
  126. package/dist-cli/chunks/what-happened-XFVUTZR7.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23922 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. 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
+ }