Files
software-mansion__argent/packages/tool-server/test/describe-chromium-script.test.ts
Jakub Piasecki 551c781fb0 feat(flow): support running end-to-end flows without agent (#429)
# Flow E2E

This PR turns flows from replayable tool-call recordings into a
declarative, portable e2e test system that runs headlessly via `argent
flow run <name>` — no LLM in the loop, non-zero exit on failure,
suitable for CI.

Agents can still replay flows via `flow-execute` and get the full output
from each step after it finishes.

## Flow format

Flows are YAML files in `.argent/flows/`, now built from **directives**
interpreted by the runner rather than raw tool calls:

- `launch` — terminate + relaunch the app under test (per-platform app
id map supported); its presence marks a flow as **e2e** (controls its
own start state) vs. a **fragment** (runs against current state,
declares an `executionPrerequisite`, composable via `run:` with
cycle/depth guards).
- `tap`, `type` (focus handshake + optional submit), `scroll-to`
(momentum-free, increments by half the container, `within:` for nested
scrollers), `await`/`assert` (`visible`/`exists`/`hidden`/`text`
conditions), `wait`, `snapshot`, `echo`, and raw `tool:` as escape hatch
— still fully supported, so previously recorded flows keep working
unchanged.
- **Selectors** are `{ text, identifier, role }`; a bare string is a
loose selector resolving identifier-first, then visible text. Any
directive failure hard-stops the flow; later steps report `skip`.

## Execution engine

- Flows store no device id — the runner binds a device at replay
(explicit `--device`/`--platform` or the single booted one).
- Selector resolution runs against **platform-unified UI trees**: iOS
native view hierarchy (full testID coverage, gated on native-devtools
connecting), Android full accessibility hierarchy (full resource-id
coverage), Chromium CDP DOM, and the Vega automation toolkit. A shared
flatten/text-hoisting pass lets `text` assertions read what an
identified container visibly shows. Vega is remote-driven, so touch
directives fail with guidance to use `tv-remote`/`keyboard`.
- Steps wait for the tree to **settle** (two consecutive identical
reads) before acting; `launch` additionally gates on platform
tree-source readiness instead of silently degrading.

## Visual snapshots

`snapshot` diffs a full-resolution screenshot against baselines in
`.argent/flows/__baselines__/<flow>/`, keyed by platform + resolution,
with configurable `maxMismatch` and `--update-baselines`. The status bar
is pinned for the whole run (iOS `simctl status_bar`, Android demo mode)
so clocks/battery don't drive diffs. Baseline/current/diff images are
returned as artifacts on failure (and the written baseline on first
capture/update); a clean pass carries none, keeping reports and agent
context lean.

## Recording

The record-live workflow (`flow-start-recording` → `flow-add-step` →
`flow-finish-recording`) now emits directives automatically: coordinate
taps are rewritten to portable selector `tap:` steps (selector captured
from the pre-tap tree), a leading `restart-app` becomes `launch:`,
`flow-execute` of a sibling fragment becomes `run:`, and device ids are
stripped. The skill instructs the agent to polish the saved YAML
afterwards (fold tap+keyboard into `type:`, swipes-to-reach into
`scroll-to:`, awaits into sugar form).

## Also included

- `argent flow run` / `argent flow list` CLI with per-step report
rendering, artifact materialization, and `--json` output.
- New structured failure codes for flow validation, device resolution,
composition, and devtools readiness.
- Extensive unit test coverage (tree adapters, selector matching,
scrolling, composition, type focus, snapshots, status bar).
2026-07-13 17:15:01 +02:00

892 lines
36 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import { DESCRIBE_DOM_SCRIPT } from "../src/tools/describe/platforms/chromium";
/**
* `DESCRIBE_DOM_SCRIPT` is an IIFE injected via Runtime.evaluate that walks the live
* renderer DOM. The rest of the suite can only mock its CDP *response*, so this test
* evals the real script against a hand-built mock DOM to lock in the visibility /
* pruning rules — the part that broke describe on React Native Web (everything nested
* under a zero-area display:contents box was pruned) and crashed it on pages with
* DOM-clobbering forms. Mirrors test/debugger/component-tree-script.test.ts.
*
* The mock implements only the DOM surface the script reads: getBoundingClientRect,
* getComputedStyle (display / visibility / opacity / overflow{,X,Y}), children,
* childNodes (text), getAttribute/hasAttribute, open shadowRoot, iframe contentDocument,
* and a Range whose rect unions the element's own painted content with the still-laid-out
* boxes of its descendants (everything but display:none) — so it reproduces the real
* behaviour where a box-less wrapper's Range is non-zero purely from a visibility:hidden /
* opacity:0 child, and returns zero only when an ancestor transform collapses the paint.
*/
const W = 1000;
const H = 1000;
class MockNode {}
class MockElement extends MockNode {}
class MockHTMLInputElement extends MockElement {}
class MockHTMLTextAreaElement extends MockElement {}
class MockHTMLImageElement extends MockElement {}
// The script reads childNodes / tagName / children through the native prototype getter
// (Object.getOwnPropertyDescriptor(proto, prop).get.call(el)) so a DOM-clobbering <form>
// can't shadow them. Mirror that here: expose each as a prototype accessor backed by a
// field, so a test can shadow the *public* property (see `clobberStructural`) while the
// prototype getter still returns the real value — exactly the real [LegacyOverrideBuiltins]
// behaviour the fix relies on. childNodes lives on Node.prototype, tagName/children on
// Element.prototype, matching where the script captures each getter.
function defineNative(proto: object, prop: string, field: string): void {
Object.defineProperty(proto, prop, {
get(this: Record<string, unknown>) {
return this[field];
},
set(this: Record<string, unknown>, v: unknown) {
this[field] = v;
},
configurable: true,
});
}
defineNative(MockNode.prototype, "childNodes", "__childNodes");
defineNative(MockNode.prototype, "textContent", "__textContent");
defineNative(MockElement.prototype, "tagName", "__tagName");
defineNative(MockElement.prototype, "children", "__children");
defineNative(MockElement.prototype, "shadowRoot", "__shadowRoot");
// Scroll-dimension getters live on Element.prototype too; the script captures their
// descriptor up front (so they must exist) and only reads them for overflow:auto/scroll.
defineNative(MockElement.prototype, "scrollHeight", "__scrollHeight");
defineNative(MockElement.prototype, "clientHeight", "__clientHeight");
defineNative(MockElement.prototype, "scrollWidth", "__scrollWidth");
defineNative(MockElement.prototype, "clientWidth", "__clientWidth");
// getAttribute / hasAttribute / getBoundingClientRect are methods on Element.prototype.
// The script invokes them via the captured `Element.prototype.X` so a [LegacyOverrideBuiltins]
// form can't shadow them to a control element (which would crash with "not a function").
// Back them with per-element fields, mirroring the real DOM. Assigned through a cast
// because the bare mock classes declare no DOM members.
const elementProto = MockElement.prototype as unknown as Record<string, unknown>;
elementProto.getAttribute = function (this: Record<string, unknown>, n: string) {
const a = (this.__attrs as Record<string, string>) ?? {};
return n in a ? a[n] : null;
};
elementProto.hasAttribute = function (this: Record<string, unknown>, n: string) {
return n in ((this.__attrs as Record<string, string>) ?? {});
};
elementProto.getBoundingClientRect = function (this: Record<string, unknown>) {
const r = this.__rect as Rect;
return { left: r.x, top: r.y, right: r.x + r.w, bottom: r.y + r.h, width: r.w, height: r.h };
};
type Rect = { x: number; y: number; w: number; h: number };
type Opts = {
tag?: string;
text?: string;
rect?: Rect;
content?: Rect | null; // painted extent of own inline content (Range)
style?: Record<string, string>;
attrs?: Record<string, string>;
children?: MockElement[];
shadow?: MockElement[]; // open shadow root children (walker pierces these)
iframeDoc?: MockElement; // <iframe> contentDocument.documentElement (same-origin pierce)
clobber?: boolean; // set .title/.id to non-string objects (DOM-clobbering)
clobberStructural?: boolean; // shadow .children/.childNodes/.tagName with named controls (LegacyOverrideBuiltins)
clobberAccessors?: boolean; // shadow getAttribute/hasAttribute/getBoundingClientRect/shadowRoot with a control element
};
function el(opts: Opts = {}): MockElement {
const node = new MockElement() as MockElement & Record<string, unknown>;
const rect = opts.rect ?? { x: 0, y: 0, w: 100, h: 20 };
node.tagName = (opts.tag ?? "div").toUpperCase();
// Backing fields read by the Element.prototype getAttribute/hasAttribute/
// getBoundingClientRect methods defined above (the script reads them via the prototype).
node.__attrs = opts.attrs ?? {};
node.__rect = rect;
node.children = opts.children ?? [];
// The text node carries the element's own painted-text rect (`content`) so a Range
// over just this text node measures the own-text extent — matching the real browser,
// where selectNodeContents(textNode) spans the text and NOT sibling element boxes.
node.childNodes = opts.text
? [{ nodeType: 3, nodeValue: opts.text, __content: opts.content ?? null }]
: [];
// An open shadow root is a DocumentFragment exposing `.children`; the walker reads
// `getShadowRoot.call(el)` then iterates `shadow.children`. null unless a fixture sets it.
node.shadowRoot = opts.shadow ? ({ children: opts.shadow } as unknown) : null;
// A same-origin <iframe> exposes contentDocument.documentElement (read directly, not via
// a prototype getter). Only meaningful when tag === "iframe".
if (opts.iframeDoc) {
(node as Record<string, unknown>).contentDocument = { documentElement: opts.iframeDoc };
}
(node as Record<string, unknown>).__content = opts.content ?? null;
if (opts.clobber) {
// simulate a <form> whose named control shadows the .title / .id properties
node.title = node;
node.id = node;
}
if (opts.clobberStructural) {
// simulate a <form> with [LegacyOverrideBuiltins] whose controls are named
// children / childNodes / tagName: the public property returns the control element
// (not iterable / not a string), while the native prototype getter still returns the
// real DOM value (preserved in the backing fields set above).
const kids = opts.children ?? [];
Object.defineProperty(node, "children", { value: kids[0], configurable: true });
Object.defineProperty(node, "childNodes", { value: kids[1], configurable: true });
Object.defineProperty(node, "tagName", { value: kids[2], configurable: true });
}
if (opts.clobberAccessors) {
// simulate a <form> whose controls are named getAttribute / hasAttribute /
// getBoundingClientRect / shadowRoot: each public member returns a control element,
// so a direct el.getAttribute(...) throws "not a function" and a direct el.shadowRoot
// read would re-walk the control's children (duplicating the subtree). The script must
// route every one of these through the captured Element.prototype accessor.
const ctrl = (opts.children ?? [])[0];
for (const m of [
"getAttribute",
"hasAttribute",
"getBoundingClientRect",
"shadowRoot",
"textContent",
]) {
Object.defineProperty(node, m, { value: ctrl, configurable: true });
}
}
const baseStyle: Record<string, string> = {
display: "block",
visibility: "visible",
opacity: "1",
overflow: "visible",
overflowX: "visible",
overflowY: "visible",
};
const s = { ...baseStyle, ...(opts.style ?? {}) };
if (opts.style?.overflow && !opts.style.overflowX) s.overflowX = opts.style.overflow;
if (opts.style?.overflow && !opts.style.overflowY) s.overflowY = opts.style.overflow;
(node as Record<string, unknown>).__style = s;
return node;
}
// An <input> the script's `instanceof HTMLInputElement` branches recognise
// (accessibleName's placeholder/value fallbacks, isPassword, isChecked): built
// like el(), then re-prototyped onto the mock input class with the live
// .type/.value/.placeholder properties those branches read directly.
function inputEl(opts: Opts & { type?: string; value?: string; placeholder?: string }) {
const node = el({ ...opts, tag: "input" }) as MockElement & Record<string, unknown>;
Object.setPrototypeOf(node, MockHTMLInputElement.prototype);
if (opts.type) node.type = opts.type;
if (opts.value) node.value = opts.value;
if (opts.placeholder) node.placeholder = opts.placeholder;
return node;
}
function run(rootChildren: MockElement[]): { tree: unknown; truncated: boolean } {
const root = el({ tag: "html", rect: { x: 0, y: 0, w: W, h: H } }) as MockElement &
Record<string, unknown>;
root.children = [el({ tag: "body", rect: { x: 0, y: 0, w: W, h: H }, children: rootChildren })];
const g = globalThis as Record<string, unknown>;
const saved = {
window: g.window,
document: g.document,
Node: g.Node,
Element: g.Element,
HTMLInputElement: g.HTMLInputElement,
HTMLTextAreaElement: g.HTMLTextAreaElement,
HTMLImageElement: g.HTMLImageElement,
};
g.window = {
innerWidth: W,
innerHeight: H,
getComputedStyle: (e: Record<string, unknown>) => e.__style,
};
g.document = {
documentElement: root,
// Resolve aria-labelledby targets by walking the mock tree's backing __children
// (the real children, unaffected by any structural clobber) for a matching id.
getElementById: (id: string) => {
const find = (n: Record<string, unknown>): Record<string, unknown> | null => {
const attrs = (n.__attrs as Record<string, string>) ?? {};
if (attrs.id === id) return n;
for (const k of (n.__children as Record<string, unknown>[]) ?? []) {
const f = find(k);
if (f) return f;
}
return null;
};
return find(root);
},
createRange: () => {
let target: Record<string, unknown> | null = null;
return {
selectNodeContents: (e: Record<string, unknown>) => {
target = e;
},
// Model a real Range over selectNodeContents(el): the union of the element's own
// painted inline content (__content) AND the layout boxes of its descendants that
// still occupy layout — i.e. everything except display:none (visibility:hidden and
// opacity:0 keep their box). This lets a fixture reproduce the real-browser case a
// plain "return __content" mock could not: a box-less wrapper whose Range is
// non-zero purely because an invisible child still lays out.
getBoundingClientRect: () => {
let box: { x: number; y: number; r: number; b: number } | null = null;
const add = (r: Rect | null | undefined) => {
if (!r || r.w <= 0 || r.h <= 0) return;
box = box
? {
x: Math.min(box.x, r.x),
y: Math.min(box.y, r.y),
r: Math.max(box.r, r.x + r.w),
b: Math.max(box.b, r.y + r.h),
}
: { x: r.x, y: r.y, r: r.x + r.w, b: r.y + r.h };
};
add(target?.__content as Rect | null | undefined);
const walkRects = (n: Record<string, unknown> | null | undefined) => {
for (const c of (n?.__children as Record<string, unknown>[]) ?? []) {
const st = (c.__style as Record<string, string>) ?? {};
if (st.display === "none") continue; // display:none collapses layout
add(c.__rect as Rect);
walkRects(c);
}
};
walkRects(target);
if (!box) return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
const b = box as { x: number; y: number; r: number; b: number };
return {
left: b.x,
top: b.y,
right: b.r,
bottom: b.b,
width: b.r - b.x,
height: b.b - b.y,
};
},
};
},
};
g.Node = MockNode;
g.Element = MockElement;
g.HTMLInputElement = MockHTMLInputElement;
g.HTMLTextAreaElement = MockHTMLTextAreaElement;
g.HTMLImageElement = MockHTMLImageElement;
try {
const payload = (0, eval)(DESCRIBE_DOM_SCRIPT) as string;
return JSON.parse(payload);
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete g[k];
else g[k] = v;
}
}
}
function valuesOf(tree: unknown): string[] {
const out: string[] = [];
(function rec(n: Record<string, unknown> | null) {
if (!n) return;
if (typeof n.value === "string") out.push(n.value);
for (const c of (n.children as Record<string, unknown>[]) ?? []) rec(c);
})(tree as Record<string, unknown>);
return out;
}
function identifiersOf(tree: unknown): string[] {
const out: string[] = [];
(function rec(n: Record<string, unknown> | null) {
if (!n) return;
if (typeof n.identifier === "string") out.push(n.identifier);
for (const c of (n.children as Record<string, unknown>[]) ?? []) rec(c);
})(tree as Record<string, unknown>);
return out;
}
function rolesOf(tree: unknown): string[] {
const out: string[] = [];
(function rec(n: Record<string, unknown> | null) {
if (!n) return;
if (typeof n.role === "string") out.push(n.role);
for (const c of (n.children as Record<string, unknown>[]) ?? []) rec(c);
})(tree as Record<string, unknown>);
return out;
}
function findById(tree: unknown, id: string): Record<string, unknown> | null {
let found: Record<string, unknown> | null = null;
(function rec(n: Record<string, unknown> | null) {
if (!n || found) return;
if (n.identifier === id) {
found = n;
return;
}
for (const c of (n.children as Record<string, unknown>[]) ?? []) rec(c);
})(tree as Record<string, unknown>);
return found;
}
function countNodes(tree: unknown): number {
let n = 0;
(function rec(node: Record<string, unknown> | null) {
if (!node) return;
n++;
for (const c of (node.children as Record<string, unknown>[]) ?? []) rec(c);
})(tree as Record<string, unknown>);
return n;
}
const ZERO = { x: 0, y: 0, w: 0, h: 0 };
const BOX = { x: 0, y: 100, w: 200, h: 30 };
afterEach(() => {
// run() restores globals in its finally, nothing else to clean up.
});
describe("DESCRIBE_DOM_SCRIPT visibility rules", () => {
it("surfaces content nested under a display:contents wrapper (the RNW bug)", () => {
const { tree } = run([
el({
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "CONTENTS", rect: BOX })],
}),
]);
expect(valuesOf(tree)).toContain("CONTENTS");
});
it("surfaces content under a display:contents wrapper even at opacity:0 (opacity affects no box)", () => {
const { tree } = run([
el({
style: { display: "contents", opacity: "0" },
rect: ZERO,
children: [el({ text: "CONTENTS0", rect: BOX })],
}),
]);
expect(valuesOf(tree)).toContain("CONTENTS0");
});
it("still prunes a normal (boxed) opacity:0 subtree", () => {
const { tree } = run([
el({
style: { opacity: "0" },
rect: { x: 0, y: 0, w: 200, h: 200 },
children: [el({ text: "INVISIBLE", rect: BOX })],
}),
]);
expect(valuesOf(tree)).not.toContain("INVISIBLE");
});
it("surfaces an absolutely-positioned child of a zero-height overflow:visible wrapper", () => {
const { tree } = run([
el({
rect: { x: 0, y: 100, w: 1000, h: 0 },
children: [el({ text: "DROPDOWN", rect: BOX })],
}),
]);
expect(valuesOf(tree)).toContain("DROPDOWN");
});
it("keeps pruning a zero-area box that clips its overflow (collapsed content)", () => {
const { tree } = run([
el({
rect: ZERO,
style: { overflow: "hidden" },
children: [el({ text: "CLIPPED", rect: BOX })],
}),
]);
expect(valuesOf(tree)).not.toContain("CLIPPED");
});
it("prunes a box-less leaf whose painted content is also zero-area (transform: scale(0))", () => {
const { tree } = run([el({ text: "SCALE0", rect: ZERO, content: null })]);
expect(valuesOf(tree)).not.toContain("SCALE0");
});
it("surfaces a box-less leaf whose text actually paints (overflowing / contents text)", () => {
const { tree } = run([
el({ text: "OVERFLOWTEXT", rect: ZERO, content: { x: 0, y: 50, w: 80, h: 15 } }),
]);
expect(valuesOf(tree)).toContain("OVERFLOWTEXT");
});
it("does not crash and ignores DOM-clobbered .title / .id (uses getAttribute)", () => {
let result: { tree: unknown } | undefined;
expect(() => {
result = run([
el({
text: "CLOBBER",
rect: BOX,
clobber: true,
attrs: { title: "realtitle", id: "realid" },
}),
]);
}).not.toThrow();
const tree = JSON.stringify(result!.tree);
expect(tree).toContain("realid");
expect(valuesOf(result!.tree)).toContain("CLOBBER");
});
it("does not crash on a <form> whose controls clobber children/childNodes/tagName (LegacyOverrideBuiltins)", () => {
// Named controls shadow the form's inherited DOM properties: el.children returns a
// single control (not iterable), el.childNodes / el.tagName likewise return elements.
// Reading any of them directly aborts the whole walk; the fix routes through the
// native prototype getter, so the form and its controls still surface.
const fieldChildren = el({
tag: "input",
attrs: { name: "children", id: "field-children" },
rect: { x: 0, y: 100, w: 200, h: 20 },
});
const fieldChildNodes = el({
tag: "input",
attrs: { name: "childNodes", id: "field-childnodes" },
rect: { x: 0, y: 130, w: 200, h: 20 },
});
const fieldTagName = el({
tag: "input",
attrs: { name: "tagName", id: "field-tagname" },
rect: { x: 0, y: 160, w: 200, h: 20 },
});
let result: { tree: unknown } | undefined;
expect(() => {
result = run([
el({
tag: "form",
rect: { x: 0, y: 100, w: 200, h: 100 },
children: [fieldChildren, fieldChildNodes, fieldTagName],
clobberStructural: true,
}),
]);
}).not.toThrow();
const serialized = JSON.stringify(result!.tree);
expect(serialized).toContain("field-children");
expect(serialized).toContain("field-childnodes");
expect(serialized).toContain("field-tagname");
});
it("does not crash (or duplicate) on a <form> clobbering getAttribute/getBoundingClientRect/hasAttribute/shadowRoot", () => {
// A <form> whose controls are named getAttribute / getBoundingClientRect /
// hasAttribute reproduced "TypeError: el.X is not a function" on real Chrome (each
// shadows the inherited method to a control element); a control named shadowRoot made
// the walker re-walk the control's children and duplicate the subtree. The fix routes
// all of these through the captured Element.prototype accessor.
const inner = el({
tag: "input",
attrs: { id: "deep-inner" },
rect: { x: 0, y: 130, w: 200, h: 20 },
});
const fieldset = el({
tag: "fieldset",
attrs: { id: "fs", name: "shadowRoot" },
rect: { x: 0, y: 110, w: 200, h: 40 },
children: [inner],
});
let result: { tree: unknown } | undefined;
expect(() => {
result = run([
el({
tag: "form",
attrs: { id: "clobber-form" },
rect: { x: 0, y: 100, w: 200, h: 60 },
children: [fieldset],
clobberAccessors: true,
}),
]);
}).not.toThrow();
const serialized = JSON.stringify(result!.tree);
// The form's own id is read via the prototype getAttribute despite the clobber.
expect(serialized).toContain("clobber-form");
// The child still surfaces — exactly once (the shadowRoot clobber must not re-walk it).
expect(serialized).toContain("deep-inner");
expect((serialized.match(/deep-inner/g) || []).length).toBe(1);
});
it("does not crash on an aria-labelledby target whose form clobbers textContent", () => {
// accessibleName resolves aria-labelledby via getElementById, then reads textContent.
// A <form id="lbl"> with a control named "textContent" shadows the inherited getter,
// so a direct read returns the control and crashes (.trim on a non-string). The fix
// routes through Node.prototype.textContent.
const labelForm = el({
tag: "form",
attrs: { id: "lbl" },
rect: { x: 0, y: 200, w: 200, h: 20 },
children: [
el({
tag: "input",
attrs: { id: "tc", name: "textContent" },
rect: { x: 0, y: 200, w: 100, h: 20 },
}),
],
clobberAccessors: true,
}) as MockElement & Record<string, unknown>;
labelForm.__textContent = "Real Label";
const labelled = el({
tag: "div",
attrs: { "aria-labelledby": "lbl" },
rect: BOX,
children: [el({ text: "BODY", rect: BOX })],
});
let result: { tree: unknown } | undefined;
expect(() => {
result = run([labelled, labelForm]);
}).not.toThrow();
// The label resolves via the prototype textContent despite the clobber.
expect(JSON.stringify(result!.tree)).toContain("Real Label");
});
it("leaves an ordinary visible element unchanged", () => {
const { tree } = run([el({ text: "NORMAL", rect: BOX })]);
expect(valuesOf(tree)).toContain("NORMAL");
});
// ---- box-less wrapper over invisible-only content must not become a phantom node ----
it("drops a box-less wrapper whose non-zero content frame comes only from an invisible child", () => {
// The wrapper has no own text; its single element child is visibility:hidden (pruned).
// A real Range over the wrapper is non-zero because the child still lays out (the mock
// Range now models that), but that must NOT resurrect the wrapper as an empty node with
// a real frame — it paints nothing.
const { tree } = run([
el({
attrs: { id: "phantom-wrap" },
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "HIDDENKID", style: { visibility: "hidden" }, rect: BOX })],
}),
]);
expect(identifiersOf(tree)).not.toContain("phantom-wrap");
expect(valuesOf(tree)).not.toContain("HIDDENKID");
});
it("keeps a box-less wrapper with own painting text even when it also has an invisible child", () => {
// Own text paints, so the wrapper is real; the invisible child is just pruned.
const { tree } = run([
el({
text: "REALTEXT",
content: { x: 0, y: 40, w: 60, h: 12 },
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "HIDDENKID2", style: { visibility: "hidden" }, rect: BOX })],
}),
]);
expect(valuesOf(tree)).toContain("REALTEXT");
expect(valuesOf(tree)).not.toContain("HIDDENKID2");
// The wrapper's frame is its OWN text rect ({0,40,60,12} → 0.06 x 0.012),
// NOT the union with the invisible child's still-laid-out box (BOX reaches
// y=130, which would have made the frame ~0.09 tall and mis-placed the tap
// point). selectNodeContents over the element would have picked up BOX; the
// own-text-only measurement does not.
const findByValue = (
n: Record<string, unknown> | null,
v: string
): Record<string, unknown> | null => {
if (!n) return null;
if (n.value === v) return n;
for (const c of (n.children as Record<string, unknown>[]) ?? []) {
const r = findByValue(c, v);
if (r) return r;
}
return null;
};
const real = findByValue(tree as Record<string, unknown>, "REALTEXT");
const f = real!.frame as { y: number; width: number; height: number };
expect(f.width).toBeCloseTo(0.06, 5);
expect(f.height).toBeCloseTo(0.012, 5);
});
it("keeps a box-less wrapper's SEMANTIC role instead of promoting it away", () => {
// A display:contents <nav> (semantic role) with a single child and no
// clickable/name/text/id of its own must NOT be promoted to its child —
// that would silently drop the "nav" role. Only a plain <div> layer is
// promoted (see "still promotes an anonymous box-less wrapper" above).
const { tree } = run([
el({
tag: "nav",
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "NAVITEM", rect: BOX })],
}),
]);
expect(rolesOf(tree)).toContain("nav");
expect(valuesOf(tree)).toContain("NAVITEM");
});
it("a large fully visibility:hidden subtree does not starve the node budget", () => {
// A closed drawer/modal can be a large visibility:hidden subtree. Descending
// into it (to catch a visibility:visible override) must not spend the node
// budget on nodes that emit nothing, or genuinely visible content elsewhere
// gets truncated. MAX_NODES is 5000; 5100 hidden nodes before a visible one
// used to exhaust it.
const hiddenKids: MockElement[] = [];
for (let i = 0; i < 5100; i++) {
hiddenKids.push(el({ text: "HK" + i, style: { visibility: "hidden" }, rect: BOX }));
}
const { tree, truncated } = run([
el({ style: { display: "contents" }, rect: ZERO, children: hiddenKids }),
el({ text: "VISIBLE_AFTER", rect: BOX }),
]);
expect(truncated).toBe(false);
expect(valuesOf(tree)).toContain("VISIBLE_AFTER");
expect(valuesOf(tree)).not.toContain("HK0");
});
// ---- visibility:hidden inherits but a descendant can override it back to visible ----
it("surfaces a visibility:visible descendant nested under a visibility:hidden ancestor", () => {
const { tree } = run([
el({
style: { visibility: "hidden" },
rect: { x: 0, y: 100, w: 200, h: 200 },
children: [el({ text: "OVERRIDE", style: { visibility: "visible" }, rect: BOX })],
}),
]);
expect(valuesOf(tree)).toContain("OVERRIDE");
});
it("suppresses a visibility:hidden element's own text but keeps its visible child", () => {
const { tree } = run([
el({
text: "HIDDENOWN",
style: { visibility: "hidden" },
rect: { x: 0, y: 100, w: 200, h: 200 },
children: [el({ text: "VISIBLECHILD", style: { visibility: "visible" }, rect: BOX })],
}),
]);
expect(valuesOf(tree)).toContain("VISIBLECHILD");
expect(valuesOf(tree)).not.toContain("HIDDENOWN");
});
it("prunes a fully visibility:hidden subtree with no visible descendant (no phantom)", () => {
const { tree } = run([
el({
style: { visibility: "hidden" },
rect: { x: 0, y: 100, w: 200, h: 200 },
children: [el({ text: "ALLHIDDEN", style: { visibility: "hidden" }, rect: BOX })],
}),
]);
expect(valuesOf(tree)).not.toContain("ALLHIDDEN");
// Only the html/body scaffold survives — no phantom node for the hidden subtree.
expect(identifiersOf(tree)).toEqual([]);
});
// ---- promotion must not discard an identifier ----
it("keeps a box-less wrapper's identifier instead of promoting it away", () => {
const { tree } = run([
el({
attrs: { id: "keepme" },
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "INNER5", rect: BOX })],
}),
]);
expect(identifiersOf(tree)).toContain("keepme");
expect(valuesOf(tree)).toContain("INNER5");
});
it("keeps a boxed structural div's identifier instead of collapsing it away", () => {
const { tree } = run([
el({
attrs: { "data-testid": "structural" },
rect: { x: 0, y: 100, w: 200, h: 200 },
children: [el({ text: "INNER5C", rect: BOX })],
}),
]);
expect(identifiersOf(tree)).toContain("structural");
expect(valuesOf(tree)).toContain("INNER5C");
});
it("still promotes an anonymous box-less wrapper (no identifier) to its single child", () => {
const withWrap = run([
el({
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "SOLO", rect: BOX })],
}),
]);
const withoutWrap = run([el({ text: "SOLO", rect: BOX })]);
expect(valuesOf(withWrap.tree)).toContain("SOLO");
// The wrapper adds no node — identical node count to the bare child.
expect(countNodes(withWrap.tree)).toBe(countNodes(withoutWrap.tree));
expect(identifiersOf(withWrap.tree)).toEqual([]);
});
// ---- box-less node framing: unionFrame across multiple children (emitted, not promoted) ----
it("frames an emitted box-less multi-child wrapper by the union of its children (unionFrame)", () => {
const { tree } = run([
el({
attrs: { role: "button", id: "unioned" }, // clickable + named -> emitted, not promoted
style: { display: "contents" },
rect: ZERO,
children: [
el({ text: "A", rect: { x: 100, y: 100, w: 100, h: 20 } }),
el({ text: "B", rect: { x: 300, y: 400, w: 100, h: 20 } }),
],
}),
]);
const node = findById(tree, "unioned");
expect(node).toBeTruthy();
expect((node!.children as unknown[]).length).toBe(2);
const f = node!.frame as { x: number; y: number; width: number; height: number };
// union of (100,100,100,20) and (300,400,100,20): x=100 y=100 right=400 bottom=420
expect(f.x).toBeCloseTo(0.1, 6);
expect(f.y).toBeCloseTo(0.1, 6);
expect(f.width).toBeCloseTo(0.3, 6); // (400-100)/1000
expect(f.height).toBeCloseTo(0.32, 6); // (420-100)/1000
});
it("frames a promoted box-less single-child node by the child's own rect", () => {
const { tree } = run([
el({
style: { display: "contents" },
rect: ZERO,
children: [el({ text: "SOLO2", rect: BOX })],
}),
]);
const node = findById(tree, "");
// The promoted node is the child itself; find it by value and check its frame == BOX.
const child = (function find(
n: Record<string, unknown> | null
): Record<string, unknown> | null {
if (!n) return null;
if (n.value === "SOLO2") return n;
for (const c of (n.children as Record<string, unknown>[]) ?? []) {
const r = find(c);
if (r) return r;
}
return null;
})(tree as Record<string, unknown>);
expect(child).toBeTruthy();
const f = child!.frame as { x: number; y: number; width: number; height: number };
expect(f.x).toBeCloseTo(BOX.x / W, 6);
expect(f.y).toBeCloseTo(BOX.y / H, 6);
expect(f.width).toBeCloseTo(BOX.w / W, 6);
expect(f.height).toBeCloseTo(BOX.h / H, 6);
expect(node).toBeNull(); // sanity: no node literally identified by "" exists
});
// ---- shadow DOM + iframe piercing (previously uncovered) ----
it("pierces an open shadow root and surfaces its content", () => {
const { tree } = run([
el({ tag: "my-widget", rect: BOX, shadow: [el({ text: "SHADOWTEXT", rect: BOX })] }),
]);
expect(valuesOf(tree)).toContain("SHADOWTEXT");
});
it("pierces a same-origin iframe's contentDocument", () => {
const innerDoc = el({
tag: "html",
rect: { x: 0, y: 0, w: W, h: H },
children: [
el({
tag: "body",
rect: { x: 0, y: 0, w: W, h: H },
children: [el({ text: "IFRAMETEXT", rect: BOX })],
}),
],
});
const { tree } = run([
el({ tag: "iframe", rect: { x: 0, y: 0, w: 500, h: 500 }, iframeDoc: innerDoc }),
]);
expect(valuesOf(tree)).toContain("IFRAMETEXT");
});
// ---- a password input's typed value must never become its label ----
it("never reads a password input's typed value as its accessible name", () => {
// A placeholder-less password input (floating/uncontrolled-label pattern)
// with a typed value: the aria/placeholder/title fallbacks are all empty,
// and the value fallback must be skipped — otherwise the plaintext secret
// becomes node.label and reaches every describe consumer verbatim.
const { tree } = run([
inputEl({ type: "password", value: "hunter2", attrs: { id: "pw" }, rect: BOX }),
]);
const pw = findById(tree, "pw");
expect(pw).toBeTruthy();
expect(pw!.password).toBe(true);
expect(pw!.label).toBeUndefined();
expect(JSON.stringify(tree)).not.toContain("hunter2");
});
it("still reads a non-password input's value as its accessible name", () => {
const { tree } = run([
inputEl({ type: "text", value: "typed text", attrs: { id: "txt" }, rect: BOX }),
]);
expect(findById(tree, "txt")!.label).toBe("typed text");
});
// ---- input focus (the flow type directive's focus wait reads this) ----
it("marks the document's activeElement as focused, excluding the body", () => {
const focusedInput = el({ tag: "input", attrs: { id: "focused-input" }, rect: BOX });
const otherInput = el({
tag: "input",
attrs: { id: "other-input" },
rect: { x: 0, y: 200, w: 200, h: 30 },
});
const body = el({
tag: "body",
attrs: { id: "the-body" },
rect: { x: 0, y: 0, w: W, h: H },
children: [focusedInput, otherInput],
});
// The mock defines no Document constructor, so the script's protoGetter
// falls back to direct reads: a stub document with activeElement/body is
// enough. The body being activeElement (the no-focus default) must NOT
// mark it focused.
const doc = { activeElement: focusedInput, body };
for (const n of [focusedInput, otherInput, body]) {
(n as unknown as Record<string, unknown>).ownerDocument = doc;
}
const { tree } = run([body]);
expect(findById(tree, "focused-input")!.focused).toBe(true);
expect(findById(tree, "other-input")!.focused).toBeUndefined();
doc.activeElement = body;
const { tree: unfocusedTree } = run([body]);
expect(findById(unfocusedTree, "focused-input")!.focused).toBeUndefined();
expect(findById(unfocusedTree, "the-body")!.focused).toBeUndefined();
});
it("flags a focused input inside a same-origin iframe once — never its host <iframe>", () => {
// Focus inside a subdocument makes BOTH activeElements point at it: the
// inner document's is the input, the outer document's is the host iframe
// element. Only the inner element carries the flag; flagging the host too
// would double-report the focus.
const innerInput = el({ tag: "input", attrs: { id: "iframe-input" }, rect: BOX });
const innerBody = el({
tag: "body",
rect: { x: 0, y: 0, w: 500, h: 500 },
children: [innerInput],
});
const innerHtml = el({
tag: "html",
rect: { x: 0, y: 0, w: 500, h: 500 },
children: [innerBody],
});
const innerDoc = { documentElement: innerHtml, activeElement: innerInput, body: innerBody };
for (const n of [innerHtml, innerBody, innerInput]) {
(n as unknown as Record<string, unknown>).ownerDocument = innerDoc;
}
const iframe = el({
tag: "iframe",
attrs: { id: "the-iframe" },
rect: { x: 0, y: 0, w: 500, h: 500 },
});
(iframe as unknown as Record<string, unknown>).contentDocument = innerDoc;
// The outer document reports the host iframe as ITS activeElement.
const outerDoc = { activeElement: iframe, body: null };
(iframe as unknown as Record<string, unknown>).ownerDocument = outerDoc;
const { tree } = run([iframe]);
expect(findById(tree, "iframe-input")!.focused).toBe(true);
expect(findById(tree, "the-iframe")!.focused).toBeUndefined();
});
// ---- a missing captured accessor must degrade, not abort the whole describe ----
it("degrades instead of aborting when a captured prototype accessor is absent", () => {
// scrollHeight is read only for overflow:auto/scroll nodes. Removing its prototype
// accessor made the old `getOwnPropertyDescriptor(...).get` throw at script top and
// abort the entire describe; protoGetter now falls back to a direct read.
const saved = Object.getOwnPropertyDescriptor(MockElement.prototype, "scrollHeight");
delete (MockElement.prototype as unknown as Record<string, unknown>).scrollHeight;
try {
let out: { tree: unknown } | undefined;
expect(() => {
out = run([el({ text: "STILLHERE", rect: BOX, style: { overflow: "auto" } })]);
}).not.toThrow();
expect(valuesOf(out!.tree)).toContain("STILLHERE");
} finally {
Object.defineProperty(MockElement.prototype, "scrollHeight", saved!);
}
});
});