Files
sanderling/pkg/spec/test/web-dom-harness.ts
pj f7135b81d8 fix(spec): a boolean state selector names what the live element reports
clickable, enabled, focused, checked and selected are derived from the
element rather than written by the markup, so matching them as raw
attributes built [clickable="true"] and reached nothing: the keys are
accepted, no unknown-key error fires, and the worked example in
docs/manual/spec-language.md found no element on web and passed having
checked nothing.

Each key is answered by the same function elementHandle derives the fact
with, since no CSS says what any of them says: :focus names the shadow
host of a focused field as well, :checked misses a checked custom
element and answers for a selected option besides, and [checked] is the
state the page loaded with rather than the one the user left it in.
2026-08-18 20:43:09 +05:30

342 lines
13 KiB
TypeScript

// 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";
const { TAPPABLE_SELECTOR, EDITABLE_SELECTOR } = __testing__;
export interface FakeElementSpec {
tag: string;
x: number;
y: number;
width: number;
height: number;
// id/testid/label/alt/title are how the host names a target: it builds the
// selector an action carries from them, so a fake needs them to exercise that
// naming. alt and title are the fallbacks the hierarchy dump folds into
// content-desc, which the host has to fall back to in the same order.
id?: string;
testid?: string;
label?: string;
alt?: string;
title?: string;
// 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;
// checked/selected are DOM properties rather than markup attributes on a real
// element, which is the whole point of them: the attribute records what the
// page started with and stops tracking the first time a user acts.
checked?: boolean;
selected?: boolean;
// overflows makes the element's content taller than its box, which is how the
// host decides an element is scrollable.
overflows?: boolean;
focused?: boolean;
// customProperties are DECLARED on this element and INHERITED by everything
// under it, which is the whole reason the runtime reads a computed style and
// not the style attribute: Compose declares the caret's box on the container
// that positions its backing input, and only the input's COMPUTED style
// carries it.
customProperties?: Record<string, string>;
children?: FakeElementSpec[];
shadow?: FakeElementSpec[];
}
export interface FakeRoot {
children: FakeElement[];
activeElement: FakeElement | null;
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;
matches(selector: string): boolean;
querySelectorAll(selector: string): FakeElement[];
scrollHeight: number;
clientHeight: number;
scrollWidth: number;
clientWidth: number;
getBoundingClientRect(): {
left: number;
top: number;
width: number;
height: number;
right: number;
bottom: number;
};
}
export function fakeElement(spec: FakeElementSpec): FakeElement {
const editable = spec.editable ?? false;
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" ? (attributes.type ?? "text") : "",
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
id: spec.id ?? "",
className: attributes.class ?? "",
textContent: spec.text ?? "",
dataset: { testid: spec.testid },
parentElement: null,
children: (spec.children ?? []).map(fakeElement),
shadowRoot: null,
getAttribute: (name: string) => attributes[name] ?? null,
// elementHandle asks an element about itself rather than sweeping the
// document for it, so a fake that only answers querySelectorAll reports
// every element as untappable.
matches: (selector: string) => matchesQuery(element, selector),
querySelectorAll: (selector: string) => queryScope(element, selector),
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
clientHeight: spec.height,
scrollWidth: spec.width,
clientWidth: spec.width,
getBoundingClientRect: () => ({
left: spec.x,
top: spec.y,
width: spec.width,
height: spec.height,
right: spec.x + spec.width,
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;
}
// 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,
get activeElement(): FakeElement | null {
return activeElementIn(children);
},
querySelectorAll: (selector: string) => queryScope(root, selector),
};
return root;
}
// A root answers activeElement with a node of its OWN tree, as the browser
// does: focus inside a shadow root names the host, and only that root's own
// activeElement names the field. Reporting the field from both roots would let
// a runtime that never descends still pass.
function activeElementIn(nodes: FakeElement[]): FakeElement | null {
for (const node of nodes) {
if (node.focused) return node;
if (node.shadowRoot?.activeElement) return node;
const inside = activeElementIn(node.children);
if (inside) return inside;
}
return null;
}
function inheritedProperty(element: FakeElement, name: string): string {
for (let node: FakeElement | null = element; node; node = node.parentElement) {
const declared = node.customProperties?.[name];
if (declared !== undefined) return declared;
}
return "";
}
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 originalDocument = global.document;
const originalWindow = global.window;
const originalComputedStyle = global.getComputedStyle;
const document: FakeRoot = fakeRoot(elements);
global.document = document;
global.window = {};
global.getComputedStyle = (element: FakeElement) => ({
getPropertyValue: (name: string) => inheritedProperty(element, name),
});
__testing__.resetTargetCache();
try {
run();
} finally {
__testing__.resetTargetCache();
global.document = originalDocument;
global.window = originalWindow;
global.getComputedStyle = originalComputedStyle;
}
}