Skip to content

Commit 9483b41

Browse files
committed
docs: clarify iOS snapshot backend strategy
1 parent 767eb2b commit 9483b41

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

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/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ Protocol and maintenance references:
3333
- `RunnerTests+SystemModal.swift`: SpringBoard/system modal detection and modal snapshot shaping.
3434
- `RunnerTests+ScreenRecorder.swift`: nested `ScreenRecorder` implementation.
3535

36+
## Snapshot Strategy
37+
38+
iOS snapshots have two explicit capture modes:
39+
40+
- full/raw snapshots use recursive XCTest snapshots for rich hierarchy and diagnostics;
41+
- compact interactive snapshots use a bounded flat XCTest query path for fast agent-facing refs.
42+
43+
Some simulator apps expose accessibility trees that lower-level AX services can inspect but XCTest
44+
cannot serialize reliably. In those cases compact interactive snapshots may return a sparse root
45+
quickly, while full snapshots preserve the XCTest error. See
46+
[`../docs/adr/0004-ios-snapshot-backend-strategy.md`](../docs/adr/0004-ios-snapshot-backend-strategy.md)
47+
for the backend boundary and the rationale for a future simulator AX-service backend.
48+
3649
## Protocol Notes
3750

3851
- The daemon posts JSON commands to `POST /command` on the runner's local HTTP listener.

src/__tests__/runtime-snapshot.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,43 @@ test('runtime snapshot warns when Android helper falls back to stock UIAutomator
126126
]);
127127
});
128128

129+
test('runtime snapshot warns when iOS compact interactive output is root-only', async () => {
130+
const device = createSnapshotOnlyDevice({
131+
nodes: [{ ref: 'e1', index: 0, depth: 0, type: 'Application' }],
132+
truncated: false,
133+
backend: 'xctest',
134+
});
135+
136+
const result = await device.capture.snapshot({
137+
session: 'default',
138+
interactiveOnly: true,
139+
compact: true,
140+
});
141+
142+
assert.deepEqual(result.warnings, [
143+
'iOS compact interactive snapshot exposed only the application root. XCTest typed accessibility queries can fail to enumerate some simulator UI trees even when screenshots and direct gestures still work. Use screenshot as visual truth, try a scoped/full snapshot for diagnostics, and prefer direct selectors when known.',
144+
]);
145+
});
146+
147+
test('runtime snapshot does not warn for a normal iOS compact interactive output', async () => {
148+
const device = createSnapshotOnlyDevice({
149+
nodes: [
150+
{ ref: 'e1', index: 0, depth: 0, type: 'Application' },
151+
{ ref: 'e2', index: 1, depth: 1, type: 'Button', label: 'Continue' },
152+
],
153+
truncated: false,
154+
backend: 'xctest',
155+
});
156+
157+
const result = await device.capture.snapshot({
158+
session: 'default',
159+
interactiveOnly: true,
160+
compact: true,
161+
});
162+
163+
assert.equal(result.warnings, undefined);
164+
});
165+
129166
test('runtime snapshot warns when Android hierarchy looks like a React Native overlay', async () => {
130167
const device = createSnapshotOnlyDevice({
131168
nodes: [

src/commands/capture-snapshot.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ function buildSnapshotWarnings(params: {
217217
}): string[] {
218218
const warnings = [...(params.result.warnings ?? [])];
219219
warnings.push(...buildEmptyAndroidInteractiveWarnings(params));
220+
warnings.push(...buildSparseIosInteractiveWarnings(params));
220221

221222
const helperFallbackWarning = formatAndroidHelperFallbackWarning(params.result.androidSnapshot);
222223
if (helperFallbackWarning) warnings.push(helperFallbackWarning);
@@ -231,6 +232,27 @@ function buildSnapshotWarnings(params: {
231232
return Array.from(new Set(warnings));
232233
}
233234

235+
function buildSparseIosInteractiveWarnings(params: {
236+
snapshot: SnapshotState;
237+
options: SnapshotCommandOptions;
238+
}): string[] {
239+
if (
240+
params.snapshot.backend !== 'xctest' ||
241+
params.options.interactiveOnly !== true ||
242+
params.options.compact !== true ||
243+
params.snapshot.nodes.length !== 1
244+
) {
245+
return [];
246+
}
247+
248+
const root = params.snapshot.nodes[0];
249+
if (root?.type !== 'Application') return [];
250+
251+
return [
252+
'iOS compact interactive snapshot exposed only the application root. XCTest typed accessibility queries can fail to enumerate some simulator UI trees even when screenshots and direct gestures still work. Use screenshot as visual truth, try a scoped/full snapshot for diagnostics, and prefer direct selectors when known.',
253+
];
254+
}
255+
234256
function buildEmptyAndroidInteractiveWarnings(params: {
235257
result: BackendSnapshotResult;
236258
snapshot: SnapshotState;

0 commit comments

Comments
 (0)