mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
refactor(spec): picker emits native selector + scroll endpoints, setup precedence
This commit is contained in:
1 parent
6600994ef1
commit
7a5fc9aca5
5 files changed
+161
-12
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user