Skip to content

Commit 37d67de

Browse files
authored
fix(android): carry accessibility selected state into snapshots (#2515)
The snapshot helper never serialized `selected`, and the host reads only the helper's XML, so no later layer could recover it: `get attrs` had no `selected` field, no snapshot node was marked selected, `is selected` could not match, and a Maestro `assertVisible {id, selected: true}` failed with "Maestro visible condition did not match" for a visible element while `selected: false` matched every Android node (#2462). The helper now emits both answers, like `enabled` and `password`, so an unselected control answers `false` and a helper older than the attribute answers nothing. The parser, the Android hierarchy node, and the published snapshot node carry it to `get attrs` and the `[selected]` marker. Snapshot lines render that marker on the default formatter path too: `--settle` and `diff` already compared selection, and a line that weighs a fact it cannot print turns a tab tap into a changed pair whose two lines look identical.
1 parent 49fbaf6 commit 37d67de

13 files changed

Lines changed: 225 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
## Unreleased
44

5+
- Fixed: Android snapshots carry the accessibility `selected` state an app sets on a control, so
6+
`is selected`, a `selected=true` selector, and a Maestro `selected:` qualifier work on Android
7+
(#2462). The helper never serialized the attribute, and the host reads only the helper's XML, so
8+
no later layer could recover it: `get attrs` had no `selected` field, no snapshot
9+
node was marked selected, and the same assertion that passed on iOS failed on Android with
10+
"Maestro visible condition did not match" for an element that is visible — while `selected: false`
11+
matched every Android node. The helper now emits both answers, like `enabled` and `password`, so
12+
an unselected control answers `false` and a helper older than the attribute answers nothing; the
13+
host parser, the Android hierarchy node, and the published snapshot node carry it through to
14+
`get attrs` and the `[selected]` marker in snapshot text. Snapshot lines now render that marker
15+
whenever selection is rendered, not only when text surfaces are summarized: `--settle` and `diff`
16+
already compared selection, and a line that compares a fact it cannot display turns a tab tap
17+
into a changed pair whose two lines look identical.
518
- Fixed: Replay test artifacts with colliding filenames retain distinct copies without overwriting
619
other diagnostics, replay sources, timing traces, or attempt manifests.
720
- Fixed: Custom test reporters reject invalid exit codes, including values such as `256` that
@@ -230,7 +243,7 @@
230243
- iOS regular snapshots now apply one backend-neutral eligibility rule after every capture backend: a node survives when its accessibility type is interactive or it carries a non-empty label, identifier, or value. This removes the tree backend's extra "hittable non-Other" membership path and drops unlabeled decorative nodes consistently; labeled images, identifier-only nodes, and value-only nodes still survive. Raw snapshot membership is unchanged.
231244
- iOS regular snapshots now run one shared clip fold inside presentation for every capture backend (#1797). Backends serialize reported facts -- every traversed node, at raw traversal depth -- and presentation alone decides what the viewport and scroll clips hide, books the scroll hints, and collapses depth; no backend carries its own copy of that interpretation anymore (the copies are what produced the scroll-overflow leak class, #1784). Three intentional edge deltas ride along, all in the direction of one backend-neutral rule: sub-pixel content-free decorations are now dropped by every backend (previously private-AX only); labeled offscreen Application/Window carriers now survive on every backend (previously tree only), still never hittable; and a query-sweep recovery snapshot without `-i` no longer lists offscreen elements. Nothing outside its clip, and nothing without geometry, is ever `hittable` in a regular snapshot, whatever the backend reported.
232245
- iOS `snapshot --raw` is now the acquired accessibility tree on every backend that can serve it (#1797). A raw request that recovered onto the private-AX backend — the route an app whose XCTest tree capture fails takes — returned the *regular* projection's viewport-pruned nodes labeled raw: everything scrolled out of the viewport, and every sub-pixel decoration, was missing from the one view whose purpose is showing what the pruned view hid. Raw now keeps every node the backend serialized, at traversal depth, and `--depth` still narrows it (for raw, presented depth *is* traversal depth). Two structural rules replace the hand-synchronized ones: the raw capture plan is derived from each backend's declared ability to serve raw, so the interactive query sweep — which has no hierarchy to return — cannot be planned for a raw request; and presentation refuses an acquisition captured for the other projection instead of relabeling it, dropping that tier with a structured failure. Breaking in the same direction: `snapshot --raw -i` now returns the acquired tree instead of an interactive-filtered one — `-i` narrows the regular projection, and the pair used to produce a third membership rule that differed per backend. Regular and `-i` output is unchanged. Backends now read one derived capture hint rather than the request itself, so what a capture is allowed to skip is stated once, next to the proof that skipping it keeps the projection complete.
233-
- Android `snapshot --raw` is now the acquired accessibility tree (#1832 C3): the regular-projection classifiers for nodes Android marks invisible and stale application windows no longer run at parse time, so `--raw` keeps everything the helper serialized (normalization only). Covered same-window surfaces are publication annotations rather than membership pruning. Also: Android blocking-dialog recovery now reads the same daemon presentation an agent's `snapshot` sees instead of a hand-rolled subset, and acts on its occlusion result — a stale "App isn't responding" surface left under the foreground one no longer triggers recovery, and a covered "Close app" is never tapped ahead of the visible one; the Android freshness route signature no longer keys on `role`/`selected`, fields the Android backend never carries; and the Android helper's declared fidelity residues (no `checked`/`checkable`/`selected`/`long-clickable`, 5000-node cap before scoping, API-level cache-reset divergence) are recorded in `CONTEXT.md`.
246+
- Android `snapshot --raw` is now the acquired accessibility tree (#1832 C3): the regular-projection classifiers for nodes Android marks invisible and stale application windows no longer run at parse time, so `--raw` keeps everything the helper serialized (normalization only). Covered same-window surfaces are publication annotations rather than membership pruning. Also: Android blocking-dialog recovery now reads the same daemon presentation an agent's `snapshot` sees instead of a hand-rolled subset, and acts on its occlusion result — a stale "App isn't responding" surface left under the foreground one no longer triggers recovery, and a covered "Close app" is never tapped ahead of the visible one; the Android freshness route signature no longer keys on `role`, a field the Android backend never carries; and the Android helper's declared fidelity residues (no `checked`/`checkable`/`long-clickable`, 5000-node cap before scoping, API-level cache-reset divergence) are recorded in `CONTEXT.md`.
234247
- `agent-device mcp` now carries its own usage guidance, so MCP-only clients (Codex CLI, Cursor, custom agents) no longer depend on a separately installed skill (#1833). The handshake `instructions` — returned by both `server/discover` and, newly, the legacy `initialize` — is a compact (< 2 KB, the Claude Code truncation limit) workflow card: start with `open {app, foreground: true}` instead of probing, act with `settle: true` and continue from the diff, verify with `wait`/`is`/`get`/`find`, copy `@refs` byte-for-byte, recover from sparse/AX-unavailable, follow error hints, `close`. A new MCP-only `help` tool serves the full guides on demand: no `topic` returns the CLI's decision card; `topic` returns `agent-device help <topic|command>` verbatim (workflow, gestures, scripting, tv, macos, web, remote, debugging, …, or any tool name for its complete flag reference), prefixed with the one-line CLI→tool-property mapping. `help` is router-owned rather than a command descriptor, so it appears in `tools/list` only — not in the CLI, Node client, or `batch` — and its description tells the model it is not a startup step. Legacy `initialize` gains the optional `instructions` field; no other legacy field changes.
235248
- Android `snapshot --scope` (and every selector command's `--scope`, e.g. `press "Save" --scope Panel`) now resolves scope exactly once, inside the Android projection, under the shared scope specification: the scope root is the first node **in document order** whose label, value, or identifier contains the scope text (case-insensitive) **and whose subtree still has content in the projection you asked for**, the result is that subtree re-rooted at depth 0, and no match returns an empty snapshot (#1832). That second clause is what makes `snapshot -i --scope panel` return the button inside a structural container `-i` drops, and stops a decorative heading that happens to match from emptying the snapshot. Before, Android ran two passes with contradictory rules — a breadth-first platform match that fell back to the full tree on a miss, then the daemon's document-order pass — so a shallower later container could win over an earlier match, and an interaction capture whose scope reached only the daemon layer was silently unscoped. `--depth` under `--scope` counts from the scope root, filtering the depths the response prints (a node shown at depth 0 is never hidden by `--depth 0`), and ancestor context above the scope root (a clickable row, a list) still shapes `-i` membership inside it. The rule is pinned by `contracts/fixtures/snapshot-scope-policy.json`, the same golden table the iOS runner consumes (#1797).
236249
- New `hover <x y|@ref|selector>` command for `--platform web` (#1783). It moves the pointer over the target without pressing, so hover-gated UI — a message row's `...` toolbar, a menu that opens on pointer enter — becomes reachable through agent-device the way it already was through the underlying `agent-browser` backend (`mouse move`). It is a member of the targeted-touch family: same `@ref`/selector/coordinate targeting, occlusion and off-screen guards, and `--settle` (the settled diff carries the revealed controls with fresh refs, e.g. `+ @e4 [button] "Delete"`), but no `--verify`, since hover reveals rather than activates. `hover @ref` publishes as a portable selector line in recorded scripts, and the Node client exposes `interactions.hover`. Hover is a pointer state that touch platforms do not have, so `capabilities` advertises it on web only and iOS/Android/Linux reject it during admission with `UNSUPPORTED_OPERATION` and a hint naming `--platform web`; `longpress` remains the mobile hold-gesture verb.

android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ static void appendNode(
5959
appendAttribute(xml, "enabled", Boolean.toString(node.isEnabled()));
6060
appendTrueAttribute(xml, "focusable", node.isFocusable());
6161
appendTrueAttribute(xml, "focused", node.isFocused());
62+
// Both answers, unlike the omitted-false booleans above: `false` is an observation, while an
63+
// absent attribute means the helper could not answer. The host keeps that difference, so an
64+
// unselected control reports selected=false and a helper older than this attribute reports
65+
// nothing at all.
66+
appendAttribute(xml, "selected", Boolean.toString(node.isSelected()));
6267
boolean scrollable = node.isScrollable();
6368
if (scrollable) {
6469
appendAttribute(xml, "scrollable", "true");
@@ -141,9 +146,9 @@ private static void appendTrueAttribute(StringBuilder xml, String name, boolean
141146
}
142147
}
143148

144-
// Declared residue (agent-device #1832): checked / checkable / selected / long-clickable are not
145-
// serialized, so toggle and selection state is invisible to agents. Adding them is a helper
146-
// protocol change (new attributes + host parser + fields on the wire node), tracked there.
149+
// Declared residue (agent-device #1832): checked / checkable / long-clickable are not serialized,
150+
// so toggle state is invisible to agents. Adding them is a helper protocol change (new attributes
151+
// + host parser + fields on the wire node), tracked there.
147152
private static void appendDrawingOrderAttribute(StringBuilder xml, AccessibilityNodeInfo node) {
148153
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
149154
appendAttribute(xml, "drawing-order", Integer.toString(node.getDrawingOrder()));
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
4+
import { buildSnapshotDiff } from '../snapshot-diff.ts';
5+
6+
function tab(selected?: boolean): SnapshotNode {
7+
return {
8+
ref: 'e31',
9+
index: 0,
10+
depth: 0,
11+
type: 'android.widget.FrameLayout',
12+
label: 'Home',
13+
enabled: true,
14+
hittable: true,
15+
...(selected === undefined ? {} : { selected }),
16+
};
17+
}
18+
19+
test('a selection-only flip diffs as changed lines that read differently', () => {
20+
const diff = buildSnapshotDiff([tab(false)], [tab(true)]);
21+
const changed = diff.lines.filter((line) => line.kind !== 'unchanged');
22+
23+
assert.equal(diff.summary.unchanged, 0);
24+
assert.equal(changed.length > 0, true);
25+
// The comparable key carries selection, so the rendered line has to as. Diff lines are formatted
26+
// without text-surface summarizing; a line that hid `[selected]` there would print a changed pair
27+
// whose two lines look identical.
28+
assert.match(changed.at(-1)!.text, /\[selected\]/);
29+
assert.doesNotMatch(changed[0]!.text, /\[selected\]/);
30+
});
31+
32+
test.each([
33+
['both unselected', [tab(false)], [tab(false)]],
34+
['unreported against explicit false', [tab()], [tab(false)]],
35+
])('a still bar with %s diffs as unchanged', (_label, previous, current) => {
36+
const diff = buildSnapshotDiff(previous, current);
37+
38+
assert.equal(
39+
diff.lines.every((line) => line.kind === 'unchanged'),
40+
true,
41+
);
42+
assert.equal(
43+
diff.lines.every((line) => !line.text.includes('[selected]')),
44+
true,
45+
);
46+
});

packages/capture-kit/src/snapshot/snapshot-freshness/android.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ export function isNavigationSensitiveAction(command: string): boolean {
2828

2929
/**
3030
* Route signature of an Android snapshot, from the fields the Android backend actually carries.
31-
* The helper serializes no `role`, `selected`, `checked` or `long-clickable` (declared residue,
31+
* The helper serializes no `role`, `checked` or `long-clickable` (declared residue,
3232
* #1832), so a signature keying on them would compare constants and claim discrimination it does
33-
* not have.
33+
* not have. `selected` is left out as a judgement call rather than an inability: a tab-bar tap
34+
* flips it on two nodes, which the 90%-identical threshold below absorbs at every tree size this
35+
* check runs on, so keying on it would only add capture retries.
3436
*/
3537
export function buildSnapshotSignatures(nodes: SnapshotState['nodes']): string[] {
3638
return nodes.map((node) =>

packages/capture-kit/src/snapshot/snapshot-lines.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,14 @@ function buildLineMetadata(
240240
): string[] {
241241
const metadata: string[] = [];
242242
if (node.enabled === false) metadata.push('disabled');
243+
// Selection is a state a snapshot diff can report as changed, and the diff renders its lines
244+
// without text-surface summarizing. A fact the diff compares has to be visible in the line it
245+
// prints, or a selection flip reads as a changed pair whose two lines look identical.
246+
if (node.selected === true) metadata.push('selected');
243247
metadata.push(...(node.presentationHints ?? []));
244248
if (!options.summarizeTextSurfaces) {
245249
return uniqueMetadata(metadata);
246250
}
247-
if (node.selected === true) metadata.push('selected');
248251
if (node.focused === true) metadata.push('focused');
249252
if (isEditableRole(type)) metadata.push('editable');
250253
if (looksScrollable(node, type)) metadata.push('scrollable');
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expect, test } from 'vitest';
2+
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';
3+
4+
// The bottom-tab shape from #2462: sibling tabs whose type, label, and identifier are all
5+
// present and differ only in which one the app marked selected.
6+
function tabBarXml(homeSelected: boolean, localSelected: boolean): string {
7+
return `<hierarchy>
8+
<node class="android.widget.FrameLayout" resource-id="tab-bar" bounds="[0,724][400,800]"
9+
window-index="0" window-type="1" window-layer="1" window-active="true" window-focused="true"
10+
window-bounds="[0,0][400,800]" visible-to-user="true" enabled="true">
11+
<node class="android.view.View" resource-id="home-tab" content-desc="Home"
12+
bounds="[0,724][200,800]" clickable="true" enabled="true" visible-to-user="true"
13+
selected="${homeSelected}" />
14+
<node class="android.view.View" resource-id="local-tab" content-desc="Local"
15+
bounds="[200,724][400,800]" clickable="true" enabled="true" visible-to-user="true"
16+
selected="${localSelected}" />
17+
</node>
18+
</hierarchy>`;
19+
}
20+
21+
// A helper older than the `selected` attribute, or any producer that omits it.
22+
const UNREPORTED_SELECTION_XML =
23+
'<hierarchy><node class="android.view.View" resource-id="legacy-tab" content-desc="Home"' +
24+
' bounds="[0,724][200,800]" clickable="true" enabled="true" visible-to-user="true" /></hierarchy>';
25+
26+
function tabNodes(raw: boolean, interactiveOnly = false) {
27+
const { nodes } = buildUiHierarchySnapshot(
28+
parseUiHierarchyTree(tabBarXml(true, false)),
29+
undefined,
30+
{ raw, interactiveOnly },
31+
);
32+
return {
33+
home: nodes.find((node) => node.identifier === 'home-tab'),
34+
local: nodes.find((node) => node.identifier === 'local-tab'),
35+
};
36+
}
37+
38+
function unreportedSelectionNode() {
39+
return buildUiHierarchySnapshot(parseUiHierarchyTree(UNREPORTED_SELECTION_XML), undefined, {
40+
raw: false,
41+
}).nodes.find((node) => node.identifier === 'legacy-tab');
42+
}
43+
44+
test.each([
45+
{ raw: false, interactiveOnly: false },
46+
{ raw: false, interactiveOnly: true },
47+
{ raw: true, interactiveOnly: false },
48+
{ raw: true, interactiveOnly: true },
49+
])(
50+
'accessibility selection reaches snapshot nodes in every projection (raw=$raw, -i=$interactiveOnly)',
51+
({ raw, interactiveOnly }) => {
52+
const { home, local } = tabNodes(raw, interactiveOnly);
53+
expect(home?.selected).toBe(true);
54+
expect(local?.selected).toBe(false);
55+
},
56+
);
57+
58+
test('attrs answer explicit false where an unreported selection answers nothing', () => {
59+
// Serialized, because that is the answer an agent reads: an unavailable fact drops the key
60+
// while JSON encodes an observed `false`.
61+
const serializedLocal = JSON.parse(JSON.stringify(tabNodes(false).local));
62+
const serializedLegacy = JSON.parse(JSON.stringify(unreportedSelectionNode()));
63+
expect(serializedLocal.selected).toBe(false);
64+
expect(serializedLegacy).not.toHaveProperty('selected');
65+
});
66+
67+
test.each([
68+
{ homeSelected: true, localSelected: false, selectedTabs: ['home-tab'] },
69+
{ homeSelected: false, localSelected: true, selectedTabs: ['local-tab'] },
70+
])(
71+
'selection, not the label, is what names the active tab ($selectedTabs)',
72+
({ homeSelected, localSelected, selectedTabs }) => {
73+
const { nodes } = buildUiHierarchySnapshot(
74+
parseUiHierarchyTree(tabBarXml(homeSelected, localSelected)),
75+
undefined,
76+
{ raw: false },
77+
);
78+
// Both tabs stay addressable by label, so selection is the only thing that can tell them apart.
79+
expect(nodes.filter((node) => node.selected === true).map((node) => node.identifier)).toEqual(
80+
selectedTabs,
81+
);
82+
expect(nodes.filter((node) => node.label === 'Home' || node.label === 'Local')).toHaveLength(2);
83+
},
84+
);
85+
86+
test('unreported selection stays unknown instead of becoming false', () => {
87+
const node = unreportedSelectionNode();
88+
expect(node).toBeDefined();
89+
expect(node?.selected).toBeUndefined();
90+
});

packages/platform-android/src/ui-hierarchy-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ function createAndroidRawSnapshotNode(
352352
rect: node.rect,
353353
enabled: node.enabled,
354354
focused: node.focused,
355+
selected: node.selected,
355356
editable: node.editable,
356357
password: node.password,
357358
hintShowing: node.hintShowing,

packages/platform-android/src/ui-hierarchy-node.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type AndroidUiHierarchy = {
1414
enabled?: boolean;
1515
visibleToUser?: boolean;
1616
focused?: boolean;
17+
selected?: boolean;
1718
editable?: boolean;
1819
password?: boolean;
1920
hintShowing?: boolean;

0 commit comments

Comments
 (0)