Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

- Fixed: Android snapshots carry the accessibility `selected` state an app sets on a control, so
`is selected`, a `selected=true` selector, and a Maestro `selected:` qualifier work on Android
(#2462). The helper never serialized the attribute, 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, and the same assertion that passed on iOS failed on Android with
"Maestro visible condition did not match" for an element that is visible — while `selected: false`
matched every Android node. 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
host parser, the Android hierarchy node, and the published snapshot node carry it through to
`get attrs` and the `[selected]` marker in snapshot text. Snapshot lines now render that marker
whenever selection is rendered, not only when text surfaces are summarized: `--settle` and `diff`
already compared selection, and a line that compares a fact it cannot display turns a tab tap
into a changed pair whose two lines look identical.
- Fixed: Replay test artifacts with colliding filenames retain distinct copies without overwriting
other diagnostics, replay sources, timing traces, or attempt manifests.
- Fixed: Custom test reporters reject invalid exit codes, including values such as `256` that
Expand Down Expand Up @@ -230,7 +243,7 @@
- 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.
- 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.
- 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.
- 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`.
- 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`.
- `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.
- 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).
- 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ static void appendNode(
appendAttribute(xml, "enabled", Boolean.toString(node.isEnabled()));
appendTrueAttribute(xml, "focusable", node.isFocusable());
appendTrueAttribute(xml, "focused", node.isFocused());
// Both answers, unlike the omitted-false booleans above: `false` is an observation, while an
// absent attribute means the helper could not answer. The host keeps that difference, so an
// unselected control reports selected=false and a helper older than this attribute reports
// nothing at all.
appendAttribute(xml, "selected", Boolean.toString(node.isSelected()));
boolean scrollable = node.isScrollable();
if (scrollable) {
appendAttribute(xml, "scrollable", "true");
Expand Down Expand Up @@ -141,9 +146,9 @@ private static void appendTrueAttribute(StringBuilder xml, String name, boolean
}
}

// Declared residue (agent-device #1832): checked / checkable / selected / long-clickable are not
// serialized, so toggle and selection state is invisible to agents. Adding them is a helper
// protocol change (new attributes + host parser + fields on the wire node), tracked there.
// Declared residue (agent-device #1832): checked / checkable / long-clickable are not serialized,
// so toggle state is invisible to agents. Adding them is a helper protocol change (new attributes
// + host parser + fields on the wire node), tracked there.
private static void appendDrawingOrderAttribute(StringBuilder xml, AccessibilityNodeInfo node) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
appendAttribute(xml, "drawing-order", Integer.toString(node.getDrawingOrder()));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { buildSnapshotDiff } from '../snapshot-diff.ts';

function tab(selected?: boolean): SnapshotNode {
return {
ref: 'e31',
index: 0,
depth: 0,
type: 'android.widget.FrameLayout',
label: 'Home',
enabled: true,
hittable: true,
...(selected === undefined ? {} : { selected }),
};
}

test('a selection-only flip diffs as changed lines that read differently', () => {
const diff = buildSnapshotDiff([tab(false)], [tab(true)]);
const changed = diff.lines.filter((line) => line.kind !== 'unchanged');

assert.equal(diff.summary.unchanged, 0);
assert.equal(changed.length > 0, true);
// The comparable key carries selection, so the rendered line has to as. Diff lines are formatted
// without text-surface summarizing; a line that hid `[selected]` there would print a changed pair
// whose two lines look identical.
assert.match(changed.at(-1)!.text, /\[selected\]/);
assert.doesNotMatch(changed[0]!.text, /\[selected\]/);
});

test.each([
['both unselected', [tab(false)], [tab(false)]],
['unreported against explicit false', [tab()], [tab(false)]],
])('a still bar with %s diffs as unchanged', (_label, previous, current) => {
const diff = buildSnapshotDiff(previous, current);

assert.equal(
diff.lines.every((line) => line.kind === 'unchanged'),
true,
);
assert.equal(
diff.lines.every((line) => !line.text.includes('[selected]')),
true,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ export function isNavigationSensitiveAction(command: string): boolean {

/**
* Route signature of an Android snapshot, from the fields the Android backend actually carries.
* The helper serializes no `role`, `selected`, `checked` or `long-clickable` (declared residue,
* The helper serializes no `role`, `checked` or `long-clickable` (declared residue,
* #1832), so a signature keying on them would compare constants and claim discrimination it does
* not have.
* not have. `selected` is left out as a judgement call rather than an inability: a tab-bar tap
* flips it on two nodes, which the 90%-identical threshold below absorbs at every tree size this
* check runs on, so keying on it would only add capture retries.
*/
export function buildSnapshotSignatures(nodes: SnapshotState['nodes']): string[] {
return nodes.map((node) =>
Expand Down
5 changes: 4 additions & 1 deletion packages/capture-kit/src/snapshot/snapshot-lines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,14 @@ function buildLineMetadata(
): string[] {
const metadata: string[] = [];
if (node.enabled === false) metadata.push('disabled');
// Selection is a state a snapshot diff can report as changed, and the diff renders its lines
// without text-surface summarizing. A fact the diff compares has to be visible in the line it
// prints, or a selection flip reads as a changed pair whose two lines look identical.
if (node.selected === true) metadata.push('selected');
metadata.push(...(node.presentationHints ?? []));
if (!options.summarizeTextSurfaces) {
return uniqueMetadata(metadata);
}
if (node.selected === true) metadata.push('selected');
if (node.focused === true) metadata.push('focused');
if (isEditableRole(type)) metadata.push('editable');
if (looksScrollable(node, type)) metadata.push('scrollable');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect, test } from 'vitest';
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';

// The bottom-tab shape from #2462: sibling tabs whose type, label, and identifier are all
// present and differ only in which one the app marked selected.
function tabBarXml(homeSelected: boolean, localSelected: boolean): string {
return `<hierarchy>
<node class="android.widget.FrameLayout" resource-id="tab-bar" bounds="[0,724][400,800]"
window-index="0" window-type="1" window-layer="1" window-active="true" window-focused="true"
window-bounds="[0,0][400,800]" visible-to-user="true" enabled="true">
<node class="android.view.View" resource-id="home-tab" content-desc="Home"
bounds="[0,724][200,800]" clickable="true" enabled="true" visible-to-user="true"
selected="${homeSelected}" />
<node class="android.view.View" resource-id="local-tab" content-desc="Local"
bounds="[200,724][400,800]" clickable="true" enabled="true" visible-to-user="true"
selected="${localSelected}" />
</node>
</hierarchy>`;
}

// A helper older than the `selected` attribute, or any producer that omits it.
const UNREPORTED_SELECTION_XML =
'<hierarchy><node class="android.view.View" resource-id="legacy-tab" content-desc="Home"' +
' bounds="[0,724][200,800]" clickable="true" enabled="true" visible-to-user="true" /></hierarchy>';

function tabNodes(raw: boolean, interactiveOnly = false) {
const { nodes } = buildUiHierarchySnapshot(
parseUiHierarchyTree(tabBarXml(true, false)),
undefined,
{ raw, interactiveOnly },
);
return {
home: nodes.find((node) => node.identifier === 'home-tab'),
local: nodes.find((node) => node.identifier === 'local-tab'),
};
}

function unreportedSelectionNode() {
return buildUiHierarchySnapshot(parseUiHierarchyTree(UNREPORTED_SELECTION_XML), undefined, {
raw: false,
}).nodes.find((node) => node.identifier === 'legacy-tab');
}

test.each([
{ raw: false, interactiveOnly: false },
{ raw: false, interactiveOnly: true },
{ raw: true, interactiveOnly: false },
{ raw: true, interactiveOnly: true },
])(
'accessibility selection reaches snapshot nodes in every projection (raw=$raw, -i=$interactiveOnly)',
({ raw, interactiveOnly }) => {
const { home, local } = tabNodes(raw, interactiveOnly);
expect(home?.selected).toBe(true);
expect(local?.selected).toBe(false);
},
);

test('attrs answer explicit false where an unreported selection answers nothing', () => {
// Serialized, because that is the answer an agent reads: an unavailable fact drops the key
// while JSON encodes an observed `false`.
const serializedLocal = JSON.parse(JSON.stringify(tabNodes(false).local));
const serializedLegacy = JSON.parse(JSON.stringify(unreportedSelectionNode()));
expect(serializedLocal.selected).toBe(false);
expect(serializedLegacy).not.toHaveProperty('selected');
});

test.each([
{ homeSelected: true, localSelected: false, selectedTabs: ['home-tab'] },
{ homeSelected: false, localSelected: true, selectedTabs: ['local-tab'] },
])(
'selection, not the label, is what names the active tab ($selectedTabs)',
({ homeSelected, localSelected, selectedTabs }) => {
const { nodes } = buildUiHierarchySnapshot(
parseUiHierarchyTree(tabBarXml(homeSelected, localSelected)),
undefined,
{ raw: false },
);
// Both tabs stay addressable by label, so selection is the only thing that can tell them apart.
expect(nodes.filter((node) => node.selected === true).map((node) => node.identifier)).toEqual(
selectedTabs,
);
expect(nodes.filter((node) => node.label === 'Home' || node.label === 'Local')).toHaveLength(2);
},
);

test('unreported selection stays unknown instead of becoming false', () => {
const node = unreportedSelectionNode();
expect(node).toBeDefined();
expect(node?.selected).toBeUndefined();
});
1 change: 1 addition & 0 deletions packages/platform-android/src/ui-hierarchy-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ function createAndroidRawSnapshotNode(
rect: node.rect,
enabled: node.enabled,
focused: node.focused,
selected: node.selected,
editable: node.editable,
password: node.password,
hintShowing: node.hintShowing,
Expand Down
1 change: 1 addition & 0 deletions packages/platform-android/src/ui-hierarchy-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type AndroidUiHierarchy = {
enabled?: boolean;
visibleToUser?: boolean;
focused?: boolean;
selected?: boolean;
editable?: boolean;
password?: boolean;
hintShowing?: boolean;
Expand Down
Loading
Loading