Skip to content

Commit aa5b07f

Browse files
authored
fix: keep iOS runner hot across app closes (#700)
* fix: keep iOS runner hot across app closes * fix: keep iOS snapshots fast after relaunch * fix: bound compact iOS snapshots on broken AX trees * docs: clarify iOS snapshot backend strategy * test: update Settings replay selectors * fix: stabilize Settings replay selectors * fix: fall back for selector interactions * chore: simplify flat interactive snapshot query
1 parent f2424f9 commit aa5b07f

21 files changed

Lines changed: 973 additions & 90 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# ADR 0004: iOS Snapshot Backend Strategy
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
Agent Device exposes iOS UI state through snapshots produced by the long-lived XCTest runner. The
10+
runner has two different snapshot needs:
11+
12+
- rich diagnostics and selector disambiguation, where a recursive XCTest snapshot is useful because
13+
it preserves hierarchy, static text, wrappers, scroll containers, and ancestry;
14+
- agent-facing compact interactive context, where the important contract is fast, bounded discovery
15+
of visible controls and stable refs for the next action.
16+
17+
These needs should not share one capture strategy blindly. Recursive `XCUIElement.snapshot()` is
18+
rich, but some real simulator app trees can make XCTest fail with `kAXErrorIllegalArgument` while
19+
the same app remains visually usable and can be inspected by lower-level simulator accessibility
20+
services. Bluesky is the current known example: Argent's `ax-service` can describe the screen, but
21+
XCTest recursive snapshots and typed `XCUIElementQuery` enumeration can degrade to no useful child
22+
nodes.
23+
24+
This is different from presentation filtering. The daemon's snapshot presentation can hide noisy
25+
or inaccessible nodes, but it cannot recover nodes that XCTest never returns. More filters,
26+
Maestro-specific heuristics, or retries in the daemon would only make this failure slower and less
27+
predictable.
28+
29+
## Decision
30+
31+
Keep XCTest as the default iOS automation runner and split iOS snapshot capture into explicit
32+
strategies:
33+
34+
- **Full tree strategy**: use recursive XCTest snapshots for normal/full snapshots, raw snapshots,
35+
diagnostics, and cases that need hierarchy. If XCTest reports a real AX serialization failure,
36+
preserve that error instead of pretending the UI is empty.
37+
- **Compact interactive strategy**: for `snapshot -i -c`, use a bounded flat XCTest query strategy
38+
that avoids recursive root snapshots and app/window property reads. It should prefer fast,
39+
one-screen actionability over hierarchy fidelity and should return a sparse root quickly when
40+
XCTest cannot enumerate controls.
41+
- **Future simulator AX-service strategy**: treat Bluesky-class failures as evidence that XCTest is
42+
not a complete semantic snapshot backend. A robust semantic fix should add a host-side simulator
43+
accessibility backend, similar in role to `idb` accessibility commands or Argent's `ax-service`,
44+
and normalize its output into the same `SnapshotNode` model. That backend can be simulator-only;
45+
physical devices can continue using XCTest unless a supported lower-level API exists.
46+
47+
The daemon should make degraded compact output observable. If an iOS compact interactive snapshot
48+
contains only the synthetic application root, surface a warning so agents know the snapshot is
49+
bounded fallback output rather than proof that the screen has no controls.
50+
51+
## Regression Notes
52+
53+
PR #639 made XCTest AX serialization failures explicit instead of swallowing them as empty
54+
snapshots. That was the correct diagnostic change, but it exposed apps whose accessibility trees
55+
XCTest cannot serialize.
56+
57+
The first compact fallback then still paid several XCTest reads (`app.label`, `app.identifier`,
58+
`app.frame`, window frame lookup) before enumerating flat controls. On broken trees those reads can
59+
hit the same AX failure path, which made `snapshot -i -c` much slower than the plain snapshot in
60+
some apps. PR #700 changed compact interactive snapshots to enter the flat strategy immediately and
61+
avoid those app/window reads.
62+
63+
## Consequences
64+
65+
Compact interactive snapshots are allowed to be less complete than full snapshots, but they must be
66+
bounded and honest. They should never block for the full daemon snapshot timeout because one app has
67+
a pathological AX tree.
68+
69+
Full snapshots remain the right tool when hierarchy matters. They may still fail loudly on
70+
XCTest-broken trees; that failure is useful because retrying the same recursive capture is unlikely
71+
to reveal a different tree.
72+
73+
A future AX-service backend is the correct place to regain Bluesky-class semantic coverage. It
74+
should be added as a platform backend with its own lifecycle, protocol, normalization, timing
75+
metrics, and fallback rules, not as another special case inside the XCTest runner.
76+
77+
When adding new iOS snapshot behavior, maintainers should first decide which strategy owns it. If a
78+
change tries to make compact snapshots rich by reintroducing recursive snapshots, or tries to make
79+
full snapshots fast by hiding XCTest failures, it is probably crossing strategy boundaries.

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,18 @@ extension RunnerTests {
88
}
99

1010
func resolveAlert(app activeApp: XCUIApplication) -> RunnerAlert? {
11+
#if !os(macOS)
12+
if let systemModal = firstBlockingSystemModal(in: springboard) {
13+
return runnerAlert(root: systemModal, ownerApp: springboard)
14+
}
15+
#endif
1116
if let alert = firstExistingElement(in: activeApp.alerts.allElementsBoundByIndex) {
1217
return runnerAlert(root: alert, ownerApp: activeApp)
1318
}
1419
if let popup = firstDismissPopupWindow(in: activeApp) {
1520
return runnerAlert(root: popup, ownerApp: activeApp)
1621
}
17-
#if os(macOS)
18-
return nil
19-
#else
20-
if let systemModal = firstBlockingSystemModal(in: springboard) {
21-
return runnerAlert(root: systemModal, ownerApp: springboard)
22-
}
2322
return nil
24-
#endif
2523
}
2624

2725
func handleAlert(_ alert: RunnerAlert, action: String) -> Response {
@@ -33,6 +31,27 @@ extension RunnerTests {
3331
if let response = unsupportedResponse(for: outcome) {
3432
return response
3533
}
34+
sleepFor(0.2)
35+
if alertStillVisible(alert, actionButtonLabel: button.label) {
36+
let frame = button.frame
37+
if !frame.isNull && !frame.isEmpty {
38+
let coordinateOutcome = tapAt(app: alert.ownerApp, x: frame.midX, y: frame.midY)
39+
if let response = unsupportedResponse(for: coordinateOutcome) {
40+
return response
41+
}
42+
sleepFor(0.2)
43+
}
44+
}
45+
if alertStillVisible(alert, actionButtonLabel: button.label) {
46+
return Response(
47+
ok: false,
48+
error: ErrorPayload(
49+
code: "INTERACTION_FAILED",
50+
message: "alert \(action) did not dismiss the visible alert",
51+
hint: "The alert button was found but the system still reports the alert after tapping it."
52+
)
53+
)
54+
}
3655
return Response(ok: true, data: DataPayload(message: action == "accept" ? "accepted" : "dismissed"))
3756
}
3857

@@ -53,6 +72,21 @@ extension RunnerTests {
5372
return RunnerAlert(root: root, ownerApp: ownerApp, buttons: buttons)
5473
}
5574

75+
private func alertStillVisible(_ alert: RunnerAlert, actionButtonLabel: String) -> Bool {
76+
guard let current = resolveAlert(app: alert.ownerApp) else {
77+
return false
78+
}
79+
let previousTitle = preferredAlertTitle(alert.root, buttons: alert.buttons)
80+
let currentTitle = preferredAlertTitle(current.root, buttons: current.buttons)
81+
if previousTitle == currentTitle {
82+
return true
83+
}
84+
let normalizedActionLabel = actionButtonLabel.trimmingCharacters(in: .whitespacesAndNewlines)
85+
return current.buttons.contains { button in
86+
button.label.trimmingCharacters(in: .whitespacesAndNewlines) == normalizedActionLabel
87+
}
88+
}
89+
5690
private func firstExistingElement(in elements: [XCUIElement]) -> XCUIElement? {
5791
elements.first { isVisibleElement($0) }
5892
}

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ extension RunnerTests {
1616
.scrollView,
1717
.table
1818
]
19+
private static let flatInteractiveFallbackBudget: TimeInterval = 1.0
1920

2021
private struct SnapshotTraversalContext {
2122
let queryRoot: XCUIElement
@@ -85,11 +86,22 @@ extension RunnerTests {
8586
}
8687

8788
func snapshotFast(app: XCUIApplication, options: SnapshotOptions) throws -> DataPayload {
89+
if options.interactiveOnly && options.compact {
90+
return snapshotFlatInteractive(app: app, options: options)
91+
}
8892
if let blocking = blockingSystemAlertSnapshot() {
8993
return blocking
9094
}
9195

92-
guard let context = try makeSnapshotTraversalContext(app: app, options: options) else {
96+
let context: SnapshotTraversalContext?
97+
do {
98+
context = try makeSnapshotTraversalContext(app: app, options: options)
99+
} catch let failure as SnapshotCaptureFailure where options.interactiveOnly {
100+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK=%@", failure.message)
101+
return snapshotFlatInteractive(app: app, options: options)
102+
}
103+
104+
guard let context else {
93105
return DataPayload(nodes: [], truncated: false)
94106
}
95107

@@ -248,6 +260,102 @@ extension RunnerTests {
248260
return DataPayload(nodes: nodes, truncated: truncated)
249261
}
250262

263+
private func snapshotFlatInteractive(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
264+
var nodes: [SnapshotNode] = [
265+
compactInteractiveRootNode(rect: .zero)
266+
]
267+
if options.depth == 0 {
268+
return DataPayload(nodes: nodes, truncated: false)
269+
}
270+
271+
let deadline = options.interactiveOnly
272+
? Date().addingTimeInterval(Self.flatInteractiveFallbackBudget)
273+
: Date.distantFuture
274+
var seen = Set<String>()
275+
var candidates: [SnapshotNode] = []
276+
for element in flatInteractiveElements(app: app, deadline: deadline) {
277+
if Date() >= deadline {
278+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE")
279+
break
280+
}
281+
guard let node = flatSnapshotNode(
282+
element: element,
283+
index: 0,
284+
parentIndex: 0,
285+
viewport: .infinite,
286+
options: options
287+
) else {
288+
continue
289+
}
290+
let key = "\(node.type)-\(node.label ?? "")-\(node.identifier ?? "")-\(node.value ?? "")-\(node.rect.x)-\(node.rect.y)-\(node.rect.width)-\(node.rect.height)"
291+
if seen.contains(key) { continue }
292+
seen.insert(key)
293+
candidates.append(node)
294+
}
295+
candidates.sort { left, right in
296+
if left.rect.y != right.rect.y {
297+
return left.rect.y < right.rect.y
298+
}
299+
if left.rect.x != right.rect.x {
300+
return left.rect.x < right.rect.x
301+
}
302+
return left.type < right.type
303+
}
304+
305+
let remaining = max(0, fastSnapshotLimit - nodes.count)
306+
let truncated = candidates.count > remaining
307+
nodes[0] = compactInteractiveRootNode(rect: compactInteractiveRootFrame(for: candidates))
308+
for candidate in candidates.prefix(remaining) {
309+
nodes.append(
310+
SnapshotNode(
311+
index: nodes.count,
312+
type: candidate.type,
313+
label: candidate.label,
314+
identifier: candidate.identifier,
315+
value: candidate.value,
316+
rect: candidate.rect,
317+
enabled: candidate.enabled,
318+
focused: candidate.focused,
319+
selected: candidate.selected,
320+
hittable: candidate.hittable,
321+
depth: 1,
322+
parentIndex: 0,
323+
hiddenContentAbove: nil,
324+
hiddenContentBelow: nil
325+
)
326+
)
327+
}
328+
return DataPayload(nodes: nodes, truncated: truncated)
329+
}
330+
331+
private func compactInteractiveRootNode(rect: CGRect) -> SnapshotNode {
332+
SnapshotNode(
333+
index: 0,
334+
type: "Application",
335+
label: nil,
336+
identifier: nil,
337+
value: nil,
338+
rect: snapshotRect(from: rect),
339+
enabled: true,
340+
focused: nil,
341+
selected: nil,
342+
hittable: false,
343+
depth: 0,
344+
parentIndex: nil,
345+
hiddenContentAbove: nil,
346+
hiddenContentBelow: nil
347+
)
348+
}
349+
350+
private func compactInteractiveRootFrame(for candidates: [SnapshotNode]) -> CGRect {
351+
guard !candidates.isEmpty else {
352+
return .zero
353+
}
354+
let maxX = candidates.map { CGFloat($0.rect.x + $0.rect.width) }.max() ?? 0
355+
let maxY = candidates.map { CGFloat($0.rect.y + $0.rect.height) }.max() ?? 0
356+
return CGRect(x: 0, y: 0, width: max(1, maxX), height: max(1, maxY))
357+
}
358+
251359
func snapshotRect(from frame: CGRect) -> SnapshotRect {
252360
return SnapshotRect(
253361
x: Double(frame.origin.x),
@@ -714,6 +822,107 @@ extension RunnerTests {
714822
safely("SNAPSHOT_QUERY", [], fetch)
715823
}
716824

825+
private func flatInteractiveElements(
826+
app: XCUIApplication,
827+
deadline: Date
828+
) -> [XCUIElement] {
829+
let queries: [XCUIElementQuery] = [
830+
app.buttons,
831+
app.links,
832+
app.textFields,
833+
app.secureTextFields,
834+
app.searchFields,
835+
app.textViews,
836+
app.switches,
837+
app.sliders,
838+
app.segmentedControls,
839+
app.cells,
840+
app.collectionViews,
841+
app.tables,
842+
app.scrollViews,
843+
app.pickers,
844+
app.steppers,
845+
app.tabBars,
846+
app.menuItems
847+
]
848+
849+
var elements: [XCUIElement] = []
850+
for query in queries {
851+
if Date() >= deadline {
852+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_FALLBACK_DEADLINE")
853+
break
854+
}
855+
elements.append(contentsOf: safeSnapshotElementsQuery {
856+
query.allElementsBoundByIndex
857+
})
858+
}
859+
return elements
860+
}
861+
862+
private func flatSnapshotNode(
863+
element: XCUIElement,
864+
index: Int,
865+
parentIndex: Int?,
866+
viewport: CGRect,
867+
options: SnapshotOptions
868+
) -> SnapshotNode? {
869+
var node: SnapshotNode?
870+
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
871+
if !element.exists { return }
872+
let frame = element.frame
873+
if frame.isNull || frame.isEmpty { return }
874+
let visible = isVisibleInViewport(frame, viewport)
875+
#if os(macOS)
876+
if !visible { return }
877+
#endif
878+
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
879+
let identifier = element.identifier.trimmingCharacters(in: .whitespacesAndNewlines)
880+
let valueText = snapshotValueText(element)
881+
let hasContent = !label.isEmpty || !identifier.isEmpty || valueText != nil
882+
let elementType = element.elementType
883+
let enabled = element.isEnabled
884+
let hittable = visible && enabled && element.isHittable
885+
if options.compact && !hasContent && !hittable && !interactiveTypes.contains(elementType) {
886+
return
887+
}
888+
if let scope = options.scope?.trimmingCharacters(in: .whitespacesAndNewlines), !scope.isEmpty {
889+
let haystack = [label, identifier, valueText ?? ""].joined(separator: "\n")
890+
if !haystack.localizedCaseInsensitiveContains(scope) {
891+
return
892+
}
893+
}
894+
895+
node = SnapshotNode(
896+
index: index,
897+
type: elementTypeName(elementType),
898+
label: label.isEmpty ? nil : label,
899+
identifier: identifier.isEmpty ? nil : identifier,
900+
value: valueText,
901+
rect: snapshotRect(from: frame),
902+
enabled: enabled,
903+
focused: elementHasFocus(element) ? true : nil,
904+
selected: element.isSelected ? true : nil,
905+
hittable: hittable,
906+
depth: 1,
907+
parentIndex: parentIndex,
908+
hiddenContentAbove: nil,
909+
hiddenContentBelow: nil
910+
)
911+
})
912+
if let exceptionMessage {
913+
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_FLAT_IGNORED_EXCEPTION=%@", exceptionMessage)
914+
return nil
915+
}
916+
return node
917+
}
918+
919+
private func nonEmptyElementText(_ read: () -> String) -> String? {
920+
let value = safely("SNAPSHOT_FLAT_TEXT", "") {
921+
read()
922+
}.trimmingCharacters(in: .whitespacesAndNewlines)
923+
return value.isEmpty ? nil : value
924+
}
925+
717926
private func isScrollableContainer(_ snapshot: XCUIElementSnapshot, visible: Bool) -> Bool {
718927
if !visible { return false }
719928
if !Self.scrollContainerTypes.contains(snapshot.elementType) { return false }

0 commit comments

Comments
 (0)