From 7a5fc9aca518c954ded6d54258ba9a0a0c1b84e2 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 1 Jun 2026 16:42:38 +0530 Subject: [PATCH] refactor(spec): picker emits native selector + scroll endpoints, setup precedence --- pkg/spec/src/action-tree.ts | 5 ++- pkg/spec/src/pick.ts | 62 +++++++++++++++++++++++--- pkg/spec/src/runtime-entry.ts | 25 +++++++++-- pkg/spec/test/pick.test.ts | 13 +++++- pkg/spec/test/runtime-entry.test.ts | 68 +++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 12 deletions(-) diff --git a/pkg/spec/src/action-tree.ts b/pkg/spec/src/action-tree.ts index 94ba323..910b149 100644 --- a/pkg/spec/src/action-tree.ts +++ b/pkg/spec/src/action-tree.ts @@ -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 } diff --git a/pkg/spec/src/pick.ts b/pkg/spec/src/pick.ts index 6e3c61f..c11f1b3 100644 --- a/pkg/spec/src/pick.ts +++ b/pkg/spec/src/pick.ts @@ -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; diff --git a/pkg/spec/src/runtime-entry.ts b/pkg/spec/src/runtime-entry.ts index f5f2c67..0f7e1e7 100644 --- a/pkg/spec/src/runtime-entry.ts +++ b/pkg/spec/src/runtime-entry.ts @@ -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)); }); diff --git a/pkg/spec/test/pick.test.ts b/pkg/spec/test/pick.test.ts index ee7f59c..dd39683 100644 --- a/pkg/spec/test/pick.test.ts +++ b/pkg/spec/test/pick.test.ts @@ -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, + }, }); } }); diff --git a/pkg/spec/test/runtime-entry.test.ts b/pkg/spec/test/runtime-entry.test.ts index 1b0a7a9..f517ec7 100644 --- a/pkg/spec/test/runtime-entry.test.ts +++ b/pkg/spec/test/runtime-entry.test.ts @@ -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; + 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; + } + } +});