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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Fixed (android): snapshot nodes and `get attrs` carry the accessibility `heading` flag and the
`roleDescription` an app set on a node. React Native puts a header, a tab, a tab list, a link, or a
menu on a plain `android.view.View` and tells the accessibility tree what it is through these two
facts; the helper never serialized either, so every one of them was a nameless `View` to an agent.
The helper now writes `heading` when the node reports it (API 28 or later) and `role-description`
when the app set one, and the parser, the Android hierarchy node, and the published snapshot node
carry them to `get attrs` and the selector digest. The class stays the `type`.
- Fixed (ios): `perf cpu profile report --kind xctrace` on Xcode 27 no longer fails with
`Apple xctrace CPU report contained no samples` on a trace that holds thousands of samples. Xcode
27 exports each `time-profile` sample stack as `<tagged-backtrace>` instead of `<backtrace>`, and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ static void appendNode(
appendAttribute(xml, "class", node.getClassName());
appendNonEmptyAttribute(xml, "package", node.getPackageName());
appendNonEmptyAttribute(xml, "content-desc", node.getContentDescription());
appendNonEmptyAttribute(xml, "role-description", roleDescription(node));
appendTrueAttribute(xml, "heading", isHeading(node));
appendAttribute(xml, "visible-to-user", Boolean.toString(node.isVisibleToUser()));
appendDrawingOrderAttribute(xml, node);
appendTrueAttribute(xml, "clickable", node.isClickable());
Expand Down Expand Up @@ -146,6 +148,19 @@ private static void appendTrueAttribute(StringBuilder xml, String name, boolean
}
}

// The platform node has no role description getter: androidx writes the value an app set
// (AccessibilityNodeInfoCompat.setRoleDescription) into the node extras under this key, and
// TalkBack reads it from there.
private static CharSequence roleDescription(AccessibilityNodeInfo node) {
return node.getExtras().getCharSequence("AccessibilityNodeInfo.roleDescription");
}

// isHeading() arrived in API 28. Older releases keep the compat flag in an extras bit this
// helper does not read, so a heading on API 23-27 reports nothing.
private static boolean isHeading(AccessibilityNodeInfo node) {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && node.isHeading();
}

// 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.
Expand Down
4 changes: 4 additions & 0 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ export type RawSnapshotNode = {
enabled?: boolean;
selected?: boolean;
focused?: boolean;
/** Accessibility heading flag an app set on the node; absent means not a heading or unavailable. */
heading?: boolean;
/** Localized role description an app set beside the native class, verbatim (`Tab`, `Tab List`, `Link`). */
roleDescription?: string;
/** Native accessibility facts; absent means unavailable, not false. */
editable?: boolean;
password?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, test } from 'vitest';
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';

// A React Native screen: `accessibilityRole="header"` is a plain View the helper flags as a heading,
// a tab bar is a View with the role description the app set, and a label carries neither.
const ROLE_FACTS_XML = `<hierarchy>
<node class="android.widget.FrameLayout" resource-id="root" bounds="[0,0][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="inventory-header" text="Inventory" heading="true"
bounds="[0,0][400,60]" enabled="true" visible-to-user="true" />
<node class="android.view.View" resource-id="section-tabs" role-description="tab list"
bounds="[0,60][400,120]" enabled="true" visible-to-user="true">
<node class="android.view.View" resource-id="tab-fields" text="Fields" role-description="tab"
bounds="[0,60][200,120]" clickable="true" enabled="true" visible-to-user="true" />
</node>
<node class="android.widget.TextView" resource-id="plain-label" text="Wi-Fi"
bounds="[0,120][400,180]" enabled="true" visible-to-user="true" />
</node>
</hierarchy>`;

