/// // 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 names ios writes the identifier under, which internal/hierarchy aliases // onto resource-id. Left out of this table they fell through to a raw // attribute lookup, and no element carries an attribute called // accessibilityIdentifier, so they resolved against the dump and named // nothing here. identifier: (v) => `[id="${cssEscape(v)}"]`, accessibilityIdentifier: (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)}"]`, // The name ios writes the class under, which internal/hierarchy aliases onto // class. It read a raw `elementType` attribute here, which nothing carries. elementType: (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)}"]`, // The name the ios sidecar writes the label under, and the canonical key // internal/hierarchy resolves the whole family through. accessibilityText: (v) => `[aria-label="${cssEscape(v)}"]`, // The attribute the markup writes, which is what the hierarchy dump carries // under this name too. It says nothing about the ladder hintText climbs: a // field whose placeholder an aria-label outranks still answers here. placeholder: (v) => `[placeholder="${cssEscape(v)}"]`, secure: secureSelector, }; // secure is derived from the field's type rather than written by the markup, so // matching it as a raw attribute reaches nothing at all. Both producers of the // fact, elementHandle below and the hierarchy dump in // internal/driver/chrome/driver.go, read `type === "password"` off a field they // call editable, so false is every editable field that is NOT a password entry // rather than everything that is not one: an element that is no field reports // null, as android reports null for everything, and answers to neither value. // // Both arms are wrapped in `:is()` because a multi-key selector concatenates the // parts into one compound, where a type selector is valid only at the head: // `{id, secure}` built `[id="pwd"]input[type="password"]`, which is a parse // error, and querySelectorAll throws rather than answering with nothing. function secureSelector(value: string): string { if (value === "true") return `:is(input[type="password"])`; if (value !== "false") return ":not(*)"; const textInput = ["password", ...NON_TEXT_INPUT_TYPES] .map((type) => `:not([type="${type}"])`) .join(""); return `:is(input${textInput}, textarea, [contenteditable]:not([contenteditable="false"]))`; } // The other five boolean states are derived from the live element too, and // matching them as an attribute reached nothing at all: `[clickable="true"]` is // a match no page carries, the key is accepted so no unknown-key error fires, // and a spec naming a control that way, as the worked example in // docs/manual/spec-language.md does, finds none and passes having checked // nothing. They are answered against the element rather than compiled into CSS // because no CSS says what any of them says: `:focus` names the shadow HOST of a // focused field as well, and never the field Compose keeps behind its caret, // `:checked` misses a checked custom element and answers for a selected