refactor(spec): one candidate producer over one target-eligibility rule

Both hosts routed verbs themselves and both policies enumerated their own
actions, and all four drifted. Web sent `swipes` to scrollable containers only,
so swipe-to-dismiss on a list row was reachable on native and unreachable on
web; the model policy folded gestures its own way and could not reach what the
seeded picker drew.

A host now reports facts about every element and never decides which verb may
act on it: targets.ts acceptsTarget owns that for both. pick.ts builtinCandidates
is the single enumeration, and the model policy reads it through
__sanderlingEnumerateBuiltin__ instead of reimplementing it in Go.

Gesture verbs change with it: scrolls stay vertical over scrollable containers,
swipes go free-form in all four directions from any element with real bounds.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
This commit is contained in:
pj committed 2026-08-12 16:48:27 +05:30
1 parent ef04a6de9d
commit b5f64bf665
23 files changed
+1494 -594

No files matched your search

+19 -7
View File
@@ -65,9 +65,9 @@ export type GeneratorNode =
| { kind: "builtin"; verb: BuiltinVerb }
| { kind: "llm"; config: { model: string; instructions?: string } };
// Candidate is one host-enumerated target for a builtin verb. The host
// resolves geometry (and a native selector) so no element handle crosses into
// the picker. width/height let swipe/scroll size a gesture off the element.
// Candidate is the resolved geometry of one target. The host resolves it (and a
// native selector) so no element handle crosses into the picker. width/height
// let swipe/scroll size a gesture off the element.
export interface Candidate {
x: number;
y: number;
@@ -76,15 +76,27 @@ export interface Candidate {
height?: number;
}
// TargetElement is one host-enumerated element, offered to EVERY verb, carrying
// the facts per-verb eligibility is decided from. The host reports the facts; it
// does not apply them. targets.ts acceptsTarget owns that decision for both
// hosts, so a verb cannot mean one thing on native and another on web.
export interface TargetElement extends Candidate {
clickable: boolean;
enabled: boolean;
editable: boolean;
scrollable: boolean;
}
// Host is the platform backing the picker draws against. In this foundation
// workflow only the interface is defined and exercised against a stub; the
// goja and DOM implementations land in the rewire workflow.
export interface Host {
platform(): "android" | "ios" | "web";
// queryCandidates returns the host-enumerated targets for a verb, in a
// deterministic order. The picker indexes into this list with the PCG, so
// the order is part of the parity contract.
queryCandidates(verb: BuiltinVerb): Candidate[];
// queryTargets returns every element the host can offer, in a deterministic
// order, with no per-verb filtering: the picker applies acceptsTarget. The
// picker indexes into this list with the PCG, so the order is part of the
// parity contract.
queryTargets(): TargetElement[];
// reportUnsupported is invoked at most once per verb@platform (see verbs.ts)
// when a verb has no support on this platform.
reportUnsupported(verb: BuiltinVerb): void;
+1 -1
View File
@@ -1,6 +1,6 @@
// Goja-side runtime entry for the native verifier (internal/verifier).
//
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryCandidates/
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryTargets/
// reportUnsupported, implemented over the hierarchy tree in bindings.go) before
// the spec evaluates. This module bundles AFTER the spec, reads that host, and
// wires the shared picker via installRuntime so the goja verifier and the V8 web
+161 -119
View File
@@ -1,29 +1,44 @@
// The shared deterministic action picker for W2 approach B.
// The shared deterministic action picker.
//
// walk() traverses a GeneratorNode tree, and nextAction() wraps it with the
// 16-attempt retry that matches worker.go NextAction. Both engines (the goja
// verifier and the V8 web runtime) run THIS code, drawing through the shared
// Pcg, so a given seed yields an identical action stream on every platform.
//
// builtinCandidates() is the ONE enumeration of what a builtin verb can do at
// the current step. The seeded policy draws a single entry from it below; the
// model policy (Go, internal/verifier/llm.go) reads the same list through
// __sanderlingEnumerateBuiltin__. Neither policy can reach an action the other
// cannot, because neither owns an enumeration of its own.
//
// PARITY CONTRACT - draw order.
// Every random decision goes through the Pcg in a FIXED, pinned order. Changing
// this order shifts the stream for a seed and breaks cross-engine
// reproducibility, so treat it as load-bearing:
//
// pickUniform(list): NO draw for an empty or single-entry list, otherwise ONE
// intN(list.length) draw. Both the actions node and the
// builtin node select through it.
// weighted node: ONE float64() draw, then an ASCENDING cumulative scan over
// max(0, weight). (matches worker.go pickWeighted.)
// actions node: the generator runs FIRST (any from(...).generate() inside
// draws intN(itemCount) for >1 items, nothing otherwise),
// THEN if the returned list has >1 entry, ONE intN(len) draw;
// a 0- or 1-element list draws nothing. (pickFromResult.)
// builtin node, per verb, in this exact sequence:
// taps/doubleTaps/longPresses: intN(candidateCount) [1 draw]
// typing: intN(candidateCount), intN(corpusLength)
// swipes: intN(candidateCount),
// 200 + intN(401) magnitude, intN(4) direction
// scrolls: intN(candidateCount), intN(4) direction
// pressKeys: intN(keyCount)
// waitOnce: no draw
// THEN pickUniform over the returned list.
// builtin node: pickUniform over builtinCandidates(verb), THEN the one
// value that verb's enumeration leaves to the policy:
// `typing` ONE intN(corpusLength) draw for the text,
// `swipes` ONE 200 + intN(401) draw for the drag distance.
// Every other verb draws nothing further.
//
// The enumerated candidate count per verb, over the host targets the verb
// accepts (targets.ts acceptsTarget), which is what pickUniform draws over:
//
// taps/doubleTaps/longPresses: one per accepted target
// typing: one per accepted target
// scrolls: two per scrollable container (down, up)
// swipes: four per accepted target (down, up, left, right)
// pressKeys: one per key in the platform's pool
// waitOnce: exactly one, so it never draws
//
// Builtin targets are resolved to a {x, y} Point by the host BEFORE the picker
// sees them, so no element handle crosses into this module.
@@ -32,24 +47,114 @@ import type { Pcg } from "./pcg.ts";
import type {
ActionDescriptor,
BuiltinVerb,
Candidate,
GeneratorNode,
Host,
} from "./action-tree.ts";
import type { Direction, Point } from "./types.ts";
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "./corpus.ts";
import { setSamplerRng } from "./sampler-rng.ts";
import { acceptsTarget } from "./targets.ts";
import { supports, warnUnsupportedOnce } from "./verbs.ts";
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN reproduce worker.go's
// `200 + rng.IntN(401)` swipe distance in pixels (200..600 inclusive).
const WAIT_MILLIS = 500;
// SCROLL_DIRECTIONS and SWIPE_DIRECTIONS are the directions each gesture verb
// enumerates, one candidate per (target, direction). Scrolls stay vertical:
// they target every scrollable container, so they are what makes the enumerated
// list long, and scrolling a mobile list means up and down. Swipes take all
// four, because swipe-to-dismiss and swipe-to-delete are horizontal gestures on
// list rows, and folding them away puts that defect class out of reach.
const SCROLL_DIRECTIONS: readonly Direction[] = ["down", "up"];
const SWIPE_DIRECTIONS: readonly Direction[] = ["down", "up", "left", "right"];
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN are the free-form swipe distance in
// pixels the seeded policy draws, 200..600 inclusive. SWIPE_NOMINAL_MAGNITUDE is
// the distance the enumeration lists, so every enumerated candidate is already a
// runnable gesture before any policy has drawn anything.
const SWIPE_MIN_MAGNITUDE = 200;
const SWIPE_MAGNITUDE_SPAN = 401;
const SWIPE_NOMINAL_MAGNITUDE = 400;
const SWIPE_DURATION_MILLIS = 250;
const DIRECTIONS: readonly Direction[] = ["up", "down", "left", "right"];
const MAX_RETRIES = 16;
// BuiltinCandidate is one enumerated action for a builtin verb, paired with the
// index of the host target it acts on, in host.queryTargets() order (NOT the
// verb-filtered order, so a host can resolve it without repeating the filter).
// targetIndex is -1 for the untargeted verbs (a key press, a wait); the model
// policy uses it to name the control the action lands on.
export interface BuiltinCandidate {
action: ActionDescriptor;
targetIndex: number;
// swipe is set on a `swipes` candidate only. The enumeration fixes where the
// drag starts and which way it travels and lists a nominal distance; the
// seeded policy rebuilds the gesture from these at a drawn distance, the way a
// typing candidate names the field and leaves the text to the policy.
swipe?: { origin: Point; direction: Direction };
}
// builtinCandidates enumerates EVERY action a builtin verb can yield against the
// host's current state. It is the single candidate producer both policies read,
// over the single eligibility rule both hosts consume. An unsupported verb
// reports once and enumerates nothing, so a platform that cannot dispatch a verb
// never offers it to either policy.
export function builtinCandidates(verb: BuiltinVerb, host: Host): BuiltinCandidate[] {
if (!supports(verb, host.platform())) {
warnUnsupportedOnce(host, verb);
return [];
}
if (verb === "waitOnce") {
return [{ action: { kind: "Wait", durationMillis: WAIT_MILLIS }, targetIndex: -1 }];
}
if (verb === "pressKeys") {
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
return keys.map((key) => ({
action: { kind: "PressKey", key } as ActionDescriptor,
targetIndex: -1,
}));
}
const candidates: BuiltinCandidate[] = [];
host.queryTargets().forEach((target, targetIndex) => {
if (!acceptsTarget(verb, target)) return;
const point = withSelector({ x: target.x, y: target.y }, target.selector);
const add = (action: ActionDescriptor) => candidates.push({ action, targetIndex });
switch (verb) {
case "taps":
add({ kind: "Tap", on: point });
break;
case "doubleTaps":
add({ kind: "DoubleTap", on: point });
break;
case "longPresses":
add({ kind: "LongPress", on: point });
break;
case "typing":
// text is left empty: it is the one value the policy supplies, drawn
// from the corpus by the seeded arm and written by the model.
add({ kind: "InputText", into: point, text: "" });
break;
case "scrolls":
for (const direction of SCROLL_DIRECTIONS) {
add(scrollDescriptor({ x: target.x, y: target.y }, direction, target));
}
break;
case "swipes":
for (const direction of SWIPE_DIRECTIONS) {
const origin = { x: target.x, y: target.y };
candidates.push({
action: swipeDescriptor(origin, direction, SWIPE_NOMINAL_MAGNITUDE),
targetIndex,
swipe: { origin, direction },
});
}
break;
}
});
return candidates;
}
// nextAction resolves an action for the current step, retrying walk() up to 16
// times when it yields null (matches worker.go NextAction). Returns null when
// every attempt comes up empty.
@@ -79,7 +184,7 @@ export function walk(
// generator so author sampling shares this single deterministic stream.
setSamplerRng(rng);
try {
return walkActions(node.generate(), rng);
return pickUniform(node.generate(), rng);
} finally {
setSamplerRng(null);
}
@@ -115,13 +220,13 @@ function walkWeighted(
return last ? walk(last[1], rng, host) : null;
}
function walkActions(
generated: ActionDescriptor[],
rng: Pcg,
): ActionDescriptor | null {
if (generated.length === 0) return null;
if (generated.length === 1) return generated[0] ?? null;
return generated[rng.intN(generated.length)] ?? null;
// pickUniform selects one entry from a list, drawing nothing when there is no
// choice to make. It is the only selection rule in this module: the seeded
// policy is exactly "enumerate, then pickUniform".
function pickUniform<T>(list: readonly T[], rng: Pcg): T | null {
if (list.length === 0) return null;
if (list.length === 1) return list[0] ?? null;
return list[rng.intN(list.length)] ?? null;
}
function walkBuiltin(
@@ -129,41 +234,17 @@ function walkBuiltin(
rng: Pcg,
host: Host,
): ActionDescriptor | null {
if (!supports(verb, host.platform())) {
warnUnsupportedOnce(host, verb);
return null;
}
if (verb === "waitOnce") {
return { kind: "Wait", durationMillis: 500 };
}
if (verb === "pressKeys") {
return walkPressKey(rng, host);
}
const candidates = host.queryCandidates(verb);
if (candidates.length === 0) return null;
const picked = candidates[rng.intN(candidates.length)];
const picked = pickUniform(builtinCandidates(verb, host), rng);
if (!picked) return null;
const point: Point = { x: picked.x, y: picked.y };
switch (verb) {
case "taps":
return tapDescriptor("Tap", point, picked.selector);
case "doubleTaps":
return tapDescriptor("DoubleTap", point, picked.selector);
case "longPresses":
return tapDescriptor("LongPress", point, picked.selector);
case "typing": {
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
return { kind: "InputText", into: withSelector(point, picked.selector), text };
}
case "swipes":
return buildSwipe(point, rng);
case "scrolls": {
const direction = DIRECTIONS[rng.intN(DIRECTIONS.length)] ?? "down";
return buildScroll(point, direction, picked, rng);
}
if (picked.action.kind === "InputText") {
return { ...picked.action, text: INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "" };
}
if (picked.swipe) {
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
const { origin, direction } = picked.swipe;
return swipeDescriptor(origin, direction, magnitude);
}
return picked.action;
}
// withSelector attaches a native selector to a resolved Point so the runner can
@@ -173,82 +254,43 @@ function withSelector(point: Point, selector?: string): Point {
return { ...point, selector } as Point;
}
function tapDescriptor(
kind: "Tap" | "DoubleTap" | "LongPress",
point: Point,
selector?: string,
): ActionDescriptor {
return { kind, on: withSelector(point, selector) } as ActionDescriptor;
}
// buildScroll lowers a scroll to a swipe over the container, matching
// worker.go's geometry: the gesture drags opposite the named content motion,
// scrollDescriptor lowers a scroll to a drag over the container, matching
// runner.go's geometry: the gesture drags opposite the named content motion,
// magnitude 40% of the container extent. Missing width/height (web root) yields
// a zero-length endpoint, which the runner re-derives from container bounds.
function buildScroll(
function scrollDescriptor(
from: Point,
direction: Direction,
candidate: { width?: number; height?: number },
_rng: Pcg,
candidate: Candidate,
): ActionDescriptor {
const width = candidate.width ?? 0;
const height = candidate.height ?? 0;
let toX = from.x;
let toY = from.y;
switch (direction) {
case "down":
toY = from.y - Math.trunc((4 * height) / 10);
break;
case "up":
toY = from.y + Math.trunc((4 * height) / 10);
break;
case "left":
toX = from.x + Math.trunc((4 * width) / 10);
break;
case "right":
toX = from.x - Math.trunc((4 * width) / 10);
break;
}
if (direction === "down") toY = from.y - Math.trunc((4 * height) / 10);
if (direction === "up") toY = from.y + Math.trunc((4 * height) / 10);
return {
kind: "Scroll",
direction,
in: from,
from,
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
to: { x: from.x, y: Math.max(0, toY) },
} as ActionDescriptor;
}
function walkPressKey(rng: Pcg, host: Host): ActionDescriptor | null {
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
if (keys.length === 0) return null;
const key = keys[rng.intN(keys.length)] ?? keys[0];
if (key === undefined) return null;
return { kind: "PressKey", key };
// swipeDescriptor builds a free-form drag from a point over a raw pixel
// distance, rather than a fraction of a container extent: `swipes` targets any
// element with real bounds, and most of those have no scroll extent to size a
// gesture against. `direction` names where the finger travels, because a swipe
// IS the gesture; a scroll names content motion and drags the other way.
function swipeDescriptor(
from: Point,
direction: Direction,
magnitude: number,
): ActionDescriptor {
const horizontal = direction === "left" || direction === "right";
const forward = direction === "down" || direction === "right";
const travel = forward ? magnitude : -magnitude;
const to = horizontal
? { x: Math.max(0, from.x + travel), y: from.y }
: { x: from.x, y: Math.max(0, from.y + travel) };
return { kind: "Swipe", from, to, durationMillis: SWIPE_DURATION_MILLIS };
}
function buildSwipe(from: Point, rng: Pcg): ActionDescriptor {
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
let toX = from.x;
let toY = from.y;
switch (rng.intN(4)) {
case 0:
toY = from.y - magnitude;
break;
case 1:
toY = from.y + magnitude;
break;
case 2:
toX = from.x - magnitude;
break;
case 3:
toX = from.x + magnitude;
break;
}
return {
kind: "Swipe",
from,
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
durationMillis: SWIPE_DURATION_MILLIS,
};
}
+12 -2
View File
@@ -8,9 +8,9 @@
// identical action stream by construction.
import { Pcg } from "./pcg.ts";
import { nextAction, walk } from "./pick.ts";
import { builtinCandidates, nextAction, walk } from "./pick.ts";
import { INPUT_CORPUS } from "./corpus.ts";
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
import type { ActionDescriptor, BuiltinVerb, GeneratorNode, Host } from "./action-tree.ts";
import type { Point } from "./types.ts";
// SerializedAction is the flat, camelCase wire shape JS emits and Go decodes
@@ -138,6 +138,16 @@ export function installRuntime(
"__sanderlingSampleInput__",
() => INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "",
);
// The model policy (Go) selects from the SAME enumeration the seeded picker
// draws from, reached through here rather than reimplemented on the Go side.
// Each entry is serialized with the wire contract Go already decodes, so the
// two policies also agree on the action a chosen candidate executes.
defineLockedGlobal("__sanderlingEnumerateBuiltin__", (verb: BuiltinVerb) =>
builtinCandidates(verb, host).map((candidate) => ({
action: serializeAction(candidate.action),
targetIndex: candidate.targetIndex,
})),
);
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
// __sanderlingSetupAction__ walks ONLY the setup generator once, for the LLM
// action generator (Go), which drives selection itself and must not run the
+69
View File
@@ -0,0 +1,69 @@
// Per-verb target eligibility: the ONE definition both hosts consume.
//
// A host enumerates every element it can offer (the goja verifier over the
// hierarchy tree, the V8 web runtime over the DOM) and reports a fixed set of
// facts about each. It does NOT decide which verb may act on which element:
// acceptsTarget does, here, once. When each host routed verbs itself the two
// drifted, and the same spec induced a different action space per platform --
// web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a
// list row was reachable on native and unreachable on web.
//
// What stays platform-specific is how a fact is COMPUTED, because the source
// models genuinely differ: `clickable` is an accessibility attribute on
// Android/iOS and a CSS selector match on web. The fact vocabulary below is the
// contract; the mapping onto it is each host's business, and it is the only
// place the platforms are allowed to disagree.
import type { BuiltinVerb, TargetElement } from "./action-tree.ts";
// TargetFact names one property of a target a verb can require.
export type TargetFact =
| "clickable"
| "enabled"
| "editable"
| "scrollable"
| "positiveBounds";
// VERB_REQUIRED_FACTS lists the facts a target must have for a verb to act on
// it. `null` marks a verb with no target at all: a key press and a wait are
// enumerated by the picker without consulting the host.
const VERB_REQUIRED_FACTS: Record<BuiltinVerb, readonly TargetFact[] | null> = {
taps: ["clickable", "enabled", "positiveBounds"],
doubleTaps: ["clickable", "enabled", "positiveBounds"],
longPresses: ["clickable", "enabled", "positiveBounds"],
typing: ["editable", "enabled", "positiveBounds"],
// Scrolls target containers that can actually scroll, and enumerate down/up.
scrolls: ["scrollable", "positiveBounds"],
// Any visible element is a valid swipe origin. Swipe-to-dismiss and
// swipe-to-delete live on list rows and cards, which are not scrollable
// containers, so scoping swipes to containers would put that whole class of
// interaction out of reach.
swipes: ["positiveBounds"],
pressKeys: null,
waitOnce: null,
};
// acceptsTarget is the per-verb element filter. Every targeted verb demands
// real bounds: a zero-bounds node centers at (0,0), and a downward gesture from
// the top-left corner is the system gesture that pulls down the notification
// shade, dragging the fuzzer out of the app.
export function acceptsTarget(verb: BuiltinVerb, target: TargetElement): boolean {
const required = VERB_REQUIRED_FACTS[verb];
if (required === null) return false;
return required.every((fact) => hasFact(target, fact));
}
function hasFact(target: TargetElement, fact: TargetFact): boolean {
switch (fact) {
case "clickable":
return target.clickable;
case "enabled":
return target.enabled;
case "editable":
return target.editable;
case "scrollable":
return target.scrollable;
case "positiveBounds":
return (target.width ?? 0) > 0 && (target.height ?? 0) > 0;
}
}
+50 -72
View File
@@ -4,7 +4,7 @@
//
// This file is the WEB Host. It installs globalThis.__sanderling__ (extract +
// LTL formula binds) before the spec evaluates, implements the Host interface
// (platform/seed/queryCandidates/reportUnsupported) over the live DOM, and then
// (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.
@@ -13,11 +13,11 @@
// 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. queryCandidates resolves each element
// to a {x, y} Point via getBoundingClientRect before the picker sees it.
// 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 } from "./action-tree.ts";
import type { BuiltinVerb, Candidate, Host, TargetElement } from "./action-tree.ts";
interface Handle {
readonly current: unknown;
@@ -467,9 +467,12 @@ function sanitizeAt(value: unknown, depth: number, seen: WeakSet<object>): unkno
return out;
}
// Per-verb DOM selector sets. The tappable set backs taps/doubleTaps/longPresses
// (every tappable element is also a valid long-press target); the editable set
// backs typing; swipes/scrolls target the scrollable element.
// The DOM half of the fact mapping. Which verb may act on which target is NOT
// decided here: queryTargets reports facts and targets.ts acceptsTarget applies
// them, the same rule the native host's targets run through. These selectors are
// only how the DOM answers "is this clickable" / "is this editable", the two
// facts with no direct DOM equivalent of the accessibility attributes native
// platforms expose.
const TAPPABLE_SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
const EDITABLE_SELECTOR = "input, textarea, [contenteditable]";
@@ -477,12 +480,6 @@ const NON_TEXT_INPUT_TYPES = [
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
];
function isVisible(element: HTMLElement): boolean {
if ((element as HTMLButtonElement).disabled) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isEditableElement(element: HTMLElement): boolean {
if (element.isContentEditable) return true;
const tag = element.tagName.toLowerCase();
@@ -494,6 +491,14 @@ function isEditableElement(element: HTMLElement): boolean {
return false;
}
// isScrollable mirrors the native `scrollable` accessibility attribute: the
// container can actually scroll, i.e. its content overflows its box. The
// document scrolling root is not special-cased in: when the page does not
// overflow there is no scroll to perform, and native would offer none either.
function isScrollable(element: HTMLElement): boolean {
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
}
function pointOf(element: Element): Candidate {
const rect = element.getBoundingClientRect();
return {
@@ -504,41 +509,33 @@ function pointOf(element: Element): Candidate {
};
}
function tappableCandidates(): Candidate[] {
return Array.from(document.querySelectorAll<HTMLElement>(TAPPABLE_SELECTOR))
.filter(isVisible)
.map(pointOf);
// collectTargets walks the document ONCE and reports every element with the facts
// the shared eligibility rule reads. The tappable/editable membership sets are
// resolved by selector first so the DOM's answer to "clickable" and "editable"
// stays expressed in CSS, as it always was.
function collectTargets(): TargetElement[] {
const clickable = new Set<Element>(Array.from(document.querySelectorAll(TAPPABLE_SELECTOR)));
const editable = new Set<Element>(
Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR)).filter(
isEditableElement,
),
);
return Array.from(document.querySelectorAll<HTMLElement>("*")).map((element) => ({
...pointOf(element),
clickable: clickable.has(element),
enabled: !(element as HTMLButtonElement).disabled,
editable: editable.has(element),
scrollable: isScrollable(element),
}));
}
function editableCandidates(): Candidate[] {
return Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR))
.filter((element) => isEditableElement(element) && isVisible(element))
.map(pointOf);
}
// Scrollable candidates back swipe/scroll: elements that overflow their box,
// plus the scrolling root so a page-level scroll always has a target.
function scrollableCandidates(): Candidate[] {
const root = document.scrollingElement ?? document.documentElement;
const candidates: Candidate[] = root ? [pointOf(root)] : [];
for (const element of Array.from(document.querySelectorAll<HTMLElement>("*"))) {
if (element === root) continue;
if (element.scrollHeight <= element.clientHeight && element.scrollWidth <= element.clientWidth) {
continue;
}
if (!isVisible(element)) continue;
candidates.push(pointOf(element));
}
return candidates;
}
// Per-tick candidate cache: the picker's 16-attempt retry re-queries the same
// verb, so we avoid re-walking the DOM and re-flushing layout within one tick.
// Per-tick target cache: the picker's 16-attempt retry re-queries every tick, so
// we avoid re-walking the DOM and re-flushing layout within one tick.
// installRuntime resets it before each __sanderlingNextAction__ invocation.
const candidateCache = new Map<BuiltinVerb, Candidate[]>();
let cachedTargets: TargetElement[] | null = null;
function resetCandidateCache(): void {
candidateCache.clear();
function resetTargetCache(): void {
cachedTargets = null;
}
const host: Host = {
@@ -546,28 +543,9 @@ const host: Host = {
seedHi: () => SEED_HI,
// lo = 0 matches the goja side's rand.NewPCG(seed, 0).
seedLo: () => 0n,
queryCandidates(verb: BuiltinVerb): Candidate[] {
const cached = candidateCache.get(verb);
if (cached) return cached;
let candidates: Candidate[];
switch (verb) {
case "taps":
case "doubleTaps":
case "longPresses":
candidates = tappableCandidates();
break;
case "typing":
candidates = editableCandidates();
break;
case "swipes":
case "scrolls":
candidates = scrollableCandidates();
break;
default:
candidates = [];
}
candidateCache.set(verb, candidates);
return candidates;
queryTargets(): TargetElement[] {
if (!cachedTargets) cachedTargets = collectTargets();
return cachedTargets;
},
reportUnsupported(verb: BuiltinVerb): void {
console.warn(`[sanderling] verb ${verb} is unsupported on web`);
@@ -578,11 +556,11 @@ const host: Host = {
// this module (the web bundle imports the runtime first). Resolve the root
// lazily so installRuntime captures it once the spec has evaluated. The root
// resolver runs once per __sanderlingNextAction__ tick (before the retry loop),
// so it is also where we reset the per-tick candidate cache.
// so it is also where we reset the per-tick target cache.
installRuntime(
host,
() => {
resetCandidateCache();
resetTargetCache();
return (globalThis as { actions?: import("./action-tree.ts").GeneratorNode }).actions ?? null;
},
evaluateExtractors,
@@ -593,10 +571,10 @@ installRuntime(
export const __testing__ = {
host,
seedBigInt,
tappableCandidates,
editableCandidates,
scrollableCandidates,
resetCandidateCache,
collectTargets,
TAPPABLE_SELECTOR,
EDITABLE_SELECTOR,
resetTargetCache,
runtime,
extractors,
evaluateExtractors,
+8 -4
View File
@@ -15,10 +15,14 @@ import type {
function installRuntime(initialState: State): void {
const state = { current: initialState };
const runtime: SanderlingRuntime = {
extract: <T>(getter: (s: State) => T): Extracted<T> => ({
current: getter(state.current),
previous: undefined,
}),
extract: <T>(getter: (s: State) => T): Extracted<T> => {
const handle: Extracted<T> = {
current: getter(state.current),
previous: undefined,
named: () => handle,
};
return handle;
},
always: () => ({ __sanderlingFormula: true } as Formula),
now: () => ({ __sanderlingFormula: true } as Formula),
next: () => ({ __sanderlingFormula: true } as Formula),
+48
View File
@@ -0,0 +1,48 @@
{
"taps": [
"save"
],
"doubleTaps": [
"save"
],
"longPresses": [
"save"
],
"typing": [
"amount"
],
"scrolls": [
"root",
"root",
"list",
"list"
],
"swipes": [
"root",
"root",
"root",
"root",
"save",
"save",
"save",
"save",
"cancel",
"cancel",
"cancel",
"cancel",
"amount",
"amount",
"amount",
"amount",
"list",
"list",
"list",
"list",
"row",
"row",
"row",
"row"
],
"pressKeys": [],
"waitOnce": []
}
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { builtinCandidates } from "../src/pick.ts";
import { resetWarnings } from "../src/verbs.ts";
import type { BuiltinVerb } from "../src/action-tree.ts";
import { fakeElement, withFakeDocument, type FakeElementSpec } from "./web-dom-harness.ts";
import { __testing__ } from "../src/web-runtime.ts";
const { host } = __testing__;
const goldenPath = fileURLToPath(new URL("./fixtures/host-parity-golden.json", import.meta.url));
const golden: Record<string, string[]> = JSON.parse(readFileSync(goldenPath, "utf8"));
const VERBS: BuiltinVerb[] = [
"taps",
"doubleTaps",
"longPresses",
"typing",
"scrolls",
"swipes",
"pressKeys",
"waitOnce",
];
// SCREEN is the canonical screen both hosts are driven over: one row per fact
// combination that any verb distinguishes. The native host builds the same rows,
// in the same order, as a hierarchy tree in
// internal/verifier/host_parity_test.go.
const SCREEN: (FakeElementSpec & { name: string })[] = [
{ name: "root", tag: "html", x: 0, y: 0, width: 400, height: 800, overflows: true },
{ name: "save", tag: "button", x: 0, y: 0, width: 200, height: 60, clickable: true },
{
name: "cancel",
tag: "button",
x: 200,
y: 0,
width: 200,
height: 60,
clickable: true,
disabled: true,
},
{ name: "amount", tag: "input", x: 0, y: 100, width: 400, height: 60, editable: true },
{ name: "list", tag: "div", x: 0, y: 200, width: 400, height: 400, overflows: true },
{ name: "row", tag: "div", x: 0, y: 600, width: 400, height: 80 },
{ name: "collapsed", tag: "button", x: 0, y: 0, width: 0, height: 0, clickable: true },
];
// The web host and the native host used to route verbs themselves and had
// drifted: web sent `swipes` to scrollable containers only, so a swipe on a list
// row was reachable on Android and unreachable on web for the same spec.
//
// Per-verb eligibility now has ONE definition (src/targets.ts); a host reports
// facts and never filters. This test is what notices if a second definition
// grows back on either side. internal/verifier/host_parity_test.go asserts the
// SAME golden from the native host, so a match on both sides proves the two
// hosts agree without either invoking the other.
test("web host targets match the cross-host golden, verb for verb", () => {
const elements = SCREEN.map(fakeElement);
withFakeDocument(elements, () => {
for (const verb of VERBS) {
resetWarnings();
const candidates = builtinCandidates(verb, host);
assert.notEqual(candidates.length, 0, `${verb} enumerated nothing at all`);
const named = candidates
.filter((candidate) => candidate.targetIndex >= 0)
.map((candidate) => SCREEN[candidate.targetIndex]!.name);
assert.deepEqual(named, golden[verb], `web host targets for ${verb}`);
}
});
});
+15 -11
View File
@@ -1,11 +1,11 @@
// Shared cross-runtime parity scenario: the FIXED seed, FIXED candidate list,
// Shared cross-runtime parity scenario: the FIXED seed, FIXED target list,
// and FIXED action root that both the node test (parity.test.ts) and the goja
// test (internal/verifier/parity_test.go) drive. Each side runs the SAME
// pick.ts over the SAME Pcg and asserts the SAME committed golden
// (fixtures/parity-golden.json); matching one golden on both sides proves the
// two engines agree without either invoking the other.
//
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
// The target ORDER and the per-tick PCG draw order are the parity contract.
// The weighted root mixes a tap branch (1 candidate draw) with a typing branch
// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a
// builtin, and the input corpus together; reordering candidates or adding or
@@ -15,25 +15,29 @@ import { Pcg } from "../src/pcg.ts";
import { nextAction } from "../src/pick.ts";
import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts";
import { taps, typing, weighted } from "../src/actions.ts";
import type { BuiltinVerb, Candidate, GeneratorNode, Host } from "../src/action-tree.ts";
import type { GeneratorNode, Host, TargetElement } from "../src/action-tree.ts";
export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n;
export const PARITY_STEPS = 20;
export const PARITY_CANDIDATES: Candidate[] = [
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
// Every fact is set so the shared eligibility rule admits all three targets for
// every verb, leaving the draw order as the only variable.
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
export const PARITY_TARGETS: TargetElement[] = [
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...EVERY_FACT },
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48, ...EVERY_FACT },
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32, ...EVERY_FACT },
];
// A 3:1 weighted split over taps and typing. The stub host returns the same
// candidate list for every verb so the only variables are the draw order and
// the JS engine's number/bigint behavior.
// A 3:1 weighted split over taps and typing. The stub host offers the same
// target list to every verb so the only variables are the draw order and the JS
// engine's number/bigint behavior.
export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]);
const HOST: Host = {
platform: () => "android",
queryCandidates: (_verb: BuiltinVerb) => PARITY_CANDIDATES,
queryTargets: () => PARITY_TARGETS,
reportUnsupported: () => {},
seedHi: () => PARITY_SEED_HI,
seedLo: () => 0n,
+241 -60
View File
@@ -1,30 +1,31 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { Pcg } from "../src/pcg.ts";
import { nextAction, walk } from "../src/pick.ts";
import { builtinCandidates, nextAction, walk } from "../src/pick.ts";
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "../src/corpus.ts";
import { resetWarnings } from "../src/verbs.ts";
import type {
ActionDescriptor,
BuiltinVerb,
Candidate,
GeneratorNode,
Host,
TargetElement,
} from "../src/action-tree.ts";
import type { Direction, Point } from "../src/types.ts";
type Platform = "android" | "ios" | "web";
// stubHost returns a fixed candidate list for every verb and records each
// reportUnsupported call so warn-once semantics are observable.
// stubHost returns a fixed target list, eligible for every verb, and records
// each reportUnsupported call so warn-once semantics are observable.
function stubHost(
platform: Platform,
candidates: Candidate[],
targets: TargetElement[],
): Host & { unsupported: BuiltinVerb[] } {
const unsupported: BuiltinVerb[] = [];
return {
unsupported,
platform: () => platform,
queryCandidates: () => candidates,
queryTargets: () => targets,
reportUnsupported: (verb) => {
unsupported.push(verb);
},
@@ -33,10 +34,12 @@ function stubHost(
};
}
const POINTS: Candidate[] = [
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200 },
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200 },
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200 },
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
const POINTS: TargetElement[] = [
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200, ...EVERY_FACT },
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200, ...EVERY_FACT },
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200, ...EVERY_FACT },
];
function builtin(verb: BuiltinVerb): GeneratorNode {
@@ -81,59 +84,132 @@ test("typing draws candidate index then corpus index, in that order", () => {
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
});
test("swipes draw candidate, magnitude (200+intN(401)), then direction", () => {
// scrollCandidate mirrors what the picker builds for one (container, direction)
// pair: a drag opposite the named content motion, 40% of the container extent.
function scrollCandidate(target: TargetElement, direction: "down" | "up") {
const from = { x: target.x, y: target.y };
const extent = Math.trunc((4 * (target.height ?? 0)) / 10);
const toY = direction === "down" ? from.y - extent : from.y + extent;
return {
kind: "Scroll",
direction,
in: from,
from,
to: { x: from.x, y: Math.max(0, toY) },
};
}
// swipeCandidate mirrors the free-form drag: a raw pixel distance, with the
// direction naming where the finger travels.
function swipeCandidate(
target: Point,
direction: Direction,
magnitude: number,
) {
const from = { x: target.x, y: target.y };
const horizontal = direction === "left" || direction === "right";
const forward = direction === "down" || direction === "right";
const travel = forward ? magnitude : -magnitude;
return {
kind: "Swipe",
from,
to: horizontal
? { x: Math.max(0, from.x + travel), y: from.y }
: { x: from.x, y: Math.max(0, from.y + travel) },
durationMillis: 250,
};
}
const NOMINAL_SWIPE_MAGNITUDE = 400;
const SCROLL_DIRECTIONS = ["down", "up"] as const;
const SWIPE_DIRECTIONS = ["down", "up", "left", "right"] as const;
// swipeDirection recovers which way a drawn swipe travelled from its endpoints.
function swipeDirection(from: Point, to: Point): Direction {
if (to.x !== from.x) return to.x > from.x ? "right" : "left";
return to.y > from.y ? "down" : "up";
}
test("scrolls enumerate every container up and down only", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const rng = new Pcg(7n, 0n);
const oracle = new Pcg(7n, 0n);
const candidateIndex = oracle.intN(POINTS.length);
const magnitude = 200 + oracle.intN(401);
const direction = oracle.intN(4);
const from = { x: POINTS[candidateIndex]!.x, y: POINTS[candidateIndex]!.y };
const expectedTo = { x: from.x, y: from.y };
switch (direction) {
case 0:
expectedTo.y = Math.max(0, from.y - magnitude);
break;
case 1:
expectedTo.y = Math.max(0, from.y + magnitude);
break;
case 2:
expectedTo.x = Math.max(0, from.x - magnitude);
break;
case 3:
expectedTo.x = Math.max(0, from.x + magnitude);
break;
}
const action = walk(builtin("swipes"), rng, host) as ActionDescriptor & {
kind: "Swipe";
};
assert.equal(action.kind, "Swipe");
assert.deepEqual(action.from, from);
assert.deepEqual(action.to, expectedTo);
assert.equal(action.durationMillis, 250);
assert.deepEqual(
builtinCandidates("scrolls", host),
POINTS.flatMap((target, targetIndex) =>
SCROLL_DIRECTIONS.map((direction) => ({
action: scrollCandidate(target, direction),
targetIndex,
})),
),
);
});
test("scrolls draw candidate index then direction", () => {
test("swipes enumerate a free-form drag per target in all four directions", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const rng = new Pcg(99n, 0n);
const oracle = new Pcg(99n, 0n);
const candidateIndex = oracle.intN(POINTS.length);
const directionIndex = oracle.intN(4);
const directions = ["up", "down", "left", "right"] as const;
// The swipe candidate is its own action shape, sized in raw pixels rather than
// off the target's extent, and it carries what the policy needs to redraw the
// distance. It is not the scroll gesture under a second name.
assert.deepEqual(
builtinCandidates("swipes", host),
POINTS.flatMap((target, targetIndex) =>
SWIPE_DIRECTIONS.map((direction) => ({
action: swipeCandidate(target, direction, NOMINAL_SWIPE_MAGNITUDE),
targetIndex,
swipe: { origin: { x: target.x, y: target.y }, direction },
})),
),
);
});
const action = walk(builtin("scrolls"), rng, host) as ActionDescriptor & {
kind: "Scroll";
};
assert.equal(action.kind, "Scroll");
assert.equal(action.direction, directions[directionIndex]);
assert.deepEqual(action.in, {
x: POINTS[candidateIndex]!.x,
y: POINTS[candidateIndex]!.y,
});
test("the gesture verbs differ in target filter and in direction set", () => {
resetWarnings();
// Same host, same targets: what separates the two verbs here is the direction
// set alone. Scrolls stay vertical because every scrollable container gets a
// candidate; swipes reach sideways because swipe-to-dismiss does.
const host = stubHost("android", POINTS);
const scrolls = builtinCandidates("scrolls", host);
const swipes = builtinCandidates("swipes", host);
const scrollDirections = new Set(
scrolls.map(
(entry) => (entry.action as ActionDescriptor & { kind: "Scroll" }).direction,
),
);
const swipeDirections = new Set(swipes.map((entry) => entry.swipe!.direction));
assert.deepEqual([...scrollDirections].sort(), ["down", "up"]);
assert.deepEqual([...swipeDirections].sort(), ["down", "left", "right", "up"]);
assert.equal(scrolls.length, POINTS.length * 2);
assert.equal(swipes.length, POINTS.length * 4);
});
test("scrolls draw one index over the enumerated candidates", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const enumerated = builtinCandidates("scrolls", host);
const oracle = new Pcg(99n, 0n);
const index = oracle.intN(enumerated.length);
const action = walk(builtin("scrolls"), new Pcg(99n, 0n), host);
assert.deepEqual(action, enumerated[index]!.action);
});
test("swipes draw candidate index then magnitude, in that order", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const enumerated = builtinCandidates("swipes", host);
const oracle = new Pcg(7n, 0n);
const index = oracle.intN(enumerated.length);
const magnitude = 200 + oracle.intN(401);
const picked = enumerated[index]!.swipe!;
const action = walk(builtin("swipes"), new Pcg(7n, 0n), host);
assert.deepEqual(
action,
swipeCandidate(picked.origin, picked.direction, magnitude),
);
});
test("doubleTaps and longPresses draw exactly one candidate index", () => {
@@ -174,14 +250,28 @@ test("waitOnce emits a 500ms wait and draws nothing", () => {
assert.notEqual(before, after);
});
test("pressKeys on native draws from NATIVE_PRESS_KEYS", () => {
test("pressKeys enumerates the platform's whole key pool", () => {
resetWarnings();
for (const [platform, keys] of [
["android", NATIVE_PRESS_KEYS],
["web", WEB_PRESS_KEYS],
] as const) {
const enumerated = builtinCandidates("pressKeys", stubHost(platform, POINTS));
assert.deepEqual(
enumerated,
keys.map((key) => ({ action: { kind: "PressKey", key }, targetIndex: -1 })),
);
}
});
test("pressKeys on native emits the only key without drawing", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const rng = new Pcg(42n, 0n);
const oracle = new Pcg(42n, 0n);
const index = oracle.intN(NATIVE_PRESS_KEYS.length);
const action = walk(builtin("pressKeys"), rng, host);
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[index] });
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[0] });
// One key is no choice, so the pool consumed no draw.
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
});
test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
@@ -194,6 +284,97 @@ test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
assert.deepEqual(action, { kind: "PressKey", key: WEB_PRESS_KEYS[index] });
});
test("an unsupported verb enumerates nothing and reports once", () => {
resetWarnings();
// No platform in the matrix declines a verb today, so the branch is reached
// through a platform the matrix has never heard of.
const host = stubHost("desktop" as Platform, POINTS);
assert.deepEqual(builtinCandidates("taps", host), []);
assert.deepEqual(builtinCandidates("taps", host), []);
assert.deepEqual(host.unsupported, ["taps"]);
});
test("the seeded pick is an index into the shared enumeration", () => {
resetWarnings();
// Whatever the verb, the drawn action is one of the enumerated entries: the
// seeded policy adds a choice, never an action the model policy cannot see.
// `typing` and `swipes` are covered separately, being the two verbs whose
// enumeration leaves one value for the policy to fill in.
const host = stubHost("android", POINTS);
for (const verb of [
"taps",
"doubleTaps",
"longPresses",
"scrolls",
"pressKeys",
"waitOnce",
] as const) {
const enumerated = builtinCandidates(verb, host).map((entry) =>
JSON.stringify(entry.action),
);
for (let seed = 1; seed <= 50; seed++) {
const action = walk(builtin(verb), new Pcg(BigInt(seed), 0n), host);
assert.ok(
enumerated.includes(JSON.stringify(action)),
`${verb} drew ${JSON.stringify(action)}, which is not an enumerated candidate`,
);
}
}
});
test("typing enumerates the field and the policy supplies the text", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const enumerated = builtinCandidates("typing", host);
// The candidate set names fields only: an empty text is the slot the seeded
// corpus draw and the model's own value both fill.
for (const entry of enumerated) {
assert.equal((entry.action as ActionDescriptor & { kind: "InputText" }).text, "");
}
const oracle = new Pcg(2024n, 0n);
const index = oracle.intN(enumerated.length);
const text = INPUT_CORPUS[oracle.intN(INPUT_CORPUS.length)];
assert.deepEqual(walk(builtin("typing"), new Pcg(2024n, 0n), host), {
...enumerated[index]!.action,
text,
});
});
test("swipes enumerate origin and direction, the policy adds distance", () => {
resetWarnings();
const host = stubHost("android", POINTS);
const origins = new Set(
builtinCandidates("swipes", host).map((entry) =>
JSON.stringify([entry.swipe!.origin, entry.swipe!.direction]),
),
);
const drawn = new Set<Direction>();
for (let seed = 1; seed <= 200; seed++) {
const action = walk(builtin("swipes"), new Pcg(BigInt(seed), 0n), host) as
ActionDescriptor & { kind: "Swipe"; from: Point; to: Point };
assert.equal(action.kind, "Swipe");
const direction = swipeDirection(action.from, action.to);
drawn.add(direction);
assert.ok(
origins.has(JSON.stringify([action.from, direction])),
`swipe from ${JSON.stringify(action.from)} going ${direction} is not enumerated`,
);
// The drawn distance stays inside 200..600, clamped at the screen edge.
const horizontal = direction === "left" || direction === "right";
const distance = horizontal
? Math.abs(action.to.x - action.from.x)
: Math.abs(action.to.y - action.from.y);
const clamped = horizontal ? action.to.x === 0 : action.to.y === 0;
assert.ok(distance <= 600, `drag of ${distance}px exceeds the drawn range`);
assert.ok(
distance >= 200 || clamped,
`drag of ${distance}px is under the drawn range and not clamped`,
);
}
// Every enumerated direction is reachable by a draw, sideways included.
assert.deepEqual([...drawn].sort(), ["down", "left", "right", "up"]);
});
test("empty candidate list yields null without drawing", () => {
resetWarnings();
const host = stubHost("android", []);
+1 -1
View File
@@ -19,7 +19,7 @@ function countingHost(platform: "android" | "ios" | "web"): Host & { calls: Buil
return {
calls,
platform: () => platform,
queryCandidates: () => [],
queryTargets: () => [],
reportUnsupported: (verb) => {
calls.push(verb);
},
+88
View File
@@ -0,0 +1,88 @@
// 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.
import { __testing__ } from "../src/web-runtime.ts";
const { TAPPABLE_SELECTOR, EDITABLE_SELECTOR } = __testing__;
export interface FakeElementSpec {
tag: string;
x: number;
y: number;
width: number;
height: number;
// clickable/editable place the element in the selector sets the host queries;
// the fake answers those queries directly rather than matching CSS.
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;
}
export interface FakeElement extends FakeElementSpec {
tagName: string;
type: string;
isContentEditable: boolean;
scrollHeight: number;
clientHeight: number;
scrollWidth: number;
clientWidth: number;
getBoundingClientRect(): {
left: number;
top: number;
width: number;
height: number;
right: number;
bottom: number;
};
}
export function fakeElement(spec: FakeElementSpec): FakeElement {
const editable = spec.editable ?? false;
return {
...spec,
tagName: spec.tag.toUpperCase(),
type: spec.tag === "input" ? "text" : "",
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
clientHeight: spec.height,
scrollWidth: spec.width,
clientWidth: spec.width,
getBoundingClientRect: () => ({
left: spec.x,
top: spec.y,
width: spec.width,
height: spec.height,
right: spec.x + spec.width,
bottom: spec.y + spec.height,
}),
};
}
// 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.
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] ?? [],
};
__testing__.resetTargetCache();
try {
run();
} finally {
__testing__.resetTargetCache();
global.document = original;
}
}
+50 -68
View File
@@ -83,78 +83,60 @@ test("installRuntime defined the host-invoked globals", () => {
assert.equal(typeof g.__sanderling__, "object");
});
// A button and a text input, each with a deterministic bounding box, exercise
// the per-verb selector routing without a full DOM.
function fakeElement(tag: string, rect: { x: number; y: number; w: number; h: number }) {
return {
tagName: tag.toUpperCase(),
disabled: false,
isContentEditable: false,
type: tag === "input" ? "text" : "",
scrollHeight: 0,
clientHeight: 0,
scrollWidth: 0,
clientWidth: 0,
getBoundingClientRect: () => ({
left: rect.x,
top: rect.y,
width: rect.w,
height: rect.h,
right: rect.x + rect.w,
bottom: rect.y + rect.h,
}),
};
}
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
function withFakeDocument(map: Record<string, unknown[]>, run: () => void) {
const g = globalThis as Record<string, unknown>;
const original = g.document;
g.document = {
querySelectorAll: (selector: string) => map[selector] ?? [],
scrollingElement: null,
documentElement: null,
};
try {
run();
} finally {
g.document = original;
}
}
test("queryCandidates routes taps to the tappable selector set", () => {
const button = fakeElement("button", { x: 10, y: 20, w: 40, h: 8 });
withFakeDocument(
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [button] },
() => {
__testing__.resetCandidateCache();
const candidates = host.queryCandidates("taps");
assert.equal(candidates.length, 1);
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 30, y: 24 });
},
);
});
test("queryCandidates routes typing to editable inputs only", () => {
const input = fakeElement("input", { x: 0, y: 0, w: 100, h: 20 });
withFakeDocument({ "input, textarea, [contenteditable]": [input] }, () => {
__testing__.resetCandidateCache();
const candidates = host.queryCandidates("typing");
assert.equal(candidates.length, 1);
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 50, y: 10 });
// The host reports facts and never routes verbs: which of these a verb may act
// on is decided by the shared rule in src/targets.ts, exercised across both
// engines by host-parity.test.ts.
test("queryTargets reports the tappable selector set as clickable", () => {
const button = fakeElement({ tag: "button", x: 10, y: 20, width: 40, height: 8, clickable: true });
const plain = fakeElement({ tag: "div", x: 0, y: 0, width: 100, height: 100 });
withFakeDocument([button, plain], () => {
const targets = host.queryTargets();
assert.equal(targets.length, 2);
assert.equal(targets[0]!.clickable, true);
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 30, y: 24 });
assert.equal(targets[1]!.clickable, false);
});
});
test("queryCandidates caches within a tick until reset", () => {
const first = fakeElement("button", { x: 0, y: 0, w: 10, h: 10 });
withFakeDocument(
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [first] },
() => {
__testing__.resetCandidateCache();
const a = host.queryCandidates("taps");
const b = host.queryCandidates("taps");
assert.equal(a, b);
},
);
test("queryTargets reports only real text inputs as editable", () => {
const input = fakeElement({ tag: "input", x: 0, y: 0, width: 100, height: 20, editable: true });
const checkbox = fakeElement({ tag: "input", x: 0, y: 40, width: 20, height: 20, editable: true });
checkbox.type = "checkbox";
withFakeDocument([input, checkbox], () => {
const targets = host.queryTargets();
assert.equal(targets[0]!.editable, true);
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 50, y: 10 });
assert.equal(targets[1]!.editable, false);
});
});
// A disabled control used to be dropped from every verb's candidates, because
// the web host folded `disabled` into its visibility check. It is a fact of its
// own now, so `taps` still skips it while `swipes` can still start on it, which
// is what the native host has always done.
test("queryTargets reports a disabled control rather than dropping it", () => {
const disabled = fakeElement({
tag: "button", x: 0, y: 0, width: 40, height: 20, clickable: true, disabled: true,
});
withFakeDocument([disabled], () => {
const targets = host.queryTargets();
assert.equal(targets.length, 1);
assert.equal(targets[0]!.clickable, true);
assert.equal(targets[0]!.enabled, false);
});
});
test("queryTargets caches within a tick until reset", () => {
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
withFakeDocument([button], () => {
const first = host.queryTargets();
const second = host.queryTargets();
assert.equal(first, second);
__testing__.resetTargetCache();
assert.notEqual(host.queryTargets(), first);
});
});
// evaluateExtractors builds State, which references document and window.