mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
escape is a key a spec has real use for and no platform could send it. android maps it to KEYCODE_ESCAPE, the ios companion to HID usage 41 and the in-simulator runner to XCUIKeyboardKey.escape, and the Key union accepts it so it can be written at all.
449 lines
14 KiB
TypeScript
449 lines
14 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,
|
|
Key,
|
|
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("eventually().within forwards the step unit unchanged", () => {
|
|
const runtime = installFakeRuntime();
|
|
eventually(() => true).within(1915, "steps");
|
|
assert.deepEqual(runtime.withinCalls[0], { amount: 1915, unit: "steps" });
|
|
});
|
|
|
|
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" });
|
|
});
|
|
|
|
// Every key docs/manual/spec-language.md documents has to be authorable. A key
|
|
// the type rejects cannot appear in a spec at all, so the requirement it stands
|
|
// for ("escape discards the edit in progress") is never actuated anywhere.
|
|
test("PressKey accepts every documented key", () => {
|
|
const keys: Key[] = [
|
|
"back",
|
|
"home",
|
|
"enter",
|
|
"tab",
|
|
"escape",
|
|
"up",
|
|
"down",
|
|
"left",
|
|
"right",
|
|
];
|
|
for (const key of keys) {
|
|
assert.deepEqual(PressKey({ key }), { kind: "PressKey", key });
|
|
}
|
|
});
|
|
|
|
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(), []);
|
|
});
|