function nodesById(raw: boolean, interactiveOnly = false) {
const { nodes } = buildUiHierarchySnapshot(parseUiHierarchyTree(ROLE_FACTS_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 heading flag and the role description reach snapshot nodes (raw=$raw, -i=$interactiveOnly)',
({ raw, interactiveOnly }) => {
const byId = nodesById(raw, interactiveOnly);
expect(byId('inventory-header')?.heading).toBe(true);
expect(byId('section-tabs')?.roleDescription).toBe('tab list');
expect(byId('tab-fields')?.roleDescription).toBe('tab');
},
);

test('a node without either fact carries neither key once serialized', () => {
const byId = nodesById(false);
const label = JSON.parse(JSON.stringify(byId('plain-label')));
expect(label).not.toHaveProperty('heading');
expect(label).not.toHaveProperty('roleDescription');
// A heading is not a role description and a role description is not a heading.
expect(JSON.parse(JSON.stringify(byId('inventory-header')))).not.toHaveProperty(
'roleDescription',
);
expect(JSON.parse(JSON.stringify(byId('tab-fields')))).not.toHaveProperty('heading');
});

test('the class stays the type: a role description refines nothing on its own', () => {
const byId = nodesById(false);
expect(byId('tab-fields')?.type).toBe('android.view.View');
expect(byId('inventory-header')?.label).toBe('Inventory');
});
2 changes: 2 additions & 0 deletions packages/platform-android/src/ui-hierarchy-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ function createAndroidRawSnapshotNode(
enabled: node.enabled,
focused: node.focused,
selected: node.selected,
heading: node.heading,
roleDescription: node.roleDescription,
editable: node.editable,
password: node.password,
hintShowing: node.hintShowing,
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-android/src/ui-hierarchy-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type AndroidUiHierarchy = {
visibleToUser?: boolean;
focused?: boolean;
selected?: boolean;
heading?: boolean;
roleDescription?: string;
editable?: boolean;
password?: boolean;
hintShowing?: boolean;
Expand Down
17 changes: 17 additions & 0 deletions packages/platform-android/src/ui-hierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export type AndroidUiNodeMetadata = {
focusable?: boolean;
focused?: boolean;
selected?: boolean;
/** Helper-only: the accessibility heading flag an app set on the node (API 28 or later). */
heading?: boolean;
/** Helper-only: the localized role description an app set beside the class, verbatim. */
roleDescription?: string;
password?: boolean;
editable?: boolean;
selectionStart?: number;
Expand Down Expand Up @@ -132,6 +136,15 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
const value = parseBounds(getAttr(name));
return value === undefined ? {} : ({ [key]: value } as Pick<AndroidUiNodeMetadata, Key>);
};
const optionalStringAttr = <Key extends keyof AndroidUiNodeMetadata>(
key: Key,
name: string,
): Partial<Pick<AndroidUiNodeMetadata, Key>> => {
const value = getAttr(name);
return value === null || value === ''
? {}
: ({ [key]: value } as Pick<AndroidUiNodeMetadata, Key>);
};
const optionalBoolAttr = <Key extends keyof AndroidUiNodeMetadata>(
key: Key,
name: string,
Expand All @@ -157,6 +170,8 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
...optionalBoolAttr('hintShowing', 'hint-showing'),
...optionalBoolAttr('visibleToUser', 'visible-to-user'),
...optionalBoolAttr('selected', 'selected'),
...optionalBoolAttr('heading', 'heading'),
...optionalStringAttr('roleDescription', 'role-description'),
...optionalNumberAttr('drawingOrder', 'drawing-order'),
...optionalBoolAttr('scrollable', 'scrollable'),
...optionalBoolAttr('canScrollForward', 'can-scroll-forward'),
Expand Down Expand Up @@ -311,6 +326,8 @@ function normalizeAndroidUiHierarchyNode(
enabled: attrs.enabled,
focused: attrs.focused,
selected: attrs.selected,
heading: attrs.heading,
roleDescription: attrs.roleDescription,
editable: attrs.editable,
password: attrs.password,
hintShowing: attrs.hintShowing,
Expand Down
36 changes: 36 additions & 0 deletions src/__tests__/android-ui-hierarchy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,42 @@ test('a published Android snapshot answers a selected-qualified read (#2462)', a
);
});

// A React Native screen: the header role is a View flagged as a heading, the tab bar and its tabs
// are Views with the role description the app set, and the helper writes neither on a plain label.
const ANDROID_ROLE_FACTS_XML = `<hierarchy>
<node class="android.widget.FrameLayout" resource-id="com.example.app:id/root" bounds="[0,0][390,300]" enabled="true" visible-to-user="true">
<node class="android.view.View" resource-id="com.example.app:id/header" text="Inventory" heading="true" bounds="[0,0][390,60]" enabled="true" visible-to-user="true"/>
<node class="android.view.View" resource-id="com.example.app:id/tabs" role-description="tab list" bounds="[0,60][390,120]" enabled="true" visible-to-user="true">
<node class="android.view.View" resource-id="com.example.app:id/tab-fields" text="Fields" role-description="tab" bounds="[0,60][195,120]" clickable="true" enabled="true" visible-to-user="true"/>
</node>
<node class="android.widget.TextView" resource-id="com.example.app:id/label" text="Wi-Fi" bounds="[0,120][390,180]" enabled="true" visible-to-user="true"/>
</node>
</hierarchy>`;

test('a published Android snapshot carries the heading flag and the role description', () => {
const nodes = publishUiHierarchy(ANDROID_ROLE_FACTS_XML).nodes;
const byId = (identifier: string) => nodes.find((node) => node.identifier === identifier)!;

assert.equal(byId('com.example.app:id/header').heading, true);
assert.equal(byId('com.example.app:id/tabs').roleDescription, 'tab list');
assert.equal(byId('com.example.app:id/tab-fields').roleDescription, 'tab');
assert.equal(byId('com.example.app:id/label').heading, undefined);
assert.equal(byId('com.example.app:id/label').roleDescription, undefined);
assert.deepEqual(
Array.from(androidUiNodes(ANDROID_ROLE_FACTS_XML)).map((node) => [
node.heading,
node.roleDescription,
]),
[
[undefined, undefined],
[true, undefined],
[undefined, 'tab list'],
[undefined, 'tab'],
[undefined, undefined],
],
);
});

test('parseUiHierarchy discards stale inactive Android application windows', () => {
const xml = `<hierarchy>
<node class="android.widget.FrameLayout" package="com.example.app" bounds="[0,0][390,844]" window-index="0" window-type="1" window-layer="10" window-active="true" window-focused="true" window-bounds="[0,0][390,844]">
Expand Down
2 changes: 2 additions & 0 deletions src/commands/capture/runtime/snapshot-unchanged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ const PRESENTATION_SCALAR_FIELDS = {
enabled: true,
selected: true,
focused: true,
heading: true,
roleDescription: true,
hittable: true,
bundleId: true,
appName: true,
Expand Down
2 changes: 2 additions & 0 deletions src/daemon/response-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ const SELECTOR_DIGEST_NODE_FIELDS = [
'enabled',
'selected',
'focused',
'heading',
'roleDescription',
'editable',
'password',
'hintShowing',
Expand Down
12 changes: 9 additions & 3 deletions website/docs/docs/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,21 @@ 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`, `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`, `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` 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.
- `heading` is the accessibility heading flag an app sets on a node, the way React Native's
`accessibilityRole="header"` does on a plain `View`; it is present only as `true`.
- `roleDescription` is the localized role description an app sets beside the native class, verbatim
(React Native writes `Tab`, `Tab List`, `Radio Group`, `Link`, `Menu`), when the class alone would
not say what the control is. The `type` stays the class; a consumer maps the description to a role.
- `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.
Expand Down
Loading