diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e7d8cb2d..8474e803bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 ` 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 ` 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. diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java index 852523ac20..0753f242d8 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java @@ -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"); @@ -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())); diff --git a/packages/capture-kit/src/snapshot/__tests__/snapshot-diff-selection.test.ts b/packages/capture-kit/src/snapshot/__tests__/snapshot-diff-selection.test.ts new file mode 100644 index 0000000000..a59604c502 --- /dev/null +++ b/packages/capture-kit/src/snapshot/__tests__/snapshot-diff-selection.test.ts @@ -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, + ); +}); diff --git a/packages/capture-kit/src/snapshot/snapshot-freshness/android.ts b/packages/capture-kit/src/snapshot/snapshot-freshness/android.ts index 33e83726c6..8421f6236c 100644 --- a/packages/capture-kit/src/snapshot/snapshot-freshness/android.ts +++ b/packages/capture-kit/src/snapshot/snapshot-freshness/android.ts @@ -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) => diff --git a/packages/capture-kit/src/snapshot/snapshot-lines.ts b/packages/capture-kit/src/snapshot/snapshot-lines.ts index b422cf4b39..f6004dc995 100644 --- a/packages/capture-kit/src/snapshot/snapshot-lines.ts +++ b/packages/capture-kit/src/snapshot/snapshot-lines.ts @@ -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'); diff --git a/packages/platform-android/src/__tests__/ui-hierarchy-selection.test.ts b/packages/platform-android/src/__tests__/ui-hierarchy-selection.test.ts new file mode 100644 index 0000000000..40ffd55778 --- /dev/null +++ b/packages/platform-android/src/__tests__/ui-hierarchy-selection.test.ts @@ -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 ` + + + + + `; +} + +// A helper older than the `selected` attribute, or any producer that omits it. +const UNREPORTED_SELECTION_XML = + ''; + +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(); +}); diff --git a/packages/platform-android/src/ui-hierarchy-builder.ts b/packages/platform-android/src/ui-hierarchy-builder.ts index cd318d3714..7edae0f476 100644 --- a/packages/platform-android/src/ui-hierarchy-builder.ts +++ b/packages/platform-android/src/ui-hierarchy-builder.ts @@ -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, diff --git a/packages/platform-android/src/ui-hierarchy-node.ts b/packages/platform-android/src/ui-hierarchy-node.ts index db4d438afa..90e4a3fb92 100644 --- a/packages/platform-android/src/ui-hierarchy-node.ts +++ b/packages/platform-android/src/ui-hierarchy-node.ts @@ -14,6 +14,7 @@ export type AndroidUiHierarchy = { enabled?: boolean; visibleToUser?: boolean; focused?: boolean; + selected?: boolean; editable?: boolean; password?: boolean; hintShowing?: boolean; diff --git a/packages/platform-android/src/ui-hierarchy.ts b/packages/platform-android/src/ui-hierarchy.ts index e339b3c35d..e130c89f14 100644 --- a/packages/platform-android/src/ui-hierarchy.ts +++ b/packages/platform-android/src/ui-hierarchy.ts @@ -34,6 +34,7 @@ export type AndroidUiNodeMetadata = { drawingOrder?: number; focusable?: boolean; focused?: boolean; + selected?: boolean; password?: boolean; editable?: boolean; selectionStart?: number; @@ -155,6 +156,7 @@ function readNodeAttributes(node: string): Omit { ...optionalNumberAttr('selectionEnd', 'selection-end'), ...optionalBoolAttr('hintShowing', 'hint-showing'), ...optionalBoolAttr('visibleToUser', 'visible-to-user'), + ...optionalBoolAttr('selected', 'selected'), ...optionalNumberAttr('drawingOrder', 'drawing-order'), ...optionalBoolAttr('scrollable', 'scrollable'), ...optionalBoolAttr('canScrollForward', 'can-scroll-forward'), @@ -306,6 +308,7 @@ function normalizeAndroidUiHierarchyNode( rect: attrs.rect, enabled: attrs.enabled, focused: attrs.focused, + selected: attrs.selected, editable: attrs.editable, password: attrs.password, hintShowing: attrs.hintShowing, diff --git a/src/__tests__/android-ui-hierarchy.test.ts b/src/__tests__/android-ui-hierarchy.test.ts index be05a59936..4de605940c 100644 --- a/src/__tests__/android-ui-hierarchy.test.ts +++ b/src/__tests__/android-ui-hierarchy.test.ts @@ -2,6 +2,8 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; import { createSnapshotVisibility } from '@agent-device/contracts/snapshot'; +import { SELECTOR_PIPELINE_POLICIES } from '@agent-device/selectors/selector-pipeline-policy'; +import { resolveSelectorPipeline } from '@agent-device/selectors/selector-pipeline'; import { androidSnapshotPublicationInput, androidUiNodes, @@ -147,7 +149,7 @@ test('parseUiHierarchy reads Android bounds with negative coordinates', () => { test('androidUiNodes exposes decoded Android hierarchy metadata', () => { const xml = - ''; + ''; assert.deepEqual(Array.from(androidUiNodes(xml)), [ { @@ -164,6 +166,7 @@ test('androidUiNodes exposes decoded Android hierarchy metadata', () => { drawingOrder: 4, focusable: true, focused: true, + selected: true, password: true, windowIndex: 0, windowType: 1, @@ -174,6 +177,41 @@ test('androidUiNodes exposes decoded Android hierarchy metadata', () => { }, ]); assert.equal('drawingOrder' in parseUiHierarchyTree(xml).children[0]!, false); + assert.equal(parseUiHierarchyTree(xml).children[0]!.selected, true); +}); + +// The #2462 screen: a bottom tab bar where the only difference between the selected tab and its +// sibling is the accessibility `selected` flag the helper now carries. +const ANDROID_TAB_BAR_XML = ` + + + + +`; + +test('a published Android snapshot answers a selected-qualified read (#2462)', async () => { + const nodes = publishUiHierarchy(ANDROID_TAB_BAR_XML).nodes; + + // The `get attrs` / `is selected` door, not a hand-picked resolution policy. + const read = async (expression: string) => { + const outcome = await resolveSelectorPipeline( + SELECTOR_PIPELINE_POLICIES.readUnique, + nodes, + expression, + { platform: 'android' }, + ); + return outcome.kind === 'target' ? outcome.node.identifier : outcome.kind; + }; + + assert.equal( + await read('id=com.example.app:id/home-tab selected=true'), + 'com.example.app:id/home-tab', + ); + assert.equal(await read('id=com.example.app:id/local-tab selected=true'), 'none'); + assert.equal( + await read('id=com.example.app:id/local-tab selected=false'), + 'com.example.app:id/local-tab', + ); }); test('parseUiHierarchy discards stale inactive Android application windows', () => { diff --git a/src/commands/output/snapshot.test.ts b/src/commands/output/snapshot.test.ts index b818e5f864..8440986243 100644 --- a/src/commands/output/snapshot.test.ts +++ b/src/commands/output/snapshot.test.ts @@ -799,7 +799,7 @@ test('formatSnapshotText keeps flattened output and adds duplicate nav warning', assert.match(text, /@e2 \[button\] "Inbox"/); }); -test('formatSnapshotLine keeps snapshot-only metadata off the default formatter path', () => { +test('formatSnapshotLine marks selection anywhere, and keeps text-surface metadata off the default path', () => { const line = formatSnapshotLine( { ref: 'e1', @@ -814,7 +814,9 @@ test('formatSnapshotLine keeps snapshot-only metadata off the default formatter 0, false, ); - assert.doesNotMatch(line, /\[selected\]/); + // Selection is a state a snapshot diff compares, and diff lines take this default path: a fact + // the diff weighs has to be readable in the line it prints. Text-surface metadata is not. + assert.match(line, /\[selected\]/); assert.doesNotMatch(line, /\[editable\]/); assert.doesNotMatch(line, /\[scrollable\]/); }); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 1f6f6e7f60..02b9daa53e 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -375,7 +375,7 @@ agent-device get attrs @e1 act on stale React Native screens. API 23 cannot report sibling `drawing-order`, so this scan fails conservative and `androidSnapshot.occlusionScanUnavailable: true` discloses the difference. Android `--raw` is the acquired tree: it also keeps nodes Android marks invisible and stale - application windows. The helper does not report `checked`/`selected` state, and it caps + application windows. The helper does not report `checked`/`checkable` state, and it caps captures at 5000 nodes before any `--scope` applies (`truncated: true`). - `--scope ` returns the subtree of the first node in document order whose label, value, or identifier contains the scope text (case-insensitive) and whose subtree still has content in diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index c2e162c51b..474becad7a 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -113,13 +113,18 @@ the strategy owns which tiers it may use. - Private-accessibility recovery and `--actions` reads are simulator-specific. Physical iOS devices have no equivalent independent semantic backend; they bound the XCTest work with a probe instead. -## Android field metadata +## Android node metadata Android snapshot nodes and `get attrs` (including the digest response) carry the native -`editable`, `password`, `hintShowing`, `selectionStart`, and `selectionEnd` facts whenever the -accessibility tree reports them. Explicit `false` and `0` are kept; an absent field means the fact -was unavailable, not false. `hintShowing` needs Android API 26 or later. - +`selected`, `editable`, `password`, `hintShowing`, `selectionStart`, and `selectionEnd` facts +whenever the accessibility tree reports them. Explicit `false` and `0` are kept; an absent field +means the fact was unavailable, not false. `hintShowing` needs Android API 26 or later. + +- `selected` is the accessibility selected state an app sets on a control — the active bottom-tab or + segmented-control item, or the chosen row of a list. Android reports it explicitly as `true` or + `false`; an older helper APK omits the field, which means the answer is unavailable rather than + unselected. Snapshot text marks the node `[selected]`, and `is selected`, a `selected=true` + selector, and a Maestro `selected:` qualifier all match on it. - `value: ""` is an explicitly empty accessibility text; a missing `value` means no text was reported. The text of an empty field is its hint on modern Android, so check `hintShowing` before reading `value` as the entered contents.