mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
d17d6883cb
## Summary The Android uiautomator describe parser collapses redundant wrapper nodes in two places. Both sites kept the inner/child node and silently discarded the wrapper's own accessibility `content-desc` label and `resource-id`, even when the surviving node had neither. The result is that a labelled tap target becomes anonymous in the describe tree, so an agent can no longer identify or target it. This change makes both collapse sites merge rather than drop: the surviving node still prefers its own label/identifier (the existing "inner is more specific" intent), but now falls back to the wrapper's values instead of throwing them away. ## Defect A — duplicate-wrapper collapse (`computeNodeOutput`) When a clickable parent and its single clickable child have identical bounds, the duplicate-wrapper collapse keeps only the inner child and discards the parent's `content-desc`/`resource-id` — even when the inner child has neither. A React Native `Pressable` with an `accessibilityLabel` wrapping a bare clickable native view (label only on the outer node) collapses to an anonymous node. Repro: ``` <node clickable content-desc="Like, 5 likes"> <node clickable content-desc="" /> </node> ``` - Observed: collapses to a single node with NO label. - Expected: the collapsed node keeps `label="Like, 5 likes"`. Fix: when collapsing, carry the wrapper's label/`resource-id` onto the surviving child only when the child lacks its own. The existing test that asserts an inner `inner` label wins over an outer `outer` label is preserved (child value still takes precedence when present). ## Defect B — bounds-less single-child pass-through (`finalizeUiNode`) A node with no parseable `bounds`, exactly one child, and its OWN `content-desc` was replaced wholesale by its child (`if (children.length === 1) return children[0]`), discarding the wrapper's label/identifier. This is reachable for the bounds-less Compose group containers the parser already supports when such a container carries a `content-desc`. Repro: ``` <node content-desc="Profile photo"> <node bounds=... content-desc="avatar" /> </node> ``` - Observed: keeps only `avatar`; `Profile photo` is lost. - Expected: both labels survive. Fix: only pass a single child straight through when the wrapper has no label/identifier of its own. When the wrapper carries its own identity, keep it as a node whose frame is the union of its (sole) child — so neither the wrapper's nor the child's label is dropped. The bounds-less multi-child path (Compose containers with 2+ children) is unchanged. ## Tests New regression test `packages/tool-server/test/describe-android-label-preserve.test.ts` covers both defects. It fails before the fix (`[]` for A, `['avatar']` for B) and passes after. The full existing uiautomator/describe android suites remain green.
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
// Inline-XML coverage for the v2 interactables-only trim. Each trim rule
|
|
// has a dedicated case below — duplicate-wrapper collapse, password
|
|
// redaction, WebView opacity, descendant aggregation, scroll-clip, system
|
|
// chrome — so the suite stays runnable without an external dump fixture.
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
parseUiAutomatorDump,
|
|
parseUiAutomatorXml,
|
|
parseUiAutomatorBounds,
|
|
} from "../src/tools/describe/platforms/android/uiautomator-parser";
|
|
import { parseDescribeResult, type DescribeNode } from "../src/tools/describe/contract";
|
|
|
|
function flatten(tree: DescribeNode): DescribeNode[] {
|
|
const out: DescribeNode[] = [];
|
|
const stack: DescribeNode[] = [tree];
|
|
while (stack.length > 0) {
|
|
const n = stack.pop()!;
|
|
out.push(n);
|
|
for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i]!);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("parseUiAutomatorBounds", () => {
|
|
it("parses well-formed [x1,y1][x2,y2]", () => {
|
|
expect(parseUiAutomatorBounds("[10,20][110,220]")).toEqual({ x: 10, y: 20, w: 100, h: 200 });
|
|
});
|
|
|
|
it("clamps negative width/height to zero", () => {
|
|
expect(parseUiAutomatorBounds("[100,200][50,180]")).toEqual({ x: 100, y: 200, w: 0, h: 0 });
|
|
});
|
|
|
|
it("returns null on malformed input", () => {
|
|
expect(parseUiAutomatorBounds("not bounds")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("parseUiAutomatorXml", () => {
|
|
it("preserves `>` inside quoted attribute values", () => {
|
|
const xml = `<hierarchy><node text="A > B" bounds="[0,0][10,10]"/></hierarchy>`;
|
|
const root = parseUiAutomatorXml(xml);
|
|
expect(root?.tag).toBe("hierarchy");
|
|
expect(root?.children[0]?.attrs.text).toBe("A > B");
|
|
});
|
|
});
|
|
|
|
describe("parseUiAutomatorDump — v2 trim focused behaviour", () => {
|
|
it("collapses a clickable parent + clickable child with identical bounds", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.FrameLayout" bounds="[0,0][100,100]" clickable="true" content-desc="outer">
|
|
<node class="android.widget.Button" bounds="[0,0][100,100]" clickable="true" content-desc="inner"/>
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100);
|
|
const all = flatten(tree).filter((n) => n.role !== "Screen");
|
|
expect(all).toHaveLength(1);
|
|
expect(all[0]?.label).toBe("inner");
|
|
expect(all[0]?.role).toBe("Button");
|
|
});
|
|
|
|
it("redacts the value of password fields", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.EditText" bounds="[0,0][100,100]" clickable="true" focusable="true" password="true" text="hunter2"/>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100);
|
|
const field = flatten(tree).find((n) => n.role === "TextField");
|
|
expect(field?.label).toBe("[password]");
|
|
expect(field?.password).toBe(true);
|
|
// The actual secret must NOT leak into `value` either.
|
|
expect(field?.value).toBeUndefined();
|
|
});
|
|
|
|
it("does not borrow a password descendant's text into a container label", () => {
|
|
// A clickable container with no label of its own borrows its descendants'
|
|
// text via descendantText(). That walk reads the raw XML, so a password
|
|
// field's secret must be redacted there too — otherwise it rides out as the
|
|
// container's own (non-password) label and bypasses redaction entirely.
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.LinearLayout" clickable="true" bounds="[0,0][100,100]">
|
|
<node class="android.widget.EditText" focusable="true" password="true" text="hunter2" bounds="[10,10][90,40]"/>
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100);
|
|
const strings = flatten(tree).flatMap((n) =>
|
|
[n.label, n.value].filter((s): s is string => !!s)
|
|
);
|
|
expect(strings.join(" | ")).not.toContain("hunter2");
|
|
// The row still surfaces the redacted marker instead of the plaintext.
|
|
expect(flatten(tree).map((n) => n.label)).toContain("[password]");
|
|
});
|
|
|
|
it("treats WebView as an opaque single leaf", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.webkit.WebView" bounds="[0,0][100,100]" content-desc="checkout">
|
|
<node class="android.view.View" bounds="[10,10][50,50]" content-desc="leaked-from-dom"/>
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100);
|
|
const webview = flatten(tree).find((n) => n.role === "WebView");
|
|
expect(webview).toBeDefined();
|
|
expect(webview?.children).toHaveLength(0);
|
|
expect(webview?.label).toContain("[web-view]");
|
|
// The DOM-side content-desc must NOT bleed through as a sibling node.
|
|
expect(flatten(tree).some((n) => n.label === "leaked-from-dom")).toBe(false);
|
|
});
|
|
|
|
it("aggregates descendant labels into a clickable container with no own label", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.view.ViewGroup" bounds="[0,0][200,200]" clickable="true">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]" text="Alice"/>
|
|
<node class="android.widget.TextView" bounds="[0,50][100,100]" text="@alice"/>
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 200, 200);
|
|
// The clickable wrapper now shows the row's labels so an agent tapping
|
|
// it knows what cell it's hitting.
|
|
const container = flatten(tree).find((n) => n.clickable);
|
|
expect(container?.label).toBe("Alice / @alice");
|
|
});
|
|
|
|
it("surfaces an EditText's content-desc as label and its text as value", () => {
|
|
// The contract pre-dates the v2 trim: DescribeNode separates the screen-
|
|
// reader-meaningful label (content-desc / role description) from the
|
|
// user-visible text (value). An EditText that has typed input AND a
|
|
// placeholder must keep both so an agent can read either piece.
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.EditText" bounds="[0,0][100,40]" focusable="true" clickable="true" text="hello" content-desc="Email"/>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 40);
|
|
const field = flatten(tree).find((n) => n.role === "TextField");
|
|
expect(field?.label).toBe("Email");
|
|
expect(field?.value).toBe("hello");
|
|
});
|
|
|
|
it("re-validates the trimmed tree against the public DescribeNode schema", () => {
|
|
// Cheap guardrail: the trim must always produce something
|
|
// `parseDescribeResult` accepts, even on minimal input.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy>
|
|
<node class="android.widget.Button" bounds="[0,0][100,40]" clickable="true" text="OK"/>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 40);
|
|
expect(() => parseDescribeResult(tree)).not.toThrow();
|
|
});
|
|
|
|
it("drops com.android.systemui chrome by default", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.FrameLayout" package="xyz.app" bounds="[0,0][100,100]">
|
|
<node class="android.widget.TextView" package="xyz.app" bounds="[0,0][50,50]" text="App content"/>
|
|
</node>
|
|
<node class="android.widget.FrameLayout" package="com.android.systemui" bounds="[0,0][100,30]" content-desc="status bar"/>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100);
|
|
const labels = flatten(tree)
|
|
.map((n) => n.label)
|
|
.filter(Boolean);
|
|
expect(labels).toContain("App content");
|
|
expect(labels).not.toContain("status bar");
|
|
});
|
|
|
|
it("retains com.android.systemui chrome when includeSystem is set", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.FrameLayout" package="com.android.systemui" bounds="[0,0][100,30]" content-desc="status bar"/>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100, { includeSystem: true });
|
|
expect(flatten(tree).some((n) => n.label === "status bar")).toBe(true);
|
|
});
|
|
|
|
it("counts scroll-hidden children but keeps visible ones", () => {
|
|
// The screen is 200x600 so both texts are within the screen rect, but
|
|
// the parent ScrollView only covers the top 200 px. The row's scroll-
|
|
// clip — inherited from its ScrollView ancestor — should drop the text
|
|
// at y=400 while keeping the one inside the viewport.
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.ScrollView" bounds="[0,0][200,200]" scrollable="true">
|
|
<node class="android.view.ViewGroup" bounds="[0,0][200,200]" clickable="true" content-desc="row">
|
|
<node class="android.widget.TextView" bounds="[0,50][200,100]" text="visible"/>
|
|
<node class="android.widget.TextView" bounds="[0,400][200,450]" text="hidden"/>
|
|
</node>
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 200, 600);
|
|
const all = flatten(tree);
|
|
expect(all.some((n) => n.label === "hidden")).toBe(false);
|
|
const row = all.find((n) => n.label === "row");
|
|
expect(row?.scrollHidden).toBe(1);
|
|
});
|
|
|
|
it("strips React Native SVG sub-paths entirely", () => {
|
|
// com.horcrux.svg.{Path,Group,Svg}View are dump-side noise — the icon's
|
|
// content-desc lives on the parent ImageView/Button, not these leaves.
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.Button" bounds="[0,0][100,100]" clickable="true" content-desc="Send">
|
|
<node class="com.horcrux.svg.SvgView" bounds="[10,10][90,90]">
|
|
<node class="com.horcrux.svg.GroupView" bounds="[10,10][90,90]">
|
|
<node class="com.horcrux.svg.PathView" bounds="[10,10][90,90]"/>
|
|
</node>
|
|
</node>
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 100, 100);
|
|
const roles = new Set(flatten(tree).map((n) => n.role));
|
|
expect(roles.has("PathView")).toBe(false);
|
|
expect(roles.has("GroupView")).toBe(false);
|
|
expect(roles.has("SvgView")).toBe(false);
|
|
// The Button itself must survive — only the SVG subtree is stripped.
|
|
expect(flatten(tree).find((n) => n.label === "Send")?.role).toBe("Button");
|
|
});
|
|
|
|
it("drops a node fully off-screen and contributing nothing", () => {
|
|
const xml = `<?xml version='1.0' encoding='UTF-8'?>
|
|
<hierarchy>
|
|
<node class="android.widget.TextView" bounds="[2000,2000][3000,3000]" text="off screen"/>
|
|
<node class="android.widget.TextView" bounds="[10,10][100,30]" text="on screen"/>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
const labels = flatten(tree)
|
|
.map((n) => n.label)
|
|
.filter(Boolean);
|
|
expect(labels).not.toContain("off screen");
|
|
expect(labels).toContain("on screen");
|
|
});
|
|
});
|