mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
both sides independently fixed the same three bugs, so each one had to pick a winner rather than keep both implementations. extractor encoding: master's recordableValue in worker.go wins over ours in marshal.go, since master's is pinned by extractor_encoding_test.go and ours had no tests. our error semantics stay: encodeExtractorValue still returns an error instead of nil, so an extractor cannot vanish from the trace silently. apply errors: only the residual generic branch takes master's unconfirmed copy, where the device may have committed the action before the call failed. the finer branches that know nothing was dispatched keep lastAction = nil, and our actionSkipReason taxonomy stays alongside master's held/skippedVerification. selector matching: our matchAttr with matchSelectorKind wins over master's match, since ours also handles idPrefix. matchSelector now calls it, which git did not flag as a conflict and left calling a function our side had deleted. the ltl doc comment takes master's correction: an unbounded eventually that never fires IS violated at run end.
470 lines
14 KiB
TypeScript
470 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 type {
|
|
Action,
|
|
Direction,
|
|
LongPressAction,
|
|
ScrollAction,
|
|
} 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(), []);
|
|
});
|
|
|
|
// The package entry is the only module a spec author can import from, so every
|
|
// member of the exported Action union, and the Direction needed to build a
|
|
// Scroll, has to be reachable there rather than only from src/types.ts.
|
|
test("index exports every action type a spec author annotates with", () => {
|
|
const direction: Direction = "down";
|
|
const scroll: ScrollAction = Scroll({ direction, in: "id:list" });
|
|
const longPress: LongPressAction = LongPress({ on: "id:row" });
|
|
const built: Action[] = [scroll, longPress];
|
|
|
|
assert.deepEqual(
|
|
built.map(action => action.kind),
|
|
["Scroll", "LongPress"],
|
|
);
|
|
});
|