mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
merge origin/master into llm-recording-and-analysis
both sides independently fixed the same three bugs, so each one had to pick a winner rather than keep both implementations. extractor encoding: master's recordableValue in worker.go wins over ours in marshal.go, since master's is pinned by extractor_encoding_test.go and ours had no tests. our error semantics stay: encodeExtractorValue still returns an error instead of nil, so an extractor cannot vanish from the trace silently. apply errors: only the residual generic branch takes master's unconfirmed copy, where the device may have committed the action before the call failed. the finer branches that know nothing was dispatched keep lastAction = nil, and our actionSkipReason taxonomy stays alongside master's held/skippedVerification. selector matching: our matchAttr with matchSelectorKind wins over master's match, since ours also handles idPrefix. matchSelector now calls it, which git did not flag as a conflict and left calling a function our side had deleted. the ltl doc comment takes master's correction: an unbounded eventually that never fires IS violated at run end.
This commit is contained in:
commit
6e85cac8b3
130 files changed
+12772
-1617
No files matched your search
@@ -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,46 @@ 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;
|
||||
matches(selector: string): boolean;
|
||||
querySelectorAll(selector: string): FakeElement[];
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
scrollWidth: number;
|
||||
@@ -56,15 +92,30 @@ 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,
|
||||
// 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,
|
||||
@@ -78,27 +129,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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user