From e18feff5fecf6308cbd25c8133519f1f7b2080f3 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 1 Jun 2026 16:24:24 +0530 Subject: [PATCH] feat(spec): shared runtime-entry installs next-action over pick.ts --- pkg/spec/src/runtime-entry.ts | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 pkg/spec/src/runtime-entry.ts diff --git a/pkg/spec/src/runtime-entry.ts b/pkg/spec/src/runtime-entry.ts new file mode 100644 index 0000000..04ef307 --- /dev/null +++ b/pkg/spec/src/runtime-entry.ts @@ -0,0 +1,127 @@ +// The single next-action entry shared by both engines (goja verifier and V8 web +// runtime). installRuntime wires the spec's root generator, the shared Pcg, and +// a Host into the globals the host invokes each tick: +// __sanderlingNextAction__() -> one serialized action (unified wire contract) +// __sanderlingExtractors__() -> the engine's extractor snapshot +// +// Both engines call THIS picker over the SAME Pcg, so a given seed yields an +// identical action stream by construction. + +import { Pcg } from "./pcg.ts"; +import { nextAction } from "./pick.ts"; +import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts"; +import type { Point } from "./types.ts"; + +// SerializedAction is the flat, camelCase wire shape JS emits and Go decodes +// (ONE decoder on each side). Builtin targets are already resolved to a Point, +// and serializeAction collapses author targets that carry {x, y}; a target that +// does not resolve to coordinates drops the action (returns null). +export type SerializedAction = + | { kind: "Tap" | "DoubleTap" | "LongPress"; x: number; y: number; selector?: string } + | { kind: "InputText"; x: number; y: number; text: string; selector?: string } + | { kind: "Swipe"; fromX: number; fromY: number; toX: number; toY: number; durationMillis: number } + | { + kind: "Scroll"; + direction: string; + fromX: number; + fromY: number; + toX: number; + toY: number; + durationMillis: number; + } + | { kind: "PressKey"; key: string } + | { kind: "Wait"; durationMillis: number }; + +const DEFAULT_SWIPE_DURATION = 250; + +// pointOf resolves a target to {x, y, selector?}. Builtins already supply a +// Point; author targets that carry numeric x/y (or a resolved element handle) +// collapse the same way. A string/selector target the host could not resolve +// returns undefined so the action is dropped. +function pointOf(target: unknown): (Point & { selector?: string }) | undefined { + if (!target || typeof target !== "object") return undefined; + const obj = target as Record; + if (typeof obj.x === "number" && typeof obj.y === "number") { + const point: Point & { selector?: string } = { x: obj.x, y: obj.y }; + if (typeof obj.selector === "string") point.selector = obj.selector; + return point; + } + return undefined; +} + +export function serializeAction(action: ActionDescriptor | null): SerializedAction | null { + if (!action) return null; + switch (action.kind) { + case "Tap": + case "DoubleTap": + case "LongPress": { + const point = pointOf(action.on); + if (!point) return null; + const out: SerializedAction = { kind: action.kind, x: point.x, y: point.y }; + if (point.selector) out.selector = point.selector; + return out; + } + case "InputText": { + const point = pointOf(action.into); + if (!point) return null; + const out: SerializedAction = { kind: "InputText", x: point.x, y: point.y, text: action.text }; + if (point.selector) out.selector = point.selector; + return out; + } + case "Swipe": { + const from = pointOf(action.from); + const to = pointOf(action.to); + if (!from || !to) return null; + return { + kind: "Swipe", + fromX: from.x, + fromY: from.y, + toX: to.x, + toY: to.y, + durationMillis: action.durationMillis ?? DEFAULT_SWIPE_DURATION, + }; + } + case "Scroll": { + const from = pointOf(action.in); + if (!from) return null; + return { + kind: "Scroll", + direction: action.direction, + fromX: from.x, + fromY: from.y, + toX: from.x, + toY: from.y, + durationMillis: DEFAULT_SWIPE_DURATION, + }; + } + case "PressKey": + return { kind: "PressKey", key: action.key }; + case "Wait": + return { kind: "Wait", durationMillis: action.durationMillis }; + } +} + +// installRuntime defines the next-action and extractor globals for one engine. +// root is the generator the spec assigned (globalThis.actions); evaluateExtractors +// is the engine's snapshot of the spec's extract() handles. +export function installRuntime( + host: Host, + root: GeneratorNode | null, + evaluateExtractors: () => Record, +): void { + const rng = new Pcg(host.seedHi(), host.seedLo()); + defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors()); + defineLockedGlobal("__sanderlingNextAction__", () => { + if (!root) return null; + return serializeAction(nextAction(root, rng, host)); + }); +} + +function defineLockedGlobal(name: string, value: unknown): void { + Object.defineProperty(globalThis, name, { + value, + writable: false, + configurable: false, + enumerable: false, + }); +}