fix(folio): never read a frame that shows two screens

android dumps a cross-fade with both screens in the tree. the route said
add-transaction while an unscoped find said home, so the oracle took a
half-rendered total as fresh and convicted on a tap that committed
nothing. one function now decides the route and returns null when the
frame is ambiguous.
This commit is contained in:
pj committed 2026-08-14 16:33:11 +05:30
1 parent 6f7e690750
commit d31de3b247
2 files changed
+301 -95

No files matched your search

+168 -10
View File
@@ -1,3 +1,38 @@
// The screen a frame shows, or null when it does not show exactly one.
//
// `present` answers whether a screen's marker node is in the accessibility
// tree. Android puts two markers there constantly: its hierarchy dump carries
// the outgoing and the incoming screen together on 425 of 1879 steps measured
// across 17 runs, better than one frame in five, occasionally three at once.
// iOS produced none in 2558 steps and web one in 560, so this is not a rule
// invented for a hypothetical.
//
// Such a frame is a navigation transition, and it is evidence about neither
// screen. Ranking the markers and taking the first is what convicted folio's
// spec in 11 android runs: `route` answered add-transaction off the screen
// being torn down while a separate unscoped find answered "on Home" off the one
// being built, so a half-rendered Home counted as a fresh balance reading and
// reset the submit window under a tap that had not landed yet. Every reading
// below takes the route as its only input, so there is no second answer left
// for it to disagree with.
//
// The engine already draws this line: a transitional tree yields no builtin
// targets at all (see targets() in internal/verifier), so a spec that keeps
// picking one of the two screens is the only thing still handing out
// coordinates from an animation.
export function routeOfFrame<R extends string>(
screens: Record<R, string>,
present: (tag: string) => boolean,
): R | null {
let shown: R | null = null;
for (const route of Object.keys(screens) as R[]) {
if (!present(screens[route])) continue;
if (shown !== null) return null;
shown = route;
}
return shown;
}
// Reads the Home screen's own TOTAL BALANCE node and advances the carrier the // Reads the Home screen's own TOTAL BALANCE node and advances the carrier the
// spec holds between Home visits. // spec holds between Home visits.
// //
@@ -8,8 +43,9 @@
// //
// Three cases, and the difference between the reported value and the carrier is // Three cases, and the difference between the reported value and the carrier is
// the whole point: // the whole point:
// - off Home there is nothing to read, so the last total we actually read is // - anywhere but Home there is nothing to read, so the last total we actually
// reported and carried on unchanged; // read is reported and carried on unchanged. A transition frame is one of
// those places: its route is null, which is not "home";
// - on Home with an unreadable total the reading is UNKNOWN, so null is // - on Home with an unreadable total the reading is UNKNOWN, so null is
// reported (the property treats null as vacuous) while the carrier keeps // reported (the property treats null as vacuous) while the carrier keeps
// the last value we did read. Writing null into the carrier is what used to // the last value we did read. Writing null into the carrier is what used to
@@ -24,17 +60,58 @@ export interface HomeTotalReading {
} }
export function readHomeTotalBalance(args: { export function readHomeTotalBalance(args: {
onHome: boolean; route: string | null;
totalText: string | undefined; totalText: string | undefined;
previousCarrier: number | null; previousCarrier: number | null;
}): HomeTotalReading { }): HomeTotalReading {
const { onHome, totalText, previousCarrier } = args; const { route, totalText, previousCarrier } = args;
if (!onHome) return { value: previousCarrier, carrier: previousCarrier, fresh: false }; if (route !== "home") return { value: previousCarrier, carrier: previousCarrier, fresh: false };
const total = parseDollarCents(totalText); const total = parseDollarCents(totalText);
if (total === null) return { value: null, carrier: previousCarrier, fresh: false }; if (total === null) return { value: null, carrier: previousCarrier, fresh: false };
return { value: total, carrier: total, fresh: true }; return { value: total, carrier: total, fresh: true };
} }
// The same reading, taken off Home's account cards instead of its total, for
// the readings the spec carries between Home visits (the account list and the
// per-account transaction counts).
//
// The empty reading is the whole point. Android renders Home's own node a frame
// or two before its list, so `findAll` over the cards comes back empty while the
// screen is already claiming to be Home. That is UNKNOWN, not "no accounts":
// writing it into the carrier is the poisoning readHomeTotalBalance was already
// fixed for. It killed the counting invariant outright on android, where
// counts_prev was {} at every evaluation point of all 17 runs measured.
//
// Callers pass null for a reading they could not take, so an empty list and an
// empty map are one case here rather than two.
export interface HomeCardReading<T> {
value: T | null;
carrier: T | null;
fresh: boolean;
}
export function readHomeCards<T>(args: {
route: string | null;
reading: T | null;
previousCarrier: T | null;
}): HomeCardReading<T> {
const { route, reading, previousCarrier } = args;
if (route !== "home") return { value: previousCarrier, carrier: previousCarrier, fresh: false };
if (reading === null) return { value: null, carrier: previousCarrier, fresh: false };
return { value: reading, carrier: reading, fresh: true };
}
function isTapOn(
lastAction: { kind?: string; on?: string | object } | null,
target: string,
): boolean {
if (lastAction == null) return false;
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false;
const on = lastAction.on;
const onString = typeof on === "string" ? on : on != null ? JSON.stringify(on) : "";
return onString.includes(target);
}
// Is this the action that commits a transaction? Nothing else in Folio reaches // Is this the action that commits a transaction? Nothing else in Folio reaches
// Repository.createTransaction: AddTransactionViewModel.submit() is the only // Repository.createTransaction: AddTransactionViewModel.submit() is the only
// caller, AddTransactionEvent.Submit is the only thing that runs it, and the // caller, AddTransactionEvent.Submit is the only thing that runs it, and the
@@ -42,11 +119,14 @@ export function readHomeTotalBalance(args: {
export function isTxnSubmitTap( export function isTxnSubmitTap(
lastAction: { kind?: string; on?: string | object } | null, lastAction: { kind?: string; on?: string | object } | null,
): boolean { ): boolean {
if (lastAction == null) return false; return isTapOn(lastAction, "TxnSubmit");
if (lastAction.kind !== "Tap" && lastAction.kind !== "DoubleTap") return false; }
const on = lastAction.on;
const onString = typeof on === "string" ? on : on != null ? JSON.stringify(on) : ""; // Likewise the only action that creates an account.
return onString.includes("TxnSubmit"); export function isAddAccountSubmitTap(
lastAction: { kind?: string; on?: string | object } | null,
): boolean {
return isTapOn(lastAction, "AddAccountSubmit");
} }
// Counts the submit actions inside the window the balance property compares // Counts the submit actions inside the window the balance property compares
@@ -154,6 +234,84 @@ export function cardTxnCountDigits(args: {
return source?.match(TRAILING_TXN_COUNT)?.[1]; return source?.match(TRAILING_TXN_COUNT)?.[1];
} }
export interface Account {
// Identity key, not a display name: on web it carries the card's initials.
name: string;
// null when the card's balance could not be read at all (see cardBalanceText).
balance: number | null;
}
// One Home card, already parsed by the three helpers above.
export interface CardReading extends Account {
digits: string | undefined;
}
// The two readings Home's card list yields, each null when there is nothing in
// it to read. Both feed readHomeCards, which is where null stops the carrier
// from being overwritten.
//
// An account list is empty for two reasons a frame cannot tell apart: the app
// has no accounts, or it has not drawn them yet. Calling both unknown costs one
// detection, the very first account of a run, and buys back every card that
// arrived late.
export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null {
if (cards.length === 0) return null;
return cards.map(({ name, balance }) => ({ name, balance }));
}
// A card whose name or count is unreadable is left out rather than guessed at;
// committedTransactionsExceedSubmits treats a missing account as no evidence.
// Every card being unreadable leaves nothing to compare, which is unknown.
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, string> | null {
const counts: Record<string, string> = {};
for (const card of cards) {
if (card.name !== "" && card.digits !== undefined) counts[card.name] = card.digits;
}
return Object.keys(counts).length === 0 ? null : counts;
}
// Did the account the fuzzer just created come into existence holding money?
//
// "Appeared in the visible set" is not "was created". Home lists the accounts
// that fit the viewport, so an existing account arrives in a later reading
// whenever the list scrolls, whenever a card that was clipped becomes laid out,
// and (before the route fix) whenever the earlier reading came off a
// half-rendered Home mid-transition. All three read as a brand new account, and
// the last two convicted this property on android over a Travel account holding
// $24,112.00 and a Savings account holding $429,585.00.
//
// The one appearance that IS attributable to a creation is the account the
// fuzzer asked for: the name it typed into AddAccountScreen, judged on the step
// where that screen's submit landed on Home. Everything else that shows up is a
// card that came into view, and this property has nothing to say about it.
//
// Returns true only for a violation it can attribute. Unattributable is not the
// same as fine, and both come back false here.
export function createdAccountHasNonZeroBalance(args: {
route: string | null;
lastAction: { kind?: string; on?: string | object } | null;
typedName: string | undefined;
before: Account[] | null;
after: Account[] | null;
}): boolean {
const { route, lastAction, before, after } = args;
if (route !== "home") return false;
if (!isAddAccountSubmitTap(lastAction)) return false;
if (before === null || after === null) return false;
const typed = (args.typedName ?? "").trim();
if (typed === "") return false;
// Web merges the card into one node whose text opens with the avatar
// initials, so the identity key is "INInvestments" where android and iOS give
// "Investments"; endsWith covers both. Two cards answering to the same typed
// name (a second "Travel", or a card the tree exposed twice) leave the
// appearance unattributable, so nothing is judged.
const matches = after.filter(account => account.name.endsWith(typed));
const created = matches.length === 1 ? matches[0] : undefined;
if (created === undefined) return false;
if (before.some(account => account.name === created.name)) return false;
return created.balance !== null && created.balance !== 0;
}
// Every accepted submit commits exactly one transaction, so over any window the // Every accepted submit commits exactly one transaction, so over any window the
// number of transactions committed cannot exceed the number of submit actions // number of transactions committed cannot exceed the number of submit actions
// taken. A double-submit is one action committing two, which is the only way to // taken. A double-submit is one action committing two, which is the only way to
+133 -85
View File
@@ -11,7 +11,7 @@ import {
weighted, weighted,
whenRoute, whenRoute,
} from "@sanderling/spec"; } from "@sanderling/spec";
import type { State } from "@sanderling/spec"; import type { AccessibilityElement, State } from "@sanderling/spec";
import { defaultActions, doubleTaps } from "@sanderling/spec/defaults"; import { defaultActions, doubleTaps } from "@sanderling/spec/defaults";
import { import {
cardAccountName, cardAccountName,
@@ -19,40 +19,76 @@ import {
cardTxnCountDigits, cardTxnCountDigits,
committedTransactionsExceedSubmits, committedTransactionsExceedSubmits,
countSubmitsInWindow, countSubmitsInWindow,
createdAccountHasNonZeroBalance,
homeAccountsOf,
homeTxnCountsOf,
parseDollarCents, parseDollarCents,
parseTypedAmount, parseTypedAmount,
readHomeCards,
readHomeTotalBalance, readHomeTotalBalance,
routeOfFrame,
submitChangesBalanceByTypedAmount, submitChangesBalanceByTypedAmount,
} from "./predicates"; } from "./predicates";
import type { Account, CardReading } from "./predicates";
interface Account { // Screen markers, and the route each one names. Detection is by testTag
// Identity key, not a display name: on web it carries the card's initials. // (resource-id on Android, accessibilityIdentifier on iOS).
name: string; const SCREENS = {
// null when the card's balance could not be read at all (see cardBalanceText). login: "LoginScreen",
balance: number | null; "add-account": "AddAccountScreen",
} "add-transaction": "AddTransactionScreen",
ledger: "LedgerScreen",
home: "HomeScreen",
} as const;
type Route = keyof typeof SCREENS;
// Route detection via testTag (resource-id on Android, accessibilityIdentifier on iOS) // The screen this frame shows, or null when it does not show exactly one: see
const loggedIn = extract("loggedIn", s => s.ax.find({ testTag: "LoginScreen" }) == null); // routeOfFrame, which owns that rule and the reason for it. Everything below
const route = extract<string | null>("route", s => { // takes its answer from here, so no two readings can disagree about which
if (s.ax.find({ testTag: "LoginScreen" })) return "login"; // screen the app is on.
if (s.ax.find({ testTag: "AddAccountScreen" })) return "add-account"; const routeOf = (s: State): Route | null =>
if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction"; routeOfFrame<Route>(SCREENS, tag => s.ax.find({ testTag: tag }) != null);
if (s.ax.find({ testTag: "LedgerScreen" })) return "ledger";
if (s.ax.find({ testTag: "HomeScreen" })) return "home";
return null;
});
// Account cards on Home: identity comes from AccountName, balance from // An element is a reading, and a target, only when the route says we are on its
// AccountBalance. Web exposes neither child (the card is one merged node // screen. Scoping a find to the screen's own node is not enough on a transition
// there), so both readings go through predicates.ts, which falls back to // frame: both screens are in the tree, so the one the app has already left
// parsing the card's own text. // still resolves and the tap goes to whatever now occupies those pixels.
const accounts = extract<Account[]>("accounts", s => const on =
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]).map(card => ({ (route: Route, tag: string) =>
name: cardAccountName({ childText: card.find({ testTag: "AccountName" })?.text, cardText: card.text }), (s: State): AccessibilityElement | undefined =>
routeOf(s) === route ? s.ax.find([{ testTag: SCREENS[route] }, { testTag: tag }]) : undefined;
const allOn =
(route: Route, tag: string) =>
(s: State): AccessibilityElement[] =>
routeOf(s) === route ? s.ax.findAll([{ testTag: SCREENS[route] }, { testTag: tag }]) : [];
const loggedIn = extract("loggedIn", s => routeOf(s) !== "login");
const route = extract<Route | null>("route", routeOf);
// One parse of Home's account cards. Identity comes from AccountName, balance
// from AccountBalance, the transaction count from AccountTxnCount. Web exposes
// none of those children (the card is one merged node there), so every reading
// goes through predicates.ts, which falls back to parsing the card's own text.
//
// Everything that comes off the card list shares this parse so the readings
// cannot disagree with each other about what was on screen.
const homeCards = (s: State): CardReading[] =>
allOn("home", "AccountCard")(s).map(card => ({
name: cardAccountName({
childText: card.find({ testTag: "AccountName" })?.text,
cardText: card.text,
}),
balance: parseDollarCents( balance: parseDollarCents(
cardBalanceText({ childText: card.find({ testTag: "AccountBalance" })?.text, cardText: card.text })), cardBalanceText({
}))); childText: card.find({ testTag: "AccountBalance" })?.text,
cardText: card.text,
})),
digits: cardTxnCountDigits({
childText: card.find({ testTag: "AccountTxnCount" })?.text,
cardText: card.text,
}),
}));
// Total balance: Home's own TOTAL BALANCE node, which the app computes over // Total balance: Home's own TOTAL BALANCE node, which the app computes over
// every account rather than over the cards that happen to be laid out inside // every account rather than over the cards that happen to be laid out inside
@@ -60,14 +96,12 @@ const accounts = extract<Account[]>("accounts", s =>
// LedgerBalance is a single-account number on a different scale and would // LedgerBalance is a single-account number on a different scale and would
// corrupt cross-screen comparisons if mixed in. Off-Home steps carry forward // corrupt cross-screen comparisons if mixed in. Off-Home steps carry forward
// the last-read Home total so `previous` and `current` stay on the same scale. // the last-read Home total so `previous` and `current` stay on the same scale.
const homeTotalText = (s: State) => const homeTotalText = (s: State) => on("home", "TotalBalance")(s)?.text;
s.ax.find([{ testTag: "HomeScreen" }, { testTag: "TotalBalance" }])?.text;
const onHome = (s: State) => s.ax.find({ testTag: "HomeScreen" }) != null;
let lastHomeTotal: number | null = null; let lastHomeTotal: number | null = null;
const totalBalance = extract<number | null>("totalBalance", s => { const totalBalance = extract<number | null>("totalBalance", s => {
const reading = readHomeTotalBalance({ const reading = readHomeTotalBalance({
onHome: onHome(s), route: routeOf(s),
totalText: homeTotalText(s), totalText: homeTotalText(s),
previousCarrier: lastHomeTotal, previousCarrier: lastHomeTotal,
}); });
@@ -82,7 +116,7 @@ const totalBalance = extract<number | null>("totalBalance", s => {
let submitsSinceHomeTotal = 0; let submitsSinceHomeTotal = 0;
const submitsInWindow = extract("submitsInWindow", s => { const submitsInWindow = extract("submitsInWindow", s => {
const fresh = readHomeTotalBalance({ const fresh = readHomeTotalBalance({
onHome: onHome(s), route: routeOf(s),
totalText: homeTotalText(s), totalText: homeTotalText(s),
previousCarrier: null, previousCarrier: null,
}).fresh; }).fresh;
@@ -95,67 +129,81 @@ const submitsInWindow = extract("submitsInWindow", s => {
return window.reported; return window.reported;
}); });
// Transactions committed per account, read off the Home cards. Carried across // The account list, carried across off-Home steps exactly like totalBalance and
// off-Home steps exactly like totalBalance, and for the same reason: the pair // for the same reason: the pair a property compares has to be two Home
// the property compares has to be two Home readings, not a Home reading and // readings, not a Home reading and whatever happened to be on screen.
// whatever happened to be on screen. A card whose count is unreadable is left let lastHomeAccounts: Account[] | null = null;
// out of the map rather than guessed at; the predicate treats a missing account const accounts = extract<Account[] | null>("accounts", s => {
// as no evidence. const reading = readHomeCards({
route: routeOf(s),
reading: homeAccountsOf(homeCards(s)),
previousCarrier: lastHomeAccounts,
});
lastHomeAccounts = reading.carrier;
return reading.value;
});
// Transactions committed per account, same carrier rule.
let lastHomeTxnCounts: Record<string, string> | null = null; let lastHomeTxnCounts: Record<string, string> | null = null;
const homeTxnCounts = extract<Record<string, string> | null>("homeTxnCounts", s => { const homeTxnCounts = extract<Record<string, string> | null>("homeTxnCounts", s => {
if (!onHome(s)) return lastHomeTxnCounts; const reading = readHomeCards({
const counts: Record<string, string> = {}; route: routeOf(s),
for (const card of s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }])) { reading: homeTxnCountsOf(homeCards(s)),
const name = cardAccountName({ previousCarrier: lastHomeTxnCounts,
childText: card.find({ testTag: "AccountName" })?.text, });
cardText: card.text, lastHomeTxnCounts = reading.carrier;
}); return reading.value;
const digits = cardTxnCountDigits({ });
childText: card.find({ testTag: "AccountTxnCount" })?.text,
cardText: card.text, // The counting invariant gets its own window because its carrier advances on a
}); // different event than the total's: a Home frame can render the footer total
if (name !== "" && digits !== undefined) counts[name] = digits; // while its card list is still empty. Sharing submitsInWindow would let the
} // count reset without the counts pair moving, and a window whose submit count
lastHomeTxnCounts = counts; // is smaller than the interval its two readings span is a false conviction
return counts; // waiting to happen.
let submitsSinceHomeCards = 0;
const submitsSinceCounts = extract("submitsSinceCounts", s => {
const fresh = readHomeCards({
route: routeOf(s),
reading: homeTxnCountsOf(homeCards(s)),
previousCarrier: null,
}).fresh;
const window = countSubmitsInWindow({
previousCount: submitsSinceHomeCards,
lastAction: s.lastAction,
fresh,
});
submitsSinceHomeCards = window.next;
return window.reported;
}); });
const lastAction = extract("lastAction", s => s.lastAction); const lastAction = extract("lastAction", s => s.lastAction);
const loginEmailField = extract("loginEmailField", s => const loginEmailField = extract("loginEmailField", on("login", "LoginEmail"));
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginEmail" }])); const loginPasswordField = extract("loginPasswordField", on("login", "LoginPassword"));
const loginPasswordField = extract("loginPasswordField", s => const loginSubmit = extract("loginSubmit", on("login", "LoginSubmit"));
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginPassword" }])); const addAccountButton = extract("addAccountButton", on("home", "AddAccountButton"));
const loginSubmit = extract("loginSubmit", s => const accountNameField = extract("accountNameField", on("add-account", "AccountNameField"));
s.ax.find([{ testTag: "LoginScreen" }, { testTag: "LoginSubmit" }])); const addAccountSubmit = extract("addAccountSubmit", on("add-account", "AddAccountSubmit"));
const addAccountButton = extract("addAccountButton", s => const addTxnButton = extract("addTxnButton", on("ledger", "AddTransactionButton"));
s.ax.find([{ testTag: "HomeScreen" }, { testTag: "AddAccountButton" }])); const txnAmountField = extract("txnAmountField", on("add-transaction", "TxnAmountField"));
const accountNameField = extract("accountNameField", s => const txnSubmit = extract("txnSubmit", on("add-transaction", "TxnSubmit"));
s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AccountNameField" }])); const accountCards = extract("accountCards", allOn("home", "AccountCard"));
const addAccountSubmit = extract("addAccountSubmit", s =>
s.ax.find([{ testTag: "AddAccountScreen" }, { testTag: "AddAccountSubmit" }]));
const addTxnButton = extract("addTxnButton", s =>
s.ax.find([{ testTag: "LedgerScreen" }, { testTag: "AddTransactionButton" }]));
const txnAmountField = extract("txnAmountField", s =>
s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnAmountField" }]));
const txnSubmit = extract("txnSubmit", s =>
s.ax.find([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }]));
const accountCards = extract("accountCards", s =>
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]));
// Property 1: every newly-appearing account starts with balance === 0. // Property 1: an account starts life holding nothing. The account it judges is
// Identity is by visible name. Guard against navigation transitions where // the one the fuzzer just created, on the step that creation landed on Home:
// accounts vanish from the visible tree. // a card that merely turns up in a later reading is a card that came into view,
// not an account that came into existence. See createdAccountHasNonZeroBalance.
const newAccountBalanceIsZero = always( const newAccountBalanceIsZero = always(
next(() => { next(() =>
const prev = accounts.previous ?? []; !createdAccountHasNonZeroBalance({
const curr = accounts.current; route: route.current,
if (prev.length === 0 || curr.length === 0) return true; lastAction: lastAction.current,
const prevNames = new Set(prev.map(a => a.name)); typedName: accountNameField.previous?.text,
return curr before: accounts.previous ?? null,
.filter(a => !prevNames.has(a.name)) after: accounts.current,
.every(a => a.balance === null || a.balance === 0); }),
}) ),
); );
// Property 2: a tap on TxnSubmit must move the total balance by exactly the // Property 2: a tap on TxnSubmit must move the total balance by exactly the
@@ -185,7 +233,7 @@ const submitCommitsOneTransactionPerAction = always(
!committedTransactionsExceedSubmits({ !committedTransactionsExceedSubmits({
countsBefore: homeTxnCounts.previous ?? null, countsBefore: homeTxnCounts.previous ?? null,
countsAfter: homeTxnCounts.current, countsAfter: homeTxnCounts.current,
submitsInWindow: submitsInWindow.current, submitsInWindow: submitsSinceCounts.current,
}), }),
), ),
); );