peachsim 0.1.637 → 0.1.639

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 (94) hide show
  1. package/README.md +8 -5
  2. package/cli/bridge-flow-runner.ts +442 -14
  3. package/cli/command-registry.ts +1 -0
  4. package/cli/commands/control.ts +42 -5
  5. package/cli/commands/inspect/core.ts +93 -24
  6. package/cli/commands/maestro.ts +2 -2
  7. package/cli/commands/serve.ts +13 -1
  8. package/cli/commands/swift.ts +326 -0
  9. package/cli/drivers/playwright-provisioning.ts +8 -16
  10. package/cli/drivers/playwright-sim-host.ts +3 -0
  11. package/cli/drivers/playwright.ts +1 -0
  12. package/cli/internal-child.ts +51 -8
  13. package/cli/main.ts +11 -0
  14. package/cli/outbound-endpoints.ts +5 -1
  15. package/dist-lib/agent-daemon-client.cjs +1 -1
  16. package/dist-lib/agent-events.cjs +1 -1
  17. package/dist-lib/agent-identity.cjs +1 -1
  18. package/dist-lib/agent-sessions.cjs +1 -1
  19. package/dist-lib/attached-projects.cjs +1 -1
  20. package/dist-lib/auth/shared-session.cjs +1 -1
  21. package/dist-lib/backend-origin.cjs +1 -1
  22. package/dist-lib/beta.cjs +1 -1
  23. package/dist-lib/beta.mjs +1 -1
  24. package/dist-lib/bridge-constants.cjs +1 -1
  25. package/dist-lib/bridge-contract-input.cjs +3 -2
  26. package/dist-lib/bridge-contract-input.mjs +3 -2
  27. package/dist-lib/bridge-contract.cjs +1 -1
  28. package/dist-lib/bridge-contract.mjs +1 -1
  29. package/dist-lib/capture-contract.cjs +1 -1
  30. package/dist-lib/capture-contract.mjs +1 -1
  31. package/dist-lib/cli-constants.cjs +1 -1
  32. package/dist-lib/cloud-contract.cjs +1 -1
  33. package/dist-lib/cloud-contract.mjs +1 -1
  34. package/dist-lib/cloud-test-args.cjs +1 -1
  35. package/dist-lib/cloud-test-args.mjs +1 -1
  36. package/dist-lib/cloud.cjs +1 -1
  37. package/dist-lib/cloud.mjs +1 -1
  38. package/dist-lib/config.cjs +1 -1
  39. package/dist-lib/detox/index.cjs +1 -1
  40. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  41. package/dist-lib/home-paths.cjs +1 -1
  42. package/dist-lib/host/bridge-host.cjs +4 -2
  43. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  44. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  45. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  46. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  47. package/dist-lib/host/websocket-proxy.cjs +1 -1
  48. package/dist-lib/index.cjs +88 -23
  49. package/dist-lib/jump-to-source-babel.cjs +1 -1
  50. package/dist-lib/jump-to-source-native.cjs +1 -1
  51. package/dist-lib/kotlin-host.cjs +1 -1
  52. package/dist-lib/menu.cjs +1 -1
  53. package/dist-lib/menu.mjs +1 -1
  54. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  56. package/dist-lib/metro-production-bundle.cjs +1 -1
  57. package/dist-lib/metro-production-bundle.mjs +1 -1
  58. package/dist-lib/metro.cjs +1 -1
  59. package/dist-lib/native-app-identity.cjs +1 -1
  60. package/dist-lib/native-app-identity.mjs +1 -1
  61. package/dist-lib/profiles.cjs +1 -1
  62. package/dist-lib/public-brand.cjs +1 -1
  63. package/dist-lib/react-native-host-modules.cjs +1 -1
  64. package/dist-lib/react-native-host-modules.mjs +1 -1
  65. package/dist-lib/render-mode.cjs +1 -1
  66. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  67. package/dist-lib/sdk.cjs +78 -20
  68. package/dist-lib/sdk.mjs +77 -20
  69. package/dist-lib/skills.cjs +441 -94
  70. package/dist-lib/swift-compile.cjs +89 -7
  71. package/dist-lib/swift-compile.mjs +89 -7
  72. package/dist-lib/swift-package.cjs +1 -1
  73. package/dist-lib/swift-package.mjs +1 -1
  74. package/dist-lib/swift-submit.cjs +89 -8
  75. package/dist-lib/swift-submit.mjs +78 -8
  76. package/dist-lib/swift.cjs +16 -33220
  77. package/dist-lib/vite-swift.cjs +510 -253
  78. package/dist-lib/vite.cjs +13 -4
  79. package/package.json +1 -1
  80. package/skills/contrast/SKILL.md +1 -0
  81. package/src/assetCatalogContract.ts +16 -0
  82. package/src/bridge-contract-input.ts +3 -2
  83. package/src/bridge-contract.ts +8 -0
  84. package/src/host/bridge-host.ts +5 -5
  85. package/src/metro-runtime-source.ts +15 -0
  86. package/src/native-seam-manifest.ts +4 -6
  87. package/src/swift-compile.ts +105 -9
  88. package/src/swift-guest.ts +51 -0
  89. package/src/swift-host.ts +900 -0
  90. package/src/swift-submit.ts +97 -6
  91. package/src/swift.ts +36 -8
  92. package/src/swiftContract.ts +52 -0
  93. package/src/vite-plugin-one.ts +4 -3
  94. package/src/vite-plugin-swift.ts +43 -755
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()
@@ -42,6 +42,7 @@ const NODE_COMMAND_NAMES = [
42
42
  'android',
43
43
  'remote',
44
44
  'preview',
45
+ 'swift',
45
46
  'box',
46
47
  'list',
47
48
  'use',
@@ -99,6 +99,14 @@ interface ControlCommandOptions {
99
99
  runtimeConfig?: RNXConfig
100
100
  }
101
101
 
102
+ export interface OpenCommandResources {
103
+ bridge?: ReturnType<typeof spawn>
104
+ bridgeReady?: boolean
105
+ port?: number
106
+ driver?: DriverLaunchResult & { driverId: DriverId }
107
+ simId?: string
108
+ }
109
+
102
110
  interface DriverConnectWaitOptions {
103
111
  attempts: number
104
112
  intervalMs: number
@@ -184,6 +192,7 @@ function normalizeKnownTarget(target: string): string {
184
192
  export async function ensureBridgeForOpen(
185
193
  preferredPort: number,
186
194
  explicitPort = false,
195
+ resources?: OpenCommandResources,
187
196
  ): Promise<number> {
188
197
  const lock = readDaemonLockfile()
189
198
  assertExplicitBridgeLock(preferredPort, explicitPort, lock)
@@ -218,6 +227,10 @@ export async function ensureBridgeForOpen(
218
227
 
219
228
  await ensureRuntimeForOpen()
220
229
  const child = spawnSootsim(['serve', '--quiet', '--port', String(preferredPort)])
230
+ if (resources) {
231
+ resources.bridge = child
232
+ resources.port = preferredPort
233
+ }
221
234
  let childExit: string | null = null
222
235
  child.once('exit', (code, signal) => {
223
236
  childExit = String(code ?? signal ?? 'unknown')
@@ -252,6 +265,10 @@ export async function ensureBridgeForOpen(
252
265
  console.error(
253
266
  ` Started a local bridge on port ${selectedPort}. Run \`peach daemon install\` to keep it ready between commands.`,
254
267
  )
268
+ if (resources) {
269
+ resources.bridgeReady = true
270
+ resources.port = selectedPort
271
+ }
255
272
  return selectedPort
256
273
  }
257
274
  if (childExit) {
@@ -1048,13 +1065,14 @@ export async function waitForSimMatchOrHostExit(
1048
1065
  commandTimeoutMs: number,
1049
1066
  predicate: (sim: BridgeSimInfo) => boolean,
1050
1067
  hostPid: number | undefined,
1051
- opts: { attempts?: number; intervalMs?: number } = {},
1068
+ opts: { attempts?: number; intervalMs?: number; signal?: AbortSignal } = {},
1052
1069
  ): Promise<WaitForSimMatchOrHostExitResult> {
1053
1070
  const attempts = opts.attempts ?? 30
1054
1071
  const intervalMs = opts.intervalMs ?? 500
1055
1072
  const watchHost = !!hostPid && Number.isInteger(hostPid) && hostPid > 1
1056
1073
  const hostExited = () => watchHost && hostPid !== undefined && !isProcessAlive(hostPid)
1057
1074
  for (let i = 0; i < attempts; i++) {
1075
+ opts.signal?.throwIfAborted()
1058
1076
  if (hostExited()) {
1059
1077
  return { kind: 'host-exit' }
1060
1078
  }
@@ -1430,7 +1448,11 @@ export async function runLinkCommand(args: string[], opts: ControlCommandOptions
1430
1448
 
1431
1449
  export async function runOpenCommand(
1432
1450
  args: string[],
1433
- opts: ControlCommandOptions = {},
1451
+ opts: ControlCommandOptions & {
1452
+ resources?: OpenCommandResources
1453
+ signal?: AbortSignal
1454
+ ownerPid?: number
1455
+ } = {},
1434
1456
  ): Promise<{ simId: string; launched: boolean } | undefined> {
1435
1457
  const parsed = parseBridgeCliArgs(args, {
1436
1458
  port: opts.port,
@@ -1581,7 +1603,14 @@ export async function runOpenCommand(
1581
1603
  }
1582
1604
  }
1583
1605
 
1584
- parsed.wsPort = await ensureBridgeForOpen(parsed.wsPort, parsed.explicitPort)
1606
+ opts.signal?.throwIfAborted()
1607
+ parsed.wsPort = await ensureBridgeForOpen(
1608
+ parsed.wsPort,
1609
+ parsed.explicitPort,
1610
+ opts.resources,
1611
+ )
1612
+ if (opts.resources) opts.resources.port = parsed.wsPort
1613
+ opts.signal?.throwIfAborted()
1585
1614
 
1586
1615
  // playwright is the browser-sim launcher. it owns an isolated profile and
1587
1616
  // can reap exactly its own browser tree. electron remains available for the
@@ -1890,7 +1919,7 @@ export async function runOpenCommand(
1890
1919
 
1891
1920
  const configuredBaseUrl = applyRNXConfigToUrl(baseUrl, runtimeConfig)
1892
1921
  const openUrl = await buildOpenUrl(target, configuredBaseUrl, token, buildShellUrlOpts)
1893
- const ownerPid = getStableOwnerPid()
1922
+ const ownerPid = opts.ownerPid ?? getStableOwnerPid()
1894
1923
  if (!ownerPid) {
1895
1924
  console.error(
1896
1925
  ' could not resolve a stable owning process; refusing unsupervised sim host',
@@ -1903,6 +1932,7 @@ export async function runOpenCommand(
1903
1932
  rnxExit(1)
1904
1933
  }
1905
1934
  const connectWait = resolveDriverConnectWaitOptions()
1935
+ opts.signal?.throwIfAborted()
1906
1936
  const result = await driver.launch({
1907
1937
  url: openUrl,
1908
1938
  // the shell serves the sim page but its WS bridge may have drifted off
@@ -1925,6 +1955,8 @@ export async function runOpenCommand(
1925
1955
  : undefined),
1926
1956
  connectTimeoutMs: connectWait.hostTimeoutMs,
1927
1957
  })
1958
+ if (opts.resources) opts.resources.driver = { ...result, driverId }
1959
+ opts.signal?.throwIfAborted()
1928
1960
  if (!result.launched) {
1929
1961
  console.error(` ${driver.name} driver: ${result.message}`)
1930
1962
  rnxExit(1)
@@ -1934,7 +1966,11 @@ export async function runOpenCommand(
1934
1966
  parsed.commandTimeoutMs,
1935
1967
  simMatchesToken,
1936
1968
  result.pid,
1937
- { attempts: connectWait.attempts, intervalMs: connectWait.intervalMs },
1969
+ {
1970
+ attempts: connectWait.attempts,
1971
+ intervalMs: connectWait.intervalMs,
1972
+ signal: opts.signal,
1973
+ },
1938
1974
  )
1939
1975
  if (outcome.kind === 'host-exit') {
1940
1976
  printDriverDiagnosticLogTail(result)
@@ -1955,6 +1991,7 @@ export async function runOpenCommand(
1955
1991
  rnxExit(1)
1956
1992
  }
1957
1993
  const match = outcome.sim
1994
+ if (opts.resources) opts.resources.simId = match.id
1958
1995
  saveCurrentSimId(match.id)
1959
1996
  signalDriverLaunchConnected(result)
1960
1997
  printOpenedSim(openUrl, match, `${driver.name} driver`, quiet)