refactor(spec): picker emits native selector + scroll endpoints, setup precedence

This commit is contained in:
pj committed 2026-06-01 16:42:38 +05:30
1 parent 6600994ef1
commit 7a5fc9aca5
5 files changed
+161 -12

No files matched your search

+4 -1
View File
@@ -44,7 +44,10 @@ export type ActionDescriptor =
| { kind: "Tap"; on: Target }
| { kind: "DoubleTap"; on: Target }
| { kind: "LongPress"; on: Target }
| { kind: "Scroll"; direction: Direction; in?: Target }
// Scroll carries the author target (in) for spec-written scrolls; the builtin
// generator additionally pre-computes the swipe from/to so the wire contract
// carries a real gesture rather than a zero-length one.
| { kind: "Scroll"; direction: Direction; in?: Target; from?: Target; to?: Target }
| { kind: "InputText"; into: Target; text: string }
| { kind: "Swipe"; from: Target; to: Target; durationMillis?: number }
| { kind: "PressKey"; key: string }
+57 -5
View File
@@ -142,24 +142,76 @@ function walkBuiltin(
switch (verb) {
case "taps":
return { kind: "Tap", on: point };
return tapDescriptor("Tap", point, picked.selector);
case "doubleTaps":
return { kind: "DoubleTap", on: point };
return tapDescriptor("DoubleTap", point, picked.selector);
case "longPresses":
return { kind: "LongPress", on: point };
return tapDescriptor("LongPress", point, picked.selector);
case "typing": {
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
return { kind: "InputText", into: point, text };
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 { kind: "Scroll", direction, in: point };
return buildScroll(point, direction, picked, rng);
}
}
}
// withSelector attaches a native selector to a resolved Point so the runner can
// re-resolve the target by id/text. The web host omits it (point-only).
function withSelector(point: Point, selector?: string): Point {
if (selector === undefined) return 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,
// 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(
from: Point,
direction: Direction,
candidate: { width?: number; height?: number },
_rng: Pcg,
): 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;
}
return {
kind: "Scroll",
direction,
in: from,
from,
to: { x: Math.max(0, toX), 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;
+21 -4
View File
@@ -8,7 +8,7 @@
// identical action stream by construction.
import { Pcg } from "./pcg.ts";
import { nextAction } from "./pick.ts";
import { nextAction, walk } from "./pick.ts";
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
import type { Point } from "./types.ts";
@@ -82,15 +82,18 @@ export function serializeAction(action: ActionDescriptor | null): SerializedActi
};
}
case "Scroll": {
const from = pointOf(action.in);
const from = pointOf(action.from) ?? pointOf(action.in);
if (!from) return null;
// The builtin generator pre-computes `to`; an author Scroll without it
// collapses to a zero-length gesture the runner re-derives from bounds.
const to = pointOf(action.to) ?? from;
return {
kind: "Scroll",
direction: action.direction,
fromX: from.x,
fromY: from.y,
toX: from.x,
toY: from.y,
toX: to.x,
toY: to.y,
durationMillis: DEFAULT_SWIPE_DURATION,
};
}
@@ -106,6 +109,11 @@ export function serializeAction(action: ActionDescriptor | null): SerializedActi
// AFTER this call (the web bundle imports the runtime before the spec, so the
// root only exists on globalThis.actions once the spec has evaluated).
// evaluateExtractors is the engine's snapshot of the spec's extract() handles.
//
// Setup precedence: when the spec assigned globalThis.setup, it is walked ONCE
// per tick first; if it yields an action that wins, otherwise the call falls
// through to the action root's 16-attempt retry. This matches the native
// verifier's prior NextAction precedence and applies on both engines.
export function installRuntime(
host: Host,
root: GeneratorNode | null | (() => GeneratorNode | null),
@@ -113,9 +121,18 @@ export function installRuntime(
): void {
const rng = new Pcg(host.seedHi(), host.seedLo());
const resolveRoot = typeof root === "function" ? root : () => root;
const resolveSetup = () =>
(globalThis as { setup?: GeneratorNode }).setup ?? null;
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
defineLockedGlobal("__sanderlingNextAction__", () => {
// resolveRoot runs first: on web it also resets the per-tick candidate
// cache, which setup's walk below must see fresh.
const current = resolveRoot();
const setup = resolveSetup();
if (setup) {
const setupAction = walk(setup, rng, host);
if (setupAction) return serializeAction(setupAction);
}
if (!current) return null;
return serializeAction(nextAction(current, rng, host));
});