docs: correct the snippets and numbers that drifted from the code

This commit is contained in:
pj committed 2026-08-14 22:41:53 +05:30
1 parent 5ad545a6ba
commit 9f1dfe27ba
2 files changed
+108 -41

No files matched your search

+45 -24
View File
@@ -4,9 +4,9 @@ title: CI
# CI # CI
`ci.yml` runs on every pull request: it builds, unit-tests, and drives two small `ci.yml` runs on every pull request: it builds, unit-tests, and drives three
web fixtures through headless Chrome. It never runs sanderling against a real small web fixtures through headless Chrome (`test/browser/testdata`). It never
app. runs sanderling against a real app.
Two other workflows do, and both are `workflow_dispatch` only. They boot devices, Two other workflows do, and both are `workflow_dispatch` only. They boot devices,
build apps and take minutes, which is not what you want on every push, and build apps and take minutes, which is not what you want on every push, and
@@ -15,33 +15,51 @@ neither is a merge gate.
## folio ## folio
Actions -> folio -> Run workflow. Inputs pick the legs (`all`, `android`, `ios`, Actions -> folio -> Run workflow. Inputs pick the legs (`all`, `android`, `ios`,
`web`), and override the seed, the wall-clock budget, and the step budget; `0` `web`), and override the seed, the step budget and the wall-clock budget. Seed
means "use the calibrated value in the workflow". and step budget take `0` to mean "use the calibrated value in the workflow"; the
wall-clock budget has no such sentinel and is passed through as written, so
every leg gets whatever you type there.
Each leg builds `examples/folio` for its platform, builds the CLI with only the Each leg builds `examples/folio` for its platform, builds the CLI with only the
tags that platform needs (`make sanderling-android` and friends), and runs tags that platform needs (`make sanderling-android` and friends), and runs
`examples/folio/sanderling/spec.ts` through `.github/scripts/folio-run.sh`. That `examples/folio/sanderling/spec.ts` through `.github/scripts/folio-run.sh`. That
script is plain bash so you can reproduce a job locally: script is plain bash so you can reproduce a job locally, with that leg's pinned
numbers:
``` ```
SEED=3 MAX_STEPS=240 .github/scripts/folio-run.sh android SEED=9 MAX_STEPS=200 .github/scripts/folio-run.sh android
``` ```
**web and ios expect the bug.** Folio double-submits a transaction when the **web and ios expect the bug.** Folio double-submits a transaction when the
submit button is double-tapped, and two properties catch it: submit button is double-tapped, and two properties catch it:
`submitMovesBalanceByTypedAmount`, which needs the balance to move by exactly `submitMovesBalanceByTypedAmount`, which demands the total balance move by
twice the typed amount, and `submitCommitsOneTransactionPerAction`, which needs exactly the amount typed, and `submitCommitsOneTransactionPerAction`, which
more transactions committed than there were submit actions. The runs pass demands no more transactions committed over a window than there were submit
`--exit-on-violation`, so: actions in it. A double tap is one action committing two transactions, so it
breaks both.
| exit | meaning | job | The runs pass `--exit-on-violation`, which exits 2 when the run recorded a
|---|---|---| violation and 1 when something went wrong. Telling those apart is the whole
| 2 | the run found a violation | green | point of the exit code: a job that only knew "non-zero" could not tell a working
| 0 | the run finished clean | red: the fuzzer stopped finding a bug that is still there | fuzzer from a broken emulator.
| 1 | something went wrong | red: the harness broke |
Distinguishing 0 from 1 is the whole point of the exit code: a job that only Exit 2 on its own is not a conviction, though, so the script reads the trace
knew "non-zero" could not tell a working fuzzer from a broken emulator. before it decides:
| what the trace says | job |
|---|---|
| a violation of one of the two properties above, with no `is_error` on its witness | green |
| a violation whose witness carries `is_error` | red: a predicate threw, and a thrown predicate is recorded as a violation like any other |
| a violation of any other property (`newAccountBalanceIsZero` fires on one android seed) | red: a real finding, but not the one this leg gates on |
| no violation and exit 0 | red: the fuzzer stopped finding a bug that is still there |
| exit 1, or any other code | red: the harness broke, and the code propagates |
The first two rows are why the check is worth the code it takes. A `TypeError`
in `predicates.ts` and a fuzzer that no longer reaches the bug both used to
print "found the submit bug" and exit 0, and they need opposite responses: one
is a spec to fix, the other is a seed to recalibrate. A thrown predicate fails
android as well, where a conviction is otherwise only a bonus, because a spec
that stopped running is not evidence about the app.
The balance property only judges a window holding exactly one submit action. The balance property only judges a window holding exactly one submit action.
Without that rule the recorded delta covers every transaction since the last Home Without that rule the recorded delta covers every transaction since the last Home
@@ -111,9 +129,11 @@ from `test/browser/testdata/throwing` (violations and uncaught exceptions, so
every panel has something to render), serves it with `sanderling replay`, and every panel has something to render), serves it with `sanderling replay`, and
fuzzes that UI with `replay-ui/sanderling/spec.ts`. fuzzes that UI with `replay-ui/sanderling/spec.ts`.
Every property there is a cross-panel agreement - two panels deriving the same Six of the seven properties there are cross-panel agreements - two panels
fact by different paths have to say the same thing - so it holds for any trace deriving the same fact by different paths have to say the same thing - so they
and needs no recalibrating when the fixture changes. Any violation fails the job. hold for any trace and need no recalibrating when the fixture changes. The
seventh is the stock `noUncaughtExceptions`, which asks nothing of the panels
and only fails if the UI throws. Any violation fails the job.
## Reading a failure ## Reading a failure
@@ -133,7 +153,8 @@ the extractor values at the step that caused it.
Expecting a violation from a single seed is timing-sensitive, most of all on an Expecting a violation from a single seed is timing-sensitive, most of all on an
emulator. Calibration reduces that; it does not remove it. If a platform starts emulator. Calibration reduces that; it does not remove it. If a platform starts
failing across repeated dispatches with exit 0, do not raise the step budget failing across repeated dispatches with "the double-submit bug was NOT found",
blindly - run a seed sweep with the campaign tool do not raise the step budget blindly - run a seed sweep with the campaign tool
(`cmd/internal-tools/campaign`), which exists for exactly this, and pin a seed (`cmd/internal-tools/campaign`), which exists for exactly this, and pin a seed
that finds the bug with room to spare. that finds the bug with room to spare. A leg failing with "a predicate threw" is
a different problem entirely and no seed will fix it.
+63 -17
View File
@@ -33,6 +33,7 @@ const submitMovesBalanceByTypedAmount = always(
const action = lastAction.current; const action = lastAction.current;
if (action?.kind !== "Tap" && action?.kind !== "DoubleTap") return true; if (action?.kind !== "Tap" && action?.kind !== "DoubleTap") return true;
if (!JSON.stringify(action.on ?? "").includes("TxnSubmit")) return true; if (!JSON.stringify(action.on ?? "").includes("TxnSubmit")) return true;
if (submitsInWindow.current !== 1) return true;
const typed = parseTypedAmount(txnAmountField.previous?.text); const typed = parseTypedAmount(txnAmountField.previous?.text);
if (typed === 0) return true; if (typed === 0) return true;
const before = totalBalance.previous; const before = totalBalance.previous;
@@ -44,38 +45,83 @@ const submitMovesBalanceByTypedAmount = always(
`always` checks the formula at every step; `next` lets it compare the step before a submit to the step after. The guards narrow it to the one transition that matters, a submit that lands back on home, and the last line states the rule: the balance moved by exactly the typed amount. Double-submit moves it by twice that, and the formula is false. `always` checks the formula at every step; `next` lets it compare the step before a submit to the step after. The guards narrow it to the one transition that matters, a submit that lands back on home, and the last line states the rule: the balance moved by exactly the typed amount. Double-submit moves it by twice that, and the formula is false.
The null guard is not defensive clutter, it is the difference between a property and a false alarm. Read a balance you could not parse as `0` and the comparison becomes `0 - 0 === typed`, which is false at every healthy submit. A reading you do not have is not evidence, so the property declines to judge. The real spec guards the same way against a balance too large for exact integer arithmetic. The window guard is the difference between a property and a false conviction. `totalBalance.previous` is the last total we read, not the total as of the last transaction, so the two numbers being compared can straddle any number of commits: a real run produced a delta of 13000 against a typed 19600, because the window held a double-submit's two 19600 debits and an unrelated 26200 credit. A delta like that is not evidence about the amount typed into any one submit. Exactly one submit action in the window still catches the bug, because the double tap is a single action.
The null guard is not defensive clutter either. Read a balance you could not parse as `0` and the comparison becomes `0 - 0 === typed`, which is false at every healthy submit. A reading you do not have is not evidence, so the property declines to judge. The real spec guards the same way against a balance too large for exact integer arithmetic.
The values it reads come from extractors, which pull state out of the UI tree once per step: The values it reads come from extractors, which pull state out of the UI tree once per step:
```ts ```ts
const route = extract<string | null>("route", s => { const SCREENS = {
if (s.ax.find({ testTag: "AddTransactionScreen" })) return "add-transaction"; login: "LoginScreen",
if (s.ax.find({ testTag: "HomeScreen" })) return "home"; "add-account": "AddAccountScreen",
// ...other screens "add-transaction": "AddTransactionScreen",
return null; ledger: "LedgerScreen",
}); home: "HomeScreen",
} as const;
type Route = keyof typeof SCREENS;
const totalBalance = extract("totalBalance", s => // The screen this frame shows, or null when it does not show exactly one.
s.ax.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }]) // Android's hierarchy dump carries the outgoing and the incoming screen
.reduce((sum, c) => sum + parseDollarCents(c.find({ testTag: "AccountBalance" })?.text), 0)); // together on better than one frame in five, and such a frame is a navigation
// transition: evidence about neither screen. Ranking the markers and returning
// the first one found is how a spec convicts itself on a half-drawn screen.
const routeOf = (s: State): Route | null => {
let shown: Route | null = null;
for (const [name, tag] of Object.entries(SCREENS) as [Route, string][]) {
if (!s.ax.find({ testTag: tag })) continue;
if (shown !== null) return null;
shown = name;
}
return shown;
};
const route = extract<Route | null>("route", routeOf);
// Home's own TOTAL BALANCE node, which the app computes over every account.
// Summing the AccountCard balances reads only the cards laid out inside the
// viewport, and a card clipped at the bottom edge looks exactly like money
// moving. Off Home there is nothing to read, so the last total we did read is
// carried forward and `previous` and `current` stay on the same scale.
let lastHomeTotal: number | null = null;
const totalBalance = extract<number | null>("totalBalance", s => {
if (routeOf(s) !== "home") return lastHomeTotal;
const total = parseDollarCents(
s.ax.find([{ testTag: "HomeScreen" }, { testTag: "TotalBalance" }])?.text);
if (total === null) return null; // unreadable is unknown; the carrier keeps its value
lastHomeTotal = total;
return total;
});
``` ```
Every Folio screen and control carries a `testTag`. Compose exposes it as the resource-id on Android and the accessibility identifier on iOS, so one selector resolves on both. `extract` runs against the live tree each step; properties and actions read `.current` and `.previous`, never the raw state. Every Folio screen and control carries a `testTag`. Compose exposes it as the resource-id on Android and the accessibility identifier on iOS, so one selector resolves on both. `extract` runs against the live tree each step; properties and actions read `.current` and `.previous`, never the raw state. An extractor may not read another extractor's handle, which is why `totalBalance` calls `routeOf` rather than `route.current`.
A second property states what new accounts must look like: a freshly created account starts at zero. A second property states what new accounts must look like: a freshly created account starts at zero. The work is in naming the account it judges.
```ts ```ts
const newAccountBalanceIsZero = always( const newAccountBalanceIsZero = always(
next(() => { next(() => {
const before = new Set((accounts.previous ?? []).map(a => a.name)); if (route.current !== "home") return true;
return accounts.current if (!isAddAccountSubmitTap(lastAction.current)) return true;
.filter(a => !before.has(a.name)) const typed = accountNameField.previous?.text?.trim();
.every(a => a.balance === 0); const before = accounts.previous ?? null;
const after = accounts.current;
if (!typed || before === null || after === null) return true;
// The only card attributable to a creation is the one named what the fuzzer
// typed, on the step its submit landed. Anything else that turned up is a
// card that scrolled into view, not an account that came into existence.
const matches = after.filter(a => a.name.endsWith(typed));
if (matches.length !== 1) return true;
const created = matches[0];
if (before.some(a => a.name === created.name)) return true;
return created.balance === null || created.balance === 0;
}) })
); );
``` ```
Diffing the two account lists and judging whatever is new is the version that reads better and does not work: Home lists the accounts that fit the viewport, so a card that scrolls in is indistinguishable from an account that was just created. That version convicted this property on android over a Travel account holding $24,112.00.
A third property, `submitCommitsOneTransactionPerAction`, states the same bug without arithmetic: over any window, no more transactions may be committed than there were submit actions. It needs no amount and no float comparison, so it survives a window of any width, and it does most of the detecting in practice.
## Reaching the screens that matter ## Reaching the screens that matter
A fuzzer that pokes at random never logs in, and never reaches a transaction form. The spec gives sanderling enough to drive the real flows, no more. A fuzzer that pokes at random never logs in, and never reaches a transaction form. The spec gives sanderling enough to drive the real flows, no more.
@@ -129,7 +175,7 @@ export const actionsRoot = weighted(
); );
``` ```
That is the whole input. Two invariants, a way in, and a weighted sense of where to spend time. Nothing here names the bug. That is the whole input. Three invariants, a way in, and a weighted sense of where to spend time. Nothing here names the bug.
## What the run does ## What the run does