///
// V8-side runtime for `sanderling test --platform web`.
//
// This file is the WEB Host. It installs globalThis.__sanderling__ (extract +
// LTL formula binds) before the spec evaluates, implements the Host interface
// (platform/seed/queryTargets/reportUnsupported) over the live DOM, and then
// delegates ALL action generation to the shared picker via installRuntime
// (runtime-entry.ts -> pick.ts). The goja verifier runs the SAME picker over the
// SAME Pcg, so a given seed yields an identical action stream by construction.
//
// The host invokes window.__sanderlingExtractors__() and
// window.__sanderlingNextAction__() over CDP each tick. LTL predicates are
// stubbed: properties run host-side in goja, which loads its own bundle.
//
// Element references never cross V8/host. queryTargets resolves each element to
// a {x, y} Point via getBoundingClientRect before the picker sees it.
import { installRuntime } from "./runtime-entry.ts";
import type { BuiltinVerb, Candidate, Host, TargetElement } from "./action-tree.ts";
interface Handle {
readonly current: unknown;
readonly previous: unknown;
named(name: string): Handle;
}
interface ExtractorEntry {
getter: (state: unknown) => unknown;
handle: Handle;
name: string;
currentValue: unknown;
previousValue: unknown;
}
const extractors: ExtractorEntry[] = [];
// extracting is true only while an extractor getter is running. The current/
// previous accessors consult it so a getter that reaches into another
// extractor's handle throws instead of reading a stale cross-extractor value.
let extracting = false;
function checkNotExtracting(slot: "current" | "previous"): void {
if (extracting) {
throw new Error(
`reading .${slot} of an extractor inside another extractor is not allowed; extractor getters may read only from the state argument`,
);
}
}
// SANDERLING_SEED is the host-computed 64-bit seed, injected as a decimal
// string via the bundle define. We parse it into a BigInt without ever going
// through a JS Number (which loses precision above 2^53), matching the goja
// side's rand.NewPCG(seed, 0): hi = seed, lo = 0.
function injectedSeed(): string | undefined {
try {
return process.env.SANDERLING_SEED;
} catch {
return undefined;
}
}
function seedBigInt(): bigint {
const raw = injectedSeed();
if (!raw) return 0n;
try {
return BigInt(raw);
} catch {
return 0n;
}
}
// Read on every call rather than once at module scope. The bundler replaces the
// seed expression with a literal, so production reads a constant either way,
// and parsing one decimal string per run costs nothing. Binding it at module
// scope bound it instead to whenever this module was first imported, which made
// the seed depend on test file ordering: a file importing this module before
// the seed was set froze it at zero, and the failure then surfaced in a
// different file that had set it correctly.
function noopFormula(): unknown {
const formula: Record = { __sanderlingFormula: true };
formula.implies = () => formula;
formula.or = () => formula;
formula.and = () => formula;
formula.not = () => formula;
formula.within = () => formula;
return formula;
}
const KNOWN_KEY_TO_CSS: Record string> = {
id: (v) => `[id="${cssEscape(v)}"]`,
"resource-id": (v) => `[id="${cssEscape(v)}"]`,
// The native rule also accepts the local name after Android's ":id/".
// The DOM has no such prefix, so a plain starts-with is the same rule here.
idPrefix: (v) => `[id^="${cssEscape(v)}"]`,
// The native rule accepts the label itself or the label at the head of an
// iOS merged label ("account_card:7, Tim, $100"). `:is()` keeps that one
// compound piece, since a multi-key selector concatenates the parts.
desc: (v) => `:is([aria-label="${cssEscape(v)}"], [aria-label^="${cssEscape(v)}, "])`,
descPrefix: (v) => `[aria-label^="${cssEscape(v)}"]`,
// The native table aliases testTag onto resource-id, which the host DOM walk
// fills from el.id, so the native path already accepts a testTag emitted as
// an id (what Compose Multiplatform does on web). Accept both here so the
// tables agree. `:is()` keeps this one compound, since a multi-key selector
// concatenates the parts.
testTag: (v) => `:is([data-testid="${cssEscape(v)}"], [id="${cssEscape(v)}"])`,
testID: (v) => `[data-testid="${cssEscape(v)}"]`,
"data-testid": (v) => `[data-testid="${cssEscape(v)}"]`,
className: (v) => `[class~="${cssEscape(v)}"]`,
class: (v) => `[class~="${cssEscape(v)}"]`,
tag: tagSelector,
"aria-label": (v) => `[aria-label="${cssEscape(v)}"]`,
ariaLabel: (v) => `[aria-label="${cssEscape(v)}"]`,
accessibilityLabel: (v) => `[aria-label="${cssEscape(v)}"]`,
contentDescription: (v) => `[aria-label="${cssEscape(v)}"]`,
"content-desc": (v) => `[aria-label="${cssEscape(v)}"]`,
label: (v) => `[aria-label="${cssEscape(v)}"]`,
placeholder: (v) => `[placeholder="${cssEscape(v)}"]`,
placeholderValue: (v) => `[placeholder="${cssEscape(v)}"]`,
hintText: (v) => `[placeholder="${cssEscape(v)}"]`,
};
// cssEscape delegates to the platform CSS.escape (per CSSOM spec). It produces
// output safe for both identifier and string contexts, since CSS string
// literals accept the same `\HEX ` and `\X` escape sequences as identifiers.
function cssEscape(value: string): string {
return CSS.escape(value);
}
const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
// tagSelector accepts only valid HTML tag-name characters. Anything else (a
// pseudo-class like `*:hover`, a comma, whitespace) would inject CSS into the
// surrounding selector. Returning a never-matching selector rather than
// throwing keeps the spec running while making the typo visible in logs.
function tagSelector(value: string): string {
if (!TAG_NAME.test(value)) return ":not(*)";
return value;
}
// SELECTOR_KEYS is every key an object selector may use, held identical to the
// native list in internal/hierarchy: test/selector-keys.test.ts and
// internal/hierarchy/selector_keys_test.go each assert their own side against
// test/fixtures/selector-keys.json, so a spec cannot be accepted by one runtime
// and rejected by the other. Keys that mean nothing to a DOM (scrollable,
// package, elementType) stay accepted and simply match nothing here, the way an
// iOS-only key matches nothing on Android.
const SELECTOR_KEYS: readonly string[] = [
"accessibilityIdentifier",
"accessibilityLabel",
"accessibilityText",
"aria-label",
"ariaLabel",
"bounds",
"checked",
"class",
"className",
"clickable",
"content-desc",
"contentDescription",
"data-testid",
"desc",
"descPrefix",
"editable",
"elementType",
"enabled",
"focused",
"hintText",
"id",
"idPrefix",
"identifier",
"label",
"package",
"placeholder",
"placeholderValue",
"resource-id",
"scrollable",
"selected",
"tag",
"testID",
"testTag",
"text",
"title",
"value",
];
const SELECTOR_KEY_SET = new Set(SELECTOR_KEYS);
const ATTRIBUTE_NAME = /^[a-zA-Z][a-zA-Z0-9_.:-]*$/;
// domCarriesAttribute is the escape hatch for attributes this list does not
// enumerate: a key some element actually has is a key that can match. A key
// that is not even a legal attribute name can carry no value and would inject
// into the surrounding selector, so it is rejected rather than probed.
function domCarriesAttribute(key: string): boolean {
if (!ATTRIBUTE_NAME.test(key)) return false;
try {
return document.querySelector(`[${key}]`) !== null;
} catch {
return false;
}
}
// unknownSelectorKeyMessage is character for character what
// hierarchy.UnknownSelectorKeyMessage produces, so one mistake reads the same
// whichever runtime the spec ran on.
function unknownSelectorKeyMessage(keys: string[]): string {
const named = keys.map((key) => JSON.stringify(key)).join(", ");
return (
`selector key ${named} cannot match: no element carries that attribute, ` +
`and it is not one of the accepted keys: ${SELECTOR_KEYS.join(", ")}`
);
}
function cssPart(key: string, value: string): string {
const builder = KNOWN_KEY_TO_CSS[key];
if (builder) return builder(value);
return `[${key}="${cssEscape(value)}"]`;
}
// A selector key that can never match yields an empty result, which reads
// exactly like a screen with no such element: the generator declines to act,
// the runner waits out the step, and the run ends clean having explored
// nothing. Throwing is what makes the mistake visible.
function selectorFromObject(selector: Record): {
css?: string;
xpath?: string;
} {
const parts: string[] = [];
let textValue: string | undefined;
const unknown: string[] = [];
for (const key of Object.keys(selector)) {
const raw = selector[key];
if (raw === undefined) continue;
const value = typeof raw === "boolean" ? String(raw) : raw;
if (!SELECTOR_KEY_SET.has(key) && !domCarriesAttribute(key)) {
if (!unknown.includes(key)) unknown.push(key);
continue;
}
if (key === "text") {
textValue = value;
continue;
}
parts.push(cssPart(key, value));
}
if (unknown.length > 0) {
throw new Error(unknownSelectorKeyMessage(unknown));
}
if (textValue !== undefined && parts.length === 0) {
return {
xpath: `//*[normalize-space(text())=${xpathStringLiteral(textValue)}]`,
};
}
return { css: parts.join("") };
}
// xpathStringLiteral wraps the value in a valid XPath 1.0 string literal.
// XPath 1.0 has no escape syntax, so a value containing both ' and " must be
// composed via concat().
function xpathStringLiteral(value: string): string {
if (!value.includes('"')) return `"${value}"`;
if (!value.includes("'")) return `'${value}'`;
const parts = value.split('"');
return `concat(${parts.map((p) => `"${p}"`).join(`, '"', `)})`;
}
function selectorFromString(selector: string): { css?: string; xpath?: string } {
const colon = selector.indexOf(":");
if (colon <= 0) {
return { css: selector };
}
const kind = selector.slice(0, colon);
const value = selector.slice(colon + 1);
if (kind === "text") {
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
}
// The string form's kind space stays open: ":" is the documented
// way to reach a raw driver attribute, and internal/hierarchy resolves an
// unknown kind to an empty result rather than an error. Only the object form
// validates, on both sides.
return { css: cssPart(kind, value) };
}
// deepQueryAll resolves a CSS selector against a root AND every shadow root
// beneath it. querySelectorAll stops dead at a shadow boundary, and a canvas app
// (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.
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);
for (const element of Array.from(scope.querySelectorAll("*"))) {
if (element.shadowRoot) visit(element.shadowRoot);
}
};
visit(root);
return found;
}
function queryElement(
root: ParentNode,
selector: unknown,
): Element | null {
if (typeof selector === "string") {
const { css, xpath } = selectorFromString(selector);
if (css) return deepQueryAll(css, root)[0] ?? null;
if (xpath) {
const result = document.evaluate(
xpath,
root as Node,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
);
return result.singleNodeValue as Element | null;
}
return null;
}
if (Array.isArray(selector)) {
let node: ParentNode | null = root;
for (const segment of selector) {
if (!node) return null;
const next = queryElement(node, segment);
if (!next) return null;
node = next;
}
return node as Element;
}
if (selector && typeof selector === "object") {
const { css, xpath } = selectorFromObject(selector as Record);
if (css) return deepQueryAll(css, root)[0] ?? null;
if (xpath) {
const result = document.evaluate(
xpath,
root as Node,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null,
);
return result.singleNodeValue as Element | null;
}
}
return null;
}
function queryAllElements(root: ParentNode, selector: unknown): Element[] {
if (typeof selector === "string") {
const { css, xpath } = selectorFromString(selector);
if (css) return deepQueryAll(css, root);
if (xpath) return evaluateXPathAll(xpath, root as Node);
return [];
}
// A selector path: every match of the first segment is searched for the rest,
// concatenated in walk order, mirroring FindAllBySelectorPath in
// internal/hierarchy. Falling through to the object branch (as this did)
// returned NOTHING for a path on web while native returned matches, so a spec
// reading state.ax.findAll([{screen}, {row}]) saw an empty list on web and
// every property over it passed by having nothing to check.
if (Array.isArray(selector)) {
const head = selector[0];
if (head === undefined) return [];
const heads = queryAllElements(root, head);
if (selector.length === 1) return heads;
const rest = selector.slice(1);
return heads.flatMap((element) => queryAllElements(element, rest));
}
if (selector && typeof selector === "object" && !Array.isArray(selector)) {
const { css, xpath } = selectorFromObject(selector as Record);
if (css) return deepQueryAll(css, root);
if (xpath) return evaluateXPathAll(xpath, root as Node);
}
return [];
}
function evaluateXPathAll(xpath: string, root: Node): Element[] {
const result = document.evaluate(
xpath,
root,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null,
);
const out: Element[] = [];
for (let i = 0; i < result.snapshotLength; i++) {
const node = result.snapshotItem(i);
if (node) out.push(node as Element);
}
return out;
}
// rawAttributes keys an element's attributes by the names the markup writes,
// which is what `attrs` means on every other backend. element.dataset would key
// `data-cents` as `cents`, so a spec reading attrs["data-cents"] the way the
// native hosts report it read undefined on web and every assertion over it
// passed vacuously.
function rawAttributes(element: Element): Record {
const out: Record = {};
for (const attribute of Array.from(element.attributes ?? [])) {
out[attribute.name] = attribute.value;
}
return out;
}
// fieldHint names an editable field the way a user reads it, in the order the
// accessible name is computed: its own aria-label, the