Merge branch 'runner-uncertain-last-action' into pr-73-followups

This commit is contained in:
pj committed 2026-08-15 14:04:56 +05:30
commit df0cb96386
14 files changed
+428 -60

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ export type {
InputTextAction,
Key,
KnownAttrSelectors,
LastAction,
LogEntry,
LongPressAction,
Point,
+11 -1
View File
@@ -102,10 +102,20 @@ export interface ExceptionRecord {
unixMillis?: number;
}
/**
* The previous step's action as the runner reports it. `applied` is true when
* the runner saw the dispatch succeed and null when the apply call failed with
* the gesture possibly already delivered: an RPC deadline can fire after the
* tap landed. Null is unknown, not "it did not happen" (`state.lastAction` is
* itself null for that), so a property attributing an effect to this action
* has to decline unless `applied` is true.
*/
export type LastAction = Action & { applied: true | null };
export interface State {
snapshots: Snapshots;
ax: AccessibilityTree;
lastAction: Action | null;
lastAction: LastAction | null;
time: number;
logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[];
+24 -3
View File
@@ -3,8 +3,12 @@ import { test } from "node:test";
import { createdAccountHasNonZeroBalance } from "../../../examples/folio/sanderling/predicates.ts";
const created = { kind: "Tap", on: "testTag:AddAccountScreen > testTag:AddAccountSubmit" };
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard" };
const created = {
kind: "Tap",
on: "testTag:AddAccountScreen > testTag:AddAccountSubmit",
applied: true as const,
};
const idle = { kind: "Tap", on: "testTag:HomeScreen > testTag:AccountCard", applied: true as const };
const account = (name: string, balance: number | null) => ({ name, balance });
@@ -27,7 +31,7 @@ test("a double-tapped create is judged the same way", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" },
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit", applied: true },
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
@@ -233,3 +237,20 @@ test("a card that was already there is not a card that was just created", () =>
false,
);
});
// The apply call failed with the gesture possibly already delivered, so nobody
// knows whether that account was created. The card carrying the typed name may
// be an older one that scrolled into view, and attributing it to a creation
// that may never have happened is a conviction built on a guess.
test("a create the runner could not confirm attributes nothing", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: { ...created, applied: null },
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
false,
);
});
@@ -12,7 +12,7 @@ test("single submit: delta matches typed amount", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
@@ -26,7 +26,7 @@ test("double submit: delta is twice the typed amount, fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
@@ -40,7 +40,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
@@ -54,7 +54,7 @@ test("wrong action kind: vacuous true even with mismatch", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "InputText", on: submitOn },
lastAction: { kind: "InputText", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
@@ -68,7 +68,7 @@ test("wrong target: vacuous true even with mismatch", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" },
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit", applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
@@ -96,7 +96,7 @@ test("zero typedAmount: vacuous true", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 0,
prevTotalBalance: 1000,
@@ -110,7 +110,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } },
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" }, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
@@ -124,7 +124,7 @@ test("selector as object without TxnSubmit: vacuous true", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } },
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" }, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
@@ -138,7 +138,7 @@ test("raw whole-dollar input: single submit clears", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
@@ -152,7 +152,7 @@ test("raw whole-dollar input: double submit fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
@@ -166,7 +166,7 @@ test("decimal input from empty prior balance clears", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: parseTypedAmount("5.50"),
prevTotalBalance: 0,
@@ -180,7 +180,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: parseTypedAmount("100"),
prevTotalBalance: 0,
@@ -194,7 +194,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "ledger",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
@@ -208,7 +208,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped",
assert.equal(
submitChangesBalanceByTypedAmount({
route: "add-transaction",
lastAction: { kind: "DoubleTap", on: submitOn },
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
@@ -222,7 +222,7 @@ test("route gate: null route is skipped", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: null,
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
@@ -236,7 +236,7 @@ test("route gate: home landing with matching delta passes", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
@@ -250,7 +250,7 @@ test("route gate: home landing with double-insert delta fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
@@ -275,7 +275,7 @@ test("above 2^53 a healthy single submit is not reported", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
@@ -289,7 +289,7 @@ test("above 2^53 a double-submit delta is not reported either", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
@@ -303,7 +303,7 @@ test("an unreadable previous balance above 2^53 is not evidence", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
@@ -320,7 +320,7 @@ test("typed amount above 2^53 is not evidence", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 1e23,
prevTotalBalance: 0,
@@ -336,7 +336,7 @@ test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires"
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 4503599627370495,
prevTotalBalance: 0,
@@ -350,7 +350,7 @@ test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", ()
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 9007199254740991,
prevTotalBalance: 0,
@@ -364,7 +364,7 @@ test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 4503599627370496,
prevTotalBalance: 0,
@@ -381,7 +381,7 @@ test("a large but exact difference between safe balances still fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: -9007199254740991,
@@ -397,7 +397,7 @@ test("21-digit typed amount with an unmoved balance is not a violation", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: parseTypedAmount("999999999999999999999"),
prevTotalBalance: 220900,
@@ -417,7 +417,7 @@ test("freshness: two submits in the window is vacuous, not a conviction", () =>
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 2,
typedAmount: 19600,
prevTotalBalance: 0,
@@ -431,7 +431,7 @@ test("freshness: two submits cannot convict even on a clean 2x delta", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 2,
typedAmount: 500,
prevTotalBalance: 1000,
@@ -448,7 +448,7 @@ test("freshness boundary: exactly one submit is the window that convicts", () =>
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
lastAction: { kind: "DoubleTap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 19600,
prevTotalBalance: 0,
@@ -462,7 +462,7 @@ test("freshness boundary: one submit with a healthy 1x delta still passes", () =
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 1,
typedAmount: 19600,
prevTotalBalance: 0,
@@ -476,7 +476,7 @@ test("freshness boundary: three submits is vacuous", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 3,
typedAmount: 500,
prevTotalBalance: 0,
@@ -493,7 +493,7 @@ test("freshness boundary: a window with no submit in it is vacuous", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
lastAction: { kind: "Tap", on: submitOn, applied: true },
submitsInWindow: 0,
typedAmount: 500,
prevTotalBalance: 1000,
@@ -502,3 +502,21 @@ test("freshness boundary: a window with no submit in it is vacuous", () => {
true,
);
});
// applied: null is the runner saying it dispatched the tap and never learned
// whether it landed. A submit that committed nothing leaves the balance where
// it was, so demanding the typed amount of movement for it convicts an app that
// did exactly what it should have.
test("a submit the runner could not confirm demands no balance move", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn, applied: null },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
}),
true,
);
});
+23
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
committedTransactionsExceedSubmits,
countSubmitsInWindow,
isTxnSubmitTap,
readHomeTotalBalance,
@@ -149,3 +150,25 @@ test("an unreadable Home does not close the window", () => {
assert.equal(trace[2]?.total, null);
assert.equal(trace[3]?.submits, 2);
});
// The window is an upper bound on the submits it holds, so a submit whose
// dispatch the runner could not confirm belongs in it: the tap may well have
// landed, and a bound that leaves it out is one the transaction it committed
// exceeds. That is the false conviction, a rise of one against a window of
// zero, on the property carrying most of the detection on android.
test("a submit the runner could not confirm still counts toward the window", () => {
const window = countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "Tap", on: submitOn, applied: null },
fresh: true,
});
assert.equal(window.reported, 1);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Travel: 3 },
countsAfter: { Travel: 4 },
submitsInWindow: window.reported,
}),
false,
);
});
+7 -3
View File
@@ -94,7 +94,7 @@ test("the measured android transition chain no longer convicts at delta 0", () =
const step = (
tags: string[],
totalText: string | undefined,
lastAction: { kind: string; on: string } | null,
lastAction: { kind: string; on: string; applied: true } | null,
) => {
const route = routeOfFrame(SCREENS, frame(...tags));
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
@@ -104,8 +104,12 @@ test("the measured android transition chain no longer convicts at delta 0", () =
return { route, total: reading.value, submits: window.reported };
};
const back = { kind: "DoubleTap", on: "id:BackButton" };
const phantomSubmit = { kind: "Tap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
const back = { kind: "DoubleTap", on: "id:BackButton", applied: true as const };
const phantomSubmit = {
kind: "Tap",
on: "testTag:AddTransactionScreen > testTag:TxnSubmit",
applied: true as const,
};
const transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back);
assert.equal(transition.route, null);