ci: dispatch workflows for folio and the replay ui (#73)

* feat(runner): stop the step loop at the first violation on request

* feat(testrun): report violations as a typed error under exit-on-violation

* feat(cli): add --exit-on-violation and exit 2 when it fires

* docs(cli): document --exit-on-violation, --max-steps, and exit codes

* fix(web): enumerate and query across shadow roots in both producers

* test(chrome): compare both producers on a shadow-dom parity page

* test(browser): drive a canvas-under-shadow-root fixture end to end

* fix(web): select the focused field inside a shadow root before typing

* fix(web): report the pathname as the screen when there is no hash route

* fix(web): settle on dom quiescence instead of returning at body ready

* feat(replay-ui): add data-testid hooks the dogfood spec drives

* feat(replay-ui): add the dogfood spec sanderling runs against the replay ui

* fix(replay-ui): scope the screenshot property to the named state panel

* chore(make): add per-platform sanderling build targets

* ci: add dispatch workflows for folio and the replay ui

* docs: describe the dispatch workflows and how to read a failure

* ci(folio): give the ios leg its jdk, android sdk, just, and a clean app start

* refactor(web): use max for the settle budget

* ci: pin calibrated seeds, skip the flaky ios reinstall, bound every job

* ci: authenticate and pin the buf setup step

the anonymous release download hit the shared runner ip rate limit and
failed the job with 'socket hang up' after three retries.

* docs: record that canvas apps need a dom proxy to be text-fuzzable

* fix(ios): bound lifecycle rpcs and claim the target device

a launch the simulator rejects sent the xctest session into a recovery
chain that answered minutes late or never, and the rpc had no deadline,
so the run hung with no trace and no error. also take a per-udid flock:
a second run's reinstall lands under the first's live automation session
and wedges it.

* docs(ci): correct the ios hang wording and note the device lock

* refactor(verifier): derive the lastAction shape from one field list

both hosts must show a spec the same lastAction. one ordered list now
feeds the goja object and the json the web host installs, so they
cannot drift.

* fix(web): install lastAction in the page before extractors read it

state.lastAction was hardcoded null on web, so every property reading it
was silently vacuous: a correct property passed without ever firing.

* fix(web): carry element identity on actions and fix findAll on paths

an action's target was coordinates only, so a property matching on which
element was acted upon could never fire. ax.findAll([a,b]) also returned
nothing on web.

* fix(chrome): wait out a route transition before sampling facts

the tree stays byte-identical and quiet across a cross-fade, so both the
quiet timer and the unchanged-tree escape called it settled mid-flight
and extractors read two screens at once.

* fix: bound the pre-run app launch

launch happens before the runner starts, so --duration never covered it
and a wedged driver hung with no trace and no error.

* fix(folio): read balances from merged cards and treat unreadable as unknown

compose for web merges the whole accountcard subtree, so the balance
child never exists there and every card parsed as 0. the property then
compared 0 to 0 and fired on any submit, which is a false positive
generator. unknown is now null and null is vacuously true.

* test(folio): cover merged-card parsing and unknown balances

* ci(folio): make web an expect-the-bug leg

the web runtime can observe the double submit now, so the health gate
understates it. seed 1 finds it at step 109, 3 runs out of 3.

* docs(ci): explain why a submit tap landing on home is the bug

* fix(ios): read a StaticText's label as its text

AXValue was the only source for text, but a StaticText carries its
string in AXLabel, so nothing on screen had .text on ios: a spec reading
it saw everything on android and nothing here.

* docs(ci): correct the calibrated step ranges

* fix(folio): stop convicting on arithmetic float64 cannot hold

past 2^53 cents the gap between representable values is 128, so a real
1600-cent move reads back as something else and the equality is false
for a healthy submit as readily as a double one. also match parseCents:
a sign or an oversized amount is rejected, not read as an amount.

* test(folio): pin the safe-integer guard and its boundary

* docs: stop teaching the zero-default that caused a false alarm

* docs: write down the silent-vacuity failure modes

* feat(folio): tag the home total and the card transaction count

the total was the only untagged node on the screen, so the spec had to
sum cards and a clipped card broke the sum.

* fix(folio): read the app's own total and refuse contaminated windows

summing cards went null when one was clipped, and the null poisoned the
carrier for the rest of the run. the balance window also spanned every
transaction since the last home visit, so the property convicted on
deltas it could not attribute: the old web witness was 3.16x the typed
amount, not 2x.

* test(folio): pin the window rules and the count invariant

* 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.

* test(folio): cover transition frames, card readings and creation

* fix(folio): only disambiguate counts that came from merged text

the equal-length digit rule exists because web merges the card and an
account named -1 makes '12' ambiguous. a dedicated count node has
nothing to disambiguate, so applying it there threw away real evidence.

* ci(folio): pin the recalibrated seeds and drop android to a health gate

web 3 and ios 7 convict 3 runs out of 3 with an exactly 2x witness.
android convicts 2 in 5 because the same seed does not walk the same
trajectory there, so it proves the app runs instead.

* docs(ci): describe the two properties and why android cannot convict

* fix(android): wait out a route cross-fade before snapshotting

the dump could hold two screens at once, and the runner refuses to act
on such a tree, so a quarter of android steps applied no action and the
count varied per run: the same seed never walked the same trajectory.
the ios companion and the chrome driver already do this.

* ci(folio): let the android leg run far enough to see its conviction

* docs: only the repo owner merges

* ci(folio): a thrown predicate is not a conviction

exit 2 means the run recorded a violation, and a predicate that throws
is recorded as one too. so was newAccountBalanceIsZero, an unrelated
property in the same spec. the gate read the exit code and went green
with detection dead.

* ci: install idb-companion from its tap and stop interpolating inputs

idb-companion is not in homebrew-core, so the ios leg died before it
built anything. replay-ui expanded dispatch inputs into the shell.

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

* test(sidecar): pin that a slow read counts toward the stability streak

* fix(web): read the page's extractors only on steps that count

the page advances the spec's carriers when it evaluates, but the runner
applied the result only on non-transitional steps. a discarded step
moved the window forward anyway, so the next accepted pair bracketed two
transactions while counting one submit, and convicted a healthy app.
extractor errors now fail the run instead of leaving goja's values in
current against v8's in previous.

* fix(chrome): anchor the transition deadline when the dom goes quiet

it was anchored at script start, so a page that churned past the window
reached the check already expired and returned mid cross-fade. the
driver now publishes the idle timeout it needs, since the caller's 1s
could never spend the 800ms window.

* fix(web): fail on a partial extractor override

same mixed-producer hazard as the install error: some extractors hold
the page's value and the rest hold goja's, and a property comparing
across that split fires on a healthy app.

* docs: six of seven, the seventh is the stock property

* fix(folio): drop a name two cards answer to

homeTxnCountsOf keyed on the account name and let the last card win, so
two accounts the fuzzer named the same collapsed into one entry. a
reading that saw one Travel card and a later one that saw both then
subtracted two different accounts' counts, and
submitCommitsOneTransactionPerAction convicted a healthy app of
double-submitting. it is a gated property in folio-run.sh, so that reads
as "found the submit bug" over a card scrolling into view.

same rule createdAccountHasNonZeroBalance already applies: a name
nothing can attribute is no evidence. counted over every card, since an
unreadable twin spoils the identity too.

* perf(folio): read each frame once

every extractor asked routeOf, and routeOf does five ax.find calls. on
web each find walks the document and every shadow root beneath it, so
the spec cost 110 tree walks a step; homeCards was parsed four times
over. now 5 and once.

keyed on the identity of the state object because both hosts build a new
one per step and hand that one object to every getter, so it cannot
outlive its frame. holding the reference is what keeps that true rather
than likely.

* fix(web): keep an undefined reading's index through JSON

json has no undefined, so an extractor whose getter returned one had its
whole index dropped by JSON.stringify. that index then kept goja's
dump-derived value while its neighbours held the page's, and a property
comparing previous to current across the split fires on a healthy app.
folio has nine on(route, tag) extractors, so this was most extractors on
most steps.

each reading is wrapped in a {value} envelope: the drop now happens
inside the entry, and an absent value means the getter returned
undefined, which is what the goja host records for the same getter. a
json null would instead claim it returned null and x.current ===
undefined would answer differently on the two hosts.

* feat(verifier): report the registered extractor count

the web path needs it to check the page sent one reading per extractor.

* fix(runner): fail when the page reports fewer readings than extractors

the comment here already claimed a partial override was fatal. it was
not: the skipped check only catches indices outside the extractor list,
so a page reporting values for some extractors and not others left the
rest holding goja's reading of the dump with nothing said.

* test(browser): drive an undefined reading through the whole web path

four layers carry it: the page's envelope, the driver's unwrap, the
runner's count check and the verifier's decode. each has a unit test and
only a run proves they compose. goes red both ways, decoding an absent
value as null and dropping the envelope.

* fix(web): offer the aria roles a user activates

only role=button was in the tappable set, so link, checkbox, radio,
switch, tab, option, the menuitems and treeitem were invisible to the
enumeration however plain the control looked. the replay ui builds its
step rows as <li role="option">, and the spec dogfooding it had to
hand-write an action to reach them because no default verb could see a
single row.

both producers build the set from the same role list, since the parity
test compares them element by element.

* test(browser): tap a role-based control end to end

every control on the page is an <li role="option">, the shape the
replay ui gives its step rows, and the spec carries no action of its
own: the property firing is the evidence the default enumeration offered
a tap on one.

* fix(web): read aria-disabled as disabled

the enabled fact came off the disabled property, which only real form
controls have. it reads undefined on the role-based controls the
tappable set now covers, so every one of them looked enabled however
plainly it was marked otherwise, and the fuzzer would spend actions on
inert ones.

both producers answer the same two ways, and the parity fixture carries
a disabled row so the comparison covers it: reverting one side alone
names the element and the fact.

* docs(replay-ui): the enumeration reaches step rows now

the comment said role="option" is not in the tappable selector set,
which stopped being true a few commits ago. selectAStep stays, for the
reason the tab weight below it stays: one row among the page's clickable
elements is a thin chance, and both step-facing properties go vacuous on
a run that never selects one.

* test(runner): bound the last-action test by steps, not wall clock

100ms of wall clock against an assertion that two steps ran fatals under
load with "the web path never installed it", which reads as a
regression. every sibling test in the package uses a long duration and
MaxSteps.

* ci: run the kotlin tests in make test

RouteTransitionTest and the stability poll cover the android settle and
nothing in ci ran them. :sidecar:test needs no android sdk, checked by
running it with ANDROID_HOME pointed at nothing.

* fix(sidecar): measure the stability streak as observed quiet

parameterising pollUntilStable also moved the clock to the start of the
read that opened a run of identical snapshots, so a read's own duration
counted as quiet. the pre-existing caller polls a real uiautomator dump:
at 400ms a read, 750ms of required quiet became 250ms of observed quiet
and the poll settled in two reads instead of four.

the parameters stay, the semantics go back.

* test(sidecar): pin the transition cap by driving it

it asserted 1500 >= 700 + 300, two constants, which can only fail if
someone edits a constant. it now drives awaitSettledTree against a fade
that lands after 700ms and asserts it hands back the settled tree before
the cap. cut the cap to 1000 and it goes red.

* ci: pin buf-setup-action to a commit

it takes a token now, so a floating tag is a token handed to whatever
that tag moves to. note v1 there is a branch, not a tag, so the ref
lookup that resolves it is matching-refs/heads/v1.

* ci: declare least-privilege permissions

none of the three declared any, so each got the repository default.
release.yml and docs.yml already do this. all three only check out,
build, test and upload artifacts.

* ci: fail fast when a server never comes up

the readiness loops fell through silently after 30 tries, so a server
that never started surfaced as an opaque driver failure minutes later.
each now says what did not answer and on which port.

* ci(folio): a missing trace is not a verdict

with no trace the android gate ran its grep against ./trace.jsonl and
reported "never reached AddTransactionScreen, so it never got past
login", which is not what happened. the web and ios branches had the
same misdiagnosis on exit 0.

same class, one line up: the classifier's own failure was swallowed, so
with the evidence reader dead the gate printed a healthy run and exited
0.

* ci(replay-ui): skip a run directory with no trace

the summarise step is if: always(), and under github's bash -eo pipefail
an unmatched glob stays literal, the redirect fails, pipefail carries it
into the assignment and -e kills the step. so a failed fuzz run went red
twice, once for the real reason.
This commit is contained in:
pj authored and GitHub committed 2026-08-15 13:01:27 +05:30
1 parent 1f71e052d7
commit b02e86b2e3
70 files changed
+6656 -430

No files matched your search

@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
cardAccountName,
cardBalanceText,
parseDollarCents,
} from "../../../examples/folio/sanderling/predicates.ts";
// Android and iOS expose AccountName/AccountBalance as their own nodes. Web
// merges the card into one node whose text is initials + name +
// "N transaction(s)" + balance, with no separator between the parts. Both
// shapes have to land on the same balance, and the name has to stay usable as
// an identity key.
const balanceOf = (cardText: string) =>
parseDollarCents(cardBalanceText({ childText: undefined, cardText }));
// Renders a card the way HomeScreen.kt does, so a test states the account and
// lets the fixture do the concatenating.
const card = (initials: string, name: string, count: number, balance: string) =>
`${initials}${name}${count === 1 ? "1 transaction" : `${count} transactions`}${balance}`;
test("structured child wins over the card text", () => {
assert.equal(
cardBalanceText({ childText: "$118.00", cardText: "SASavings1 transaction$118.00" }),
"$118.00",
);
assert.equal(
cardAccountName({ childText: "Savings", cardText: "SASavings1 transaction$118.00" }),
"Savings",
);
});
test("merged card text: balance is the amount at the end, not scraped digits", () => {
// The naive reading, text.replace(/[^0-9]/g, ""), absorbs the 12 of
// "12 transactions" and returns 12258900.
assert.equal(balanceOf("INInvestments12 transactions$2,589.00"), 258900);
});
test("merged card text: singular transaction label", () => {
assert.equal(balanceOf("SASavings1 transaction$118.00"), 11800);
});
test("merged card text: negative balance keeps its sign", () => {
assert.equal(cardBalanceText({ childText: undefined, cardText: "TRTravel3 transactions-$45.50" }), "-$45.50");
assert.equal(balanceOf("TRTravel3 transactions-$45.50"), -4550);
});
test("structured child: negative balance keeps its sign", () => {
assert.equal(parseDollarCents(cardBalanceText({ childText: "-$45.50", cardText: undefined })), -4550);
});
test("zero-balance card is 0, not unknown", () => {
assert.equal(balanceOf("EFEmergency Fund0 transactions$0.00"), 0);
assert.equal(balanceOf("Aa0 transactions$0.00"), 0);
});
// The trap a lazy balance regex falls into: a name ending in digits runs
// straight into the transaction count, so only anchoring the amount at the end
// of the string gets it right.
test("name ending in digits does not leak into the balance", () => {
assert.equal(balanceOf(card("T2", "Travel 2024", 12, "$75.00")), 7500);
assert.equal(balanceOf(card("T2", "Travel 2024", 0, "$0.00")), 0);
assert.equal(balanceOf(card("20", "2024", 3, "-$1,234.56")), -123456);
});
// The account key only has to be stable and per-account. newAccountBalanceIsZero
// reads it as a set member: a key that drifted as an account's transaction
// count grew would make an existing account look brand new, and the property
// would fire on it for holding the balance it just earned.
test("account key is stable as the transaction count and balance move", () => {
const cards: [string, string][] = [
["CH", "Checking"],
["T2", "Travel 2024"],
["A2", "Account 2"],
["Aa", "a"],
["?", ""],
["5T", "5 transactions"],
["X9", "x9"],
];
for (const [initials, name] of cards) {
const keys = new Set<string>();
for (let count = 0; count <= 130; count++) {
keys.add(cardAccountName({
childText: undefined,
cardText: card(initials, name, count, `$${count * 7}.50`),
}));
}
assert.equal(keys.size, 1, `key for ${JSON.stringify(name)} drifted: ${[...keys].join(", ")}`);
}
});
test("account keys are distinct across the accounts a run creates", () => {
const names: [string, string][] = [
["CH", "Checking"],
["SA", "Savings"],
["TR", "Travel"],
["EF", "Emergency Fund"],
["IN", "Investments"],
["T2", "Travel 2024"],
["A2", "Account 2"],
["A1", "Account 12"],
["Aa", "a"],
];
const keys = names.map(([initials, name]) =>
cardAccountName({ childText: undefined, cardText: card(initials, name, 4, "$9.00") }));
assert.equal(new Set(keys).size, names.length);
});
// elementHandle in the web runtime truncates node text at 200 characters, so a
// long account name (the input corpus types 4096 "a"s) pushes the balance off
// the end of the string. That balance is unknown, and unknown must not read as
// zero: newAccountBalanceIsZero passes an unknown balance rather than
// convicting a card it could not read.
test("card text truncated past the balance reads as unknown, not zero", () => {
const cardText = "AA" + "a".repeat(198);
assert.equal(cardBalanceText({ childText: undefined, cardText }), undefined);
assert.equal(balanceOf(cardText), null);
});
test("empty and missing text are unknown, not zero", () => {
assert.equal(parseDollarCents(undefined), null);
assert.equal(parseDollarCents(""), null);
assert.equal(parseDollarCents("no digits here"), null);
assert.equal(parseDollarCents("$12"), null);
assert.equal(cardBalanceText({ childText: undefined, cardText: undefined }), undefined);
assert.equal(cardAccountName({ childText: undefined, cardText: undefined }), "");
});
// The two accessibility shapes have to read the same per-card balance, which is
// what the accounts extractor compares. The Home total is no longer a sum of
// these: it is the app's own TOTAL BALANCE node (see folio-total-balance.test.ts).
test("merged card text and a structured child give the same balance", () => {
const merged = ["INInvestments12 transactions$2,589.00", "Aa0 transactions$0.00"].map(cardText =>
cardBalanceText({ childText: undefined, cardText }));
assert.deepEqual(merged, ["$2,589.00", "$0.00"]);
assert.deepEqual(merged.map(parseDollarCents), [258900, 0]);
});
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
committedTransactionsExceedSubmits,
countSubmitsInWindow,
homeAccountsOf,
homeTxnCountsOf,
readHomeCards,
} from "../../../examples/folio/sanderling/predicates.ts";
import type {
CardReading,
HomeCardReading,
TxnCount,
} from "../../../examples/folio/sanderling/predicates.ts";
const card = (name: string, balance: number | null, count: TxnCount | undefined) => ({
name,
balance,
count,
});
test("a laid-out card list reads as an account list and a count map", () => {
const cards = [card("Checking", 0, "0"), card("Travel", 2411200, "1")];
assert.deepEqual(homeAccountsOf(cards), [
{ name: "Checking", balance: 0 },
{ name: "Travel", balance: 2411200 },
]);
assert.deepEqual(homeTxnCountsOf(cards), { Checking: "0", Travel: "1" });
});
// Android draws Home's own node a frame or two before its list, so `findAll`
// over the cards comes back empty while the screen already claims to be Home.
// "No cards on screen" is not "no accounts".
test("Home with nothing laid out yet is unknown, not empty", () => {
assert.equal(homeAccountsOf([]), null);
assert.equal(homeTxnCountsOf([]), null);
});
test("a card with no readable name or count is left out of the map", () => {
assert.deepEqual(
homeTxnCountsOf([card("", 0, "3"), card("Checking", 0, undefined), card("Savings", 0, "2")]),
{ Savings: "2" },
);
});
test("every card unreadable leaves nothing to compare, which is unknown", () => {
assert.equal(homeTxnCountsOf([card("", 0, "3"), card("Checking", 0, undefined)]), null);
});
test("off Home the carrier is reported unchanged", () => {
const carried = { Checking: "3" };
assert.deepEqual(readHomeCards({ route: "ledger", reading: null, previousCarrier: carried }), {
value: carried,
carrier: carried,
fresh: false,
});
});
test("a readable list replaces the carrier and closes the window", () => {
assert.deepEqual(
readHomeCards({ route: "home", reading: { Checking: "5" }, previousCarrier: { Checking: "3" } }),
{ value: { Checking: "5" }, carrier: { Checking: "5" }, fresh: true },
);
});
// The poisoned carrier, the same defect readHomeTotalBalance was fixed for and
// this reading was not. An empty reading written into the carrier is handed
// straight back on every later off-Home step, so one un-laid-out Home turns the
// comparison into {} against {} for the rest of the run. Measured on android:
// counts_prev was {} at EVERY evaluation point of all 17 runs, which is a
// counting invariant that cannot fire at all.
test("an un-laid-out Home reports unknown but leaves the carrier intact", () => {
const carried = { Checking: "3", Savings: "1" };
assert.deepEqual(readHomeCards({ route: "home", reading: null, previousCarrier: carried }), {
value: null,
carrier: carried,
fresh: false,
});
});
// The trace the fix has to survive, stepped through the carrier and the window
// the spec holds. A double-submit commits two rows against one action, the
// Home it lands on has not drawn its list yet, and the counting invariant must
// still be able to see the pair once a real Home comes back.
function run(steps: { route: string | null; cards: CardReading[]; lastAction: unknown }[]) {
let carrier: Record<string, TxnCount> | null = null;
let submits = 0;
const out: { counts: Record<string, TxnCount> | null; submits: number }[] = [];
for (const step of steps) {
const reading: HomeCardReading<Record<string, TxnCount>> = readHomeCards({
route: step.route,
reading: homeTxnCountsOf(step.cards),
previousCarrier: carrier,
});
carrier = reading.carrier;
const window = countSubmitsInWindow({
previousCount: submits,
lastAction: step.lastAction as { kind?: string; on?: string } | null,
fresh: reading.fresh,
});
submits = window.next;
out.push({ counts: reading.value, submits: window.reported });
}
return out;
}
const idle = { kind: "Tap", on: "testTag:AccountCard" };
const doubleSubmit = { kind: "DoubleTap", on: "testTag:AddTransactionScreen > testTag:TxnSubmit" };
test("an un-laid-out Home no longer kills the counting invariant", () => {
const trace = run([
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: null },
{ route: "ledger", cards: [], lastAction: idle },
{ route: "home", cards: [], lastAction: doubleSubmit },
{ route: "ledger", cards: [], lastAction: idle },
{ route: "home", cards: [card("Checking", 0, "5")], lastAction: idle },
]);
// The un-laid-out Home is unknown for its own step, and the two steps after
// it get the last list anyone actually read rather than an empty one.
assert.deepEqual(trace[2]?.counts, null);
assert.deepEqual(trace[3]?.counts, { Checking: "3" });
assert.deepEqual(trace[4]?.counts, { Checking: "5" });
// The window it did not close still holds the double-submit, so one action
// against two committed rows is visible at the step that can compare them.
assert.equal(trace[4]?.submits, 1);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: trace[3]?.counts ?? null,
countsAfter: trace[4]?.counts ?? null,
submitsInWindow: trace[4]?.submits ?? 0,
}),
true,
);
});
// The other half of the pairing: the counts window has to close on the counts
// reading, not on the total's. A Home frame can render its footer total while
// its list is still empty, and a window that reset there would compare a pair of
// readings spanning submits it had already forgotten.
test("an un-laid-out Home does not close the counting window", () => {
const trace = run([
{ route: "home", cards: [card("Checking", 0, "3")], lastAction: null },
{ route: "ledger", cards: [], lastAction: doubleSubmit },
{ route: "home", cards: [], lastAction: doubleSubmit },
{ route: "home", cards: [card("Checking", 0, "7")], lastAction: idle },
]);
assert.equal(trace[3]?.submits, 2);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3" },
countsAfter: trace[3]?.counts ?? null,
submitsInWindow: trace[3]?.submits ?? 0,
}),
true,
);
});
+235
View File
@@ -0,0 +1,235 @@
import assert from "node:assert/strict";
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 account = (name: string, balance: number | null) => ({ name, balance });
// The property still has teeth: the account the fuzzer just asked for, holding
// money on the step its creation landed on Home, is a real violation.
test("an account created holding money is a violation", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
true,
);
});
test("a double-tapped create is judged the same way", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: { kind: "DoubleTap", on: "id:AddAccountSubmit" },
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
true,
);
});
test("an account created empty is what the app is supposed to do", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 0)],
}),
false,
);
});
test("a balance that could not be read is not evidence", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", null)],
}),
false,
);
});
// The false positives this replaces. Home lists the accounts that fit the
// viewport, so an account arrives in a later reading for reasons that have
// nothing to do with being created: the list scrolled, a clipped card finished
// laying out, or (before the route fix) the earlier reading came off a
// half-rendered Home mid-transition. Measured on android: an existing Travel
// holding $24,112.00 and an existing Savings holding $429,585.00, convicted for
// coming into view.
test("an account scrolling into view is not an account being created", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: idle,
typedName: "Travel",
before: [account("Emergency Fund", 461012300), account("Checking", 0)],
after: [
account("Emergency Fund", 461012300),
account("Checking", 0),
account("Travel", 2411200),
account("Savings", 0),
],
}),
false,
);
});
test("nor is one that appears with no action at all behind it", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: null,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 2411200)],
}),
false,
);
});
// Even on the creation step, the only card judged is the one that answers to
// the name the fuzzer typed. A card that came into view alongside it is still
// just a card that came into view.
test("a funded account arriving beside the created one is not judged", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Savings",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Savings", 0), account("Travel", 2411200)],
}),
false,
);
});
test("off Home there is no reading to judge", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "ledger",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
false,
);
});
test("a transition frame names no route, so nothing is judged there either", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: null,
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
false,
);
});
test("an unknown reading on either side is not evidence", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: null,
after: [account("Travel", 5000)],
}),
false,
);
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: null,
}),
false,
);
});
// defaultActions types edge-case text into the name field, and an empty name is
// not a name we can find a card by.
test("an empty typed name attributes nothing", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: " ",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
false,
);
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: undefined,
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000)],
}),
false,
);
});
// Web merges the card into one node whose text opens with the avatar initials,
// so the identity key carries them: "TRTravel" is the card for "Travel".
test("the merged web key still matches the name that was typed", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("CHChecking", 0)],
after: [account("CHChecking", 0), account("TRTravel", 5000)],
}),
true,
);
});
// Two cards answering to one typed name leave the appearance unattributable:
// the fuzzer creates duplicates from a five-name list, and the tree has been
// seen exposing the same card twice on a transition frame.
test("two cards matching the typed name are not attributable to the creation", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0)],
after: [account("Checking", 0), account("Travel", 5000), account("MyTravel", 900)],
}),
false,
);
});
test("a card that was already there is not a card that was just created", () => {
assert.equal(
createdAccountHasNonZeroBalance({
route: "home",
lastAction: created,
typedName: "Travel",
before: [account("Checking", 0), account("Travel", 2411200)],
after: [account("Checking", 0), account("Travel", 2411200)],
}),
false,
);
});
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { oncePerFrame, routeOfFrame } from "../../../examples/folio/sanderling/predicates.ts";
// oncePerFrame is what stops the folio spec re-walking the accessibility tree
// once per extractor, and the whole of its safety is that the state object is a
// fresh one every step (goja's stateObject, web's buildState). These tests pin
// both halves: the same frame is read once, a different frame is a different
// answer. A cache that outlived its frame would freeze every reading the spec
// takes and the properties over them would go quietly vacuous.
const SCREENS = { login: "LoginScreen", home: "HomeScreen" } as const;
interface Frame {
present: readonly string[];
finds: number;
}
const frameShowing = (...present: readonly string[]): Frame => ({ present, finds: 0 });
const routeOf = oncePerFrame((frame: Frame) =>
routeOfFrame(SCREENS, tag => {
frame.finds++;
return frame.present.includes(tag);
}),
);
test("one frame is walked once, however many readings ask", () => {
const home = frameShowing("HomeScreen");
assert.equal(routeOf(home), "home");
assert.equal(routeOf(home), "home");
assert.equal(routeOf(home), "home");
assert.equal(home.finds, 2);
});
test("a new frame is a new answer", () => {
const home = frameShowing("HomeScreen");
const login = frameShowing("LoginScreen");
assert.equal(routeOf(home), "home");
assert.equal(routeOf(login), "login");
assert.equal(login.finds, 2);
});
test("a transition frame is not answered off the frame before it", () => {
assert.equal(routeOf(frameShowing("HomeScreen")), "home");
assert.equal(routeOf(frameShowing("HomeScreen", "LoginScreen")), null);
});
test("returning to an earlier frame re-reads it", () => {
const home = frameShowing("HomeScreen");
routeOf(home);
routeOf(frameShowing("LoginScreen"));
assert.equal(routeOf(home), "home");
assert.equal(home.finds, 4);
});
// What makes memoizing the card list worth more than memoizing the route: the
// three readings taken off it share one parse instead of three.
test("a frame's reading is handed back by identity", () => {
const cardsOf = oncePerFrame((frame: Frame) => frame.present.map(tag => ({ tag })));
const home = frameShowing("HomeScreen");
assert.equal(cardsOf(home), cardsOf(home));
assert.notEqual(cardsOf(home), cardsOf(frameShowing("HomeScreen")));
});
@@ -13,6 +13,7 @@ test("single submit: delta matches typed amount", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1500,
@@ -26,6 +27,7 @@ test("double submit: delta is twice the typed amount, fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
@@ -39,6 +41,7 @@ test("DoubleTap kind also caught when delta exceeds typed amount", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
@@ -52,6 +55,7 @@ test("wrong action kind: vacuous true even with mismatch", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "InputText", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
@@ -65,6 +69,7 @@ test("wrong target: vacuous true even with mismatch", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: "testTag:LoginScreen > testTag:LoginSubmit" },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 1000,
@@ -78,6 +83,7 @@ test("null lastAction: vacuous true", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: null,
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
@@ -91,6 +97,7 @@ test("zero typedAmount: vacuous true", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 0,
prevTotalBalance: 1000,
currTotalBalance: 1500,
@@ -104,6 +111,7 @@ test("selector as object: coerced safely and TxnSubmit detected", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "TxnSubmit" } },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
@@ -117,6 +125,7 @@ test("selector as object without TxnSubmit: vacuous true", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: { testTag: "LoginSubmit" } },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 1000,
@@ -130,6 +139,7 @@ test("raw whole-dollar input: single submit clears", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
currTotalBalance: 10000,
@@ -143,6 +153,7 @@ test("raw whole-dollar input: double submit fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("50"),
prevTotalBalance: 5000,
currTotalBalance: 15000,
@@ -156,6 +167,7 @@ test("decimal input from empty prior balance clears", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("5.50"),
prevTotalBalance: 0,
currTotalBalance: 550,
@@ -169,6 +181,7 @@ test("DoubleTap kind with raw whole-dollar input fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("100"),
prevTotalBalance: 0,
currTotalBalance: 20000,
@@ -182,6 +195,7 @@ test("route gate: ledger landing with stale carrier is skipped", () => {
submitChangesBalanceByTypedAmount({
route: "ledger",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 0,
@@ -195,6 +209,7 @@ test("route gate: add-transaction landing with double-submit delta is skipped",
submitChangesBalanceByTypedAmount({
route: "add-transaction",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 10000,
@@ -208,6 +223,7 @@ test("route gate: null route is skipped", () => {
submitChangesBalanceByTypedAmount({
route: null,
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 0,
@@ -221,6 +237,7 @@ test("route gate: home landing with matching delta passes", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 5000,
@@ -234,6 +251,7 @@ test("route gate: home landing with double-insert delta fires", () => {
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 5000,
prevTotalBalance: 0,
currTotalBalance: 10000,
@@ -241,3 +259,246 @@ test("route gate: home landing with double-insert delta fires", () => {
false,
);
});
// Precision. Cents are integers in float64 here, so the equality only means
// something while every number involved is exactly representable. The app takes
// any amount that fits a Kotlin Long, and an iOS run reached a balance around
// 1e18 cents, where representable values sit 128 apart: the delta of a
// perfectly healthy single submit no longer reads back as the typed amount.
const HUGE_BALANCE = 999999999999999900;
test("above 2^53 the arithmetic itself is wrong, which is why the guard exists", () => {
assert.notEqual(Math.abs(HUGE_BALANCE + 1600 - HUGE_BALANCE), 1600);
});
test("above 2^53 a healthy single submit is not reported", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
currTotalBalance: HUGE_BALANCE + 1600,
}),
true,
);
});
test("above 2^53 a double-submit delta is not reported either", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
currTotalBalance: HUGE_BALANCE + 3200,
}),
true,
);
});
test("an unreadable previous balance above 2^53 is not evidence", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1600,
prevTotalBalance: HUGE_BALANCE,
currTotalBalance: 5000,
}),
true,
);
});
// A typed amount past the safe range cannot be compared either. parseTypedAmount
// returns 0 for those now, but the predicate takes the number from its caller
// and must not convict on one it cannot hold.
test("typed amount above 2^53 is not evidence", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 1e23,
prevTotalBalance: 0,
currTotalBalance: 0,
}),
true,
);
});
// The boundary, from both sides. MAX_SAFE_INTEGER still gets judged; one cent
// more is where counting stops being exact.
test("boundary: a double submit landing exactly on MAX_SAFE_INTEGER still fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 4503599627370495,
prevTotalBalance: 0,
currTotalBalance: 9007199254740990,
}),
false,
);
});
test("boundary: a single submit landing exactly on MAX_SAFE_INTEGER passes", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 9007199254740991,
prevTotalBalance: 0,
currTotalBalance: 9007199254740991,
}),
true,
);
});
test("boundary: one cent past MAX_SAFE_INTEGER stops being evidence", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 4503599627370496,
prevTotalBalance: 0,
currTotalBalance: 9007199254740992,
}),
true,
);
});
// The guard covers the balances and the typed amount, not their difference: two
// safe balances subtract exactly whenever the result could have matched a safe
// typed amount, so a mismatch here is real and must still be reported.
test("a large but exact difference between safe balances still fires", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 500,
prevTotalBalance: -9007199254740991,
currTotalBalance: 9007199254740991,
}),
false,
);
});
// The 21-digit corpus amount end to end: the app refuses it, so nothing moves,
// and the property must stay quiet rather than demand a 1e23-cent move.
test("21-digit typed amount with an unmoved balance is not a violation", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: parseTypedAmount("999999999999999999999"),
prevTotalBalance: 220900,
currTotalBalance: 220900,
}),
true,
);
});
// Freshness. prevTotalBalance is the last total we READ, so the window between
// it and now can hold more than one submit's transactions. A delta measured
// over such a window is not evidence about the amount typed into any one of
// them, and the android run that produced a 13000 delta against a typed 19600
// is what that looks like: the window held a double-submit's two 19600 debits
// and an unrelated 26200 credit.
test("freshness: two submits in the window is vacuous, not a conviction", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 2,
typedAmount: 19600,
prevTotalBalance: 0,
currTotalBalance: -13000,
}),
true,
);
});
test("freshness: two submits cannot convict even on a clean 2x delta", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 2,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
}),
true,
);
});
// The boundary of the rule, from both sides. One submit is the only window the
// property judges: zero means the total moved without a submit landing in it
// (nothing to attribute the move to), and two or more means the move is shared.
test("freshness boundary: exactly one submit is the window that convicts", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "DoubleTap", on: submitOn },
submitsInWindow: 1,
typedAmount: 19600,
prevTotalBalance: 0,
currTotalBalance: -39200,
}),
false,
);
});
test("freshness boundary: one submit with a healthy 1x delta still passes", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 1,
typedAmount: 19600,
prevTotalBalance: 0,
currTotalBalance: -19600,
}),
true,
);
});
test("freshness boundary: three submits is vacuous", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 3,
typedAmount: 500,
prevTotalBalance: 0,
currTotalBalance: 2500,
}),
true,
);
});
// A zero count would mean the step's own action was not counted as a submit,
// which contradicts the action gate above it. Guard it anyway: a window with no
// submit in it explains no balance move.
test("freshness boundary: a window with no submit in it is vacuous", () => {
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: { kind: "Tap", on: submitOn },
submitsInWindow: 0,
typedAmount: 500,
prevTotalBalance: 1000,
currTotalBalance: 2000,
}),
true,
);
});
+151
View File
@@ -0,0 +1,151 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
countSubmitsInWindow,
isTxnSubmitTap,
readHomeTotalBalance,
} from "../../../examples/folio/sanderling/predicates.ts";
const submitOn = "testTag:AddTransactionScreen > testTag:TxnSubmit";
test("a tap on TxnSubmit is a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "Tap", on: submitOn }), true);
});
test("a double-tap on TxnSubmit is ONE commit action, not two", () => {
const window = countSubmitsInWindow({
previousCount: 0,
lastAction: { kind: "DoubleTap", on: submitOn },
fresh: true,
});
assert.equal(window.reported, 1);
});
test("a selector object naming TxnSubmit is a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "Tap", on: { testTag: "TxnSubmit" } }), true);
});
test("typing into the amount field is not a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "InputText", on: submitOn }), false);
});
test("tapping some other button is not a commit", () => {
assert.equal(isTxnSubmitTap({ kind: "Tap", on: "testTag:AddAccountSubmit" }), false);
});
test("no action at all is not a commit", () => {
assert.equal(isTxnSubmitTap(null), false);
});
test("a fresh Home reading closes the window and the next one starts empty", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: true }),
{ reported: 1, next: 0 },
);
});
test("landing off Home keeps the submit in the window for the next step", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 0, lastAction: { kind: "Tap", on: submitOn }, fresh: false }),
{ reported: 1, next: 1 },
);
});
test("a non-submit step neither adds to nor forgets the window", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "Tap", on: "testTag:AccountCard" }, fresh: false }),
{ reported: 1, next: 1 },
);
});
test("a second submit with no Home reading between them counts two", () => {
assert.deepEqual(
countSubmitsInWindow({ previousCount: 1, lastAction: { kind: "DoubleTap", on: submitOn }, fresh: true }),
{ reported: 2, next: 0 },
);
});
// The two traces the freshness rule exists to tell apart, driven step by step
// through the same pair of carriers the spec holds.
function run(steps: { route: string | null; totalText?: string; lastAction: unknown }[]) {
let carrier: number | null = null;
let submits = 0;
const out: { total: number | null; submits: number }[] = [];
for (const step of steps) {
const reading = readHomeTotalBalance({
route: step.route,
totalText: step.totalText,
previousCarrier: carrier,
});
carrier = reading.carrier;
const window = countSubmitsInWindow({
previousCount: submits,
lastAction: step.lastAction as { kind?: string; on?: string } | null,
fresh: reading.fresh,
});
submits = window.next;
out.push({ total: reading.value, submits: window.reported });
}
return out;
}
const idle = { kind: "Tap", on: "testTag:AccountCard" };
const submit = { kind: "Tap", on: submitOn };
const doubleSubmit = { kind: "DoubleTap", on: submitOn };
// A double-submit pops the back stack twice (each Submit calls
// navigator.back), so unlike a healthy single submit it lands back on Home,
// which is why the property can see it at all.
test("clean double submit: one action in the window, delta is 2x", () => {
const trace = run([
{ route: "home", totalText: "$0.00", lastAction: null },
{ route: "ledger", lastAction: idle },
{ route: "ledger", lastAction: idle },
{ route: "home", totalText: "$100.00", lastAction: doubleSubmit },
]);
assert.equal(trace[3]?.submits, 1);
assert.equal(trace[0]?.total, 0);
assert.equal(trace[3]?.total, 10000);
});
// The contaminated window from the android run: an unrelated submit committed
// while we were off Home, then the double-submit landed. The delta spans three
// transactions, so it is not evidence about either typed amount.
test("two submits between Home visits: the window is not evidence", () => {
const trace = run([
{ route: "home", totalText: "$0.00", lastAction: null },
{ route: "ledger", lastAction: idle },
{ route: "ledger", lastAction: submit },
{ route: "ledger", lastAction: idle },
{ route: "home", totalText: "-$130.00", lastAction: doubleSubmit },
]);
assert.equal(trace[4]?.submits, 2);
});
// Freshness is restored by seeing Home, not by time passing.
test("a Home visit between two submits restores a one-action window", () => {
const trace = run([
{ route: "home", totalText: "$0.00", lastAction: null },
{ route: "ledger", lastAction: submit },
{ route: "home", totalText: "$262.00", lastAction: idle },
{ route: "ledger", lastAction: idle },
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
]);
assert.equal(trace[1]?.submits, 1);
assert.equal(trace[2]?.submits, 1);
assert.equal(trace[4]?.submits, 1);
});
// An unreadable Home is not a Home reading: it must not close the window, or
// the count would go back to zero against a total nobody read.
test("an unreadable Home does not close the window", () => {
const trace = run([
{ route: "home", totalText: "$0.00", lastAction: null },
{ route: "ledger", lastAction: submit },
{ route: "home", totalText: undefined, lastAction: idle },
{ route: "home", totalText: "$66.00", lastAction: doubleSubmit },
]);
assert.equal(trace[2]?.total, null);
assert.equal(trace[3]?.submits, 2);
});
+73 -82
View File
@@ -1,97 +1,88 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { computeHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
import { readHomeTotalBalance } from "../../../examples/folio/sanderling/predicates.ts";
test("on Home with two cards ($10, $20): returns $30", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: 0,
}),
3000,
test("on Home the app's own total is the reading, the carrier and fresh", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "home", totalText: "$30.00", previousCarrier: 0 }),
{ value: 3000, carrier: 3000, fresh: true },
);
});
test("off Home (no cards) after a Home visit of $30: returns carrier $30", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: 3000,
}),
3000,
test("off Home there is nothing to read, so the carrier is reported unchanged", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: 3000 }),
{ value: 3000, carrier: 3000, fresh: false },
);
});
test("off Home (no cards) with carrier still 0: returns 0", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: 0,
}),
0,
test("off Home before any Home visit reports the null carrier, still not fresh", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "ledger", totalText: undefined, previousCarrier: null }),
{ value: null, carrier: null, fresh: false },
);
});
test("sequence: Home $30, off-Home, Home $50 tracks new Home totals", () => {
let carrier = 0;
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: carrier,
test("a negative total parses with its sign", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "home", totalText: "-$1,234.56", previousCarrier: 0 }),
{ value: -123456, carrier: -123456, fresh: true },
);
});
test("a fresh Home total overrides whatever the carrier held", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "home", totalText: "$7.50", previousCarrier: 9999 }),
{ value: 750, carrier: 750, fresh: true },
);
});
// The poisoned carrier. An unreadable Home total is UNKNOWN for that step, so
// null is reported and the property goes vacuous, but the carrier must keep the
// last total we actually read. Writing null into the carrier is what used to end
// the run: off-Home steps hand the carrier straight back, so a single
// unreadable Home left every later step null.
test("an unreadable Home total reports null but leaves the carrier intact", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "home", totalText: undefined, previousCarrier: 3000 }),
{ value: null, carrier: 3000, fresh: false },
);
});
test("a garbled Home total is unknown, not zero", () => {
assert.deepEqual(
readHomeTotalBalance({ route: "home", totalText: "$", previousCarrier: 3000 }),
{ value: null, carrier: 3000, fresh: false },
);
});
test("an unreadable Home no longer poisons the steps after it", () => {
let carrier: number | null = null;
const seen: (number | null)[] = [];
const step = (route: string | null, totalText: string | undefined) => {
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
carrier = reading.carrier;
seen.push(reading.value);
};
step("home", "$30.00");
step("home", undefined);
step("ledger", undefined);
step("ledger", undefined);
step("home", "$50.00");
assert.deepEqual(seen, [3000, null, 3000, 3000, 5000]);
});
// The clipped fifth account card that started this: it is not a card reading
// any more, and the footer total the app renders is unaffected by which cards
// the viewport happens to fit.
test("Home total is one node, so an off-screen account cannot change it", () => {
const withFiveCards = readHomeTotalBalance({
route: "home",
totalText: "$2,589.00",
previousCarrier: 0,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$20.00", "$30.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 5000);
});
test("Ledger step (no Home cards) holds the carrier, ignores Ledger balance", () => {
let carrier = 0;
carrier = computeHomeTotalBalance({
cardBalanceTexts: ["$10.00", "$20.00"],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
carrier = computeHomeTotalBalance({
cardBalanceTexts: [],
previousCarrier: carrier,
});
assert.equal(carrier, 3000);
});
test("negative card balance parses with sign and sums correctly", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["-$5.00", "$10.00"],
previousCarrier: 0,
}),
500,
);
});
test("single card on Home overrides any previous carrier", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: ["$7.50"],
previousCarrier: 9999,
}),
750,
);
});
test("undefined card balance text is treated as 0", () => {
assert.equal(
computeHomeTotalBalance({
cardBalanceTexts: [undefined, "$10.00"],
previousCarrier: 0,
}),
1000,
);
assert.deepEqual(withFiveCards, { value: 258900, carrier: 258900, fresh: true });
});
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
countSubmitsInWindow,
readHomeCards,
readHomeTotalBalance,
routeOfFrame,
submitChangesBalanceByTypedAmount,
} from "../../../examples/folio/sanderling/predicates.ts";
// The spec's own screen table. A frame is the set of markers its accessibility
// tree carries, which is all routeOfFrame is allowed to look at.
const SCREENS = {
login: "LoginScreen",
"add-account": "AddAccountScreen",
"add-transaction": "AddTransactionScreen",
ledger: "LedgerScreen",
home: "HomeScreen",
};
const frame =
(...tags: string[]) =>
(tag: string) =>
tags.includes(tag);
test("a frame showing one screen names its route", () => {
assert.equal(routeOfFrame(SCREENS, frame("LoginScreen")), "login");
assert.equal(routeOfFrame(SCREENS, frame("AddAccountScreen")), "add-account");
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen")), "add-transaction");
assert.equal(routeOfFrame(SCREENS, frame("LedgerScreen")), "ledger");
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen")), "home");
});
test("a frame showing no screen at all is unknown", () => {
assert.equal(routeOfFrame(SCREENS, frame()), null);
assert.equal(routeOfFrame(SCREENS, frame("SomethingElse")), null);
});
// The android transition frame: 425 of 1879 steps across 17 measured runs carry
// two screens, in every combination the navigation graph allows. Ranking the
// markers and taking the first answers add-transaction for the first of these
// while a second, unscoped look answers "on Home" -- and two answers for one
// frame is the defect. There is one answer now, and on a transition frame it is
// "I do not know".
test("a transition frame showing two screens names neither", () => {
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen")), null);
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen", "LedgerScreen")), null);
assert.equal(routeOfFrame(SCREENS, frame("AddAccountScreen", "HomeScreen")), null);
assert.equal(routeOfFrame(SCREENS, frame("HomeScreen", "LoginScreen")), null);
assert.equal(routeOfFrame(SCREENS, frame("AddTransactionScreen", "LedgerScreen")), null);
});
test("the three-screen frames android also emits name nothing", () => {
assert.equal(
routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen", "LedgerScreen")),
null,
);
});
// Everything read off Home takes the route as its only input, so a frame that
// is not Home cannot be read as Home by anything.
test("a transition frame's half-drawn Home total is not a reading", () => {
const route = routeOfFrame(SCREENS, frame("AddTransactionScreen", "HomeScreen"));
assert.deepEqual(
readHomeTotalBalance({ route, totalText: "$86,911.00", previousCarrier: 8681600 }),
{ value: 8681600, carrier: 8681600, fresh: false },
);
});
test("nor is its half-drawn card list", () => {
const route = routeOfFrame(SCREENS, frame("AddAccountScreen", "HomeScreen"));
const carried = { Travel: "8", Checking: "0" };
const partial = { Travel: "8" };
assert.deepEqual(readHomeCards<Record<string, string>>({ route, reading: partial, previousCarrier: carried }), {
value: carried,
carrier: carried,
fresh: false,
});
});
// The false conviction itself, android seed 3, steps 98-102 of the recorded
// trace. Five submits deep into the window the app sits on AddTransaction with
// "339" typed; a double-tap on Back starts the trip Home; the frame that comes
// back carries BOTH screens with Home's total already drawn behind the outgoing
// one. The old spec read that total as a fresh Home reading, reset the window to
// zero, and aimed the next tap at a TxnSubmit button that had stopped existing.
// The tap landed on Home, committed nothing, and the property demanded 33900 of
// movement for it. Nine of the eleven android convictions were this, all at
// delta 0.0x, all a single Tap where the real bug is a DoubleTap.
test("the measured android transition chain no longer convicts at delta 0", () => {
let carrier: number | null = 8681600;
let submits = 5;
const step = (
tags: string[],
totalText: string | undefined,
lastAction: { kind: string; on: string } | null,
) => {
const route = routeOfFrame(SCREENS, frame(...tags));
const reading = readHomeTotalBalance({ route, totalText, previousCarrier: carrier });
const window = countSubmitsInWindow({ previousCount: submits, lastAction, fresh: reading.fresh });
carrier = reading.carrier;
submits = window.next;
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 transition = step(["AddTransactionScreen", "HomeScreen"], "$86,911.00", back);
assert.equal(transition.route, null);
assert.equal(transition.total, 8681600);
assert.equal(transition.submits, 5);
const landing = step(["HomeScreen"], "$86,911.00", phantomSubmit);
assert.equal(landing.submits, 6);
assert.equal(
submitChangesBalanceByTypedAmount({
route: landing.route,
lastAction: phantomSubmit,
submitsInWindow: landing.submits,
typedAmount: 33900,
prevTotalBalance: transition.total,
currTotalBalance: landing.total,
}),
true,
);
// What the reset bought the old spec: the same landing, judged against a
// window of one and a total the transition frame had already banked.
assert.equal(
submitChangesBalanceByTypedAmount({
route: "home",
lastAction: phantomSubmit,
submitsInWindow: 1,
typedAmount: 33900,
prevTotalBalance: 8691100,
currTotalBalance: 8691100,
}),
false,
);
});
@@ -0,0 +1,452 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
cardTxnCount,
committedTransactionsExceedSubmits,
homeTxnCountsOf,
} from "../../../examples/folio/sanderling/predicates.ts";
// Android and iOS give the count its own node; web merges the card into one
// string, where the count sits between the name and the balance. The reading
// carries which of the two it came from: a number is a count nothing else could
// have leaked into, a string is a digit run that may have.
test("a dedicated count node reads as a number, not a digit run", () => {
assert.equal(
cardTxnCount({ childText: "12 transactions", cardText: "INInvestments12 transactions$2,589.00" }),
12,
);
});
test("merged card text: the count is taken from in front of the balance", () => {
assert.equal(
cardTxnCount({ childText: undefined, cardText: "INInvestments12 transactions$2,589.00" }),
"12",
);
});
test("merged card text: the singular label parses too", () => {
assert.equal(
cardTxnCount({ childText: undefined, cardText: "SASavings1 transaction$118.00" }),
"1",
);
});
// The balance has to come off first, or a name ending in digits would be read
// as the count.
test("a card with no readable count is unknown, not zero", () => {
assert.equal(cardTxnCount({ childText: undefined, cardText: undefined }), undefined);
assert.equal(cardTxnCount({ childText: undefined, cardText: "no digits here" }), undefined);
assert.equal(cardTxnCount({ childText: "", cardText: "AA" + "a".repeat(198) }), undefined);
});
// Measured on a real web run: the account named "-1" holding 2 transactions
// merges to "-1-12 transactions-$119.00", and the maximal digit run reads 12.
test("merged text runs a digit-ending name into the count", () => {
assert.equal(
cardTxnCount({ childText: undefined, cardText: "-1-12 transactions-$119.00" }),
"12",
);
});
const before = { Checking: "3", Savings: "1" };
test("healthy window: three submits, three transactions", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "5", Savings: "2" },
submitsInWindow: 3,
}),
false,
);
});
test("rejected submits commit nothing, which is under the bound", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "3", Savings: "1" },
submitsInWindow: 4,
}),
false,
);
});
// The bug, stated directly: one tap, two rows.
test("double submit: one action commits two transactions", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "5", Savings: "1" },
submitsInWindow: 1,
}),
true,
);
});
// The point of counting actions against transactions rather than gating on a
// one-submit window: a wide window is still a sound comparison.
test("wide window: five submits committing six transactions still fires", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "8", Savings: "4" },
submitsInWindow: 5,
}),
true,
);
});
test("boundary: committed equal to the submit count is not a violation", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "4", Savings: "1" },
submitsInWindow: 1,
}),
false,
);
});
test("boundary: one transaction past the submit count is", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: { Checking: "4", Savings: "2" },
submitsInWindow: 1,
}),
true,
);
});
// Only accounts in both readings count. A card that scrolled out of the
// viewport, or one whose count was unreadable, drops out of the sum, so the
// result is a lower bound on what committed. Losing a card can only cost a
// detection; it must never manufacture one.
test("an account missing from the later reading is not counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3", Savings: "1" },
countsAfter: { Checking: "3" },
submitsInWindow: 0,
}),
false,
);
});
test("an account appearing only in the later reading is not counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3" },
countsAfter: { Checking: "3", Travel: "9" },
submitsInWindow: 0,
}),
false,
);
});
test("a card that scrolled away and back is not double counted", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "3" },
countsAfter: { Checking: "4", Savings: "40" },
submitsInWindow: 1,
}),
false,
);
});
test("an unknown reading on either side is not evidence", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: null,
countsAfter: { Checking: "99" },
submitsInWindow: 0,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "0" },
countsAfter: null,
submitsInWindow: 0,
}),
false,
);
});
// The real trace this came from: at the violating step of seeds 3 and 5 the
// window held exactly one submit action and the account's count moved by two.
test("the measured web witness: submits 1, count delta 2", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { INInvestments: "12", Aa: "0" },
countsAfter: { INInvestments: "14", Aa: "0" },
submitsInWindow: 1,
}),
true,
);
});
// The length rule, which is what keeps the merged-text prefix honest. The
// account named "-1" reads 19 at nine transactions and 110 at ten: same account,
// a delta of 91 out of a true delta of 1. Different run lengths are dropped.
test("a count crossing a digit boundary is dropped, not convicted on", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { "-1-": "19" },
countsAfter: { "-1-": "110" },
submitsInWindow: 1,
}),
false,
);
});
test("same run length keeps the prefixed delta exact", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { "-1-": "110" },
countsAfter: { "-1-": "112" },
submitsInWindow: 1,
}),
true,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { "-1-": "110" },
countsAfter: { "-1-": "111" },
submitsInWindow: 1,
}),
false,
);
});
test("an unreadably long run is not evidence", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "1".repeat(21) },
countsAfter: { Checking: "9".repeat(21) },
submitsInWindow: 0,
}),
false,
);
});
// The other side of that rule, and the reason it is scoped to merged text: a
// count read off its own node has no account name in front of it, so its digits
// ARE the count and a decade crossing is just a number getting longer. Both
// windows below are real android seed-9 readings that the unscoped length rule
// threw away, in runs that then finished clean.
test("a dedicated node's count crossing a decade is usable evidence", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 7 },
countsAfter: { Checking: 12 },
submitsInWindow: 1,
}),
true,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Savings: 4 },
countsAfter: { Savings: 10 },
submitsInWindow: 1,
}),
true,
);
});
// Recovering the window is only worth anything if it still acquits the healthy
// case, so the same crossing under a submit that earned it must not fire.
test("a dedicated node's healthy decade crossing does not convict", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 9 },
countsAfter: { Checking: 10 },
submitsInWindow: 1,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 9 },
countsAfter: { Checking: 11 },
submitsInWindow: 1,
}),
true,
);
});
// The same numbers off merged text, where the digits may not be the count at
// all: still dropped.
test("the merged-text equivalent of that crossing is still dropped", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "9" },
countsAfter: { Checking: "10" },
submitsInWindow: 0,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "7" },
countsAfter: { Checking: "12" },
submitsInWindow: 1,
}),
false,
);
});
// The boundary itself. A pair whose two readings came from different sources is
// vouched for by neither rule: the string may carry a name prefix the number
// does not, so subtracting them is not a transaction count.
test("a pair straddling the two sources is not comparable", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: 7 },
countsAfter: { Checking: "12" },
submitsInWindow: 1,
}),
false,
);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: { Checking: "7" },
countsAfter: { Checking: 12 },
submitsInWindow: 1,
}),
false,
);
});
// End to end from the two accessibility shapes, which is where the distinction
// is actually made: the same account, the same true counts, read once off a
// dedicated node and once off merged card text.
const dedicated = (name: string, count: number) => ({
name,
balance: 0,
count: cardTxnCount({ childText: `${count} transactions`, cardText: undefined }),
});
const merged = (initials: string, name: string, count: number) => ({
name,
balance: 0,
count: cardTxnCount({
childText: undefined,
cardText: `${initials}${name}${count} transactions$0.00`,
}),
});
test("a dedicated-node card list convicts across a decade", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: homeTxnCountsOf([dedicated("Checking", 9)]),
countsAfter: homeTxnCountsOf([dedicated("Checking", 11)]),
submitsInWindow: 1,
}),
true,
);
});
test("the merged-text card list drops the same pair", () => {
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: homeTxnCountsOf([merged("CH", "Checking", 9)]),
countsAfter: homeTxnCountsOf([merged("CH", "Checking", 11)]),
submitsInWindow: 1,
}),
false,
);
});
// Home lists whatever fits the viewport, and Folio lets two accounts share a
// name, so one name can arrive on two cards. Keying counts by name collapsed
// them onto the last card, and the two readings a window compares then came off
// DIFFERENT cards: the probe below is a healthy app, one submit, and a scroll.
test("two cards sharing a name do not become one count", () => {
const before = homeTxnCountsOf([{ name: "Travel", balance: 0, count: 0 }]);
const after = homeTxnCountsOf([
{ name: "Travel", balance: 0, count: 0 },
{ name: "Travel", balance: 500, count: 8 },
]);
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: after,
submitsInWindow: 1,
}),
false,
);
assert.deepEqual(after, null);
});
test("a name on two cards is dropped from both readings", () => {
const before = homeTxnCountsOf([
{ name: "Travel", balance: 0, count: "3" },
{ name: "Travel", balance: 0, count: "9" },
{ name: "Checking", balance: 0, count: "2" },
]);
const after = homeTxnCountsOf([
{ name: "Travel", balance: 0, count: "3" },
{ name: "Travel", balance: 0, count: "11" },
{ name: "Checking", balance: 0, count: "2" },
]);
assert.deepEqual(before, { Checking: "2" });
assert.deepEqual(after, { Checking: "2" });
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: after,
submitsInWindow: 0,
}),
false,
);
});
// The other card need not be readable to spoil the identity: an unreadable
// count still means the name on the map may not be the card that was read.
test("a duplicate name is dropped even when the twin has no count", () => {
assert.deepEqual(
homeTxnCountsOf([
{ name: "Travel", balance: 0, count: 4 },
{ name: "Travel", balance: 0, count: undefined },
{ name: "Savings", balance: 0, count: 1 },
]),
{ Savings: 1 },
);
});
// Dropping the ambiguous name must not disable the property for the rest.
test("a unique name is still counted beside a dropped duplicate", () => {
const before = homeTxnCountsOf([
{ name: "Travel", balance: 0, count: 3 },
{ name: "Travel", balance: 0, count: 1 },
{ name: "Checking", balance: 0, count: 4 },
]);
const after = homeTxnCountsOf([
{ name: "Travel", balance: 0, count: 3 },
{ name: "Travel", balance: 0, count: 1 },
{ name: "Checking", balance: 0, count: 6 },
]);
assert.deepEqual(before, { Checking: 4 });
assert.equal(
committedTransactionsExceedSubmits({
countsBefore: before,
countsAfter: after,
submitsInWindow: 1,
}),
true,
);
});
test("distinct names are all counted", () => {
assert.deepEqual(
homeTxnCountsOf([
{ name: "Checking", balance: 0, count: "3" },
{ name: "Savings", balance: 0, count: "1" },
]),
{ Checking: "3", Savings: "1" },
);
});
+29 -4
View File
@@ -43,14 +43,39 @@ test("zero returns 0", () => {
assert.equal(parseTypedAmount("0"), 0);
});
test("leading plus sign tolerated as positive", () => {
assert.equal(parseTypedAmount("+50"), 5000);
// The app's parseCents matches ^\d+(\.\d{1,2})?$ against the trimmed input, so
// a sign is rejected and no transaction is created. Reading "-50" as 5000 cents
// would make the balance property demand a move the app never made.
test("leading plus sign rejected, like the app", () => {
assert.equal(parseTypedAmount("+50"), 0);
});
test("leading minus sign tolerated as positive", () => {
assert.equal(parseTypedAmount("-50"), 5000);
test("leading minus sign rejected, like the app", () => {
assert.equal(parseTypedAmount("-50"), 0);
});
test("comma-separated thousands accepted", () => {
assert.equal(parseTypedAmount("1,234.56"), 123456);
});
// The input corpus types this 21-digit run into every field. parseCents calls
// toLongOrNull on the whole part, which is null past Long.MAX, so the app
// refuses the submit; float64 would have read it as 1e23 and asked the property
// to find a balance move of 1e23 cents that never happened.
test("21-digit corpus amount returns 0: the app rejects it", () => {
assert.equal(parseTypedAmount("999999999999999999999"), 0);
});
test("amount too large for exact cents returns 0", () => {
assert.equal(parseTypedAmount("100000000000000"), 0);
});
// 9007199254740991 cents is Number.MAX_SAFE_INTEGER: the last amount whose
// cents survive the multiply intact.
test("largest exactly representable amount is kept", () => {
assert.equal(parseTypedAmount("90071992547409.91"), 9007199254740991);
});
test("one cent past the safe range returns 0", () => {
assert.equal(parseTypedAmount("90071992547409.92"), 0);
});
+16
View File
@@ -14,6 +14,15 @@ export interface FakeElementSpec {
y: number;
width: number;
height: number;
// id/testid/label/alt/title are how the host names a target: it builds the
// selector an action carries from them, so a fake needs them to exercise that
// naming. alt and title are the fallbacks the hierarchy dump folds into
// content-desc, which the host has to fall back to in the same order.
id?: string;
testid?: string;
label?: string;
alt?: string;
title?: string;
// clickable/editable place the element in the selector sets the host queries;
// the fake answers those queries directly rather than matching CSS.
clickable?: boolean;
@@ -28,6 +37,9 @@ export interface FakeElement extends FakeElementSpec {
tagName: string;
type: string;
isContentEditable: boolean;
id: string;
dataset: Record<string, string | undefined>;
getAttribute(name: string): string | null;
scrollHeight: number;
clientHeight: number;
scrollWidth: number;
@@ -49,6 +61,10 @@ export function fakeElement(spec: FakeElementSpec): FakeElement {
tagName: spec.tag.toUpperCase(),
type: spec.tag === "input" ? "text" : "",
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
id: spec.id ?? "",
dataset: { testid: spec.testid },
getAttribute: (name: string) =>
({ "aria-label": spec.label, alt: spec.alt, title: spec.title })[name] ?? null,
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
clientHeight: spec.height,
scrollWidth: spec.width,
+215 -2
View File
@@ -128,6 +128,53 @@ test("queryTargets reports a disabled control rather than dropping it", () => {
});
});
// A target with no selector is a target no property can name. The action the
// picker builds from it carries coordinates only, so `lastAction.on` is empty
// and any property matching on WHICH control was acted upon cannot fire.
test("queryTargets names a uniquely identified target", () => {
const submit = fakeElement({
tag: "button", x: 0, y: 0, width: 40, height: 20, clickable: true, id: "TxnSubmit",
});
const byTestid = fakeElement({
tag: "button", x: 0, y: 40, width: 40, height: 20, clickable: true, testid: "cancel",
});
const byLabel = fakeElement({
tag: "button", x: 0, y: 80, width: 40, height: 20, clickable: true, label: "Close",
});
// alt and title are the fallbacks the hierarchy dump folds into content-desc,
// so the host has to fall back to them in the same order or a name it calls
// unique resolves to a different element on the Go side.
const byAlt = fakeElement({ tag: "img", x: 0, y: 120, width: 40, height: 20, alt: "Logo" });
const byTitle = fakeElement({ tag: "div", x: 0, y: 160, width: 40, height: 20, title: "Help" });
const anonymous = fakeElement({ tag: "div", x: 0, y: 200, width: 10, height: 10 });
withFakeDocument([submit, byTestid, byLabel, byAlt, byTitle, anonymous], () => {
const targets = host.queryTargets();
assert.equal(targets[0]!.selector, "id:TxnSubmit");
assert.equal(targets[1]!.selector, "data-testid:cancel");
assert.equal(targets[2]!.selector, "desc:Close");
assert.equal(targets[3]!.selector, "desc:Logo");
assert.equal(targets[4]!.selector, "desc:Help");
assert.equal(targets[5]!.selector, undefined);
});
});
// A repeated id (folio's Home screen renders one AccountCard testTag per
// account) names no single element, so the runner would re-resolve the action
// onto whichever sibling it found first. Better unnamed than mis-aimed.
test("queryTargets leaves duplicated identities unnamed", () => {
const first = fakeElement({
tag: "div", x: 0, y: 0, width: 40, height: 20, clickable: true, id: "AccountCard",
});
const second = fakeElement({
tag: "div", x: 0, y: 40, width: 40, height: 20, clickable: true, id: "AccountCard",
});
withFakeDocument([first, second], () => {
const targets = host.queryTargets();
assert.equal(targets[0]!.selector, undefined);
assert.equal(targets[1]!.selector, undefined);
});
});
test("queryTargets caches within a tick until reset", () => {
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
withFakeDocument([button], () => {
@@ -159,6 +206,13 @@ function withState(run: () => void) {
}
}
// Every reading leaves the runtime inside a {value} envelope, so an extractor
// whose getter returned undefined keeps its index instead of being dropped by
// JSON.stringify.
function readingOf(values: Record<number, { value?: unknown }>, index: number): unknown {
return values[index]!.value;
}
test("named() sets the extractor's display name", () => {
const handle = __testing__.runtime.extract(() => "home").named("route");
const entry = __testing__.extractors.find((e) => e.handle === handle);
@@ -204,6 +258,63 @@ test("an uncaught cross-extractor read aborts evaluateExtractors", () => {
);
});
// JSON.stringify drops an undefined-valued key, so a reading written straight
// into the table took the extractor's whole INDEX with it when the getter
// returned undefined - folio's on(route, tag) off its own screen, which is most
// of its extractors on most steps. The host then kept goja's dump-derived value
// for those and the page's for the rest, and a property comparing previous to
// current across that split convicts an app that did nothing wrong.
test("an extractor that returned undefined keeps its index through JSON", () => {
__testing__.extractors.length = 0;
__testing__.runtime.extract(() => undefined);
__testing__.runtime.extract(() => null);
__testing__.runtime.extract(() => 5);
let table: Record<number, { value?: unknown }> = {};
withState(() => {
table = __testing__.evaluateExtractors();
});
const overTheWire = JSON.parse(JSON.stringify(table)) as Record<string, { value?: unknown }>;
assert.deepEqual(Object.keys(overTheWire), ["0", "1", "2"]);
// undefined and null have to stay distinguishable across the wire: the goja
// host records undefined for a getter that returned undefined, so reporting
// null instead would make `x.current === undefined` answer one thing on
// native and another on web.
assert.equal("value" in overTheWire["0"]!, false);
assert.equal(overTheWire["1"]!.value, null);
assert.equal(overTheWire["2"]!.value, 5);
});
// state.lastAction is the one piece of state the page cannot observe for
// itself: only the runner knows which action it actually applied. While the web
// runtime hardcoded null there, a spec property gated on the last action (e.g.
// folio's submitMovesBalanceByTypedAmount, which only looks at taps on
// TxnSubmit) was vacuously true on web forever, and the run went green having
// checked nothing.
function lastActionSeenByASpec(pushed: unknown): unknown {
const setLastAction = (globalThis as Record<string, unknown>)
.__sanderlingSetLastAction__ as (value: unknown) => void;
__testing__.extractors.length = 0;
__testing__.runtime.extract((state) => (state as { lastAction: unknown }).lastAction);
let out: Record<number, { value?: unknown }> = {};
withState(() => {
setLastAction(pushed);
out = __testing__.evaluateExtractors();
});
return readingOf(out, 0);
}
test("state.lastAction carries the action the host pushed", () => {
const action = { kind: "Tap", on: "id:TxnSubmit" };
assert.deepEqual(lastActionSeenByASpec(action), action);
});
test("state.lastAction is null when the host pushed nothing", () => {
// The first step of a run, and any step whose action was never applied: the
// goja host reports null there, so the web host must too.
assert.equal(lastActionSeenByASpec(null), null);
});
// sanitize runs over every extractor's return value before it leaves the
// runtime. A user extractor that returns a page object reachable from
// document/window can be self-referential, carry functions, or nest deeply;
@@ -212,11 +323,11 @@ test("an uncaught cross-extractor read aborts evaluateExtractors", () => {
function sanitizeViaExtract(value: unknown): unknown {
__testing__.extractors.length = 0;
__testing__.runtime.extract(() => value);
let out: Record<number, unknown> = {};
let out: Record<number, { value?: unknown }> = {};
withState(() => {
out = __testing__.evaluateExtractors();
});
return out[0];
return readingOf(out, 0);
}
test("sanitize breaks a self-referential cycle instead of overflowing", () => {
@@ -338,3 +449,105 @@ test("selectorFromObject text-only selector becomes an XPath", () => {
xpath: `//*[normalize-space(text())="Go"]`,
});
});
// An ax element is labelled with the selector it was found by, in the same
// canonical grammar selectorStringFromJS emits in internal/verifier/marshal.go.
// The label is what a spec's own Tap({ on: state.ax.find(...) }) carries to the
// runner: with no label the action is coordinates only, `lastAction.on` is
// empty, and a property matching on WHICH control was tapped cannot fire.
const { selectorTag } = __testing__;
test("selectorTag renders the selector shapes the goja host renders", () => {
assert.equal(selectorTag("testTag:TxnSubmit"), "testTag:TxnSubmit");
assert.equal(selectorTag({ testTag: "TxnSubmit" }), "testTag:TxnSubmit");
assert.equal(
selectorTag([{ testTag: "AddTransactionScreen" }, { testTag: "TxnSubmit" }]),
"testTag:AddTransactionScreen > testTag:TxnSubmit",
);
assert.equal(selectorTag({ testTag: "Row", "aria-label": "first" }), "testTag:Row aria-label:first");
assert.equal(selectorTag(undefined), "");
});
// A selector path scopes the second segment to each match of the first. It
// returned nothing at all on web while returning matches on native, so folio's
// accounts/totalBalance extractors (findAll([{HomeScreen}, {AccountCard}]))
// were empty on every web step and the properties over them checked nothing.
test("ax.findAll resolves a selector path segment by segment", () => {
const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 };
const node = (id: string, answers: Record<string, unknown[]> = {}) => ({
id,
tagName: "DIV",
className: "",
textContent: id,
dataset: {},
getAttribute: () => null,
getBoundingClientRect: () => rect,
querySelectorAll: (selector: string) => answers[selector] ?? [],
});
const cardCss = `:is([data-testid="AccountCard"], [id="AccountCard"])`;
const screenCss = `:is([data-testid="HomeScreen"], [id="HomeScreen"])`;
const cards = [node("first"), node("second")];
const home = node("HomeScreen", { [cardCss]: cards });
const g = globalThis as Record<string, unknown>;
const originalDocument = g.document;
const originalWindow = g.window;
g.document = { querySelectorAll: (selector: string) => (selector === screenCss ? [home] : []) };
g.window = {};
try {
__testing__.extractors.length = 0;
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
return ax
.findAll([{ testTag: "HomeScreen" }, { testTag: "AccountCard" }])
.map((card) => card.text);
});
const values = __testing__.evaluateExtractors();
// Scoped to the head match: the cards come from the HomeScreen node, not
// from a document-wide sweep for AccountCard.
assert.deepEqual(readingOf(values, 0), ["first", "second"]);
} finally {
g.document = originalDocument;
g.window = originalWindow;
}
});
test("ax.find and ax.findAll label the element with its selector", () => {
const rect = { left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 };
const submit = {
id: "TxnSubmit",
tagName: "DIV",
className: "",
textContent: "Submit",
dataset: {},
getAttribute: () => null,
getBoundingClientRect: () => rect,
};
const matches = `:is([data-testid="TxnSubmit"], [id="TxnSubmit"])`;
const g = globalThis as Record<string, unknown>;
const originalDocument = g.document;
const originalWindow = g.window;
g.document = { querySelectorAll: (selector: string) => (selector === matches ? [submit] : []) };
g.window = {};
try {
__testing__.extractors.length = 0;
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { find(s: unknown): Record<string, unknown> | undefined } }).ax;
return ax.find({ testTag: "TxnSubmit" });
});
__testing__.runtime.extract((state) => {
const ax = (state as { ax: { findAll(s: unknown): Record<string, unknown>[] } }).ax;
return ax.findAll({ testTag: "TxnSubmit" });
});
const values = __testing__.evaluateExtractors();
const found = readingOf(values, 0) as Record<string, unknown>;
assert.equal(found.__sanderlingSelector, "testTag:TxnSubmit");
// findAll passes each element through map(); passing the callback by
// reference would hand the array INDEX to the runtime as the selector.
const all = readingOf(values, 1) as Record<string, unknown>[];
assert.equal(all[0]!.__sanderlingSelector, "testTag:TxnSubmit");
} finally {
g.document = originalDocument;
g.window = originalWindow;
}
});