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.
Files changed (79) hide show
  1. package/README.md +8 -5
  2. package/cli/bridge-flow-runner.ts +442 -14
  3. package/cli/commands/inspect/core.ts +93 -24
  4. package/cli/commands/maestro.ts +2 -2
  5. package/cli/commands/serve.ts +13 -1
  6. package/cli/drivers/playwright-provisioning.ts +8 -16
  7. package/cli/drivers/playwright-sim-host.ts +3 -0
  8. package/cli/drivers/playwright.ts +1 -0
  9. package/cli/internal-child.ts +51 -8
  10. package/dist-lib/agent-daemon-client.cjs +1 -1
  11. package/dist-lib/agent-events.cjs +1 -1
  12. package/dist-lib/agent-identity.cjs +1 -1
  13. package/dist-lib/agent-sessions.cjs +1 -1
  14. package/dist-lib/attached-projects.cjs +1 -1
  15. package/dist-lib/auth/shared-session.cjs +1 -1
  16. package/dist-lib/backend-origin.cjs +1 -1
  17. package/dist-lib/beta.cjs +1 -1
  18. package/dist-lib/beta.mjs +1 -1
  19. package/dist-lib/bridge-constants.cjs +1 -1
  20. package/dist-lib/bridge-contract-input.cjs +3 -2
  21. package/dist-lib/bridge-contract-input.mjs +3 -2
  22. package/dist-lib/bridge-contract.cjs +1 -1
  23. package/dist-lib/bridge-contract.mjs +1 -1
  24. package/dist-lib/capture-contract.cjs +1 -1
  25. package/dist-lib/capture-contract.mjs +1 -1
  26. package/dist-lib/cli-constants.cjs +1 -1
  27. package/dist-lib/cloud-contract.cjs +1 -1
  28. package/dist-lib/cloud-contract.mjs +1 -1
  29. package/dist-lib/cloud-test-args.cjs +1 -1
  30. package/dist-lib/cloud-test-args.mjs +1 -1
  31. package/dist-lib/cloud.cjs +1 -1
  32. package/dist-lib/cloud.mjs +1 -1
  33. package/dist-lib/config.cjs +1 -1
  34. package/dist-lib/detox/index.cjs +1 -1
  35. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  36. package/dist-lib/home-paths.cjs +1 -1
  37. package/dist-lib/host/bridge-host.cjs +4 -2
  38. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  39. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  40. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  41. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  42. package/dist-lib/host/websocket-proxy.cjs +1 -1
  43. package/dist-lib/index.cjs +76 -20
  44. package/dist-lib/jump-to-source-babel.cjs +1 -1
  45. package/dist-lib/jump-to-source-native.cjs +1 -1
  46. package/dist-lib/kotlin-host.cjs +1 -1
  47. package/dist-lib/menu.cjs +1 -1
  48. package/dist-lib/menu.mjs +1 -1
  49. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  50. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  51. package/dist-lib/metro-production-bundle.cjs +1 -1
  52. package/dist-lib/metro-production-bundle.mjs +1 -1
  53. package/dist-lib/metro.cjs +1 -1
  54. package/dist-lib/native-app-identity.cjs +1 -1
  55. package/dist-lib/native-app-identity.mjs +1 -1
  56. package/dist-lib/profiles.cjs +1 -1
  57. package/dist-lib/public-brand.cjs +1 -1
  58. package/dist-lib/react-native-host-modules.cjs +1 -1
  59. package/dist-lib/react-native-host-modules.mjs +1 -1
  60. package/dist-lib/render-mode.cjs +1 -1
  61. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  62. package/dist-lib/sdk.cjs +78 -20
  63. package/dist-lib/sdk.mjs +77 -20
  64. package/dist-lib/skills.cjs +394 -62
  65. package/dist-lib/swift-compile.cjs +89 -7
  66. package/dist-lib/swift-compile.mjs +89 -7
  67. package/dist-lib/swift-package.cjs +1 -1
  68. package/dist-lib/swift-package.mjs +1 -1
  69. package/dist-lib/swift-submit.cjs +1 -1
  70. package/dist-lib/swift-submit.mjs +1 -1
  71. package/dist-lib/swift.cjs +122 -34
  72. package/dist-lib/vite-swift.cjs +89 -7
  73. package/dist-lib/vite.cjs +1 -1
  74. package/package.json +1 -1
  75. package/src/bridge-contract-input.ts +3 -2
  76. package/src/bridge-contract.ts +8 -0
  77. package/src/host/bridge-host.ts +5 -5
  78. package/src/native-seam-manifest.ts +4 -6
  79. 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, asks that exact package for
239
- its required browser executable, and runs that package's installer once when
240
- the executable is absent. It never substitutes another revision found in the
241
- shared Playwright cache. Later opens reuse that sim and reload it at the
242
- requested target. Use `peach do reload` when the target has not changed.
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 { inspectWaitReady, waitReadyReason } from './commands/inspect/core'
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 { SootSimReplayTarget, SootSimTapResult } from '@peach/globals'
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: `containsChild` and
109
- // `containsDescendants` walk the ios accessibility hierarchy, which hides the
110
- // children of an accessible element; `focused` is XCUIElement.hasFocus; `css`
111
- // is web only.
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 || SELECTOR_FILTER_KEYS.some((key) => opts[key] != 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
- // `get a11y` — flat accessibility summary projected from the visible semantic
908
- // tree, which already excludes hidden and covered content, minus the painted
909
- // content VoiceOver cannot reach.
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: 'find', selector: { visible: true } },
978
+ query: { kind: 'accessibility' },
916
979
  })
917
- if (!isSemanticQueryResult(result)) return []
918
- return result.nodes.flatMap((node) => {
919
- if (node.accessibilityHidden) return []
920
- if (!node.role && !node.label && !node.text && !node.pressable) return []
921
- return [
922
- {
923
- role:
924
- node.role ??
925
- (node.pressable ? 'button' : node.type === 'text' ? 'statictext' : 'none'),
926
- label: node.label ?? node.text ?? null,
927
- hint: null,
928
- state: null,
929
- testID: node.testID ?? null,
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.geometry.x),
932
- y: Math.round(node.geometry.y),
996
+ x: Math.round(node.frame.x),
997
+ y: Math.round(node.frame.y),
933
998
  },
934
999
  size: {
935
- w: Math.round(node.geometry.width),
936
- h: Math.round(node.geometry.height),
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 ───