/// // 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(", ")}` ); } // A key naming no rule is a raw attribute name, and a raw attribute matches on a // substring, a boolean value exactly (docs/manual/spec-language.md), which is // what internal/hierarchy does with the same key. Matching exactly here made // `data-state:sent` name the badge on Android and nothing at all on web. function cssPart(key: string, value: string): string { const builder = KNOWN_KEY_TO_CSS[key]; if (builder) return builder(value); const operator = value === "true" || value === "false" ? "=" : "*="; return `[${key}${operator}"${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: innermostTextXPath(textValue) }; } return { css: parts.join("") }; } // innermostTextXPath matches an element whose text contains value and whose // descendants do not. An element's XPath string value is its whole subtree's // text, so without the not() clause a badge's ancestors up to answer for // it and find lands on the document. internal/hierarchy suppresses the same // matches, and internal/driver/chrome/translate.go builds the same predicate. function innermostTextXPath(value: string): string { const contains = `contains(normalize-space(.), ${xpathStringLiteral(value)})`; return `.//*[${contains} and not(.//*[${contains}])]`; } // 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); // Substring of the element's whole text, the way internal/hierarchy reads the // same selector: an element reading "Sent ✓" answers to text:Sent on every // platform, and one React wrote as `{count} unsent` answers to text:unsent // though its text arrives as two text nodes, which normalize-space(text()) // reads only the first of. Anchored at the context node, so a scoped .find // reads its own subtree rather than the page. if (kind === "text") { return { xpath: innermostTextXPath(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. 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 => { const matched = new Set(Array.from(scope.querySelectorAll(selector))); for (const element of Array.from(scope.querySelectorAll("*"))) { if (matched.has(element)) found.push(element); 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