///
// V8-side runtime for `sanderling test --platform web`.
//
// The user spec is bundled with this file as the first import so that
// globalThis.__sanderling__ is installed before the spec evaluates. The host
// invokes window.__sanderlingExtractors__() and window.__sanderlingNextAction__()
// over CDP each tick. LTL predicates are intentionally stubbed: properties
// run host-side in goja, which loads its own bundle of the same spec.
//
// Element references never cross V8/host. Action targets that reference an
// AccessibilityElement collapse to `{x, y}` from getBoundingClientRect()
// before serialization.
interface Handle {
current: unknown;
previous: unknown;
}
interface ExtractorEntry {
getter: (state: unknown) => unknown;
handle: Handle;
name: string;
}
interface ActionGeneratorHandle {
__sanderlingActionGenerator: true;
__sanderlingKind: string;
generate?: () => unknown;
entries?: readonly [number, ActionGeneratorHandle][];
}
const extractors: ExtractorEntry[] = [];
let actionsRoot: ActionGeneratorHandle | null = null;
// Deterministic PRNG so `sanderling test --platform web --seed N` is
// reproducible. The host computes a 64-bit seed and injects it via the bundle
// define SANDERLING_SEED; web-runtime reads it at init. Bit-identical parity
// with the goja PCG stream is deliberately out of scope here: this only
// guarantees same-seed -> same-run on web.
function deriveSeed32(raw: string | undefined): number {
if (!raw) return 0;
// Why: a 64-bit seed loses precision as a JS Number, so fold it to 32 bits
// off the decimal string without ever materializing the full value.
let hash = 0;
for (let index = 0; index < raw.length; index++) {
hash = (Math.imul(hash, 31) + raw.charCodeAt(index)) | 0;
}
return hash >>> 0;
}
// mulberry32: a small, well-known 32-bit PRNG. Returns a float in [0, 1).
function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return function next(): number {
state = (state + 0x6d2b79f5) | 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// esbuild Define replaces the exact member expression `process.env.SANDERLING_SEED`
// with the host-injected seed string at bundle time. When unset (e.g. the
// bundler unit tests or a non-test eval) it stays undefined and the seed is 0.
function injectedSeed(): string | undefined {
try {
return process.env.SANDERLING_SEED;
} catch {
return undefined;
}
}
const seededRandomValue = mulberry32(deriveSeed32(injectedSeed()));
function random(): number {
return seededRandomValue();
}
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)}"]`,
testTag: (v) => `[data-testid="${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;
}
function selectorFromObject(selector: Record): {
css?: string;
xpath?: string;
} {
const parts: string[] = [];
let textValue: string | undefined;
let descPrefix: string | undefined;
for (const key of Object.keys(selector)) {
const raw = selector[key];
if (raw === undefined) continue;
const value = typeof raw === "boolean" ? String(raw) : raw;
if (key === "text") {
textValue = value;
continue;
}
if (key === "descPrefix") {
descPrefix = value;
continue;
}
const builder = KNOWN_KEY_TO_CSS[key];
if (builder) {
parts.push(builder(value));
} else {
parts.push(`[${key}="${cssEscape(value)}"]`);
}
}
if (descPrefix !== undefined) {
parts.push(`[aria-label^="${cssEscape(descPrefix)}"]`);
}
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)}]` };
}
if (kind === "descPrefix") {
return { css: `[aria-label^="${cssEscape(value)}"]` };
}
return selectorFromObject({ [kind]: value });
}
function queryElement(
root: ParentNode,
selector: unknown,
): Element | null {
if (typeof selector === "string") {
const { css, xpath } = selectorFromString(selector);
if (css) return root.querySelector(css);
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 root.querySelector(css);
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 Array.from(root.querySelectorAll(css));
if (xpath) return evaluateXPathAll(xpath, root as Node);
return [];
}
if (selector && typeof selector === "object" && !Array.isArray(selector)) {
const { css, xpath } = selectorFromObject(selector as Record);
if (css) return Array.from(root.querySelectorAll(css));
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;
}
function elementHandle(element: Element): Record {
const rect = element.getBoundingClientRect();
const x = Math.round(rect.left + rect.width / 2);
const y = Math.round(rect.top + rect.height / 2);
const ariaLabel = element.getAttribute("aria-label") ?? "";
const text = (element.textContent ?? "").trim().slice(0, 200);
const datasetCopy: Record = {};
const dataset = (element as HTMLElement).dataset ?? {};
for (const key of Object.keys(dataset)) {
const value = (dataset as Record)[key];
if (value !== undefined) datasetCopy[key] = value;
}
return {
id: element.id,
text,
desc: ariaLabel,
class: (element as HTMLElement).className ?? "",
clickable: true,
enabled: !(element as HTMLButtonElement).disabled,
focused: document.activeElement === element,
x,
y,
bounds: {
left: Math.round(rect.left),
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
},
attrs: {
tag: element.tagName.toLowerCase(),
"aria-label": ariaLabel,
...datasetCopy,
},
dataset: datasetCopy,
find(selector: unknown): unknown {
const child = queryElement(element, selector);
return child ? elementHandle(child) : undefined;
},
findAll(selector: unknown): unknown[] {
return queryAllElements(element, selector).map(elementHandle);
},
};
}
function buildAx(): unknown {
return {
find(selector: unknown): unknown {
const element = queryElement(document, selector);
return element ? elementHandle(element) : undefined;
},
findAll(selector: unknown): unknown[] {
return queryAllElements(document, selector).map(elementHandle);
},
};
}
function buildState(): unknown {
return {
snapshots: {},
ax: buildAx(),
document,
window,
lastAction: null,
time: 0,
logs: [],
exceptions: [],
};
}
const runtime = {
extract(getter: (state: unknown) => T, name?: string): Handle {
const handle: Handle = { current: undefined, previous: undefined };
const resolvedName = name && name.length > 0 ? name : `extractor_${extractors.length}`;
extractors.push({
getter: getter as (s: unknown) => unknown,
handle,
name: resolvedName,
});
return handle;
},
always: noopFormula,
now: noopFormula,
next: noopFormula,
eventually: noopFormula,
actions(generator: () => unknown): ActionGeneratorHandle {
const handle: ActionGeneratorHandle = {
__sanderlingActionGenerator: true,
__sanderlingKind: "actions",
generate: generator,
};
if (!actionsRoot) actionsRoot = handle;
return handle;
},
weighted(...entries: [number, ActionGeneratorHandle][]): ActionGeneratorHandle {
const handle: ActionGeneratorHandle = {
__sanderlingActionGenerator: true,
__sanderlingKind: "weighted",
entries,
};
actionsRoot = handle;
return handle;
},
from(items: readonly T[]): { generate: () => T | undefined } {
return {
generate(): T | undefined {
if (items.length === 0) return undefined;
return items[Math.floor(random() * items.length)];
},
};
},
tap(p: { on: unknown }): unknown {
return { kind: "Tap", on: p.on };
},
doubleTap(p: { on: unknown }): unknown {
return { kind: "DoubleTap", on: p.on };
},
longPress(): unknown {
// Why: web has no long-press gesture for v1; the factory returns null so LongPress() calls no-op.
return null;
},
scroll(): unknown {
// Why: web has no native scroll gesture for v1; the factory returns null so Scroll() calls no-op.
return null;
},
inputText(p: { into: unknown; text: string }): unknown {
return { kind: "InputText", into: p.into, text: p.text };
},
swipe(_p: { from: unknown; to: unknown; durationMillis?: number }): unknown {
// Why: web has no swipe gesture; the factory returns null so Swipe() calls in specs no-op.
return null;
},
pressKey(p: { key: string }): unknown {
if (!WEB_PRESS_KEYS.includes(p.key)) {
throw new Error(
`pressKey: unsupported key ${JSON.stringify(p.key)} (allowed: ${WEB_PRESS_KEYS.join(", ")})`,
);
}
return { kind: "PressKey", key: p.key };
},
wait(p: { durationMillis: number }): unknown {
return { kind: "Wait", durationMillis: p.durationMillis };
},
taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle,
doubleTaps: { __sanderlingActionGenerator: true, __sanderlingKind: "doubleTaps" } as ActionGeneratorHandle,
longPresses: { __sanderlingActionGenerator: true, __sanderlingKind: "longPresses" } as ActionGeneratorHandle,
scrolls: { __sanderlingActionGenerator: true, __sanderlingKind: "scrolls" } as ActionGeneratorHandle,
typing: { __sanderlingActionGenerator: true, __sanderlingKind: "typing" } as ActionGeneratorHandle,
swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle,
waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle,
pressKeys: { __sanderlingActionGenerator: true, __sanderlingKind: "pressKey" } as ActionGeneratorHandle,
};
// Lock the runtime globals so a misbehaving (or malicious) page script can't
// shadow or replace them between AddScriptToEvaluateOnNewDocument running and
// the host invoking the extractor/next-action callbacks.
defineLockedGlobal("__sanderling__", runtime);
function defineLockedGlobal(name: string, value: unknown): void {
Object.defineProperty(globalThis, name, {
value,
writable: false,
configurable: false,
enumerable: false,
});
}
function evaluateExtractors(): Record {
const state = buildState();
const result: Record = {};
for (let i = 0; i < extractors.length; i++) {
const entry = extractors[i];
if (!entry) continue;
entry.handle.previous = entry.handle.current;
let value: unknown;
try {
value = entry.getter(state);
} catch {
value = undefined;
}
entry.handle.current = value;
result[i] = sanitize(value);
}
return result;
}
// SANITIZE_MAX_DEPTH bounds how far sanitize will recurse. State exposes
// `document` and `window`, both of which contain cycles; without a depth or
// seen-set guard a user extractor returning either crashes the runtime via
// stack overflow.
const SANITIZE_MAX_DEPTH = 32;
function sanitize(value: unknown): unknown {
return sanitizeAt(value, 0, new WeakSet());
}
function sanitizeAt(value: unknown, depth: number, seen: WeakSet