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 802cd38bb1..c5c1da5249 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 @@ -43,6 +43,9 @@ static void appendNode( // (#2063 empty-fill verification). if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { appendAttribute(xml, "hint-showing", Boolean.toString(node.isShowingHintText())); + // The hint itself, whether or not the field is showing it: `text` holds the hint only while + // the field is empty, so a filled field's placeholder is readable nowhere else. + appendNonEmptyAttribute(xml, "hint", node.getHintText()); } appendAttribute(xml, "editable", Boolean.toString(node.isEditable())); // Accessibility selection offsets, not a measurement of the value's length. Read-only diff --git a/packages/kernel/src/snapshot.ts b/packages/kernel/src/snapshot.ts index eace66eba8..b6b817433d 100644 --- a/packages/kernel/src/snapshot.ts +++ b/packages/kernel/src/snapshot.ts @@ -246,6 +246,11 @@ export type RawSnapshotNode = { editable?: boolean; password?: boolean; hintShowing?: boolean; + /** + * Placeholder text of a text field (the Android hint), whether or not the field is showing it. + * Absent when the field has none or the producer did not read it. + */ + placeholder?: string; /** Accessibility selection offsets, never a character count or proof of value equality. */ selectionStart?: number; selectionEnd?: number; diff --git a/packages/platform-android/src/__tests__/ui-hierarchy-placeholder.test.ts b/packages/platform-android/src/__tests__/ui-hierarchy-placeholder.test.ts new file mode 100644 index 0000000000..c72a64418b --- /dev/null +++ b/packages/platform-android/src/__tests__/ui-hierarchy-placeholder.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'vitest'; +import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts'; + +// A form: an empty field shows its hint (and reports the hint as its text), a filled field no longer +// shows it but still has one, and a label has none. +const PLACEHOLDER_XML = ` + + + + + +`; + +function nodesById(raw: boolean, interactiveOnly = false) { + const { nodes } = buildUiHierarchySnapshot(parseUiHierarchyTree(PLACEHOLDER_XML), undefined, { + raw, + interactiveOnly, + }); + return (identifier: string) => nodes.find((node) => node.identifier === identifier); +} + +test.each([ + { raw: false, interactiveOnly: false }, + { raw: true, interactiveOnly: false }, + { raw: true, interactiveOnly: true }, +])( + 'the placeholder reaches snapshot nodes whether or not the field shows it (raw=$raw, -i=$interactiveOnly)', + ({ raw, interactiveOnly }) => { + const byId = nodesById(raw, interactiveOnly); + expect(byId('name-input')?.placeholder).toBe('Key echo'); + expect(byId('name-input')?.hintShowing).toBe(true); + expect(byId('email-input')?.placeholder).toBe('Email address'); + expect(byId('email-input')?.hintShowing).toBe(false); + }, +); + +test('the value stays what the platform reported: the hint while showing, the text once filled', () => { + const byId = nodesById(false); + expect(byId('name-input')?.value).toBe('Key echo'); + expect(byId('email-input')?.value).toBe('ada@example.com'); +}); + +test('a node without a hint carries no placeholder key once serialized', () => { + const byId = nodesById(false); + expect(JSON.parse(JSON.stringify(byId('name-label')))).not.toHaveProperty('placeholder'); +}); diff --git a/packages/platform-android/src/ui-hierarchy-builder.ts b/packages/platform-android/src/ui-hierarchy-builder.ts index a3aed983f0..e4f0ffaffb 100644 --- a/packages/platform-android/src/ui-hierarchy-builder.ts +++ b/packages/platform-android/src/ui-hierarchy-builder.ts @@ -359,6 +359,7 @@ function createAndroidRawSnapshotNode( editable: node.editable, password: node.password, hintShowing: node.hintShowing, + placeholder: node.placeholder, selectionStart: node.selectionStart, selectionEnd: node.selectionEnd, visibleToUser: node.visibleToUser, diff --git a/packages/platform-android/src/ui-hierarchy-node.ts b/packages/platform-android/src/ui-hierarchy-node.ts index 4ac4a47a1f..205c56ef4a 100644 --- a/packages/platform-android/src/ui-hierarchy-node.ts +++ b/packages/platform-android/src/ui-hierarchy-node.ts @@ -22,6 +22,7 @@ export type AndroidUiHierarchy = { editable?: boolean; password?: boolean; hintShowing?: boolean; + placeholder?: string; selectionStart?: number; selectionEnd?: number; // Two independent facts, never collapsed, and never undefined: the helper omits false attributes diff --git a/packages/platform-android/src/ui-hierarchy.ts b/packages/platform-android/src/ui-hierarchy.ts index 006502d9b3..7317d057ba 100644 --- a/packages/platform-android/src/ui-hierarchy.ts +++ b/packages/platform-android/src/ui-hierarchy.ts @@ -48,6 +48,8 @@ export type AndroidUiNodeMetadata = { * `getText()` returns the hint on modern Android). Absent in raw uiautomator dumps. */ hintShowing?: boolean; + /** Helper-only: the field's hint text (API 26 or later), whether or not it is showing. */ + hint?: string; scrollable?: boolean; canScrollForward?: boolean; canScrollBackward?: boolean; @@ -168,6 +170,7 @@ function readNodeAttributes(node: string): Omit { ...optionalNumberAttr('selectionStart', 'selection-start'), ...optionalNumberAttr('selectionEnd', 'selection-end'), ...optionalBoolAttr('hintShowing', 'hint-showing'), + ...optionalStringAttr('hint', 'hint'), ...optionalBoolAttr('visibleToUser', 'visible-to-user'), ...optionalBoolAttr('selected', 'selected'), ...optionalBoolAttr('heading', 'heading'), @@ -331,6 +334,7 @@ function normalizeAndroidUiHierarchyNode( editable: attrs.editable, password: attrs.password, hintShowing: attrs.hintShowing, + placeholder: attrs.hint, selectionStart: attrs.selectionStart, selectionEnd: attrs.selectionEnd, visibleToUser: attrs.visibleToUser, diff --git a/src/commands/capture/runtime/snapshot-unchanged.test.ts b/src/commands/capture/runtime/snapshot-unchanged.test.ts index c0ef723b71..a965b9b943 100644 --- a/src/commands/capture/runtime/snapshot-unchanged.test.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.test.ts @@ -73,6 +73,7 @@ test.each>([ { enabled: false }, { selected: true }, { focused: true }, + { placeholder: 'Key echo' }, { hittable: false }, { bundleId: 'com.example.app' }, { appName: 'Example' }, diff --git a/src/commands/capture/runtime/snapshot-unchanged.ts b/src/commands/capture/runtime/snapshot-unchanged.ts index 1ec07b70ee..da581313bc 100644 --- a/src/commands/capture/runtime/snapshot-unchanged.ts +++ b/src/commands/capture/runtime/snapshot-unchanged.ts @@ -114,6 +114,7 @@ const PRESENTATION_SCALAR_FIELDS = { focused: true, heading: true, roleDescription: true, + placeholder: true, hittable: true, bundleId: true, appName: true, diff --git a/src/daemon/__tests__/response-views.test.ts b/src/daemon/__tests__/response-views.test.ts index 73f7fde8ed..392a7f25ca 100644 --- a/src/daemon/__tests__/response-views.test.ts +++ b/src/daemon/__tests__/response-views.test.ts @@ -320,6 +320,7 @@ test('attrs digest keeps explicit false/zero/empty field facts; unavailable ones editable: false, password: false, hintShowing: false, + placeholder: 'Key echo', selectionStart: 0, selectionEnd: 0, }; diff --git a/src/daemon/response-views.ts b/src/daemon/response-views.ts index 2a21a34a12..2454c8c59a 100644 --- a/src/daemon/response-views.ts +++ b/src/daemon/response-views.ts @@ -120,6 +120,7 @@ const SELECTOR_DIGEST_NODE_FIELDS = [ 'editable', 'password', 'hintShowing', + 'placeholder', 'selectionStart', 'selectionEnd', 'hittable', diff --git a/website/docs/docs/snapshots.md b/website/docs/docs/snapshots.md index f9ef3f34e0..99b08b5bf1 100644 --- a/website/docs/docs/snapshots.md +++ b/website/docs/docs/snapshots.md @@ -117,10 +117,10 @@ the strategy owns which tiers it may use. ## Android node metadata Android snapshot nodes and `get attrs` (including the digest response) carry the native -`selected`, `heading`, `roleDescription`, `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, `heading` API 28 or later. +`selected`, `heading`, `roleDescription`, `editable`, `password`, `hintShowing`, `placeholder`, +`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` +and `placeholder` need Android API 26 or later, `heading` API 28 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 @@ -135,6 +135,9 @@ or later, `heading` API 28 or later. - `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. +- `placeholder` is the field's hint text itself, present whether the field is empty or filled: an + empty field shows it (`hintShowing: true`, and `value` repeats it), a filled field no longer does. + A field without a hint omits it. - `selectionStart`/`selectionEnd` are accessibility selection offsets. They are independent of `editable` (read-only selectable text exposes them too), they are not a character count, and they do not prove that a masked or secure value equals expected text.