Merge branch 'spec-parity-holes' into trial-merge

This commit is contained in:
pj committed 2026-08-15 21:27:47 +05:30
commit 79dbc471b8
10 files changed
+709 -75

No files matched your search

+8 -3
View File
@@ -193,13 +193,18 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
// (Compose for Web mounts its canvas and its whole accessibility tree inside a
// shadow root on the mount element) keeps its entire UI on the far side of one:
// without this a spec sees four nodes and can neither enumerate a target nor
// resolve a testTag. Light-DOM matches come first, then shadow content in walk
// order. XPath has no equivalent, so `text:` selectors stop at the boundary.
// resolve a testTag. Matches come back in the order expandShadowContent walks
// and buildTree (internal/driver/chrome/driver.go) emits: a host, then that
// host's shadow content, then the host's light children. Sweeping the light DOM
// first and descending afterwards put a shadow-hosted match behind a later
// light-DOM one, so find() answered with a different element on each host.
// XPath has no equivalent, so `text:` selectors stop at the boundary.
function deepQueryAll(selector: string, root: ParentNode): Element[] {
const found: Element[] = [];
const visit = (scope: ParentNode): void => {
for (const element of Array.from(scope.querySelectorAll(selector))) found.push(element);
const matched = new Set<Element>(Array.from(scope.querySelectorAll(selector)));
for (const element of Array.from(scope.querySelectorAll<HTMLElement>("*"))) {
if (matched.has(element)) found.push(element);
if (element.shadowRoot) visit(element.shadowRoot);
}
};
+213 -24
View File
@@ -1,8 +1,21 @@
// A minimal stand-in for the DOM surface the web host reads, shared by the web
// runtime's own tests and the cross-host eligibility test. The host asks the
// document for three things -- every element, the tappable set, the editable set
// -- and reads geometry, `disabled` and the scroll extents off each element, so
// that is all a fake has to answer.
// A small DOM the web runtime can be driven over, shared by the web runtime's
// own tests and the cross-host eligibility test.
//
// It is a fake, but the structure is real: elements nest, a host owns a shadow
// root, and querySelectorAll WALKS the tree and stops at a shadow boundary
// exactly as the browser's does. That is what makes the shadow descent in
// deepQueryAll and expandShadowContent (src/web-runtime.ts) observable here at
// all; the previous harness answered three fixed selectors from a flat list, so
// deleting either descent changed no test result.
//
// What it fabricates is layout: getBoundingClientRect, scrollHeight and
// clientHeight are handed over from the spec. No headless DOM computes those,
// and they are precisely the facts collectTargets reads, so a real DOM
// implementation would have to be stubbed for them anyway.
//
// An unsupported selector throws rather than matching nothing, so a test whose
// selector this cannot parse fails loudly instead of quietly asserting over an
// empty list.
import { __testing__ } from "../src/web-runtime.ts";
@@ -23,23 +36,45 @@ export interface FakeElementSpec {
label?: string;
alt?: string;
title?: string;
// clickable/editable place the element in the selector sets the host queries;
// the fake answers those queries directly rather than matching CSS.
// text is what an ax element handle reports as `text`, the same field the
// goja host reads off a hierarchy node, so a test can name WHICH of two
// same-id elements a lookup resolved to.
text?: string;
attrs?: Record<string, string>;
// clickable/editable place the element in the two fact sets the host queries
// by selector. They are answered from these flags rather than by matching
// their CSS: the cross-host golden (fixtures/host-parity-golden.json, built
// row for row in internal/verifier/host_parity_test.go) pins fact
// combinations no CSS can produce, such as an <input> that is editable and
// not clickable. A test states the facts there; this harness reports them.
clickable?: boolean;
editable?: boolean;
disabled?: boolean;
// overflows makes the element's content taller than its box, which is how the
// host decides an element is scrollable.
overflows?: boolean;
children?: FakeElementSpec[];
shadow?: FakeElementSpec[];
}
export interface FakeElement extends FakeElementSpec {
export interface FakeRoot {
children: FakeElement[];
querySelectorAll(selector: string): FakeElement[];
}
export interface FakeElement extends Omit<FakeElementSpec, "children" | "shadow"> {
tagName: string;
type: string;
isContentEditable: boolean;
id: string;
className: string;
textContent: string;
dataset: Record<string, string | undefined>;
parentElement: FakeElement | null;
children: FakeElement[];
shadowRoot: FakeRoot | null;
getAttribute(name: string): string | null;
querySelectorAll(selector: string): FakeElement[];
scrollHeight: number;
clientHeight: number;
scrollWidth: number;
@@ -56,15 +91,26 @@ export interface FakeElement extends FakeElementSpec {
export function fakeElement(spec: FakeElementSpec): FakeElement {
const editable = spec.editable ?? false;
return {
const attributes: Record<string, string> = { ...spec.attrs };
if (spec.id !== undefined) attributes.id = spec.id;
if (spec.testid !== undefined) attributes["data-testid"] = spec.testid;
if (spec.label !== undefined) attributes["aria-label"] = spec.label;
if (spec.alt !== undefined) attributes.alt = spec.alt;
if (spec.title !== undefined) attributes.title = spec.title;
const element: FakeElement = {
...spec,
tagName: spec.tag.toUpperCase(),
type: spec.tag === "input" ? "text" : "",
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
id: spec.id ?? "",
className: attributes.class ?? "",
textContent: spec.text ?? "",
dataset: { testid: spec.testid },
getAttribute: (name: string) =>
({ "aria-label": spec.label, alt: spec.alt, title: spec.title })[name] ?? null,
parentElement: null,
children: (spec.children ?? []).map(fakeElement),
shadowRoot: null,
getAttribute: (name: string) => attributes[name] ?? null,
querySelectorAll: (selector: string) => queryScope(element, selector),
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
clientHeight: spec.height,
scrollWidth: spec.width,
@@ -78,27 +124,170 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
bottom: spec.y + spec.height,
}),
};
for (const child of element.children) child.parentElement = element;
if (spec.shadow) element.shadowRoot = fakeRoot(spec.shadow.map(fakeElement));
return element;
}
// withFakeDocument installs a document answering the host's three queries over
// `elements`, resets the host's per-tick cache, and restores the real document
// afterwards.
// A shadow root's children have no parentElement, as in a real DOM, so a
// descendant selector cannot reach across the boundary from either side.
function fakeRoot(children: FakeElement[]): FakeRoot {
const root: FakeRoot = {
children,
querySelectorAll: (selector: string) => queryScope(root, selector),
};
return root;
}
function queryScope(scope: { children: FakeElement[] }, selector: string): FakeElement[] {
const found: FakeElement[] = [];
const walk = (nodes: FakeElement[]): void => {
for (const node of nodes) {
if (matchesQuery(node, selector)) found.push(node);
walk(node.children);
}
};
walk(scope.children);
return found;
}
function matchesQuery(element: FakeElement, selector: string): boolean {
if (selector === TAPPABLE_SELECTOR) return element.clickable === true;
if (selector === EDITABLE_SELECTOR) return element.editable === true;
return matchesSelectorList(element, selector);
}
function matchesSelectorList(element: FakeElement, selector: string): boolean {
return splitTopLevel(selector, ",").some((complex) => matchesComplex(element, complex));
}
function matchesComplex(element: FakeElement, complex: string): boolean {
const compounds = splitTopLevel(complex, " ");
const subject = compounds.pop();
if (subject === undefined) return false;
if (!matchesCompound(element, subject)) return false;
let ancestor = element.parentElement;
for (const compound of compounds.reverse()) {
while (ancestor && !matchesCompound(ancestor, compound)) ancestor = ancestor.parentElement;
if (!ancestor) return false;
ancestor = ancestor.parentElement;
}
return true;
}
const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*/;
const ATTRIBUTE = /^([a-zA-Z][\w-]*)(?:([~^]?)=(.+))?$/;
function matchesCompound(element: FakeElement, compound: string): boolean {
let rest = compound;
while (rest.length > 0) {
if (rest.startsWith("*")) {
rest = rest.slice(1);
continue;
}
if (rest.startsWith("[")) {
const end = closingIndex(rest, "[", "]");
if (!matchesAttribute(element, rest.slice(1, end))) return false;
rest = rest.slice(end + 1);
continue;
}
if (rest.startsWith(":is(") || rest.startsWith(":not(")) {
const end = closingIndex(rest, "(", ")");
const inner = rest.slice(rest.indexOf("(") + 1, end);
const anyMatched = splitTopLevel(inner, ",").some((part) =>
matchesSelectorList(element, part),
);
if (rest.startsWith(":is(") ? !anyMatched : anyMatched) return false;
rest = rest.slice(end + 1);
continue;
}
const tag = TAG_NAME.exec(rest);
if (!tag) throw new Error(`web-dom-harness cannot parse selector ${JSON.stringify(compound)}`);
if (element.tagName !== tag[0].toUpperCase()) return false;
rest = rest.slice(tag[0].length);
}
return true;
}
function matchesAttribute(element: FakeElement, body: string): boolean {
const parsed = ATTRIBUTE.exec(body);
if (!parsed) throw new Error(`web-dom-harness cannot parse attribute [${body}]`);
const [, name, operator, quoted] = parsed;
const actual = element.getAttribute(name!);
if (actual === null) return false;
if (quoted === undefined) return true;
const value = unescapeCss(quoted.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"));
if (operator === "~") return actual.split(/\s+/).includes(value);
if (operator === "^") return actual.startsWith(value);
return actual === value;
}
// Selector values reach the harness escaped by CSS.escape, so `[id="1a"]`
// arrives as `[id="\31 a"]` and comparing it raw would never match.
function unescapeCss(value: string): string {
return value.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_, hex: string, literal: string) =>
hex ? String.fromCodePoint(parseInt(hex, 16)) : literal,
);
}
function closingIndex(input: string, open: string, close: string): number {
let depth = 0;
let quote = "";
for (let index = input.indexOf(open); index < input.length; index++) {
const character = input[index]!;
if (quote) {
if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'") quote = character;
else if (character === open) depth++;
else if (character === close && --depth === 0) return index;
}
throw new Error(`web-dom-harness cannot parse selector ${JSON.stringify(input)}`);
}
function splitTopLevel(input: string, separator: string): string[] {
const parts: string[] = [];
let current = "";
let depth = 0;
let quote = "";
for (const character of input) {
if (quote) {
current += character;
if (character === quote) quote = "";
continue;
}
if (character === '"' || character === "'") quote = character;
else if (character === "(" || character === "[") depth++;
else if (character === ")" || character === "]") depth--;
else if (depth === 0 && (character === separator || (separator === " " && /\s/.test(character)))) {
parts.push(current);
current = "";
continue;
}
current += character;
}
parts.push(current);
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
}
// withFakeDocument installs a document whose top-level children are `elements`,
// resets the host's per-tick cache, and restores the real globals afterwards.
// window goes in alongside document because buildState reads both, so an
// extractor reaching state.ax needs it.
export function withFakeDocument(elements: FakeElement[], run: () => void): void {
const global = globalThis as Record<string, unknown>;
const original = global.document;
const answers: Record<string, FakeElement[]> = {
"*": elements,
[TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable),
[EDITABLE_SELECTOR]: elements.filter((element) => element.editable),
};
global.document = {
querySelectorAll: (selector: string) => answers[selector] ?? [],
};
const originalDocument = global.document;
const originalWindow = global.window;
const document: FakeRoot = fakeRoot(elements);
global.document = document;
global.window = {};
__testing__.resetTargetCache();
try {
run();
} finally {
__testing__.resetTargetCache();
global.document = original;
global.document = originalDocument;
global.window = originalWindow;
}
}
+129 -45
View File
@@ -84,6 +84,7 @@ test("installRuntime defined the host-invoked globals", () => {
});
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
type FakeElementSpec = Parameters<typeof fakeElement>[0];
// The host reports facts and never routes verbs: which of these a verb may act
// on is decided by the shared rule in src/targets.ts, exercised across both
@@ -175,6 +176,55 @@ test("queryTargets leaves duplicated identities unnamed", () => {
});
});
// The enumeration ORDER is the parity contract. buildTree in
// internal/driver/chrome/driver.go emits a host's shadow children before its
// light ones, and TestHierarchy_DerivesTheSameFactsAsTheWebRuntime compares the
// two enumerations position by position.
test("queryTargets splices shadow content in before the host's light children", () => {
const page = fakeElement({
tag: "div", x: 0, y: 0, width: 400, height: 800, id: "page",
children: [
{
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount",
shadow: [
{ tag: "button", x: 0, y: 0, width: 40, height: 20, id: "shadow-save", clickable: true },
],
children: [{ tag: "div", x: 0, y: 20, width: 40, height: 20, id: "mount-light-child" }],
},
{ tag: "div", x: 0, y: 100, width: 400, height: 100, id: "after" },
],
});
withFakeDocument([page], () => {
assert.deepEqual(
host.queryTargets().map((target) => target.selector),
["id:page", "id:mount", "id:shadow-save", "id:mount-light-child", "id:after"],
);
});
});
// The tappable set is resolved by selector, and querySelectorAll stops dead at
// a shadow boundary, so a control inside a shadow root carries the clickable
// fact only if the selector sweep descends. A Compose for Web app keeps every
// control it has on the far side of one boundary.
test("queryTargets reports a shadow-hosted control as clickable", () => {
const mount = fakeElement({
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount",
shadow: [
{ tag: "button", x: 0, y: 0, width: 40, height: 20, id: "shadow-save", clickable: true },
{ tag: "input", x: 0, y: 20, width: 40, height: 20, id: "shadow-amount", editable: true },
],
});
withFakeDocument([mount], () => {
const targets = host.queryTargets();
assert.deepEqual(
targets.map((target) => target.selector),
["id:mount", "id:shadow-save", "id:shadow-amount"],
);
assert.equal(targets[1]!.clickable, true);
assert.equal(targets[2]!.editable, true);
});
});
test("queryTargets caches within a tick until reset", () => {
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
withFakeDocument([button], () => {
@@ -473,28 +523,23 @@ test("selectorTag renders the selector shapes the goja host renders", () => {
// accounts/totalBalance extractors (findAll([{HomeScreen}, {AccountCard}]))
// were empty on every web step and the properties over them checked nothing.
test("ax.findAll resolves a selector path segment by segment", () => {
const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 };
const node = (id: string, answers: Record<string, unknown[]> = {}) => ({
id,
tagName: "DIV",
className: "",
textContent: id,
dataset: {},
getAttribute: () => null,
getBoundingClientRect: () => rect,
querySelectorAll: (selector: string) => answers[selector] ?? [],
const card = (id: string, y: number): FakeElementSpec => ({
tag: "div", x: 0, y, width: 10, height: 10, testid: "AccountCard", text: id,
});
// The stray card is outside HomeScreen, so a document-wide sweep for the
// second segment picks it up and the scoping assertion below fails.
const page = fakeElement({
tag: "div", x: 0, y: 0, width: 100, height: 100,
children: [
{
tag: "div", x: 0, y: 0, width: 100, height: 50, testid: "HomeScreen",
children: [card("first", 0), card("second", 10)],
},
card("stray", 60),
],
});
const cardCss = `:is([data-testid="AccountCard"], [id="AccountCard"])`;
const screenCss = `:is([data-testid="HomeScreen"], [id="HomeScreen"])`;
const cards = [node("first"), node("second")];
const home = node("HomeScreen", { [cardCss]: cards });
const g = globalThis as Record<string, unknown>;
const originalDocument = g.document;
const originalWindow = g.window;
g.document = { querySelectorAll: (selector: string) => (selector === screenCss ? [home] : []) };
g.window = {};
try {
withFakeDocument([page], () => {
__testing__.extractors.length = 0;
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
@@ -506,30 +551,14 @@ test("ax.findAll resolves a selector path segment by segment", () => {
// Scoped to the head match: the cards come from the HomeScreen node, not
// from a document-wide sweep for AccountCard.
assert.deepEqual(readingOf(values, 0), ["first", "second"]);
} finally {
g.document = originalDocument;
g.window = originalWindow;
}
});
});
test("ax.find and ax.findAll label the element with its selector", () => {
const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 };
const submit = {
id: "TxnSubmit",
tagName: "DIV",
className: "",
textContent: "Submit",
dataset: {},
getAttribute: () => null,
getBoundingClientRect: () => rect,
};
const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`;
const g = globalThis as Record<string, unknown>;
const originalDocument = g.document;
const originalWindow = g.window;
g.document = { querySelectorAll: (selector: string) => (selector === matches ? [submit] : []) };
g.window = {};
try {
const submit = fakeElement({
tag: "div", x: 0, y: 0, width: 10, height: 10, id: "TxnSubmit", text: "Submit",
});
withFakeDocument([submit], () => {
__testing__.extractors.length = 0;
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
@@ -546,8 +575,63 @@ test("ax.find and ax.findAll label the element with its selector", () => {
// reference would hand the array INDEX to the runtime as the selector.
const all = readingOf(values, 1) as Record<string, unknown>[];
assert.equal(all[0]!.__sanderlingSelector, "testTag:TxnSubmit");
} finally {
g.document = originalDocument;
g.window = originalWindow;
}
});
});
// One page, one selector, two hosts. The goja host resolves a selector against
// the hierarchy dump, whose buildTree (internal/driver/chrome/driver.go) emits
// a host's shadow children BEFORE its light ones, so a pre-order search there
// reaches a shadow-hosted match first. deepQueryAll swept the whole light DOM
// first and only then descended, so this page answered find({id:"x"}) with the
// light node in V8 and the shadow node in goja, and on web V8's answer is the
// one that reaches the properties.
test("ax.find resolves the shadow-hosted match the hierarchy dump reaches first", () => {
const page = fakeElement({
tag: "div", x: 0, y: 0, width: 400, height: 800, id: "page",
children: [
{
tag: "div", x: 0, y: 0, width: 400, height: 100, id: "mount",
shadow: [{ tag: "span", x: 0, y: 0, width: 40, height: 20, id: "x", text: "shadow" }],
},
{ tag: "span", x: 0, y: 100, width: 40, height: 20, id: "x", text: "light" },
],
});
withFakeDocument([page], () => {
__testing__.extractors.length = 0;
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
return ax.find("id:x")?.text;
});
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
return ax.findAll("id:x").map((element) => element.text);
});
const values = __testing__.evaluateExtractors();
assert.equal(readingOf(values, 0), "shadow");
assert.deepEqual(readingOf(values, 1), ["shadow", "light"]);
});
});
// A nested undefined is the one reading shape the two hosts do NOT encode
// alike, and this pins the split instead of hiding it. JSON has no undefined,
// so the key goes with the value here; goja marshals the same member as null,
// and it cannot do otherwise, because an exported goja object reports undefined
// and null identically, so dropping those keys there would drop the genuine
// nulls this host keeps. Carrying the member across would take a wire format
// that can express undefined.
//
// What both hosts DO agree on is the member's value: reading it answers
// undefined either way, and that is the guarantee a property may rely on. Key
// presence (`in`, Object.keys) is not.
// TestExtractorEncoding_NestedUndefinedIsNotOnTheWire in
// internal/verifier/extractor_encoding_test.go pins the other half.
test("a nested undefined leaves the page as a dropped key, a nested null does not", () => {
__testing__.extractors.length = 0;
__testing__.runtime.extract(() => ({ absent: undefined, empty: null, present: 1 }));
let wire = "";
withState(() => {
// Exactly what extractorScript in internal/driver/chrome/driver.go sends.
wire = JSON.stringify(__testing__.evaluateExtractors());
});
assert.equal(wire, `{"0":{"value":{"empty":null,"present":1}}}`);
});