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 { 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; 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, 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: (getter: (state: State) => T, name?: string): Extracted => { calls.extracts.push(getter as (state: State) => unknown); calls.extractNames.push(name); const handle: Extracted = { 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(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("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"); }); function elementWithChildren(cells: Record): AccessibilityElement { return { find: selector => { if (typeof selector === "string" || Array.isArray(selector)) return undefined; const tag = (selector as Record).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(), []); });