Files
sanderling/pkg/spec/test/api.test.ts
T
pj c76ba4b497 feat(spec): refuse a multi-item authored sampler while enumerating
from().generate() draws from the picker's rng, which exists only inside
walkActions. The model policy enumerates authored leaves outside that walk, so
the sampler silently yielded its first item on every step: measured over 30
draws the seeded arm reached three targets in roughly equal proportion and the
model was offered only the first. The two policies had different action spaces
and nothing said so.

A single-item sampler short-circuits before the rng, so both policies get the
same value and it is not refused.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
2026-08-13 00:44:00 +05:30

422 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import {
DoubleTap,
InputText,
LongPress,
PressKey,
Scroll,
Swipe,
Tap,
Wait,
actions,
always,
doubleTaps,
eventually,
extract,
from,
keyedBy,
longPresses,
next,
now,
pressKeys,
scrolls,
swipes,
taps,
typing,
waitOnce,
weighted,
whenRoute,
} from "../src/index.ts";
import { setSamplerRng } from "../src/actions.ts";
import { SAMPLER_REFUSAL_NAME, setEnumeratingCandidates } from "../src/sampler-rng.ts";
import { Pcg } from "../src/pcg.ts";
import type { GeneratorNode } from "../src/action-tree.ts";
import type {
AccessibilityElement,
EventuallyFormula,
Extracted,
Formula,
State,
SanderlingRuntime,
} from "../src/types.ts";
// extract() and the LTL constructors stay host-bound on __sanderling__; the
// action factories now return plain data and need no runtime. This fake records
// the host-bound calls so the forwarding tests can assert them.
interface RecordedRuntime extends SanderlingRuntime {
extracts: Array<(state: State) => unknown>;
extractNames: Array<string | undefined>;
alwaysArgs: Array<(() => boolean) | Formula>;
nowPredicates: Array<() => boolean>;
nextPredicates: Array<() => boolean>;
eventuallyPredicates: Array<() => boolean>;
withinCalls: Array<{ amount: number; unit: string }>;
impliesCalls: number;
orCalls: number;
andCalls: number;
notCalls: number;
}
function makeChainableFormula(record: RecordedRuntime): Formula {
return {
__sanderlingFormula: true,
implies(other: Formula): Formula {
record.impliesCalls++;
void other;
return makeChainableFormula(record);
},
or(other: Formula): Formula {
record.orCalls++;
void other;
return makeChainableFormula(record);
},
and(other: Formula): Formula {
record.andCalls++;
void other;
return makeChainableFormula(record);
},
not(): Formula {
record.notCalls++;
return makeChainableFormula(record);
},
};
}
function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
const base = makeChainableFormula(record);
return {
...base,
within(amount, unit) {
record.withinCalls.push({ amount, unit });
return makeChainableFormula(record);
},
};
}
function installFakeRuntime(): RecordedRuntime {
const calls = {
extracts: [] as Array<(state: State) => unknown>,
extractNames: [] as Array<string | undefined>,
alwaysArgs: [] as Array<(() => boolean) | Formula>,
nowPredicates: [] as Array<() => boolean>,
nextPredicates: [] as Array<() => boolean>,
eventuallyPredicates: [] as Array<() => boolean>,
withinCalls: [] as Array<{ amount: number; unit: string }>,
impliesCalls: 0,
orCalls: 0,
andCalls: 0,
notCalls: 0,
};
const runtime: SanderlingRuntime = {
extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => {
calls.extracts.push(getter as (state: State) => unknown);
calls.extractNames.push(name);
const handle: Extracted<T> = {
current: undefined as unknown as T,
previous: undefined,
named: () => handle,
};
return handle;
},
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
calls.alwaysArgs.push(predicateOrFormula);
return makeChainableFormula(recorded);
},
now: (predicate: () => boolean): Formula => {
calls.nowPredicates.push(predicate);
return makeChainableFormula(recorded);
},
next: (predicate: () => boolean): Formula => {
calls.nextPredicates.push(predicate);
return makeChainableFormula(recorded);
},
eventually: (predicate: () => boolean): EventuallyFormula => {
calls.eventuallyPredicates.push(predicate);
return makeChainableEventually(recorded);
},
};
const recorded = Object.assign(runtime, calls) as RecordedRuntime;
// web-runtime.ts locks __sanderling__ as non-writable (configurable) when it
// is imported earlier in the same bun-test process, so a plain assignment
// would throw. defineProperty installs (and reinstalls) the fake regardless.
Object.defineProperty(globalThis, "__sanderling__", {
value: recorded,
writable: false,
configurable: true,
enumerable: false,
});
return recorded;
}
test("extract forwards the getter to the runtime", () => {
const runtime = installFakeRuntime();
const getter = (state: State) => state.snapshots["balance"];
extract<unknown>(getter);
assert.equal(runtime.extracts.length, 1);
assert.equal(runtime.extracts[0], getter);
assert.equal(runtime.extractNames[0], undefined);
});
test("extract accepts an explicit name", () => {
const runtime = installFakeRuntime();
const getter = (state: State) => state.snapshots["balance"];
extract<unknown>("ledgerBalance", getter);
assert.equal(runtime.extracts.length, 1);
assert.equal(runtime.extracts[0], getter);
assert.equal(runtime.extractNames[0], "ledgerBalance");
});
test("extract throws when given a name with no getter", () => {
installFakeRuntime();
assert.throws(
() => (extract as unknown as (n: string) => unknown)("orphan"),
/getter is required/,
);
});
test("always wraps a predicate into a formula via the runtime", () => {
const runtime = installFakeRuntime();
const predicate = () => true;
const formula = always(predicate);
assert.equal(runtime.alwaysArgs[0], predicate);
assert.equal(formula.__sanderlingFormula, true);
});
test("now/next/eventually forward predicates", () => {
const runtime = installFakeRuntime();
const p1 = () => true;
const p2 = () => false;
const p3 = () => true;
now(p1);
next(p2);
eventually(p3);
assert.equal(runtime.nowPredicates[0], p1);
assert.equal(runtime.nextPredicates[0], p2);
assert.equal(runtime.eventuallyPredicates[0], p3);
});
test("eventually().within forwards unit and amount", () => {
const runtime = installFakeRuntime();
eventually(() => true).within(3, "seconds");
assert.deepEqual(runtime.withinCalls[0], { amount: 3, unit: "seconds" });
});
test("formula chaining exposes implies/or/and/not", () => {
const runtime = installFakeRuntime();
const a = now(() => true);
const b = now(() => false);
a.implies(b).or(b).and(b).not();
assert.equal(runtime.impliesCalls, 1);
assert.equal(runtime.orCalls, 1);
assert.equal(runtime.andCalls, 1);
assert.equal(runtime.notCalls, 1);
});
test("Tap returns a TapAction descriptor", () => {
assert.deepEqual(Tap({ on: "id:login_continue" }), {
kind: "Tap",
on: "id:login_continue",
});
});
test("DoubleTap returns a DoubleTapAction descriptor", () => {
assert.deepEqual(DoubleTap({ on: "id:save" }), { kind: "DoubleTap", on: "id:save" });
});
test("LongPress returns a LongPressAction descriptor", () => {
assert.deepEqual(LongPress({ on: "id:row" }), { kind: "LongPress", on: "id:row" });
});
test("Scroll returns a ScrollAction descriptor", () => {
assert.deepEqual(Scroll({ direction: "down", in: "id:list" }), {
kind: "Scroll",
direction: "down",
in: "id:list",
});
});
test("Tap accepts an AccessibilityElement", () => {
const element: AccessibilityElement = {
id: "login_continue",
find: () => undefined,
findAll: () => [],
};
const action = Tap({ on: element });
assert.equal(action.kind, "Tap");
assert.equal(action.on, element);
});
test("InputText returns an InputTextAction descriptor", () => {
assert.deepEqual(InputText({ into: "id:phone", text: "+1234567890" }), {
kind: "InputText",
into: "id:phone",
text: "+1234567890",
});
});
test("Swipe returns a SwipeAction descriptor", () => {
assert.deepEqual(
Swipe({ from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 }),
{ kind: "Swipe", from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 },
);
});
test("PressKey returns a PressKeyAction descriptor", () => {
assert.deepEqual(PressKey({ key: "back" }), { kind: "PressKey", key: "back" });
});
test("Wait returns a WaitAction descriptor", () => {
assert.deepEqual(Wait({ durationMillis: 500 }), { kind: "Wait", durationMillis: 500 });
});
test("actions returns an actions node carrying the generator", () => {
const generator = () => [Tap({ on: "id:x" })];
const node = actions(generator);
assert.equal(node.kind, "actions");
assert.equal((node as { generate: unknown }).generate, generator);
});
test("weighted returns a weighted node carrying the branches", () => {
const node = weighted([80, taps], [20, swipes]);
assert.equal(node.kind, "weighted");
assert.deepEqual((node as { branches: unknown }).branches, [
[80, taps],
[20, swipes],
]);
});
test("builtin factories return builtin nodes carrying their verb", () => {
const cases: Array<[GeneratorNode, string]> = [
[taps, "taps"],
[doubleTaps, "doubleTaps"],
[longPresses, "longPresses"],
[scrolls, "scrolls"],
[typing, "typing"],
[swipes, "swipes"],
[waitOnce, "waitOnce"],
[pressKeys, "pressKeys"],
];
for (const [node, verb] of cases) {
assert.equal(node.kind, "builtin");
assert.equal((node as { verb: string }).verb, verb);
}
});
test("from with a single item returns it without drawing", () => {
const sampler = from(["only"]);
setSamplerRng(new Pcg(42n, 0n));
try {
assert.equal(sampler.generate(), "only");
} finally {
setSamplerRng(null);
}
});
test("from draws intN(len) from the active picker rng", () => {
const items = ["a", "b", "c"];
const sampler = from(items);
const rng = new Pcg(42n, 0n);
const oracle = new Pcg(42n, 0n);
setSamplerRng(rng);
try {
const index = oracle.intN(items.length);
assert.equal(sampler.generate(), items[index]);
} finally {
setSamplerRng(null);
}
});
test("from falls back to the first item outside a picker walk", () => {
setSamplerRng(null);
assert.equal(from(["a", "b", "c"]).generate(), "a");
});
test("from refuses a multi-item draw while the model policy enumerates", () => {
setEnumeratingCandidates(true);
try {
assert.throws(() => from(["a", "b", "c"]).generate(), {
name: SAMPLER_REFUSAL_NAME,
message: /draws 1 of 3 sampled items/,
});
} finally {
setEnumeratingCandidates(false);
}
});
test("from keeps serving a single item while the model policy enumerates", () => {
setEnumeratingCandidates(true);
try {
assert.equal(from(["only"]).generate(), "only");
} finally {
setEnumeratingCandidates(false);
}
});
function elementWithChildren(cells: Record<string, string>): AccessibilityElement {
return {
find: selector => {
if (typeof selector === "string" || Array.isArray(selector)) return undefined;
const tag = (selector as Record<string, string>).testTag;
if (!tag) return undefined;
const text = cells[tag];
if (text === undefined) return undefined;
return { text, find: () => undefined, findAll: () => [] };
},
findAll: () => [],
};
}
test("keyedBy joins testTag-resolved texts with a stable delimiter", () => {
installFakeRuntime();
const row = elementWithChildren({
TxnDate: "2026-04-26",
TxnNote: "Coffee",
TxnAmount: "$5.00",
});
const key = keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]);
assert.equal(key, "2026-04-26\x1fCoffee\x1f$5.00");
});
test("keyedBy returns empty string for an undefined element", () => {
assert.equal(keyedBy(undefined, ["TxnDate"]), "");
});
test("keyedBy substitutes empty strings for missing children", () => {
const row = elementWithChildren({ TxnDate: "2026-04-26" });
assert.equal(keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), "2026-04-26\x1f\x1f");
});
test("whenRoute body is skipped when the current route does not match", () => {
const route = { current: "home" as string | null };
let bodyCalled = false;
const node = whenRoute(route, "ledger", () => {
bodyCalled = true;
return [Tap({ on: "id:x" })];
});
assert.equal(node.kind, "actions");
assert.deepEqual((node as { generate: () => unknown }).generate(), []);
assert.equal(bodyCalled, false);
});
test("whenRoute runs the body when the current route matches", () => {
const route = { current: "ledger" as string | null };
const node = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]);
const result = (node as { generate: () => Array<{ kind: string }> }).generate();
assert.equal(result.length, 1);
assert.equal(result[0]?.kind, "Tap");
});
test("whenRoute accepts an array of allowed routes", () => {
const route = { current: "add-account" as string | null };
const node = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]);
assert.equal((node as { generate: () => unknown[] }).generate().length, 1);
});
test("whenRoute body is skipped for a null route", () => {
const route = { current: null as string | null };
const node = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]);
assert.deepEqual((node as { generate: () => unknown }).generate(), []);
});