mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
837d088744
This PR adds tests for #148. ## Summary Moves the Android emulator branch's test coverage out of #148 so that PR can be reviewed without the test churn. This branch adds the test files back on top of the (now test-stripped) base branch. Tests inseparable from source changes (`describe-tool`, `workspace-reader`, `workspace-reader-integration`) and the `boot-simulator.test.ts` deletion stayed on #148 - base PR's CI passes on its own. ## Test plan - [x] `npx tsc --build` — clean - [x] `npx prettier --check .` — clean - [x] `npm test --workspaces --if-present` — 585/585 tool-server tests pass; all other workspaces green --------- Co-authored-by: Paweł Fornagiel <pawel.fornagiel2@gmail.com>
237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
parseUiAutomatorDump,
|
|
parseUiAutomatorXml,
|
|
} from "../src/tools/describe/platforms/android/uiautomator-parser";
|
|
|
|
describe("uiautomator numeric entities (review #5)", () => {
|
|
it("decodes &#N; decimal character references in text / content-desc", () => {
|
|
// `→` is U+2192, which can appear in uiautomator dumps encoded as →
|
|
// Without numeric-ref handling these survived undecoded into labels.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="Next →" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
const node = tree.children[0]!;
|
|
expect(node.label).toBe("Next →");
|
|
});
|
|
|
|
it("decodes &#xH; hex character references", () => {
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="Done ✓" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("Done ✓");
|
|
});
|
|
|
|
it("decodes multi-codepoint (supplementary plane) numeric refs", () => {
|
|
// 😀 is U+1F600 — outside the BMP, needs String.fromCodePoint (not String.fromCharCode).
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="Hi 😀" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("Hi 😀");
|
|
});
|
|
|
|
it("replaces out-of-range / surrogate references with empty instead of throwing", () => {
|
|
// U+D800 is a lone surrogate high-half; 0x110001 is past Unicode max.
|
|
// String.fromCodePoint would throw for the latter — the decoder has to
|
|
// swallow it so the rest of the tree is still usable.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="X�Y�Z" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("XYZ");
|
|
});
|
|
|
|
it("still decodes the five named entities alongside numeric ones", () => {
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="A & B <c> !" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("A & B <c> !");
|
|
});
|
|
|
|
it("does NOT double-decode — &lt; stays as literal '<' (XML §4.6)", () => {
|
|
// Per XML 1.0 §4.6, `&lt;` represents the five literal characters
|
|
// `<`, not `<`. A chained decoder (numeric refs, then each named ref
|
|
// as its own .replace pass) feeds the ampersand produced by the first
|
|
// step into the second step, collapsing `&lt;` → `<` → `<`.
|
|
// The single-pass alternation scans left-to-right and consumes each
|
|
// match once, so decoded output never re-feeds the decoder.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="&lt;" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("<");
|
|
});
|
|
|
|
it("does NOT double-decode — &lt; (numeric ampersand + 'lt;') also stays literal", () => {
|
|
// Same bug surface via a numeric reference. `&` decodes to `&` in a
|
|
// chained implementation, and the second pass then sees `<` and
|
|
// collapses it to `<`. Single-pass keeps the decoded `&` distinct.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="&lt;" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("<");
|
|
});
|
|
|
|
it("does NOT double-decode — &amp; stays literal '&'", () => {
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="&amp;" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children[0]!.label).toBe("&");
|
|
});
|
|
});
|
|
|
|
describe("uiautomator deeply-nested tree (review #6)", () => {
|
|
it("parses a 15k-deep hierarchy without blowing the JS stack", () => {
|
|
// The review claimed 15k-deep was realistic on a misconfigured
|
|
// RecyclerView + overlays. Build a dump that deep and confirm the new
|
|
// iterative converter handles it.
|
|
const depth = 15_000;
|
|
let xml = `<?xml version='1.0' ?>\n<hierarchy rotation="0">\n`;
|
|
for (let i = 0; i < depth; i++) {
|
|
xml += `<node index="${i}" text="" resource-id="" class="android.view.View" package="com.x" content-desc="" bounds="[${i},${i}][${depth},${depth}]">\n`;
|
|
}
|
|
for (let i = 0; i < depth; i++) xml += `</node>\n`;
|
|
xml += `</hierarchy>\n`;
|
|
|
|
// This is the assertion that caught the recursion bug: a recursive
|
|
// tree converter throws `Maximum call stack size exceeded` on this depth.
|
|
expect(() => parseUiAutomatorDump(xml, depth, depth)).not.toThrow();
|
|
});
|
|
|
|
it("parses a 10k-deep tree to a usable XML AST without overflow", () => {
|
|
// Same defence as the previous test, but checking the XML-parser stage in
|
|
// isolation: a 10k-nested `<node>` chain must produce a walkable AST.
|
|
const depth = 10_000;
|
|
let xml = `<?xml version='1.0' ?>\n<hierarchy rotation="0">\n`;
|
|
for (let i = 0; i < depth; i++) {
|
|
xml += `<node index="${i}" class="android.view.View" bounds="[0,0][100,100]" text="" resource-id="" content-desc="" package="com.x">\n`;
|
|
}
|
|
for (let i = 0; i < depth; i++) xml += `</node>\n`;
|
|
xml += `</hierarchy>\n`;
|
|
|
|
const parsed = parseUiAutomatorXml(xml);
|
|
expect(parsed).not.toBeNull();
|
|
expect(parsed!.children).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("parseUiAutomatorXml — tolerates raw `>` inside attribute values", () => {
|
|
// XML §2.4: only `<` and `&` MUST be escaped. `>` MAY appear unescaped, and
|
|
// real Android dumps do emit it that way (e.g. text="A > B" comparison
|
|
// strings, breadcrumb dividers). The previous tag regex used `[^<>]*?` for
|
|
// the attribute block, so any node with a raw `>` got dropped entirely and
|
|
// its subtree silently reparented onto the document root.
|
|
it("preserves a node whose `text` attribute contains a raw `>`", () => {
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="A > B" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
expect(tree.children).toHaveLength(1);
|
|
expect(tree.children[0]!.label).toBe("A > B");
|
|
});
|
|
});
|
|
|
|
describe("parseUiAutomatorXml — robust against malformed structure", () => {
|
|
it("ignores a stray closing tag without dropping subsequent siblings", () => {
|
|
// A leftover `</node>` with no matching opener used to pop a real parent
|
|
// off the stack; the next opening tag then became a second `root`,
|
|
// overwriting the first. Now: pop is guarded, and root is set only once.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.LinearLayout" bounds="[0,0][1000,1000]"
|
|
text="" resource-id="" content-desc="" package="com.x">
|
|
<node class="android.widget.TextView" bounds="[0,0][100,50]"
|
|
text="first" resource-id="" content-desc="" package="com.x" />
|
|
</node>
|
|
</node>
|
|
<node class="android.widget.TextView" bounds="[0,200][100,250]"
|
|
text="second" resource-id="" content-desc="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
// Both top-level child nodes should survive under the synthetic Screen root.
|
|
const labels = tree.children.flatMap((c) => collectLabels(c));
|
|
expect(labels).toContain("first");
|
|
expect(labels).toContain("second");
|
|
});
|
|
});
|
|
|
|
function collectLabels(n: {
|
|
label?: string;
|
|
children: { label?: string; children: unknown[] }[];
|
|
}): string[] {
|
|
const out: string[] = [];
|
|
if (n.label) out.push(n.label);
|
|
for (const c of n.children) out.push(...collectLabels(c as Parameters<typeof collectLabels>[0]));
|
|
return out;
|
|
}
|
|
|
|
describe("parseUiAutomatorDump — preserves siblings under a bounds-less wrapper", () => {
|
|
it("does not drop multiple children when the parent has no bounds", () => {
|
|
// Compose hierarchies emit bounds-less wrappers with multiple children
|
|
// routinely. The previous "collapse to sole child or drop" rule silently
|
|
// dropped every child whenever there were 2+, so the agent never saw
|
|
// them. Now the wrapper is replaced with a synthetic node whose frame is
|
|
// the union of the children, and the children remain reachable.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="androidx.compose.ui.platform.ComposeView">
|
|
<node class="android.widget.Button" bounds="[0,0][100,50]"
|
|
text="left" content-desc="" resource-id="" package="com.x" />
|
|
<node class="android.widget.Button" bounds="[200,0][300,50]"
|
|
text="right" content-desc="" resource-id="" package="com.x" />
|
|
</node>
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1000, 1000);
|
|
const labels = tree.children.flatMap((c) => collectLabels(c));
|
|
expect(labels).toContain("left");
|
|
expect(labels).toContain("right");
|
|
});
|
|
});
|
|
|
|
describe("parseUiAutomatorDump — clips off-screen rects to the screen", () => {
|
|
it("never produces a frame whose x + width exceeds 1", () => {
|
|
// Rail/badge at the right edge that uiautomator reports past the screen
|
|
// edge (real on tablets / foldables / drawer-overlay states). Without
|
|
// clipping, x clamped to 1 and width=190/1080≈0.176 made the tap centre
|
|
// land at 1.088 — off-screen.
|
|
const xml = `<?xml version='1.0' ?>
|
|
<hierarchy rotation="0">
|
|
<node class="android.widget.Button" bounds="[1090,0][1280,200]"
|
|
text="X" content-desc="" resource-id="" package="com.x" />
|
|
<node class="android.widget.Button" bounds="[-100,0][50,100]"
|
|
text="Y" content-desc="" resource-id="" package="com.x" />
|
|
</hierarchy>`;
|
|
const tree = parseUiAutomatorDump(xml, 1080, 1920);
|
|
for (const child of tree.children) {
|
|
expect(child.frame.x + child.frame.width).toBeLessThanOrEqual(1);
|
|
expect(child.frame.y + child.frame.height).toBeLessThanOrEqual(1);
|
|
expect(child.frame.x).toBeGreaterThanOrEqual(0);
|
|
expect(child.frame.y).toBeGreaterThanOrEqual(0);
|
|
}
|
|
});
|
|
});
|