peachsim 0.1.637 → 0.1.638
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 +8 -5
- package/cli/bridge-flow-runner.ts +442 -14
- package/cli/commands/inspect/core.ts +93 -24
- package/cli/commands/maestro.ts +2 -2
- package/cli/commands/serve.ts +13 -1
- package/cli/drivers/playwright-provisioning.ts +8 -16
- package/cli/drivers/playwright-sim-host.ts +3 -0
- package/cli/drivers/playwright.ts +1 -0
- package/cli/internal-child.ts +51 -8
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.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 +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +3 -2
- package/dist-lib/bridge-contract-input.mjs +3 -2
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +4 -2
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +76 -20
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/native-app-identity.cjs +1 -1
- package/dist-lib/native-app-identity.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +78 -20
- package/dist-lib/sdk.mjs +77 -20
- package/dist-lib/skills.cjs +394 -62
- package/dist-lib/swift-compile.cjs +89 -7
- package/dist-lib/swift-compile.mjs +89 -7
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +122 -34
- package/dist-lib/vite-swift.cjs +89 -7
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/src/bridge-contract-input.ts +3 -2
- package/src/bridge-contract.ts +8 -0
- package/src/host/bridge-host.ts +5 -5
- package/src/native-seam-manifest.ts +4 -6
- package/src/swift-compile.ts +105 -9
package/README.md
CHANGED
|
@@ -235,11 +235,14 @@ Pass a target to `peach open <port-or-url>` when a script or agent needs an expl
|
|
|
235
235
|
selection.
|
|
236
236
|
|
|
237
237
|
The first `peach open` launches an isolated Chrome for Testing sim. The CLI
|
|
238
|
-
resolves Playwright from the current directory or beside its executable
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
238
|
+
resolves Playwright from the current directory or beside its executable and
|
|
239
|
+
launches the newest installed Chromium revision in its browser cache, honoring
|
|
240
|
+
`PLAYWRIGHT_BROWSERS_PATH`. Set `PEACH_CHROMIUM_EXECUTABLE` to an existing
|
|
241
|
+
executable to select a binary explicitly. Headless and headed launches use that
|
|
242
|
+
same binary. When no installed browser is available, the CLI runs the resolved
|
|
243
|
+
Playwright core's absolute installer entry. Later opens reuse that sim and
|
|
244
|
+
reload it at the requested target. Use `peach do reload` when the target has not
|
|
245
|
+
changed.
|
|
243
246
|
Reserve `--new` for a genuinely concurrent sim; repeated new browser trees
|
|
244
247
|
consume memory and CPU on shared machines.
|
|
245
248
|
|
|
@@ -6,7 +6,11 @@ import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-
|
|
|
6
6
|
import { flowTimeoutScale } from '../src/flow-timeout-scale'
|
|
7
7
|
import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
|
|
8
8
|
import { sleep } from '../src/sleep'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
inspectWaitReady,
|
|
11
|
+
isPeachAccessibilityTree,
|
|
12
|
+
waitReadyReason,
|
|
13
|
+
} from './commands/inspect/core'
|
|
10
14
|
import { waitForSootsimIdle } from './commands/inspect/settling'
|
|
11
15
|
import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
|
|
12
16
|
import { PERMISSION_KINDS } from './commands/permissions'
|
|
@@ -16,7 +20,11 @@ import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
|
|
|
16
20
|
import { ensureCliRecordingEntitlement } from './recording-access'
|
|
17
21
|
import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract'
|
|
18
22
|
import type { WsBridge } from './ws-bridge'
|
|
19
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
PeachAccessibilityNode,
|
|
25
|
+
SootSimReplayTarget,
|
|
26
|
+
SootSimTapResult,
|
|
27
|
+
} from '@peach/globals'
|
|
20
28
|
import type {
|
|
21
29
|
SootSimExternalAppLifecycleOptions,
|
|
22
30
|
SootSimLaunchArguments,
|
|
@@ -46,6 +54,9 @@ type FlowTargetSelector = {
|
|
|
46
54
|
enabled?: boolean
|
|
47
55
|
selected?: boolean
|
|
48
56
|
checked?: boolean
|
|
57
|
+
focused?: boolean
|
|
58
|
+
containsChild?: FlowTargetSelector
|
|
59
|
+
containsDescendants?: FlowTargetSelector[]
|
|
49
60
|
below?: string | FlowTargetSelector
|
|
50
61
|
above?: string | FlowTargetSelector
|
|
51
62
|
leftOf?: string | FlowTargetSelector
|
|
@@ -105,16 +116,10 @@ const SELECTOR_FILTER_KEYS = [
|
|
|
105
116
|
'leftOf',
|
|
106
117
|
'rightOf',
|
|
107
118
|
] as const
|
|
108
|
-
// maestro filters without a verified ios reading here yet: `
|
|
109
|
-
// `containsDescendants`
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
const UNSUPPORTED_SELECTOR_KEYS = [
|
|
113
|
-
'containsChild',
|
|
114
|
-
'containsDescendants',
|
|
115
|
-
'focused',
|
|
116
|
-
'css',
|
|
117
|
-
]
|
|
119
|
+
// maestro filters without a verified ios reading here yet: `css` is web only.
|
|
120
|
+
// `containsChild`, `containsDescendants`, and `focused` read the engine's
|
|
121
|
+
// XCTest accessibility projection (see findElementAccessibilityTree).
|
|
122
|
+
const UNSUPPORTED_SELECTOR_KEYS = ['css']
|
|
118
123
|
// ignoring a key widens the match, so an assertion passes that maestro fails.
|
|
119
124
|
function assertSupportedSelector(selector: unknown) {
|
|
120
125
|
if (!selector || typeof selector !== 'object') return
|
|
@@ -125,10 +130,384 @@ function assertSupportedSelector(selector: unknown) {
|
|
|
125
130
|
if (unsupported.length > 0) {
|
|
126
131
|
throw new Error(`selector ${unsupported.join(', ')} is not supported by peach yet`)
|
|
127
132
|
}
|
|
128
|
-
for (const key of ['childOf', 'below', 'above', 'leftOf', 'rightOf']) {
|
|
133
|
+
for (const key of ['childOf', 'below', 'above', 'leftOf', 'rightOf', 'containsChild']) {
|
|
129
134
|
assertSupportedSelector(Reflect.get(selector, key))
|
|
130
135
|
}
|
|
136
|
+
const descendants = Reflect.get(selector, 'containsDescendants')
|
|
137
|
+
if (descendants !== undefined) {
|
|
138
|
+
if (!Array.isArray(descendants)) {
|
|
139
|
+
throw new Error('selector containsDescendants must be a list of selectors')
|
|
140
|
+
}
|
|
141
|
+
for (const descendant of descendants) assertSupportedSelector(descendant)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// the filters whose meaning is defined on the XCTest accessibility hierarchy.
|
|
146
|
+
// ordinary lookups stay on the faster render tree; these read the engine's
|
|
147
|
+
// accessibility projection through captureAccessibilityTree().
|
|
148
|
+
const ACCESSIBILITY_SELECTOR_KEYS = [
|
|
149
|
+
'containsChild',
|
|
150
|
+
'containsDescendants',
|
|
151
|
+
'focused',
|
|
152
|
+
] as const
|
|
153
|
+
|
|
154
|
+
function needsAccessibilityProjection(selector: string | FlowTargetSelector): boolean {
|
|
155
|
+
if (typeof selector === 'string') return false
|
|
156
|
+
if (ACCESSIBILITY_SELECTOR_KEYS.some((key) => selector[key] != null)) return true
|
|
157
|
+
return ['childOf', 'below', 'above', 'leftOf', 'rightOf'].some((key) => {
|
|
158
|
+
const nested: unknown = Reflect.get(selector, key)
|
|
159
|
+
return isSelectorRecord(nested) && needsAccessibilityProjection(nested)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type AccessibilityNodePredicate = (node: PeachAccessibilityNode) => boolean
|
|
164
|
+
|
|
165
|
+
// preorder aggregate, the same order TreeNode.aggregate() walks on ios.
|
|
166
|
+
function accessibilityTreeNodes(
|
|
167
|
+
tree: readonly PeachAccessibilityNode[],
|
|
168
|
+
): PeachAccessibilityNode[] {
|
|
169
|
+
const out: PeachAccessibilityNode[] = []
|
|
170
|
+
const visit = (node: PeachAccessibilityNode): void => {
|
|
171
|
+
out.push(node)
|
|
172
|
+
for (const child of node.children) visit(child)
|
|
173
|
+
}
|
|
174
|
+
for (const root of tree) visit(root)
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// a container's own hierarchy: the node plus every descendant, matching
|
|
179
|
+
// ViewHierarchy(element.treeNode).aggregate().
|
|
180
|
+
function accessibilitySubtreeNodes(
|
|
181
|
+
node: PeachAccessibilityNode,
|
|
182
|
+
): PeachAccessibilityNode[] {
|
|
183
|
+
return [node, ...node.children.flatMap(accessibilitySubtreeNodes)]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// maestro matches the whole id or text against the selector's regex; the
|
|
187
|
+
// render-tree resolver compiles the same anchored pattern.
|
|
188
|
+
function accessibilityTextMatcher(
|
|
189
|
+
value: string,
|
|
190
|
+
kind: 'text' | 'id',
|
|
191
|
+
): (candidate: string | null) => boolean {
|
|
192
|
+
const pattern = new RegExp(`^(?:${value})$`)
|
|
193
|
+
return (candidate) => {
|
|
194
|
+
// IOSDriver emits an empty attribute for absent label, value and id.
|
|
195
|
+
const native = candidate ?? ''
|
|
196
|
+
if (kind === 'id')
|
|
197
|
+
return (
|
|
198
|
+
pattern.test(native) || pattern.test(native.slice(native.lastIndexOf('/') + 1))
|
|
199
|
+
)
|
|
200
|
+
const stripped = native.replaceAll('\n', ' ')
|
|
201
|
+
return (
|
|
202
|
+
native === value ||
|
|
203
|
+
stripped === value ||
|
|
204
|
+
pattern.test(native) ||
|
|
205
|
+
pattern.test(stripped)
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
211
|
+
function accessibilityBounds(node: PeachAccessibilityNode): {
|
|
212
|
+
x: number
|
|
213
|
+
y: number
|
|
214
|
+
width: number
|
|
215
|
+
height: number
|
|
216
|
+
} {
|
|
217
|
+
const left = Math.trunc(node.frame.x)
|
|
218
|
+
const top = Math.trunc(node.frame.y)
|
|
219
|
+
return {
|
|
220
|
+
x: left,
|
|
221
|
+
y: top,
|
|
222
|
+
width: Math.trunc(node.frame.x + node.frame.width) - left,
|
|
223
|
+
height: Math.trunc(node.frame.y + node.frame.height) - top,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const ACCESSIBILITY_TRAIT_FILTERS: Record<string, AccessibilityNodePredicate> = {
|
|
228
|
+
// IOSDriver sets attributes["text"] on every node, including empty text.
|
|
229
|
+
TEXT: () => true,
|
|
230
|
+
SQUARE: (node) => {
|
|
231
|
+
const bounds = accessibilityBounds(node)
|
|
232
|
+
return Math.abs(1 - bounds.width / bounds.height) < 0.03
|
|
233
|
+
},
|
|
234
|
+
LONG_TEXT: (node) => (node.value?.length ?? 0) > 200,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Orchestra.buildFilter's basic filters, in its order: text, id, size,
|
|
238
|
+
// traits, enabled, selected, checked, focused. css is refused before this runs.
|
|
239
|
+
function accessibilityBasicFilters(
|
|
240
|
+
selector: FlowTargetSelector,
|
|
241
|
+
): AccessibilityNodePredicate[] {
|
|
242
|
+
const filters: AccessibilityNodePredicate[] = []
|
|
243
|
+
if (selector.text != null) {
|
|
244
|
+
const matches = accessibilityTextMatcher(selector.text, 'text')
|
|
245
|
+
// ios textMatches reads attributes text, hintText (the placeholder) and
|
|
246
|
+
// accessibilityText; the canonical label/value/placeholder carry those.
|
|
247
|
+
filters.push(
|
|
248
|
+
(node) => matches(node.label) || matches(node.value) || matches(node.placeholder),
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
if (selector.id != null) {
|
|
252
|
+
const matches = accessibilityTextMatcher(selector.id, 'id')
|
|
253
|
+
filters.push((node) => matches(node.testID))
|
|
254
|
+
}
|
|
255
|
+
if (selector.width != null || selector.height != null) {
|
|
256
|
+
const tolerance = selector.tolerance ?? 0
|
|
257
|
+
filters.push((node) => {
|
|
258
|
+
const bounds = accessibilityBounds(node)
|
|
259
|
+
return (
|
|
260
|
+
(selector.width == null ||
|
|
261
|
+
Math.abs(bounds.width - selector.width) <= tolerance) &&
|
|
262
|
+
(selector.height == null ||
|
|
263
|
+
Math.abs(bounds.height - selector.height) <= tolerance)
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
for (const trait of selector.traits ? selector.traits.split(' ') : []) {
|
|
268
|
+
const filter = ACCESSIBILITY_TRAIT_FILTERS[trait.replaceAll('-', '_').toUpperCase()]
|
|
269
|
+
if (!filter) throw new Error(`unknown selector trait: ${trait}`)
|
|
270
|
+
filters.push(filter)
|
|
271
|
+
}
|
|
272
|
+
if (selector.enabled != null) {
|
|
273
|
+
filters.push((node) => node.enabled === selector.enabled)
|
|
274
|
+
}
|
|
275
|
+
if (selector.selected != null) {
|
|
276
|
+
filters.push((node) => node.selected === selector.selected)
|
|
277
|
+
}
|
|
278
|
+
if (selector.checked != null) {
|
|
279
|
+
filters.push((node) => node.checked === selector.checked)
|
|
280
|
+
}
|
|
281
|
+
// XCTest may omit hasFocus; the native driver defaults an absent snapshot
|
|
282
|
+
// key to false, and editing focus is a separate native concept.
|
|
283
|
+
if (selector.focused != null) {
|
|
284
|
+
filters.push((node) => (node.hasFocus ?? false) === selector.focused)
|
|
285
|
+
}
|
|
286
|
+
return filters
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Filters.deepestMatchingElement: a node contributes its deepest matching
|
|
290
|
+
// descendants when any exist, else itself when it matches.
|
|
291
|
+
function deepestMatchingAccessibilityNodes(
|
|
292
|
+
nodes: readonly PeachAccessibilityNode[],
|
|
293
|
+
matches: AccessibilityNodePredicate,
|
|
294
|
+
): PeachAccessibilityNode[] {
|
|
295
|
+
const out: PeachAccessibilityNode[] = []
|
|
296
|
+
const included = new Set(nodes)
|
|
297
|
+
const nested = new Set(
|
|
298
|
+
nodes.flatMap((node) => node.children.filter((child) => included.has(child))),
|
|
299
|
+
)
|
|
300
|
+
for (const node of nodes.filter((node) => !nested.has(node))) {
|
|
301
|
+
const children = deepestMatchingAccessibilityNodes(node.children, matches)
|
|
302
|
+
if (children.length > 0) out.push(...children)
|
|
303
|
+
else if (matches(node)) out.push(node)
|
|
304
|
+
}
|
|
305
|
+
return distinctAccessibilityNodes(out)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function distinctAccessibilityNodes(
|
|
309
|
+
nodes: readonly PeachAccessibilityNode[],
|
|
310
|
+
): PeachAccessibilityNode[] {
|
|
311
|
+
// TreeNode.distinct compares native fields and children, not engine identity.
|
|
312
|
+
// bucket by scalar attributes first; full subtree comparisons are only needed
|
|
313
|
+
// for nodes with identical attributes and the same number of children.
|
|
314
|
+
const buckets = new Map<string, PeachAccessibilityNode[]>()
|
|
315
|
+
return nodes.filter((node) => {
|
|
316
|
+
const key = JSON.stringify([
|
|
317
|
+
node.testID ?? '',
|
|
318
|
+
node.label ?? '',
|
|
319
|
+
node.placeholder ?? '',
|
|
320
|
+
node.value ?? '',
|
|
321
|
+
node.enabled,
|
|
322
|
+
node.selected,
|
|
323
|
+
node.checked,
|
|
324
|
+
node.clickable,
|
|
325
|
+
node.hasFocus ?? false,
|
|
326
|
+
accessibilityBounds(node),
|
|
327
|
+
node.children.length,
|
|
328
|
+
])
|
|
329
|
+
const bucket = buckets.get(key)
|
|
330
|
+
if (bucket?.some((other) => sameNativeAccessibilityShape(node, other))) return false
|
|
331
|
+
if (bucket) bucket.push(node)
|
|
332
|
+
else buckets.set(key, [node])
|
|
333
|
+
return true
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isSelectorRecord(value: unknown): value is FlowTargetSelector {
|
|
338
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function accessibilityAnchorSelector(value: unknown): FlowTargetSelector {
|
|
342
|
+
if (typeof value === 'string') return { text: value }
|
|
343
|
+
return isSelectorRecord(value) ? value : {}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// upstream Filters.containsChild compares the resolved child TreeNode against
|
|
347
|
+
// a candidate's immediate children by structural equality: the native
|
|
348
|
+
// attribute map, the boolean flags, and the children, recursively. the native
|
|
349
|
+
// hintText attribute is the placeholder (accessibilityHint is not a TreeNode
|
|
350
|
+
// attribute), and engine identity (elementId/nodeId) plus the projection's own
|
|
351
|
+
// metadata (type, role, isAccessibilityElement, state) are not part of that
|
|
352
|
+
// native shape.
|
|
353
|
+
function sameNativeAccessibilityShape(
|
|
354
|
+
a: PeachAccessibilityNode,
|
|
355
|
+
b: PeachAccessibilityNode,
|
|
356
|
+
): boolean {
|
|
357
|
+
if (
|
|
358
|
+
(a.testID ?? '') !== (b.testID ?? '') ||
|
|
359
|
+
(a.label ?? '') !== (b.label ?? '') ||
|
|
360
|
+
(a.placeholder ?? '') !== (b.placeholder ?? '') ||
|
|
361
|
+
(a.value ?? '') !== (b.value ?? '') ||
|
|
362
|
+
a.enabled !== b.enabled ||
|
|
363
|
+
a.selected !== b.selected ||
|
|
364
|
+
a.checked !== b.checked ||
|
|
365
|
+
a.clickable !== b.clickable ||
|
|
366
|
+
(a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
|
|
367
|
+
JSON.stringify(accessibilityBounds(a)) !== JSON.stringify(accessibilityBounds(b)) ||
|
|
368
|
+
a.children.length !== b.children.length
|
|
369
|
+
) {
|
|
370
|
+
return false
|
|
371
|
+
}
|
|
372
|
+
for (const [index, child] of a.children.entries()) {
|
|
373
|
+
const other = b.children[index]
|
|
374
|
+
if (!other || !sameNativeAccessibilityShape(child, other)) return false
|
|
375
|
+
}
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Filters.containsDescendants: every selector must match inside a child
|
|
380
|
+
// subtree; the node itself is excluded. A selector matches a subtree node by
|
|
381
|
+
// the full filter applied to that single node (Filters' `matches`), which for
|
|
382
|
+
// relative filters resolves anchors against the same one-node list.
|
|
383
|
+
function accessibilitySubtreeMatches(
|
|
384
|
+
all: readonly PeachAccessibilityNode[],
|
|
385
|
+
node: PeachAccessibilityNode,
|
|
386
|
+
selector: FlowTargetSelector,
|
|
387
|
+
): boolean {
|
|
388
|
+
if (filterAccessibilityNodes(all, [node], selector).length > 0) return true
|
|
389
|
+
return node.children.some((child) => accessibilitySubtreeMatches(all, child, selector))
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Orchestra's childOf scopes the whole lookup to the container's hierarchy
|
|
393
|
+
// before any filter runs (findElementViewHierarchy + aggregate); a container
|
|
394
|
+
// that does not resolve leaves nothing to search.
|
|
395
|
+
function filterAccessibilityNodes(
|
|
396
|
+
all: readonly PeachAccessibilityNode[],
|
|
397
|
+
nodes: readonly PeachAccessibilityNode[],
|
|
398
|
+
selector: FlowTargetSelector,
|
|
399
|
+
): PeachAccessibilityNode[] {
|
|
400
|
+
if (selector.childOf == null) {
|
|
401
|
+
return filterAccessibilityNodesIn(all, nodes, selector)
|
|
402
|
+
}
|
|
403
|
+
const container = filterAccessibilityNodes(
|
|
404
|
+
all,
|
|
405
|
+
all,
|
|
406
|
+
accessibilityAnchorSelector(selector.childOf),
|
|
407
|
+
)[0]
|
|
408
|
+
if (!container) return []
|
|
409
|
+
return filterAccessibilityNodesIn(all, accessibilitySubtreeNodes(container), selector)
|
|
131
410
|
}
|
|
411
|
+
|
|
412
|
+
// Orchestra.buildFilter over the accessibility projection: basic filters
|
|
413
|
+
// through deepestMatching, then the relative filters intersected in
|
|
414
|
+
// buildFilter's order, then index or clickableFirst. `nodes` is the candidate
|
|
415
|
+
// list a relative anchor filter sees, exactly as Filters.intersect applies
|
|
416
|
+
// every filter to the same input list.
|
|
417
|
+
function filterAccessibilityNodesIn(
|
|
418
|
+
all: readonly PeachAccessibilityNode[],
|
|
419
|
+
nodes: readonly PeachAccessibilityNode[],
|
|
420
|
+
selector: FlowTargetSelector,
|
|
421
|
+
): PeachAccessibilityNode[] {
|
|
422
|
+
const basic = accessibilityBasicFilters(selector)
|
|
423
|
+
let candidates =
|
|
424
|
+
basic.length > 0
|
|
425
|
+
? deepestMatchingAccessibilityNodes(nodes, (node) =>
|
|
426
|
+
basic.every((filter) => filter(node)),
|
|
427
|
+
)
|
|
428
|
+
: [...nodes]
|
|
429
|
+
|
|
430
|
+
const relativeGeometry = {
|
|
431
|
+
below: (node: PeachAccessibilityNode, other: PeachAccessibilityNode) =>
|
|
432
|
+
accessibilityBounds(node).y > accessibilityBounds(other).y,
|
|
433
|
+
above: (node: PeachAccessibilityNode, other: PeachAccessibilityNode) =>
|
|
434
|
+
accessibilityBounds(node).y < accessibilityBounds(other).y,
|
|
435
|
+
leftOf: (node: PeachAccessibilityNode, other: PeachAccessibilityNode) =>
|
|
436
|
+
accessibilityBounds(node).x < accessibilityBounds(other).x,
|
|
437
|
+
rightOf: (node: PeachAccessibilityNode, other: PeachAccessibilityNode) =>
|
|
438
|
+
accessibilityBounds(node).x > accessibilityBounds(other).x,
|
|
439
|
+
} as const
|
|
440
|
+
for (const key of ['below', 'above', 'leftOf', 'rightOf'] as const) {
|
|
441
|
+
const anchor = selector[key]
|
|
442
|
+
if (anchor == null) continue
|
|
443
|
+
const anchors = filterAccessibilityNodes(
|
|
444
|
+
all,
|
|
445
|
+
nodes,
|
|
446
|
+
accessibilityAnchorSelector(anchor),
|
|
447
|
+
)
|
|
448
|
+
candidates = candidates.filter((node) =>
|
|
449
|
+
anchors.some((other) => relativeGeometry[key](node, other)),
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (selector.containsChild != null) {
|
|
454
|
+
// Orchestra resolves the child selector globally (findElement with
|
|
455
|
+
// optional=false) before the predicate runs. an unresolved child matches
|
|
456
|
+
// nothing, so a caller's retry window still applies.
|
|
457
|
+
const child = filterAccessibilityNodes(
|
|
458
|
+
all,
|
|
459
|
+
all,
|
|
460
|
+
accessibilityAnchorSelector(selector.containsChild),
|
|
461
|
+
)[0]
|
|
462
|
+
candidates = child
|
|
463
|
+
? candidates.filter((node) =>
|
|
464
|
+
node.children.some((candidate) =>
|
|
465
|
+
sameNativeAccessibilityShape(candidate, child),
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
: []
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (selector.containsDescendants != null) {
|
|
472
|
+
const descendants = selector.containsDescendants.map(accessibilityAnchorSelector)
|
|
473
|
+
candidates = candidates.filter((node) =>
|
|
474
|
+
descendants.every((descendant) =>
|
|
475
|
+
node.children.some((child) =>
|
|
476
|
+
accessibilitySubtreeMatches(all, child, descendant),
|
|
477
|
+
),
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Filters.intersect converts every filter result to a TreeNode set,
|
|
483
|
+
// including selectors containing only structural or relative filters.
|
|
484
|
+
candidates = distinctAccessibilityNodes(candidates)
|
|
485
|
+
|
|
486
|
+
if (selector.index != null) {
|
|
487
|
+
const index = Math.trunc(Number(selector.index))
|
|
488
|
+
const sorted = [...candidates].sort((a, b) => {
|
|
489
|
+
const aBounds = accessibilityBounds(a)
|
|
490
|
+
const bBounds = accessibilityBounds(b)
|
|
491
|
+
return aBounds.y - bBounds.y || aBounds.x - bBounds.x
|
|
492
|
+
})
|
|
493
|
+
const resolved = index >= 0 ? index : sorted.length + index
|
|
494
|
+
if (resolved < 0 || resolved >= sorted.length) return []
|
|
495
|
+
const picked = sorted[resolved]
|
|
496
|
+
return picked ? [picked] : []
|
|
497
|
+
}
|
|
498
|
+
// clickableFirst is a stable descending sort; ios reports no clickable bit,
|
|
499
|
+
// so every node ties and the filter order survives.
|
|
500
|
+
return [...candidates].sort((a, b) => Number(b.clickable) - Number(a.clickable))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function resolveAccessibilitySelector(
|
|
504
|
+
tree: readonly PeachAccessibilityNode[],
|
|
505
|
+
selector: FlowTargetSelector,
|
|
506
|
+
): PeachAccessibilityNode | null {
|
|
507
|
+
const all = accessibilityTreeNodes(tree)
|
|
508
|
+
return filterAccessibilityNodes(all, all, selector)[0] ?? null
|
|
509
|
+
}
|
|
510
|
+
|
|
132
511
|
type FocusedTextInputInfo = {
|
|
133
512
|
nodeId?: number | null
|
|
134
513
|
id?: string | null
|
|
@@ -910,6 +1289,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
910
1289
|
|
|
911
1290
|
private async findElement(opts: FlowTargetSelector) {
|
|
912
1291
|
assertSupportedSelector(opts)
|
|
1292
|
+
// the structural filters are defined on the XCTest accessibility
|
|
1293
|
+
// hierarchy, so they resolve against the engine's accessibility
|
|
1294
|
+
// projection instead of the render tree.
|
|
1295
|
+
if (needsAccessibilityProjection(opts)) {
|
|
1296
|
+
return this.findElementAccessibilityTree(opts)
|
|
1297
|
+
}
|
|
913
1298
|
// id selectors, patterns and filtered selectors resolve over the full
|
|
914
1299
|
// candidate set in maestro's order, so a repeated id (one per list row)
|
|
915
1300
|
// means the top row. a literal text selector keeps the direct lookup.
|
|
@@ -951,6 +1336,47 @@ export class SootSimBridgeFlowRunner {
|
|
|
951
1336
|
})()`)
|
|
952
1337
|
}
|
|
953
1338
|
|
|
1339
|
+
// structural filters (containsChild / containsDescendants / focused) read the
|
|
1340
|
+
// engine's XCTest accessibility projection — one bridge evaluate, then the
|
|
1341
|
+
// same Orchestra.buildFilter semantics the render resolver implements, over
|
|
1342
|
+
// PeachAccessibilityNode instead of the visible semantic tree.
|
|
1343
|
+
private async findElementAccessibilityTree(opts: FlowTargetSelector) {
|
|
1344
|
+
const tree = await this.evaluate(`(async () => {
|
|
1345
|
+
const test = window.__sootsimTest
|
|
1346
|
+
if (!test || typeof test.captureAccessibilityTree !== 'function') throw new Error('engine accessibility snapshot is unavailable')
|
|
1347
|
+
return await test.captureAccessibilityTree()
|
|
1348
|
+
})()`)
|
|
1349
|
+
if (!isPeachAccessibilityTree(tree))
|
|
1350
|
+
throw new Error('engine returned an invalid accessibility snapshot')
|
|
1351
|
+
if (tree.length === 0) return null
|
|
1352
|
+
if (tree.some((node) => node.platform !== 'ios'))
|
|
1353
|
+
throw new Error(
|
|
1354
|
+
'containsChild, containsDescendants and focused require the iOS accessibility hierarchy',
|
|
1355
|
+
)
|
|
1356
|
+
const node = resolveAccessibilitySelector(tree, opts)
|
|
1357
|
+
if (!node) return null
|
|
1358
|
+
const frame = node.frame
|
|
1359
|
+
return {
|
|
1360
|
+
nodeId: Number.isFinite(node.nodeId) ? node.nodeId : null,
|
|
1361
|
+
id: node.testID,
|
|
1362
|
+
testID: node.testID,
|
|
1363
|
+
text: node.label,
|
|
1364
|
+
accessibilityLabel: node.label,
|
|
1365
|
+
absolutePosition: { x: frame.x, y: frame.y },
|
|
1366
|
+
layout: { width: frame.width, height: frame.height },
|
|
1367
|
+
// the engine applies Maestro's integer-frame viewport filter to the
|
|
1368
|
+
// native hierarchy before this lookup; preserve the native bounds.
|
|
1369
|
+
visibleFrame: {
|
|
1370
|
+
x: frame.x,
|
|
1371
|
+
y: frame.y,
|
|
1372
|
+
width: frame.width,
|
|
1373
|
+
height: frame.height,
|
|
1374
|
+
area: frame.width * frame.height,
|
|
1375
|
+
},
|
|
1376
|
+
isTextInput: node.isTextInput,
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
954
1380
|
// maestro's selector resolver (Orchestra.buildFilter over Filters.kt):
|
|
955
1381
|
// id and text patterns, size, traits, enabled/selected, the relative
|
|
956
1382
|
// below/above/leftOf/rightOf filters, `index:` (nth match) and `childOf:`
|
|
@@ -1410,7 +1836,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
1410
1836
|
// looks up by id or text alone, so a selector that narrows further only
|
|
1411
1837
|
// takes it for an element its own match already found.
|
|
1412
1838
|
const narrowed =
|
|
1413
|
-
opts.index != null ||
|
|
1839
|
+
opts.index != null ||
|
|
1840
|
+
SELECTOR_FILTER_KEYS.some((key) => opts[key] != null) ||
|
|
1841
|
+
ACCESSIBILITY_SELECTOR_KEYS.some((key) => opts[key] != null)
|
|
1414
1842
|
const direct = element || !narrowed ? await this.activatePressTarget(opts) : null
|
|
1415
1843
|
if (direct?.ok) {
|
|
1416
1844
|
await this.settleAfterInput()
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
SimSemanticSelector,
|
|
28
28
|
} from '../../../src/bridge-contract'
|
|
29
29
|
import type {
|
|
30
|
+
PeachAccessibilityNode,
|
|
30
31
|
SootSimRouteInfo,
|
|
31
32
|
SootSimRequestEntry,
|
|
32
33
|
SootSimTimelineEvent,
|
|
@@ -904,40 +905,108 @@ export interface AccessibilityTreeNode {
|
|
|
904
905
|
size: { w: number; h: number } | null
|
|
905
906
|
}
|
|
906
907
|
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
908
|
+
// runtime shape check for the engine's PeachAccessibilityNode projection: the
|
|
909
|
+
// bridge is a JSON boundary, so a node that lost a field must read as "no
|
|
910
|
+
// snapshot" rather than flatten into a half-populated row.
|
|
911
|
+
export function isPeachAccessibilityTree(
|
|
912
|
+
value: unknown,
|
|
913
|
+
): value is PeachAccessibilityNode[] {
|
|
914
|
+
if (!Array.isArray(value)) return false
|
|
915
|
+
return value.every((node) => {
|
|
916
|
+
if (node === null || typeof node !== 'object') return false
|
|
917
|
+
if (
|
|
918
|
+
Reflect.get(node, 'platform') !== 'ios' &&
|
|
919
|
+
Reflect.get(node, 'platform') !== 'android'
|
|
920
|
+
)
|
|
921
|
+
return false
|
|
922
|
+
if (typeof Reflect.get(node, 'elementId') !== 'string') return false
|
|
923
|
+
if (!Number.isInteger(Reflect.get(node, 'nodeId'))) return false
|
|
924
|
+
for (const key of ['type', 'role']) {
|
|
925
|
+
if (typeof Reflect.get(node, key) !== 'string') return false
|
|
926
|
+
}
|
|
927
|
+
for (const key of ['testID', 'label', 'hint', 'value', 'placeholder']) {
|
|
928
|
+
const field = Reflect.get(node, key)
|
|
929
|
+
if (field !== null && typeof field !== 'string') return false
|
|
930
|
+
}
|
|
931
|
+
for (const key of [
|
|
932
|
+
'isAccessibilityElement',
|
|
933
|
+
'enabled',
|
|
934
|
+
'selected',
|
|
935
|
+
'checked',
|
|
936
|
+
'clickable',
|
|
937
|
+
'isTextInput',
|
|
938
|
+
]) {
|
|
939
|
+
if (typeof Reflect.get(node, key) !== 'boolean') return false
|
|
940
|
+
}
|
|
941
|
+
const hasFocus = Reflect.get(node, 'hasFocus')
|
|
942
|
+
if (hasFocus !== undefined && typeof hasFocus !== 'boolean') return false
|
|
943
|
+
const state = Reflect.get(node, 'state')
|
|
944
|
+
if (state !== null) {
|
|
945
|
+
if (typeof state !== 'object') return false
|
|
946
|
+
const checked = Reflect.get(state, 'checked')
|
|
947
|
+
if (typeof checked !== 'boolean' && checked !== 'mixed') return false
|
|
948
|
+
for (const key of ['disabled', 'selected', 'busy', 'expanded']) {
|
|
949
|
+
if (typeof Reflect.get(state, key) !== 'boolean') return false
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const value = Reflect.get(node, 'accessibilityValue')
|
|
953
|
+
if (value !== null) {
|
|
954
|
+
if (typeof value !== 'object') return false
|
|
955
|
+
for (const key of ['min', 'max', 'now']) {
|
|
956
|
+
const field = Reflect.get(value, key)
|
|
957
|
+
if (field !== undefined && (typeof field !== 'number' || !Number.isFinite(field)))
|
|
958
|
+
return false
|
|
959
|
+
}
|
|
960
|
+
const text = Reflect.get(value, 'text')
|
|
961
|
+
if (text !== undefined && typeof text !== 'string') return false
|
|
962
|
+
}
|
|
963
|
+
if (!isSemanticGeometry(Reflect.get(node, 'frame'))) return false
|
|
964
|
+
return isPeachAccessibilityTree(Reflect.get(node, 'children'))
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// `get a11y` — flat VoiceOver presentation of the engine's XCTest
|
|
969
|
+
// accessibility projection (SootSimTestBridge.captureAccessibilityTree).
|
|
970
|
+
// VoiceOver reads an accessibility element as one group, so an element's
|
|
971
|
+
// children are hidden from this flat view even though the snapshot keeps them
|
|
972
|
+
// for Maestro's structural selectors (containsChild / containsDescendants).
|
|
910
973
|
export async function inspectAccessibilityTree(
|
|
911
974
|
bridge: InspectBridge,
|
|
912
975
|
): Promise<AccessibilityTreeNode[]> {
|
|
913
976
|
const result: unknown = await bridge.send({
|
|
914
977
|
type: 'query',
|
|
915
|
-
query: { kind: '
|
|
978
|
+
query: { kind: 'accessibility' },
|
|
916
979
|
})
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
980
|
+
const tree =
|
|
981
|
+
result !== null && typeof result === 'object'
|
|
982
|
+
? Reflect.get(result, 'accessibility')
|
|
983
|
+
: undefined
|
|
984
|
+
if (!isPeachAccessibilityTree(tree))
|
|
985
|
+
throw new Error('engine returned an invalid accessibility snapshot')
|
|
986
|
+
const nodes: AccessibilityTreeNode[] = []
|
|
987
|
+
const visit = (node: PeachAccessibilityNode): void => {
|
|
988
|
+
if (node.isAccessibilityElement) {
|
|
989
|
+
nodes.push({
|
|
990
|
+
role: node.role,
|
|
991
|
+
label: node.label,
|
|
992
|
+
hint: node.hint,
|
|
993
|
+
state: node.state ? { ...node.state } : null,
|
|
994
|
+
testID: node.testID,
|
|
930
995
|
position: {
|
|
931
|
-
x: Math.round(node.
|
|
932
|
-
y: Math.round(node.
|
|
996
|
+
x: Math.round(node.frame.x),
|
|
997
|
+
y: Math.round(node.frame.y),
|
|
933
998
|
},
|
|
934
999
|
size: {
|
|
935
|
-
w: Math.round(node.
|
|
936
|
-
h: Math.round(node.
|
|
1000
|
+
w: Math.round(node.frame.width),
|
|
1001
|
+
h: Math.round(node.frame.height),
|
|
937
1002
|
},
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
|
|
1003
|
+
})
|
|
1004
|
+
if (node.platform === 'ios') return
|
|
1005
|
+
}
|
|
1006
|
+
for (const child of node.children) visit(child)
|
|
1007
|
+
}
|
|
1008
|
+
for (const root of tree) visit(root)
|
|
1009
|
+
return nodes
|
|
941
1010
|
}
|
|
942
1011
|
|
|
943
1012
|
// ─── find ───
|