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.
- package/README.md +8 -5
- package/cli/bridge-flow-runner.ts +442 -14
- package/cli/command-registry.ts +1 -0
- package/cli/commands/control.ts +42 -5
- package/cli/commands/inspect/core.ts +93 -24
- package/cli/commands/maestro.ts +2 -2
- package/cli/commands/serve.ts +13 -1
- package/cli/commands/swift.ts +326 -0
- 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/cli/main.ts +11 -0
- package/cli/outbound-endpoints.ts +5 -1
- 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 +88 -23
- 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 +441 -94
- 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 +89 -8
- package/dist-lib/swift-submit.mjs +78 -8
- package/dist-lib/swift.cjs +16 -33220
- package/dist-lib/vite-swift.cjs +510 -253
- package/dist-lib/vite.cjs +13 -4
- package/package.json +1 -1
- package/skills/contrast/SKILL.md +1 -0
- package/src/assetCatalogContract.ts +16 -0
- 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/metro-runtime-source.ts +15 -0
- package/src/native-seam-manifest.ts +4 -6
- package/src/swift-compile.ts +105 -9
- package/src/swift-guest.ts +51 -0
- package/src/swift-host.ts +900 -0
- package/src/swift-submit.ts +97 -6
- package/src/swift.ts +36 -8
- package/src/swiftContract.ts +52 -0
- package/src/vite-plugin-one.ts +4 -3
- 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
|
|
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()
|
package/cli/command-registry.ts
CHANGED
package/cli/commands/control.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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)
|