fix ltl semantics and unify action enumeration (#71)

* fix(ltl): give every thunk a construction identity

Two distinct unnamed predicates both described as "Thunk(...)", so obligation
collapse merged their residuals and could drop a live violation. Identity is
assigned at construction and the fields are unexported, so a thunk cannot be
built without one.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(ltl): reduce a thrown-predicate residual instead of panicking

The verifier substitutes an ErrorFormula for the residual of a property whose
predicate threw, and that residual is fed back in on the next step. reduce had
no case for it, so the run crashed. It re-reports the same failure now.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(ltl): make a bounded always the dual of a bounded eventually

G<=n(f) and not F<=n(not f) disagreed on traces where the inner was still
pending when the window closed, so nnf's negation normal form was not semantics
preserving. Both sides now range over the observations at which their inner can
definitely resolve: the eventually keeps a pending inner as a disjunct, and the
always discharges vacuously at window close.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(ltl): arm a one-shot root once per run

A root that carries its own horizon is one obligation for the whole run, not one
per observation. Re-instantiating a top-level eventually monitored G F<=n(p)
instead of F<=n(p) and left one live obligation per step behind; a bounded
always restarted its window every step and never closed.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(verifier): stop wrapping a top-level eventually in always

`eventually(p).within(300, "seconds")` as a property meant "within 300 seconds
of every step", which spawned an obligation per step with its own resolved
deadline. A 553-step run carried 553 of them and serialized a 75 KB residual.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(ltl): serialize the resolved deadline of a bounded window

Two obligations spawned at different steps from one duration-bounded formula
differ only in the deadline the evaluator resolved for them, so they serialized
identically and the trace erased a distinction the evaluator makes. The authored
window stays in amount/unit; the resolved deadline rides alongside.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(verifier): split a witness's origin step from its detection step

A deferred obligation spans two steps: the one that armed it and the one whose
reduction failed. They were conflated under one index, so the extractor snapshot
(which is the detecting step's state) was reported against the origin step.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(runner): record a witness's detection step in the trace

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* feat(replay-ui): show the step a violation was detected at

The witness evidence is the detecting step's state, so say which step that is
and let a reader jump to it.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(verifier): record the extractor state the predicates actually read

On the web path extractor bodies are evaluated in V8 and injected here, but only
the goja value was replaced. The trace diff and the violation witness therefore
described a state no property ever saw.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* 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

* fix(runner): name a builtin scroll by its drag origin

A builtin gesture carries endpoints and no selector, so every scroll rendered as
"Scroll down " in the prompt's recent-action memory and two scrollable regions
were indistinguishable.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(chrome): clear storage over cdp instead of scripting an opaque origin

Launch runs while the tab is still on about:blank, whose opaque origin denies
storage access, so localStorage.clear() threw SecurityError and every web run
died at launch. Storage.clearDataForOrigin needs no navigation. The exception
helper lands here because "Uncaught" is what hid this for so long.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(chrome): enable the swiftshader webgl fallback

Headless Chrome runs with --disable-gpu, and without this flag it refuses the
software WebGL backend: getContext returns null, so a canvas-rendered app paints
nothing and every screenshot is identical black.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* fix(web): resolve testTag through data-testid or id

Compose Multiplatform emits its testTag into the element id, which the native
table already accepts via the resource-id alias. The two web selector tables
were the only place that rejected it.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* test(spec): type-check the spec api as part of make test

The fake runtime in api.test.ts did not return a chainable handle from extract,
so the file had not type-checked since named() was added. Wiring the check into
make test stops it drifting again.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J

* docs(manual): one-shot eventually and the gesture verbs

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
This commit is contained in:
pj authored and GitHub committed 2026-08-12 18:06:04 +05:30
1 parent 7343085614
commit 26b49b379a
48 files changed
+2604 -712

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;
}
}
+56 -73
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;
@@ -85,7 +85,12 @@ function noopFormula(): unknown {
const KNOWN_KEY_TO_CSS: Record<string, (value: string) => string> = {
id: (v) => `[id="${cssEscape(v)}"]`,
"resource-id": (v) => `[id="${cssEscape(v)}"]`,
testTag: (v) => `[data-testid="${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)}"]`,
@@ -467,9 +472,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 +485,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 +496,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 +514,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 +548,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 +561,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 +576,10 @@ installRuntime(
export const __testing__ = {
host,
seedBigInt,
tappableCandidates,
editableCandidates,
scrollableCandidates,
resetCandidateCache,
collectTargets,
TAPPABLE_SELECTOR,
EDITABLE_SELECTOR,
resetTargetCache,
runtime,
extractors,
evaluateExtractors,
+6 -1
View File
@@ -112,7 +112,12 @@ function installFakeRuntime(): RecordedRuntime {
extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => {
calls.extracts.push(getter as (state: State) => unknown);
calls.extractNames.push(name);
return { current: undefined as unknown as T, previous: undefined };
const handle: Extracted<T> = {
current: undefined as unknown as T,
previous: undefined,
named: () => handle,
};
return handle;
},
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
calls.alwaysArgs.push(predicateOrFormula);
+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;
}
}
+67 -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.
@@ -328,6 +310,23 @@ test("selectorFromObject maps known keys to their canonical attribute", () => {
});
});
// Compose Multiplatform emits its testTag into `id`, which the native table
// already accepts via the resource-id alias. The web table must not be the one
// place that rejects it.
test("selectorFromObject resolves testTag through data-testid or id", () => {
assert.deepEqual(selectorFromObject({ testTag: "LoginSubmit" }), {
css: `:is([data-testid="LoginSubmit"], [id="LoginSubmit"])`,
});
});
// Multi-key selectors concatenate their parts into one compound, so the
// two-attribute testTag match has to stay a single compound piece.
test("selectorFromObject composes testTag with a second key", () => {
assert.deepEqual(selectorFromObject({ testTag: "Row", "aria-label": "first" }), {
css: `:is([data-testid="Row"], [id="Row"])[aria-label="first"]`,
});
});
test("selectorFromObject falls back to a literal attribute for unknown keys", () => {
assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), {
css: `[data-foo="bar"]`,