19 KiB
name: sanderling-spec-authoring description: Write a sanderling spec for an app: test hooks, extractors, selectors, properties, and an action tree that reaches the states the properties read. Use when adopting sanderling for a new app, when adding a property to an existing spec, and when a run is green because it never reached the state the property was written for.
Writing a sanderling spec
A spec is a TypeScript module the runner evaluates once per step. It exports
properties and actionsRoot, plus an optional setup and generator.
Writing one is easy. Writing one that would catch a real bug is not, because a
spec that checks nothing looks exactly like a spec that checks everything: both
are a green run.
So the order below is arranged around getting evidence early that each piece
reads what you think it reads. When the spec is written, audit it with
sanderling-spec-review before trusting a green run from it.
Write it in this order
Hooks, then extractors, then one property, then run it and read the witness, then everything else. Writing six properties before the first run is how people end up with six that cannot fire, and nothing in the output tells you which.
Hooks first
Your app needs stable handles or nothing can name what it is asserting on. The
hooks the replay UI's spec drives (data-testid, data-step) were added to the
UI for that spec, and its header says why: a UI with no stable handles is a UI
nothing can assert on, and that is as true for a person writing a test as it is
for a fuzzer.
Put a hook on the screen or route markers, on every container you will scope a
lookup to, and on every fact you will read. testTag is the portable one: it
surfaces as resource-id on Android and accessibilityIdentifier on iOS, and on
web it resolves to data-testid or id.
Extractors
extract(name, fn) reads one fact off state.ax per step. .current is this
step's value, .previous the last step's, undefined on the first step.
Name every one. The name is what you get back later: extractor_changes in
trace.jsonl carries the prev/curr pair for each extractor whose value moved,
and the witness recorded at a violation carries the extractor values behind it,
by name. An unnamed extractor shows up as extractor_3, which tells you nothing
at the point you most need to know what the property was looking at.
The rule that decides whether the spec is worth anything:
Return
nullwhen the element is absent. Never0,"", or[].
An unreadable fact is unknown, and a default turns unknown into a claim. Both
directions bite. Folio parsed a missing balance as 0 and its property became
Math.abs(0 - 0) === typedAmount, false at every healthy submit. Read a missing
panel's row count as 0 and the property says the cart is empty when the truth
is that the cart is not on screen. Where the ambiguity is real, call it unknown:
an empty findAll is both "no rows" and "not drawn yet", and folio treats it as
unknown, which costs the very first account of a run and buys back every card
that arrived late.
Extractors run before properties and action generators, and they may not read
each other. If two readings must come off one parse, put the parse in a helper
both call: examples/folio/sanderling/predicates.ts does this with
oncePerFrame, keyed on the state object, since both hosts build a new state
object per step.
Selectors
ax.find and ax.findAll take a string ("id:CartBadge"), an object
({id: "CartBadge"}), or an array of objects for a path. Element handles carry
their own .find / .findAll scoped to their subtree.
The two forms resolve identically: an object key is matched by the same rule its
string form uses, so {id: "X"} and "id:X" can never pick different elements.
What differs is the rule per key. Measured against an Android dump holding
com.app:id/CartBadge, whose content-desc is Cart, 3 items, alongside
AddAccountSubmit:
| Selector | Resolves to |
|---|---|
{id: "CartBadge"} |
the badge: id matches the whole resource-id, or the part after :id/ |
{id: "Sub"} |
nothing: id wants a whole name, not a fragment |
{testTag: "CartBadge"} |
the badge: testTag reaches resource-id on Android and accessibilityIdentifier on iOS |
{testTag: "Sub"} |
AddAccountSubmit, because every key outside the id / desc / descPrefix special cases is a substring match |
{desc: "Cart"} |
the badge: desc takes the whole description, or an iOS merged label starting Cart, |
testTag is the portable key and the one to reach for, but name the element in
full. A substring match on Sub is not a match, it is a coincidence, and it
will one day pick a different control.
A selector that matches nothing makes every property over it vacuous, and nothing anywhere reports that. This is why the selector you verify is the one you found a real value for in a witness, not the one that looked right when you wrote it.
Scope the lookup to a container instead of taking the first match on the
page. From replay-ui/sanderling/spec.ts: an earlier draft of
screenshotShowsTheSelectedStep took the first screenshot on the page, the
fuzzer put the "before" panel on another tab, which left the "after" panel's
image first, and the property fired against a UI that was behaving correctly. It
now reads s.ax.find([{ "data-testid": "state-before" }, { "data-testid": "screenshot" }])
and is scoped to the panel it means.
A screen marker is not enough scope on its own during a navigation. Android's
hierarchy dump carries the outgoing and the incoming screen together on 425 of
1879 steps measured across 17 runs, better than one frame in five, so a find
scoped to a screen the app has already left still resolves. Decide the route once
per frame, return null when more than one screen marker is present, and have
every reading take its answer from there. routeOfFrame in folio's
predicates.ts is that rule and carries the measurements.
Properties
always(f) requires f at every step. next(f) inside it compares this step
to the next, which is how you state "this action had that effect". now(f)
evaluates at the current step inside a formula body.
eventually(f).within(n, "steps" | "seconds" | "milliseconds") requires f
before the window closes and convicts at the step it does not. Unbounded, it
does not stop being a liveness obligation: one that never fires is violated when
the run ends, with the reason eventually never satisfied. So an eventually
over a state your run may not reach fires on every run that does not reach it,
and that is the usual way a first spec ends up red for no reason. At the top
level an eventually is one goal for the whole run, armed once and discharged
for good the first time it holds; written inside always it re-arms at every
step, which asks for the window to be met from everywhere. Every formula has
.implies, .and, .or, .not.
The stock properties are in @sanderling/spec/defaults. Both are cheap and both
are narrower than their names suggest, so know which platform yours runs on.
noUncaughtExceptions fails when state.exceptions is non-empty, and today only
the web runtime fills it: pkg/spec/src/web-runtime.ts installs error and
unhandledrejection listeners in the page. On Android and iOS nothing populates
the field, so it holds at every step whatever the app does. Export it on web,
where it is free and real; on native, understand that a green run says nothing
about crashes.
noLogcatErrors fails on any log line the driver reports at level E, which is
where an uncaught Java or Kotlin throwable lands, so on Android it is the closest
thing to noUncaughtExceptions. It holds vacuously on web and iOS. Neither
platform has an equivalent today: an iOS crash is invisible to both properties.
What makes a good first property
Prefer a cross-panel agreement: two parts of the UI that derive the same fact
by different paths must say the same thing. The toolbar prints a step count and
the list renders rows; a badge counts violation records and the panel counts the
rows it can show for them. Those hold on any run, so they never need
recalibrating against a fixture, and an app that gets the fact wrong in one of
the two places cannot satisfy them however it was driven there.
Three of the seven properties in replay-ui/sanderling/spec.ts are this shape:
stepCountMatchesTheList, screenshotShowsTheSelectedStep and
badgeCountMatchesThePanel. The rest of that spec shows what to write when no
second panel derives the fact: a range invariant on user input
(selectedStepIsInRange), a counting invariant inside one panel
(exactlyOneStepIsSelected), a no-effect property across an action
(switchingTabsKeepsTheStep), and the stock noUncaughtExceptions. All of them
still hold on any run, which is the property worth keeping.
Contrast a property that needs the fuzzer to reach a specific state, like
folio's "a submit moves the balance by no more than the amount typed". That is where
the real bugs are, and it is the harder thing to keep honest: it needs an action
tree that reaches the state, a window that closes often enough to bound what
happened inside it, and attribution that cannot blame the wrong action. Folio's
counting form went 117 steps between two readings on one iOS run and gathered 37
submits against a rise of 15 transactions, which is perfectly sound and says
nothing at all; the fix was to state the same rule over a number the app redraws
on nearly every frame, so the window is usually one action wide. Write these
second, and read sanderling-spec-review before you believe one.
Whichever you write, name the input that makes it return false before you move on. If you cannot, it is decoration.
Actions
actions(() => Action[]) returns the candidate actions for this step and the
picker chooses one. The verbs are Tap, DoubleTap, LongPress, InputText,
Scroll, Swipe, PressKey, and Wait. The built-in generators are taps,
doubleTaps, longPresses, typing, scrolls, swipes, pressKeys, and
waitOnce. defaultActions bundles five of them: taps and typing at 100,
scrolls 50, swipes 25, double taps 10. longPresses, pressKeys and waitOnce
are not in it, so a spec that only exports defaultActions never presses android
back, never long-presses, and never waits. Weight those in yourself if the app
has behaviour behind them.
weighted([n, generator], ...) composes them with relative weights.
whenRoute(routeExtractor, routes, body) runs body only on the named screens.
The optional setup export runs before actionsRoot for as long as it returns
actions, which is where login and onboarding belong; it re-engages on its own if
the app logs itself out mid-run. Values come from from(items),
integers().between(min, max), strings().length(min, max).alpha(),
emails().domain(host), and edgeCaseText(), all drawn from the run's seeded
PRNG so a seed replays exactly.
The default enumeration explores, but reaching a specific interesting state usually needs a weighted action of your own. With about 15 clickable elements on the replay UI's page, an undirected run went 40 steps without switching a single tab, which left both tab-facing properties vacuously true. Its badge agreement is worse: it needs two readings on one step, a badge, which only a step that has a violation renders, and a violations panel to compare it against. Undirected actions put both on the same step 0 times in the 80 steps of the first dogfood run. The property was reachable in principle and judged nothing in practice, and aiming at the step alone just moved the misses to the other side, 0 judged either way. It took an action that selects a violating step and then opens a panel if none is up. Folio weights its transaction chain at 45 for the same reason: both balance properties observe that flow and nothing else reaches it.
So for every property, name the action in the tree that puts everything it reads on screen at the same step. If there is none, add one, and give it enough weight that a short run gets there.
Soundness outranks everything else here
A property must never convict an app that behaved correctly. A property that convicts more often and is sometimes wrong is strictly worse than one that convicts less and is never wrong, because a false conviction costs someone a day and then costs the whole suite its credibility. When in doubt, a property should decline to judge.
Declining costs at most a detection. Convicting a healthy app costs the spec.
Concretely that means unknown stays null, a bound is preferred to an equality
where the window can hold more than one cause, and a value carried across a
screen change is dropped rather than compared.
It also means reading state.lastAction for what it actually promises, which is
three different things and not one:
state.lastAction === null: no action ran.applied: true: the runner saw the dispatch succeed.applied: null: it was dispatched and nobody knows whether it landed.
The rule is short. An action of unknown fate still counts toward bounds on what the app could have done, but it never licenses attributing an effect to it. Leave it out of the bound and you convict a healthy app: folio saw a transaction rise of one against a window of zero submits and called it a double submit. Demand its effect and you convict the app of the runner's own uncertainty.
relaunched: true says the runner had to bring the app back to the foreground
after the action, so the two readings straddle a restart. The action still
happened and still counts toward the bound, but nothing about state running
continuously between the two readings survives it, and a property demanding that
action's effect has to decline. Like applied, its null is "not reported", not
"the app never restarted": web and iOS cannot read the foreground at all, so only
an explicit true licenses declining.
Run it, then read the witness
Write one property, run it, and read the witness before you write the second.
This is the step that gets skipped and it is the one that pays. A spec that has
never had its readings confirmed against a real run looks exactly like a spec
that has, right up until you find out that an extractor reads null on the
platform you care about, or that a selector matches nothing, or that the value
being compared is not the value you thought.
sanderling test --spec spec.ts --bundle-id com.example.app --platform android --duration 2m
sanderling replay
Confirm two things before adding anything. First, that each extractor holds a
real value at some step, by finding it in extractor_changes in trace.jsonl;
the replay UI's hierarchy panel separately tells you whether the element your
selector names is in the tree at all. Second, that the property actually
compared values on some step rather than short-circuiting on its own guard. A
green run is evidence only if you can point at a step where a property fired.
Only then write the next property.
Once a property is worth keeping, its logic is worth testing away from the
device. Folio keeps its predicates in a plain module and unit-tests them in
pkg/spec/test/folio-*.test.ts, run by make test-spec-api; the app's own
Kotlin tests are make test-folio. Those files are the model for testing a
property in isolation, including the direction people skip: a fixture where the
effect happens legitimately, asserting that the predicate stays silent.
A spec to adapt
Complete and self-contained: a storefront whose header badge and cart panel both know how many things are in the cart.
import { InputText, Tap, actions, always, extract, from, integers, weighted } from "@sanderling/spec";
import { defaultActions, noUncaughtExceptions } from "@sanderling/spec/defaults";
function wholeNumber(text: string | undefined): number | null {
if (!text) return null;
const parsed = Number(text.trim());
return Number.isInteger(parsed) ? parsed : null;
}
// The header badge: the app's own count of what is in the cart.
const badgeCount = extract("badgeCount", s =>
wholeNumber(s.ax.find({ testTag: "CartBadge" })?.text));
// The same fact by another path: the rows the cart panel renders. No panel is
// null rather than 0, because nothing on screen is a fact we do not have, and
// 0 would claim the cart is empty.
const cartRowCount = extract("cartRowCount", s => {
const panel = s.ax.find({ testTag: "CartPanel" });
return panel ? panel.findAll({ testTag: "CartRow" }).length : null;
});
const checkoutEnabled = extract("checkoutEnabled", s => {
const button = s.ax.find({ testTag: "CheckoutButton" });
return button ? button.enabled === true : null;
});
// Two parts of the UI count the cart by different routes through the app's own
// state, so they cannot disagree about how many things are in it.
const badgeMatchesTheCart = always(() => {
const badge = badgeCount.current;
const rows = cartRowCount.current;
if (badge === null || rows === null) return true;
return badge === rows;
});
// Checkout is offered exactly when there is something to check out.
const emptyCartCannotCheckOut = always(() => {
const rows = cartRowCount.current;
const enabled = checkoutEnabled.current;
if (rows === null || enabled === null) return true;
return rows > 0 || !enabled;
});
export const properties = {
noUncaughtExceptions,
badgeMatchesTheCart,
emptyCartCannotCheckOut,
};
const productCards = extract("productCards", s => s.ax.findAll({ testTag: "ProductCard" }));
const cartButton = extract("cartButton", s => s.ax.find({ testTag: "CartButton" }));
const quantityField = extract("quantityField", s =>
s.ax.find([{ testTag: "CartPanel" }, { testTag: "QuantityField" }]));
const addAProduct = actions(() => {
const cards = productCards.current;
return cards.length === 0 ? [] : [Tap({ on: from(cards).generate() })];
});
const openTheCart = actions(() => {
const button = cartButton.current;
return button ? [Tap({ on: button })] : [];
});
const quantities = integers().between(1, 5);
const changeAQuantity = actions(() => {
const field = quantityField.current;
return field ? [InputText({ into: field, text: String(quantities.generate()) })] : [];
});
// Both properties read the cart panel, so a run that never opens it judges
// nothing. defaultActions carries the rest of the app.
export const actionsRoot = weighted(
[35, addAProduct],
[25, openTheCart],
[15, changeAQuantity],
[25, defaultActions],
);
Both properties here decline whenever the panel is off screen, which is honest
and also the thing to measure first: if openTheCart never wins the draw, they
judge nothing, exactly like the replay UI's badge property did for 80 steps.
The two real specs in the repo are the fuller references.
replay-ui/sanderling/spec.ts is the cross-panel spec written the way this page
recommends. examples/folio/sanderling/spec.ts with its predicates.ts is the
harder kind, a spec that attributes effects to actions across screens, and every
comment in it records a way it was once wrong. docs/manual/spec-language.md is
the lookup reference for anything not covered here.