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));
});
+11 -2
View File
@@ -53,7 +53,11 @@ test("taps draws one candidate index and targets its point", () => {
const action = walk(builtin("taps"), rng, host);
assert.deepEqual(action, {
kind: "Tap",
on: { x: POINTS[expectedIndex]!.x, y: POINTS[expectedIndex]!.y },
on: {
x: POINTS[expectedIndex]!.x,
y: POINTS[expectedIndex]!.y,
selector: POINTS[expectedIndex]!.selector,
},
});
});
@@ -72,6 +76,7 @@ test("typing draws candidate index then corpus index, in that order", () => {
assert.deepEqual(action.into, {
x: POINTS[candidateIndex]!.x,
y: POINTS[candidateIndex]!.y,
selector: POINTS[candidateIndex]!.selector,
});
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
});
@@ -142,7 +147,11 @@ test("doubleTaps and longPresses draw exactly one candidate index", () => {
const kind = verb === "doubleTaps" ? "DoubleTap" : "LongPress";
assert.deepEqual(action, {
kind,
on: { x: POINTS[index]!.x, y: POINTS[index]!.y },
on: {
x: POINTS[index]!.x,
y: POINTS[index]!.y,
selector: POINTS[index]!.selector,
},
});
}
});
+68
View File
@@ -98,7 +98,75 @@ test("PressKey and Wait pass through their fields", () => {
});
});
test("builtin Scroll carries the pre-computed to endpoint", () => {
assert.deepEqual(
serializeAction({
kind: "Scroll",
direction: "down",
in: { x: 100, y: 200 },
from: { x: 100, y: 200 },
to: { x: 100, y: 120 },
}),
{
kind: "Scroll",
direction: "down",
fromX: 100,
fromY: 200,
toX: 100,
toY: 120,
durationMillis: 250,
},
);
});
test("an unresolved (string) target drops the action", () => {
assert.equal(serializeAction({ kind: "Tap", on: "id:never-resolved" }), null);
assert.equal(serializeAction(null), null);
});
// Wire round-trip: serialize -> JSON -> parse asserts every field the Go
// decodeAction reads survives by its exact camelCase name. A rename (fromX vs
// from_x) would silently turn web/native actions into no-ops; this catches it.
test("serialized actions JSON round-trip with the decoder's field names", () => {
const cases: ActionDescriptor[] = [
{ kind: "Tap", on: { x: 1, y: 2, selector: "id:a" } as never },
{ kind: "InputText", into: { x: 3, y: 4 } as never, text: "x" },
{ kind: "Swipe", from: { x: 5, y: 6 }, to: { x: 7, y: 8 }, durationMillis: 9 },
{ kind: "Scroll", direction: "up", in: { x: 1, y: 1 } as never },
{ kind: "PressKey", key: "enter" },
{ kind: "Wait", durationMillis: 10 },
];
for (const descriptor of cases) {
const wire = serializeAction(descriptor);
assert.ok(wire, `expected ${descriptor.kind} to serialize`);
const decoded = JSON.parse(JSON.stringify(wire)) as Record<string, unknown>;
assert.equal(decoded.kind, wire!.kind);
switch (wire!.kind) {
case "Tap":
assert.equal(typeof decoded.x, "number");
assert.equal(typeof decoded.y, "number");
assert.equal(decoded.selector, "id:a");
break;
case "InputText":
assert.equal(typeof decoded.x, "number");
assert.equal(decoded.text, "x");
break;
case "Swipe":
assert.equal(decoded.fromX, 5);
assert.equal(decoded.toY, 8);
assert.equal(decoded.durationMillis, 9);
break;
case "Scroll":
assert.equal(decoded.direction, "up");
assert.equal(typeof decoded.fromX, "number");
assert.equal(typeof decoded.toX, "number");
break;
case "PressKey":
assert.equal(decoded.key, "enter");
break;
case "Wait":
assert.equal(decoded.durationMillis, 10);
break;
}
}
});