Skip to content

Commit f30328d

Browse files
AdzeBthymikee
andauthored
fix(android): preserve editable-field metadata in snapshots (#2290)
* fix(android): preserve editable-field observation metadata * fix(android): carry field facts through the attrs digest and selection offsets past editability - `get attrs --level digest` kept only the pre-#2288 semantic fields, so `editable`/`password`/`hintShowing`/`selectionStart`/`selectionEnd` vanished on the token-cheap route. The digest now keeps them, with a regression covering explicit false/zero/empty and omission when unavailable. - The helper emitted selection offsets only inside `isEditable()`, but read-only selectable text exposes a selection too. Each nonnegative offset is now emitted independently; -1 stays absent. Parser-to-snapshot regression for a non-editable selectable node. - Docs: the field-metadata notes get their own section instead of leading the efficiency tips. - Dropped the test-isolation commit: main already mocks notifyIosRunnerAppRelaunched, and the lifecycle test passes without it. --------- Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
1 parent e0f8c55 commit f30328d

9 files changed

Lines changed: 140 additions & 10 deletions

File tree

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

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,21 @@ static void appendNode(
3434
if (windowMetadata != null) {
3535
appendWindowMetadata(xml, windowMetadata);
3636
}
37-
appendNonEmptyAttribute(xml, "text", node.getText());
37+
CharSequence text = node.getText();
38+
if (text != null) {
39+
appendAttribute(xml, "text", text);
40+
}
3841
// getText() returns the HINT for an empty field on modern Android, so `text` alone cannot
3942
// distinguish a cleared field from one whose value equals its hint; only this flag can
4043
// (#2063 empty-fill verification).
41-
appendTrueAttribute(
42-
xml,
43-
"hint-showing",
44-
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && node.isShowingHintText());
44+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
45+
appendAttribute(xml, "hint-showing", Boolean.toString(node.isShowingHintText()));
46+
}
47+
appendAttribute(xml, "editable", Boolean.toString(node.isEditable()));
48+
// Accessibility selection offsets, not a measurement of the value's length. Read-only
49+
// selectable text exposes a selection too, so they do not depend on `editable`; -1 = unavailable.
50+
appendNonNegativeAttribute(xml, "selection-start", node.getTextSelectionStart());
51+
appendNonNegativeAttribute(xml, "selection-end", node.getTextSelectionEnd());
4552
appendNonEmptyAttribute(xml, "resource-id", node.getViewIdResourceName());
4653
appendAttribute(xml, "class", node.getClassName());
4754
appendNonEmptyAttribute(xml, "package", node.getPackageName());
@@ -66,7 +73,7 @@ static void appendNode(
6673
Boolean.toString(
6774
hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_BACKWARD)));
6875
}
69-
appendTrueAttribute(xml, "password", node.isPassword());
76+
appendAttribute(xml, "password", Boolean.toString(node.isPassword()));
7077
appendAttribute(
7178
xml,
7279
"bounds",
@@ -122,6 +129,12 @@ private static void appendNonEmptyAttribute(
122129
appendAttribute(xml, name, value);
123130
}
124131

132+
private static void appendNonNegativeAttribute(StringBuilder xml, String name, int value) {
133+
if (value >= 0) {
134+
appendAttribute(xml, name, Integer.toString(value));
135+
}
136+
}
137+
125138
private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) {
126139
if (value) {
127140
appendAttribute(xml, name, "true");

packages/kernel/src/snapshot.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ export type RawSnapshotNode = {
107107
enabled?: boolean;
108108
selected?: boolean;
109109
focused?: boolean;
110+
/** Native accessibility facts; absent means unavailable, not false. */
111+
editable?: boolean;
112+
password?: boolean;
113+
hintShowing?: boolean;
114+
/** Accessibility selection offsets, never a character count or proof of value equality. */
115+
selectionStart?: number;
116+
selectionEnd?: number;
110117
visibleToUser?: boolean;
111118
hittable?: boolean;
112119
depth?: number;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { expect, test } from 'vitest';
2+
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';
3+
4+
test.each([false, true])(
5+
'editable field metadata survives snapshot presentation (raw=%s)',
6+
(raw) => {
7+
const tree = parseUiHierarchyTree(`<hierarchy><node class="android.widget.EditText"
8+
resource-id="field" text="" bounds="[0,0][200,100]" visible-to-user="true"
9+
editable="true" password="true" hint-showing="false" selection-start="0" selection-end="0"
10+
focusable="true" focused="true" /></hierarchy>`);
11+
const { nodes } = buildUiHierarchySnapshot(tree, undefined, { raw });
12+
expect(nodes.find((node) => node.identifier === 'field')).toMatchObject({
13+
value: '',
14+
editable: true,
15+
password: true,
16+
hintShowing: false,
17+
selectionStart: 0,
18+
selectionEnd: 0,
19+
});
20+
},
21+
);
22+
23+
test('selection offsets survive on a read-only selectable node (independent of editable)', () => {
24+
const tree = parseUiHierarchyTree(`<hierarchy><node class="android.widget.TextView"
25+
resource-id="field" text="Read-only text" bounds="[0,0][200,100]" visible-to-user="true"
26+
editable="false" selection-start="2" selection-end="5" /></hierarchy>`);
27+
const { nodes } = buildUiHierarchySnapshot(tree, undefined, { raw: true });
28+
expect(nodes.find((node) => node.identifier === 'field')).toMatchObject({
29+
editable: false,
30+
selectionStart: 2,
31+
selectionEnd: 5,
32+
});
33+
});
34+
35+
test('missing native field metadata remains unknown instead of becoming false or zero', () => {
36+
const tree = parseUiHierarchyTree(`<hierarchy><node class="android.widget.EditText"
37+
resource-id="field" bounds="[0,0][200,100]" focusable="true" /></hierarchy>`);
38+
const { nodes } = buildUiHierarchySnapshot(tree, undefined, { raw: true });
39+
const field = nodes.find((node) => node.identifier === 'field');
40+
expect(field).toBeDefined();
41+
expect(field?.value).toBeUndefined();
42+
expect(field?.editable).toBeUndefined();
43+
expect(field?.password).toBeUndefined();
44+
expect(field?.hintShowing).toBeUndefined();
45+
expect(field?.selectionStart).toBeUndefined();
46+
expect(field?.selectionEnd).toBeUndefined();
47+
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,11 @@ function createAndroidRawSnapshotNode(
352352
rect: node.rect,
353353
enabled: node.enabled,
354354
focused: node.focused,
355+
editable: node.editable,
356+
password: node.password,
357+
hintShowing: node.hintShowing,
358+
selectionStart: node.selectionStart,
359+
selectionEnd: node.selectionEnd,
355360
visibleToUser: node.visibleToUser,
356361
hittable: isAgentTarget(node) || undefined,
357362
depth: compactedAndroidNodeDepth(state.nodes, parentIndex),

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export type AndroidUiHierarchy = {
1414
enabled?: boolean;
1515
visibleToUser?: boolean;
1616
focused?: boolean;
17+
editable?: boolean;
18+
password?: boolean;
19+
hintShowing?: boolean;
20+
selectionStart?: number;
21+
selectionEnd?: number;
1722
// Two independent facts, never collapsed, and never undefined: the helper omits false attributes
1823
// while stock UiAutomator writes them out, so reading an absent attribute as a value gave two
1924
// encodings of one control opposite answers.

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export type AndroidUiNodeMetadata = {
3535
focusable?: boolean;
3636
focused?: boolean;
3737
password?: boolean;
38+
editable?: boolean;
39+
selectionStart?: number;
40+
selectionEnd?: number;
3841
/**
3942
* Helper-only: the `text` attribute is the field's HINT, not its value (an empty input's
4043
* `getText()` returns the hint on modern Android). Absent in raw uiautomator dumps.
@@ -147,6 +150,9 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
147150
focusable: boolAttr('focusable'),
148151
focused: boolAttr('focused'),
149152
password: boolAttr('password'),
153+
...optionalBoolAttr('editable', 'editable'),
154+
...optionalNumberAttr('selectionStart', 'selection-start'),
155+
...optionalNumberAttr('selectionEnd', 'selection-end'),
150156
...optionalBoolAttr('hintShowing', 'hint-showing'),
151157
...optionalBoolAttr('visibleToUser', 'visible-to-user'),
152158
...optionalNumberAttr('drawingOrder', 'drawing-order'),
@@ -300,6 +306,11 @@ function normalizeAndroidUiHierarchyNode(
300306
rect: attrs.rect,
301307
enabled: attrs.enabled,
302308
focused: attrs.focused,
309+
editable: attrs.editable,
310+
password: attrs.password,
311+
hintShowing: attrs.hintShowing,
312+
selectionStart: attrs.selectionStart,
313+
selectionEnd: attrs.selectionEnd,
303314
visibleToUser: attrs.visibleToUser,
304315
clickable: attrs.clickable === true,
305316
focusable: attrs.focusable === true,

src/daemon/__tests__/response-views.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,27 @@ test('get attrs digest compacts the node under a ref target', () => {
300300
expect(digest).toEqual({ ref: 'e7', node: COMPACT_NODE });
301301
});
302302

303+
test('attrs digest keeps explicit false/zero/empty field facts; unavailable ones stay absent (#2288)', () => {
304+
const fieldFacts = {
305+
value: '',
306+
editable: false,
307+
password: false,
308+
hintShowing: false,
309+
selectionStart: 0,
310+
selectionEnd: 0,
311+
};
312+
const digest = getView!({ ref: 'e7', node: { ...MATCHED_NODE, ...fieldFacts } }, 'digest');
313+
expect(digest.node).toEqual({ ...COMPACT_NODE, ...fieldFacts });
314+
// MATCHED_NODE carries none of the field facts: the digest must not invent them.
315+
const unavailable = getView!({ ref: 'e7', node: MATCHED_NODE }, 'digest').node as Record<
316+
string,
317+
unknown
318+
>;
319+
for (const field of Object.keys(fieldFacts)) {
320+
if (field !== 'value') expect(field in unavailable).toBe(false);
321+
}
322+
});
323+
303324
test('find/get default and full return today’s shape unchanged (same reference)', () => {
304325
const data: DaemonResponseData = { ref: '@e7', text: 'Sign in', node: MATCHED_NODE };
305326
expect(findView!(data, 'default')).toBe(data);

src/daemon/response-views.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,10 +90,12 @@ function pickScreenshotDigestMetadata(data: DaemonResponseData): DaemonResponseD
9090
return metadata;
9191
}
9292

93-
// The semantic attributes of a single matched node an agent reasons about. The
94-
// verbose framing a digest drops — geometry (`rect`), tree indices
95-
// (`index`/`parentIndex`/`depth`), and process/app plumbing
96-
// (`pid`/`bundleId`/`appName`/`windowTitle`/`surface`/…) — is intentionally absent.
93+
// The semantic attributes of a single matched node an agent reasons about,
94+
// including the field facts whose explicit false/zero/empty is the signal (#2288:
95+
// absent means unavailable). The verbose framing a digest drops — geometry
96+
// (`rect`), tree indices (`index`/`parentIndex`/`depth`), and process/app
97+
// plumbing (`pid`/`bundleId`/`appName`/`windowTitle`/`surface`/…) — is
98+
// intentionally absent.
9799
const SELECTOR_DIGEST_NODE_FIELDS = [
98100
'role',
99101
'type',
@@ -104,6 +106,11 @@ const SELECTOR_DIGEST_NODE_FIELDS = [
104106
'enabled',
105107
'selected',
106108
'focused',
109+
'editable',
110+
'password',
111+
'hintShowing',
112+
'selectionStart',
113+
'selectionEnd',
107114
'hittable',
108115
] as const;
109116

website/docs/docs/snapshots.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,17 @@ the strategy owns which tiers it may use.
111111
an empty tree.
112112
- Private-accessibility recovery and `--actions` reads are simulator-specific. Physical iOS devices
113113
have no equivalent independent semantic backend; they bound the XCTest work with a probe instead.
114+
115+
## Android field metadata
116+
117+
Android snapshot nodes and `get attrs` (including the digest response) carry the native
118+
`editable`, `password`, `hintShowing`, `selectionStart`, and `selectionEnd` facts whenever the
119+
accessibility tree reports them. Explicit `false` and `0` are kept; an absent field means the fact
120+
was unavailable, not false. `hintShowing` needs Android API 26 or later.
121+
122+
- `value: ""` is an explicitly empty accessibility text; a missing `value` means no text was
123+
reported. The text of an empty field is its hint on modern Android, so check `hintShowing`
124+
before reading `value` as the entered contents.
125+
- `selectionStart`/`selectionEnd` are accessibility selection offsets. They are independent of
126+
`editable` (read-only selectable text exposes them too), they are not a character count, and
127+
they do not prove that a masked or secure value equals expected text.

0 commit comments

Comments
 (0)