Splotch's automated suites span three test layers. The app-unit, asset-pipeline, store-drawing,
repo-script, and E2E suites run on every push/PR, alongside the Firefox and WebKit smoke jobs. The
two-scenario WebKit commit-timing gate runs post-merge, on pushes to main, because its verdict is
a millisecond P95 that needs WebKit and a quiet host; its full seven-scenario form and the
real-device launch tests run only on tagged releases. The native shells still compile on every PR
that touches a native input, in a path-filtered workflow nothing waits on. ADR-0100's 2026-08-11
amendment records why its obsolete pre-merge blob-encoding guard retired.
| Layer | Tool | Command | Runs in CI |
|---|---|---|---|
| Unit (app) | Vitest (happy-dom) | npm run test:unit:coverage |
every push / PR |
| Unit (asset pipeline) | Vitest (Node) | npm run test:asset-gen |
every push / PR |
| Unit (store drawings) | Vitest (Node) | npm run test:store-drawings |
every push / PR |
| Opt-in centerlines | pytest + Vitest | npm run test:centerline-tracing |
tracer/consumer paths + manual dispatch |
| Unit (repo scripts) | Vitest (Node) | npm run test:tools |
every push / PR |
| E2E (web) | Playwright | npm run test:e2e |
every push / PR |
| Smoke (API contract) | Node + vite dev |
npm run test:api:smoke |
every push / PR (browserless job) |
| Smoke (hosted deploy) | Node + Netlify | npm run test:deploy:smoke |
daily production + manual deploy URL |
| Smoke (Firefox) | Playwright Firefox | npm run test:firefox:smoke |
every push / PR (parallel job) |
| Smoke (WebKit) | Playwright WebKit | npm run test:webkit:smoke |
every push / PR (parallel job) |
| Compile (Android) | Gradle javac | npm run android:compile |
PRs touching native inputs (path-filtered) |
| Compile (iOS) | xcodebuild | npm run ios:build:release |
PRs touching native inputs (macOS runner) |
| Smoke (Android) | Maestro + emulator | npm run test:android |
tagged releases only (API 33 + API 24) |
| Smoke (iOS) | Maestro + simulator | npm run test:ios |
tagged releases only (macOS runner) |
| WebKit commit timing | Playwright WebKit | npm run perf:web:undo:webkit:fast |
pushes to main; full suite on release tags |
A separate quality CI job (type-check, ESLint, Prettier --format:check, and
pnpm audit --audit-level=high) also runs on every push/PR alongside the tests — see Continuous
integration below. Two commands reproduce the non-browser half of that gate before a push, each in
CI's order and each continuing past a failure so one run reports everything: npm run check:quality
mirrors the quality job, and npm run test:browserless mirrors the browserless job — the four
Vitest tiers plus test:api:smoke, about a minute locally. A tools test per script reads its job's
steps out of test.yml and fails on drift. The hosted deploy smoke runs separately against real
deployments; its narrower test:blobs:smoke diagnostic is manual.
Before pushing a deletion that may leave an export unused, run npm run lint:dead. An edit to the
inline boot script in web/src/app.html also needs its CSP hash checked through
web/securityPolicy.test.ts; take the new hash from the test output rather than typing it. After
adding or renaming a Playwright spec, run the repo-script tier too: it scans spec files outside the
touched directory.
npm test runs the first five (test:unit:coverage + test:asset-gen + test:store-drawings +
test:tools + test:e2e). It is not a mirror of any one CI job: it adds the Playwright suite and
omits test:api:smoke, and test:unit alone runs only the first tier. The native smoke tests are
intentionally not part of npm test — they need an emulator/simulator and the native
toolchains.
Centerline tracing is also intentionally outside npm test: it is the repository's first isolated
Python/uv capability. Its dedicated path-filtered workflow provisions Python 3.11 and uv only when
tools/centerline-tracing/**, the store-drawing consumer, or that workflow changes. The quick named
suite runs ruff, pytest without the representative integration mark, and the capability-local Vitest
bridge/consumer contract. The workflow then runs the marked representative trace; the full corpus
benchmark remains manual.
A test that has only ever passed carries no evidence it is connected to anything, and a green suite
looks identical whether the coverage is real or vacuous. npm run lint catches that whole class in
the quality job, at no runtime cost. eslint.config.js scopes @vitest/eslint-plugin onto
**/*.test.{ts,mjs} and web/src/**/*TestHarness.ts, and eslint-plugin-playwright onto
web/tests/**/*.spec.ts, and turns on the same six guards under each plugin's spelling: a test body
with no assertion, a committed .only (which silently skips the rest of its file), an unconditional
skip, an expect that never reaches a matcher, a retrying assertion whose promise is dropped
(expect.poll, a web-first assertion), and an assertion reachable only through a branch. The shared
E2E helpers — every other web/tests/**/*.ts module — get only the three rules about an assertion's
own shape (valid-expect, missing-playwright-await, valid-expect-in-promise): a helper is not a
test, so the rules that judge a test body do not apply. Code outside those globs is not checked.
The branch rule is the only one that constrains how a test is written. A parametrized case states its expectation as a value rather than branching around the assertion:
// Not this — the assertion is skipped for half the table, and the skip is silent.
if (expected === 'shown') expect(label).toBeVisible();
// This — the case's own value drives the matcher, so every row asserts.
await expect(label).toBeVisible({ visible: expected === 'shown' });
expect(output.includes(hint)).toBe(scenario.wantsHint);The other two shapes that replace a branch: narrow a union through an asserts-signature expect*
helper (expectParsedBody in web/src/lib/server/http.test.ts) rather than an if on the
discriminant, and split a case that asserts something genuinely different into its own parametrized
block (web/src/lib/components/Icon.svelte.test.ts) — pairing the split with a test that the two
lists still partition the input, so an exception naming something that no longer exists fails rather
than quietly emptying a block.
Helpers named expect* count as assertions, so a test that delegates is not read as assertion-free.
Conditional skips (test.skip(!!process.env.DEV_SERVER, '…')) stay allowed — that is the supported
way to gate a spec on the environment.
The rules themselves have the same failure mode as the tests they police: one scoped to a glob
nothing matches reports nothing, which is indistinguishable from a clean repo.
tools/tests/vacuous-test-lint.test.mjs is the positive control — it seeds each defect and asserts
the rule fires, and pins what each deliberate relaxation lets through. Extend it when you add a
rule.
The lint rules above catch a test that structurally cannot fail. They cannot catch the other shape: a test full of real assertions that would have passed against the code as it was before the fix. It reads as coverage, ships as coverage, and pins nothing — and unlike a vacuous test, nothing in the suite will ever tell you.
So prove it: revert the fix, rerun the test, and require it to fail. A test that stays green against the unfixed code is evidence about the test, not the fix. Either it asserts the wrong thing or the behavior it describes is unobservable at that seam — both are worth knowing before the test is committed as protection.
Two shapes from the Codex rival wrapper (now tools/rival-agent/tests/stream.test.mjs and
tools/tests/launch-codex.test.mjs) are the worked examples, because both passed in the broken
state before the revert check caught them:
- The defect is real but unobservable through the seam under test. Asserting that a log file was complete when a promise resolved passed either way, because in practice the flush always wins the race; a volume-based attempt at 20,000 lines could not separate the two versions either. Distinguishing them needed an injected stream that fails while flushing — the ordering was only observable once the failure was injectable.
- The assertion never reaches the path a user runs. Asserting an argument parser's return value passed while the command that parser feeds crashed on invocation, because the crash lived in the caller. A unit assertion on a helper is not a test of the command documented in a skill.
Commit the fix before running the revert check. The check is destructive, and
git checkout -- <file> restores the working tree from the index (HEAD only when nothing is
staged), so it cannot separate the temporary revert from unrelated uncommitted work in the same file
— it silently discards both, and if the revert itself was staged it restores the reverted shape
rather than the fix. Commit first, or invert the edit rather than restoring the file. Never reach
for a bare git stash here: the stash stack is shared across worktrees and other sessions pop it.
A test also passes for the wrong reason when something outside it decides the outcome. Four shapes
the 2026-09 clean-code campaign found (rule 9 of docs/CODING-STANDARDS.md):
- Build the fixture per test. Two performance-matrix release-gate tests passed with their
deciding input removed, because a sibling test had mutated the shared fixture object they read (PR
#2414). Return a fresh fixture from a factory, and register cleanup with
onTestFinishedwhere the test creates a temp directory or a listener. - Drive the real store, not a double that copies its rule. Plain-object settings doubles broke
23 tests when PR #2386 changed the store's surface, and the repair copied the store's
aiCredentialKind()rule into them, so the request-header assertions checked the tests' own copy. PR #2413 moved them onto the realsettingsState. Import the owner's constant or function rather than re-declaring it, and type any remaining stub from the real function (vi.fn<typeof realFn>()) so a changed signature failsnpm run check. - Pin copy exactly as rendered. Never collapse whitespace before comparing rendered text. A Prettier re-wrap of a template text line changes the DOM text, which a whitespace-normalizing unit test hides and an anchored E2E regex then catches (PR #2400). If Prettier wants to wrap a long interpolation, bind a short local alias for it instead (PR #2411).
- Name a timeout and give it measured headroom. A per-test cap sized near the slowest observed
run fails on a loaded runner. Measure, name the constant (
PNPM_LAUNCHES_TIMEOUT_MS,REAL_REPO_TEST_OPTIONS), and prefer fake timers or a mockedsleepover real waits (PRs #2401, #2407, #2417).
Three Node smoke entry points guard the server contract:
test:api:smokeboots a throwawayvite devand checks the/api/*shapes (admin auth flow, bearer gate, token add/remove,verify-access-code) plus the CORS/preflight contract the native apps depend on. No Blobs, so it asserts the snapshot'spersistentisfalse. CI runs it in thebrowserlessjob on every push/PR; run it locally after any endpoint change (see theapiskill). Its dev server listens onSMOKE_PORT, notSPLOTCH_E2E_PORT; in a concurrent worktree, setSMOKE_PORTto an unused port.test:deploy:smokeis the normal real deploy gate. It checks/,/privacy, and the SSR-rendered/admin, security and cache headers,version.json(exactly the checked-out commit's on a preview or loopback target), both native-origin CORS preflights, safe unauthenticated API failures, and the admin persistence contract. Production and unrecognized targets use only the read-onlypersistent:trueassertion; Netlify previews add the token write/read/delete round-trip. It never makes a model call. Run it from the ref that produced the target deploy:The Hosted Deploy Smoke workflow probes production daily and accepts an optional preview or production URL on manual dispatch. It does not use the unrelated GitHub PagesDEPLOY_SMOKE_URL=https://deploy-preview-11--splotchy.netlify.app \ ADMIN_ACCESS_TOKEN=… npm run test:deploy:smoke
deployment_statusevent. The checker derives version strictness from the target, in the workflow and on the command line alike: a preview must serve the selected ref's version exactly, while production, its aliases, and any unrecognized remote host need only a valid, non-cacheable version, so production can trail docs/tooling-only commits that ADR-0070 intentionally excludes from Netlify builds.DEPLOY_SMOKE_REQUIRE_CURRENT_VERSION=trueorfalseoverrides that choice.test:blobs:smokeis the narrower diagnostic that proves Netlify Blobs is actually live on the deployed function — the failure mode of ADR-0025, which the localvite devtests structurally cannot catch:It assertsBLOBS_SMOKE_URL=https://deploy-preview-11--splotchy.netlify.app \ ADMIN_ACCESS_TOKEN=… npm run test:blobs:smoke
persistent:true. On a Netlify preview it also round-trips a unique token through Blobs and cleans up; production and unrecognized targets remain read-only. Run it on a PR's deploy preview before merging any adapter/Netlify-config change, and against production to isolate a persistence failure. The full hosted gate includes this same target-sensitive contract.
npm run test:unit # one-shot, then the web-build SSR guards
npm run test:unit:coverage # the same, with the measured coverage ratchet enforced on the first run
npm run test:unit:watch # watch mode (unit config only)
node tools/run-web-tool.mjs vitest run src/lib/server/securityHeaders.test.ts # one file, path relative to web/
node tools/run-web-tool.mjs vitest run -t "name" # by test name: Vitest's -t, not Playwright's -gnpm run test:unit -- <file> does not target one file. The script chains two vitest runs with
&&, and npm appends the arguments to the second (webSsr) run only, so the first still runs the
whole suite.
Configured in web/vitest.config.ts. Environment is happy-dom (not jsdom). Covers the pure
logic + state modules (colorRing, state/*, storage, including the native dual-layer hydrate
via a mocked @capacitor/preferences).
The coverage command uses V8 over every TypeScript module under web/src, including unimported
files at zero coverage; tests, declarations, and explicit *TestHarness.ts seams are excluded. The
committed baseline floors are 83.8% statements, 74.5% branches, 85.6% functions, and 86.4% lines,
derived from the measured 83.90% / 74.57% / 85.73% / 86.56% baseline and the 0.00–0.06 percentage
point variance observed between Node 22 and Node 24. They advance through reviewed configuration
changes rather than rewriting themselves during a test run.
The TypeScript-only denominator is deliberate: .svelte component bodies are outside this report,
even when a Vitest test imports a component's module script. Moving logic from a .ts module into a
.svelte file can therefore raise the ratio without adding coverage; a green gate proves the
committed floor only across the .ts surface. Browser and component coverage remain separate and
are not merged into this Vitest-only report.
Files that need no DOM at all — lib/server/** and pure-logic modules — carry a
// @vitest-environment node first line so they skip the per-file happy-dom setup (the suite's
biggest fixed cost). Keep the happy-dom default for any test whose module (or its imports) touches
localStorage, document, or window — running those in node would silently switch the code
onto its non-browser fallback paths.
Vitest aborts a timed-out test's context signal, but it cannot cancel the callback's underlying
async continuation. A unit test that polls through real timers and can later touch shared or global
mocks accepts { signal } and calls signal.throwIfAborted() after every await before
continuing. Without that guard, teardown can install the next test's globals while the timed-out
callback keeps running, producing secondary mock-count failures in a test that did nothing wrong.
Prefer fake timers when they represent the behavior faithfully. They do not represent
AbortSignal.timeout: Node runs it on internal timers the fake clock never advances, so a deadline
test stubs it with a controller that aborts on the faked setTimeout (the pattern in
web/src/lib/server/github.test.ts). If a cold dynamic import alone exceeds the default timeout on
a contended shared host, scope a named larger timeout to that one test — never raise the whole
file's timeout with vi.setConfig.
A component, route page, or action test mounts into the happy-dom environment above with Svelte's
own mount, settles with flushSync (or await tick() for effects), and awaits unmount(), which
returns a promise. web/src/routes/feedback/page.test.ts is the reference. What it needs that is
not obvious:
browserstaystruein a server render here.web/vitest-setup.tspins$app/environment'sbrowsertotruefor every unit-tier file,@vitest-environment nodeones included. Asvelte/serverrenderof markup that does not branch onbrowseris fine in this tier (the*.ssr.test.tsfiles); one that must see what the server bundle sees belongs in the web-build SSR tier below.- A route page needs
$app/formsmocked for itsuse:enhance, and$lib/platform/calmTransitionmocked to zero duration: happy-dom cancels a running transition on unmount with an unhandled rejection. - Give a stand-in pointer event
button: 0(andbuttons: 1while pressed). The pointer handlers act on the primary button only, and an event built withnew Event('pointerdown')has nobuttonat all, so the handler ignores it (thepointerEventhelper inweb/src/lib/actions/scribbleTap.test.ts). - Import Svelte state modules statically. A
vi.resetModules()harness hands the re-imported modules a second copy of Svelte's runtime, and an effect created under the test's$effect.rootis then an orphan to the module's effects (web/src/lib/state/dialogTheme.svelte.test.ts).
test:unit and test:unit:coverage each finish with a second Vitest run,
vitest run --config vitest.webSsr.config.ts, over web/src/**/*.webSsr.test.ts only. The unit
config excludes that pattern. These differences from the unit config are the point of the tier:
- It compiles
__IS_CAPACITOR__asfalse, as the Netlify SSR bundle does. The unit config compiles ittrue, so a web-only render branch never renders there. - Its environment is
node, not happy-dom, so a test here has nowindow,document, orlocalStorage, as on the server. - It loads no setup file, so
browseris not pinnedtrue. A test in this tier mocks$app/environmenttobrowser: false, which is what the server bundle sees.
perRequestRoutes.webSsr.test.ts lives here (#1924). It server-renders every page the web build
renders per request and fails if any module-level state reads differently afterwards, because that
state is shared by every request a server instance answers. Its drift checks fail when a new
module-level instance under lib/ has no observer in its table, when a component's
<script module> declares rune state, or when a page's effective prerender becomes false
without an entry in its render table. A new per-request page or a new lib/state singleton
therefore needs a line in that file before the suite is green.
Run the tier on its own with:
node tools/run-web-tool.mjs vitest run --config vitest.webSsr.config.tsnpm run test:asset-genConfigured in tools/asset-gen/vitest.config.mjs. These run in Node against committed fixtures and
mocked generator workflows, with no model calls or network access. CI runs them in the browser-free
browserless job, after the app-unit suite and alongside the repo-script suite, in parallel with
the e2e shards. Like the repo-script suite below, it needs web/.svelte-kit/ generated first.
npm run test:store-drawingsConfigured in tools/store-drawings/vitest.config.mjs. These run in Node against the committed SVG
samples and static instruction module. They cover the supported SVG subset, coordinate fitting,
closed width/color vocabularies, and exact generator drift without launching a browser.
web/tests/store-drawing-replay.spec.ts is the complementary browser contract for ADR-0122's hero
capture seam: it invokes one stroke on the real drawing route and asserts that the engine commits a
rendered history command without leaving synthetic pointer chrome behind. A small three-stroke scene
then runs through both the pointer and engine paths on separate live pages and requires their
composited canvases to retain greater than 0.9998 soft color fidelity.
npm run test:centerline-tracing
uv run --project tools/centerline-tracing --locked \
pytest -m integration tools/centerline-tracing/tests/test_integration.pyThe Python package, lockfile, and local virtual environment stay under tools/centerline-tracing/.
The quick suite covers graph schema round-trips, pruning geometry and scale invariance, fill rules,
vector/raster agreement, bridge behavior, deterministic repeated tracing, locked-environment
failure, batch atomicity, and direct handoff to the exported convertSvg() consumer. The marked
integration case compares a representative trace against the golden using metric thresholds rather
than bytes so small SciPy/BLAS selection drift does not create a platform-fragile test.
npm run test:toolsIn a fresh checkout or worktree, run npm run check first: its precheck runs svelte-kit sync,
which generates the web/.svelte-kit/tsconfig.json that web/tsconfig.json extends. Neither the
worktree bootstrap nor pnpm install creates it, and without it the suites that load web/src
modules fail with [TSCONFIG_ERROR] Failed to load tsconfig 'web/.svelte-kit/tsconfig.json'.
Configured in tools/vitest.config.mjs (Node env), tests in tools/tests/. Covers repo automation
helpers whose regressions would be silent — currently the audit-burndown docs/AUDIT.md surgery in
tools/audit-burndown/lib/burndown-core.mjs (entry-boundary parsing, pure block removal,
dprint-clean seams; see the burn-down-audits skill) and complete runner-specific skill replacement
in tools/ruler/apply-skill-forks.mjs (package isolation, paired-runner coverage, and shared-source
collision guards). The latter covers generic Ruler-managed forks; packages listed in
tools/ruler/lib/direct-provider-skills.mjs are maintained directly in their declared provider
trees and excluded from Ruler drift ownership. tools/ruler/apply-ruler.mjs snapshots and restores
every registered path around generation, including its failure path. Add a test here when a tools/
helper's failure mode is corrupting state rather than crashing.
The suite also hosts the drift guards over things prose can't keep in agreement —
e2e-engine-tags.test.mjs and e2e-harness-imports.test.mjs over the specs themselves, and
skill-spec-citations.test.mjs over the docs: every tests/… path an agent-instruction file names
must resolve to a real file in web/tests/, so a spec split can't strand a documented command that
then selects zero tests. The scan covers the skill trees, every CLAUDE.md/AGENTS.md, and the
audit-burndown role prompts. Globs and placeholders (engine-*.spec.ts, tests/<name>.ts) read as
prose and are skipped; design history — skill notes, ADRs, docs/AUDIT.md — is outside the scanned
surface on purpose, as is any non-Markdown source, whose spec names are indistinguishable from the
synthetic ones the tools/tests/ fixtures feed their reporters.
Visual baselines with a -chromium-linux.png suffix must come from CI's Linux render, not from a
Mac update run. Download the failing shard's playwright-report-shard-N artifact and inspect its
index.html template named playwrightReportBase64; its embedded zip maps each *-actual.png
attachment to a data/<hash>.png file. Copy the actual image only after verifying the failing
snapshot name and platform. A local --update-snapshots updates the Darwin baseline instead.
npm run test:e2e # headless — whole suite
npm run test:e2e:ui # Playwright UI mode
npm run test:e2e:headed # headed, slowed down (SLOWMO=500)
npm run test:e2e:debug # inspector
# one spec / one title, not the whole suite (trailing args pass through to Playwright):
npm run test:e2e -- flows-undo-persistence.spec.ts -g "the undo button enables on a stroke and reverts it"For ad-hoc validation of a single change, filter through the npm script — not raw
npx playwright test from the repo root. The config + baseURL live in web/, so raw npx from
the root navigates to an empty baseURL (Cannot navigate to invalid URL) and also loses the
Chromium fallback (cryptic chrome-headless-shell error in cloud). node tools/run-web-tool.mjs
sets the web/ cwd and Chromium path for you, and forwards everything after -- to Playwright.
Configured in web/playwright.config.ts. By default it builds the production artifact and serves it
with vite preview (set DEV_SERVER=1 for fast iteration against vite dev). Specs live in
web/tests/ and exercise the real drawing engine, the responsive palette, and the full UI flows.
Run Vitest and SvelteKit sync/type checks before or after production Playwright runs in the same checkout. Their SvelteKit plugins rewrite shared generated files; overlapping them with the browser build can mismatch the HTML and client bootstrap IDs and prevent the app from starting. Use separate checkouts when these commands need to run concurrently.
These run on real Chromium but cannot catch native or WebView boot failures — that's what the Android smoke test is for.
Start discovery at the repository's supported CI contention level: one worker per logical CPU
available to the process (ADR-0078), with retries disabled. Read that count with
node -p "require('node:os').availableParallelism()", then run at least three independent full
suites through the sweep driver:
npm run test:e2e:sweep -- --workers=<printed count> --reps=3 --out=/tmp/splotch-e2e-discoveryThe driver builds once, unsets the CI variables that enable retries, and lets Playwright start a fresh preview server for every repetition. That fresh server is part of the protocol: the suite fills server-side rate-limit windows, so a shell loop against one long-lived server manufactures failures that the next real run would never inherit.
Use this sequence for a suspected flake:
- Reproduce the failure on the pre-fix code at the supported CI worker count. Record the failing product state, not only the assertion text.
- Decide whether the timing window is reachable by a real user. Contention can amplify either a test-harness race or a product bug; a failure under stress alone does not classify it. Fix the product when a user can reach the bad state. Otherwise make the spec wait for the durable outcome its next operation actually needs.
- Keep the pre-fix reproduction as evidence, then run a focused diagnostic amplifier with retries
disabled, for example
npm run test:e2e -- <spec> -g "<title>" --workers=<higher count> --retries=0 --repeat-each=10. Higher-than-supported worker counts are diagnostic amplification only, never a new default. - Verify the outcome-based fix with that focused amplifier, then run at least three clean
full-suite repetitions through
test:e2e:sweepat the supported CI worker count. Use the sweep for any stateful or full-suite repetition so every repetition gets fresh server state.
A clean streak is validation, not proof of a zero flake rate. Report the sample size and contention level so a later recurrence can update the evidence instead of contradicting an absolute claim.
The full suite runs parallel workers, derived from the machine rather than hardcoded: a worker costs
~2 cores, so capacity is cores / 2 — local runs sit there and CI goes to twice it, i.e.
cores (playwright.config.ts, ADR-0078; on the 4-core boxes measured, 2 and 4). So every spec
shares the CPU with the others, and a test that passes alone but fails in the full run is almost
always a timing race under that contention, not a real regression. Locally retries: 0 surfaces it
immediately; CI retries, so a flake can still ship green — which is why a retried pass is annotated
(playwright-flaky-reporter.ts) rather than left silent in the log of a job nobody opens. Neither
annotations nor job summaries can be searched across runs, so the same reporter also writes every
run — clean or not — as playwright-report/flaky.json inside the job's uploaded report artifact:
each retried pass with its project and spec file, plus the run id, attempt, commit, branch (the real
head branch on a pull request, not the <n>/merge ref), shard, run status, and test count, so
masked flakes can be summed over a week of jobs instead of scraped from thirty log archives. The
scheduled Flaky digest workflow does that summing: it carries those records forward in a history
artifact past the reports' 7-day expiry and ranks masked flakes per test, with main and PR-branch
counts kept apart and every report job it could not read listed as a gap rather than a clean run.
Read its job summary, or run npm run gen:flaky-digest locally (tools/flaky-digest/README.md).
Write specs that can't race in the first place:
-
Never assert on a single interaction against a lazily-wired control. Overlays that idle-mount (Settings, ADR-0049) can drop the first click before their handler is attached, so a bare
.click()+expect(modal).toBeVisible()flakes.tests/helpers.tsexports a sharedretryOpen(ready, open, opts?)primitive for this — it retriesopen()untilreadyshows, skipping the click when it is already open. The ready locator must be durable: prerendered or native behavior must not be able to satisfy it before hydration and then reset it. Either wait on a hydration-only sentinel before interacting (the Beta page's on-mount-only support link andopenHydratedContents' computed panel cap are examples), or retry the complete action plus its durable product outcome as one transaction.openSettingsModal(alsotests/helpers.ts) andopenDrawer/openBrushMenu/openColoringDialog/openParentalGate(tests/flows-harness.ts) are all one-liners overretryOpen. Reach for one of those contracts (or wrap the complete open-and-outcome transaction inexpect(...).toPass()) rather than repeating a bare click or treating transient visibility as readiness. -
A view an overlay picks at open time can't be waited into — reopen it. Retrying an assertion only works while the thing asserted on is still coming; when the open itself chose a different view, no timeout reaches it.
ColoringBookpicks between its book grid and a single book's pages inonOpenand holds the books that open shows until it closes: a book that becomes known mid-open, by download or by the installed-book scan (a manifest fetch plus a store scan), joins at the next open, so the picker never moves under a finger. So a picker opened while only the starter book is known shows its pages and stays there, which is how "no viewport draws a cover smaller than four columns" came to fail with the cover grid simply absent (issue #936). The sentinel to retry the open against is the view you need, not the dialog:openColoringBookGrid(tests/flows-harness.ts) closes and reopens until an open lands on the grid, since each open re-reads the installed set. Prefer it overopenColoringDialogin any spec that then reads a book cover, and seed the page withgotoAppWithInstalledColoringBook/…AllColoringBooksInstalledso there is a grid to land on — a spec that means to exercise the fresh-install view drives the dialog directly instead (coloring-pack-download.spec.ts). On the web, seeding is also what makes a boot count as a returning visit: a device with no pack storage downloads nothing until the child draws a few strokes or opens the picker, so a plaingotoAppspec that needs other books must engage first. -
No fixed
waitForTimeoutto wait for something to happen. Use a web-first assertion that retries until the condition holds (expect(locator).toBeVisible(),expect.poll(() => …),expect(...).toPass()). A fixed sleep is only legitimate when it is monotonic-safe under load: (a) deliberately idling past a known threshold to reproduce a timing bug (e.g. the stroke-resume gap inengine-pointer-recovery.spec.ts), or (b) proving a negative — that state must not change within a window (e.g. the "SW never registers" check inpwa-registration.spec.ts). A slower worker only lengthens the real wait in both cases, so they can't false-red. Comment the reason when you keep one. -
Use the drawing app's durable ready state.
gotoApp()does not return merely because the prerendered#drawingCanvasis visible: it waits for the hydrated drawing debug seam and a non-zero rendered composite. Specs intentionally covering first paint or hydration navigate withpage.goto()so the shared postcondition stays strong. UsedrawCommittedStrokewhen setup requires one durable command: it retries input only while the renderer's monotonic stroke revision proves that the previous attempt produced neither a pending nor a committed stroke, so a slow commit cannot become a duplicate stroke. Keep the visual or product-specific assertion in the spec. -
Poll async render/canvas state; size the window for a starved worker. Canvas reveals and debounced relayouts settle asynchronously and lag hard under contention. The magic brush samples a sheet that rasterizes async, holding a stroke's ops out of the paper until a fold-in repaint (
REVEAL_SETTLE_MSinflows-magic-brush.spec.ts); the engine debounces resize byRESIZE_SETTLE_MS. Useexpect.pollwith a generous timeout, not a one-shotawait page.evaluate(...)+expect(...). -
Read reactive/engine state through a retrying assertion.
expect(await count(page)).toBe(n)reads exactly once and races the repaint;await expect.poll(() => count(page)).toBe(n)waits for it to settle. Same forgetViewState()/pixelAt()reads after a rotation. -
A sequence that must land inside a timing window must retry as a whole. A triple-tap that has to fall inside dragToClear's 1000ms multi-click window (
clear-tutorial.spec.ts) can straddle it under load — wrap the entire burst intoPass(), don't just add a longer wait between taps. -
A mocked endpoint control resolves after delivery, not after queuing. If a helper exposes
succeed()orfail(), resolve that control only after the intercepted handler's awaitedroute.fulfill()completes. Queuing a response does not prove the page has received it, so an assertion started from that signal races the network operation it means to observe. Keep the delivery handshake beside the mock that owns it; do not create a generic route-controller abstraction without a second honest caller. -
Pace synthetic gesture phases on rendered frames. Touch start, dependent moves, and touch end can be delivered to the compositor as one coalescible burst under load even when arbitrary wall-clock sleeps separate them in the test process. Advance a phase with
requestAnimationFramein the page when the next phase depends on compositor observation. Keep that pacing local until a second real caller earns a narrowly named helper; do not introduce a genericnextFrameorwaitForStableabstraction. -
Leave an element by moving the real mouse, never by dispatching a boundary event. A synthetic
pointerleaveruns the app's handler but leaves the browser's hover target where the cursor is. Any later hover recompute — an overlay under the parked cursor unmounting, such as a flyout closing on the click that chose from it — then sends a trustedpointerenterback to the element, on whichever frame the removal paints. The eraser-bubble spec inflows-palette-brush.spec.tslost that race 3 in 60 single-worker runs (7 in 40 with event logging slowing the frame): the flyout closed, the synthetic leave hid the bubble, and the recompute re-entered the canvas a frame later. Hover a stable control instead (locator.hover()), which moves the hit-test target itself. -
A control's UI state commits a tick before the imperative engine adopts it, so wait on the engine. The tool buttons update
aria-pressedreactively, but the engine enters that mode through a Svelte$effect(setBrushinDrawingCanvas), soaria-pressed=truedoes not prove the engine switched, and a stroke drawn in that window would commit under the previous brush — already painted before anything can observe it.pickBrush()closes it by polling the engine's ownwindow.__committedBrushMode(the dev-harness seam inlib/boot/devHarnessSeam.ts, ADR-0080); prefer that shape — a signal for the state you actually depend on — over retrying an action until its effect appears. Where you must assert instead, pick a metric a wrong-mode action can't satisfy: a canvas-fill pixel count is not one, since a pen stroke fills the band too. -
Let a fly-in dialog land before acting on it — a real click included. An
evaluatethat readsgetBoundingClientRect()and dispatches synthetic pointer events there never waits for the dialog to stop moving, and a real Playwright.click()only usually does: its stability check is two consecutive frames with an identical rect, and a CSS animation still pending its start time (a starved compositor grants it late) holds its first keyframe across frames and passes that check. Captured under full-suite contention inai-report.spec.ts: the click on the confirmation's Send button dispatched 52ms after open, at a 17×21px dialog (the 5% keyframe), 9px from the launch origin — inside the dead zone below, so it was swallowed and nothing was sent. The immune tests in that spec were the ones that pressed Escape or ran under reduced motion, which is the tell.dialogFlyFromOrigin(app.css) starts a modal scaled down on the button that opened it, andmodalDialogarms a launch dead zone at that same point (launchGuard: 72px, 600ms) whose capture-phasepointerdownhandler swallows everything inside it — dialog content included, by design (issue #308's ghost click). So for the opening frames the whole dialog sits in the dead zone: Settings' content pane centers 6px from the launch origin at the first keyframe and only clears the radius ~13ms into the animation. A CSS animation advances with rendered frames, so a starved worker parks the dialog on that keyframe for far longer than 13ms of wall clock, and the gesture is aimed straight into the guard and silently does nothing. That was issue #665 — the three zoom/pinch specs that were the entire residual flake rate (ADR-0078 §4), all failing as "the pinch produced no zoom". The fix is to await the dialog'sAnimation.finishedbefore touching it —settleFlyIn(tests/helpers.ts), whichopenSettingsModalandlandedReportConfirm(tests/ai-harness.ts) wrap. Landed, the Settings pane rests 574px from the origin and the report confirmation's buttons 130px+, so the dependency on animation progress is removed instead of timed. Every dialog carryingmodal-fly-in(#settingsModal,#color-picker,#coloring-book-dialog,.ai-prompt-modal,.parental-gate,.ai-report-confirm) needs the same wait between its open and the first pointer action on its content. The colour picker is the sharpest case: it snapshots every hexagon's center and its snap radius on thepointerdownthat starts a drag, so a drag begun on an unlanded grid would keep a scaled-down snapshot for the whole gesture. Whether that explains the one red run ADR-0078 §4a recorded for the pointer-exploration spec is unproven — a down at the first keyframe is swallowed by the launch zone before the picker sees it, and the failure was never reproduced — but the spec now waits like its tap sibling, which closes the window either way. QuerygetAnimations()on the dialog element alone — the fly-in animates it directly, and{ subtree: true }would start waiting on unrelated descendant animations too. -
Budget a frame-paced condition in frames, not milliseconds. The wide Settings pane mounts one section per frame once the card lands (issue #910), and
aria-busyclears only when the last one is in — so the wait for it is on the fill's clock, which is the frame. Playwright's assertion timeout is wall clock, and contention stretches a frame without adding any, which is how the default 5s cap onexpect(pane).toHaveAttribute('aria-busy', 'false')came to fail a WebKit smoke run three times over on a fill that was merely unfinished (issue #918 — theweb/tree under test was byte-identical to a greenmain).settleSettingsPane(tests/helpers.ts) samples the attribute from inside the page once perrequestAnimationFrameand gives up only afterSETTINGS_FILL_FRAME_BUDGETframes, so a starved worker costs it wall clock and nothing else, and the budget still bounds a fill that has genuinely stopped. Sampling from inside the page is half the point: a round trip to the test process measures the harness, and on a starved worker one can span the whole fill. Reach for the same shape wherever the thing being waited on advances with rendered frames — and note this is the complementary half of the bullet above: a CSS animation runs on rendered frames too, but it exposesAnimation.finished, so there you await the browser's own signal instead of counting. -
Pace a spec on the animation it depends on, never on a clock sized to it. The lifting-halo spec in
drawing-motion.spec.tsneeds a lift that has started and not ended when reduced motion switches it off, and theanimationcancelthat switch produces is dispatched only by a rendering update. Three fixed sleeps sized that on an idle machine and went red 8 times in 20 repeats at four times capacity (issue #2196): the shard rendered no frame inside the 300 ms budget, so the cancel never came and the ring was still there. The rewrite waits on the browser's own signal for each phase — the grow-in'sanimationend, then the lift'sAnimation.readybefore the attribute flips, then the lift'sanimationcancel— and asserts the removal through a web-firsttoHaveCount(0), so a starved worker stretches each wait and breaks none of them. Two of those choices are load-bearing, both measured in Chromium: a play-pending animation cancelled before its first frame fires noanimationcancelat all, soreadyis what makes the cancel certain; and anAnimation.finishedcontinuation runs beforeanimationenddispatches, so a spec that depends on the product's end handler having run waits on the event, not the promise. Svelte scopes a component's keyframe names with its hash, so matchanimationNameby suffix, not equality.A spec that has to act inside an animation needs the mirror-image move: widen the animation rather than timing the act. The halo lift-off specs in
halo-lift.spec.tsrelease the stroke partway through a 120 ms grow-in, which no sleep can hit reliably, so they override--halo-in-durationto 5 s through a:root:rootrule that outranks the component's scoped declaration — the lift keeps its real duration, and no slow frame can end the grow-in before the release. The override has to come from a stylesheet rather than an inline style: the halo does not exist until the press mounts it, so by the time a spec could reach the element a starved frame may already have finished the grow-in. Those specs also resolve their outcome on the element's removal as well as on its animation events, so the pre-fix product — which deleted the halo before the lift could start — fails the assertion instead of hanging on an event that never comes. -
A modal open is not the only thing that arms a dead zone.
launchGuard.guardTapZoneis armed by any tap that repaints something else under the finger, so a spec can be swallowed well after the fly-in has landed.ColoringBookarms one when a book cover swaps the grid for that book's pages, which is exactly where a spec then clicks a page tile — two specs went red on the first run of that guard.flows-harness'ssettleTapGuard(page)idles past the window (it is one of the few legitimate fixed sleeps: a known duration, and a zone self-clears on the next query rather than exposing state to poll), andopenFarmPageGridalready calls it so its callers don't have to. When a click at a just-tapped point mysteriously does nothing, check for a zone before suspecting the control. -
Drive strokes through
draw/dragStroke, never a hand-rolled run ofmouse.moves. The engine reads a sample far from the previous one and more thanPOINTER_RESUME_GAP_MSlater as a finger that lifted and set down (strokeMath.pointerWasResumed), restarts the stroke there, and never paints the span between — so under contention a four-point sweep can come back as its start dot alone. A starved worker owns the timing half of that predicate; the helper holds the other half by subdividing every hop withmouse.move'ssteps, sized fromPOINTER_RESUME_JUMP_RATIOimported from the engine's own module (ADR-0080). -
Calibrate a discriminating threshold against measured distributions on both sides. The magic reveal's colour count had to reject a flat pen pass (measured 1-3 buckets) and accept a rainbow slice (measured min 3 at the old quantization) — the two overlapped, so a correct reveal failed a few percent of the time and no retry could help, since a redraw repaints the same gradient. Sample both populations before picking the number, and leave margin on each side, rather than reasoning about what the value "should" be.
-
Shared per-test setup belongs in a fixture, never in a helper module's top-level
test.beforeEach. A helper is evaluated once per worker process, so a hook it registers at import time attaches only to the first spec file in that worker that imports it; every later spec file gets no setup at all and runs againstabout:blank. That was issue #624 — ~12/dev/enginespecs "flaking" with a missing#drawingCanvasor an undefinedwindow.__engine, green on retry (a retry re-runs the file alone) and green in isolation. Extendtestin the helper instead (base.extend({ page: async ({ page }, use) => { …setup…; await use(page) } }), seetests/engine-harness.ts) and have specs importtest/expectfrom the helper;tools/tests/e2e-harness-imports.test.mjsfails the build if one importstestfrom@playwright/testinstead. -
Prove it's fixed under load, not in isolation. Flakes only appear under contention, so verify with
npm run test:e2e -- <spec> --repeat-each=10(which still fans out across the configured workers) before trusting green — a single isolated pass proves nothing. A stubborn one may only show every ~1-in-5 runs; raise--repeat-eachuntil you've seen it both fail on the old code and hold on the new.
The full suite is Chromium-only. A tiny critical-path subset (boot, draw a stroke, Settings dialog,
Color Picker dialog) also runs in the firefox and webkit Playwright projects so every declared
browser engine has an automated push/PR signal:
- Each project joins only when its binary is installed, so Chromium-only local and cloud checkouts
keep working. CI explicitly installs the selected browser and sets
REQUIRE_FIREFOXorREQUIRE_WEBKIT, turning a missing binary into a hard failure. Run one project withnpm run test:firefox:smokeornpm run test:webkit:smoke. - CI keeps
firefox-smokeandwebkit-smokeparallel to the sharded Chromium Tests job. Firefox is the cheaper leg because it needs none of WebKit's GStreamer/ffmpeg stack. WebKit adds ~110 system packages, and installing them lands on every run; the setup action's apt cache removes network fetches, not the install. Keeping both browsers out of every Chromium shard avoids adding either setup cost to the critical path. - All three Ubuntu browser jobs get their browsers from
.github/actions/setup-playwright(browser cache +install-depsbehind an apt.debcache, each keyed per browser set); macOS keeps its ownsetup-playwright-webkit, which needs no apt step and caches elsewhere. On a cache hit the whole setup is offline (dpkg -ifrom the cached debs); the network path runs only when a key rotates, andwarm-playwright-cache.ymlrepopulates the default-branch caches daily and on dependency merges so PR runs rarely take it. A setup step dragged out by a network-starved runner is bounded only by the job'stimeout-minutes— re-run it by hand for a fresh machine. - Routing is by tag, not filename.
ENGINE_SMOKE_TAG(tests/tags.ts) sits on the spec'stest.describe; Firefox and WebKitgrepfor it while ChromiumgrepInverts it. Add portable cross-engine coverage by tagging it; an untagged spec runs under Chromium. - Import the tag, never type it. Playwright validates no tag, so a typo can route a spec to
Chromium alone while both smoke jobs remain green on the correctly tagged tests.
tools/tests/e2e-engine-tags.test.mjsrejects literals and unknown tags, requires a tagged spec, and guards agreement among the project, script, and CI-job names. - Keep the spec cross-engine portable: no CDP sessions (
rotateViewportViaCdpintests/cdp.tsand thetouchDriverintests/settings-zoom.spec.tsare Chromium-only), no dev-harness routes, no assertions tied to Chromium's rasterizer. Chromium skips the tagged specs — their coverage is already in the full suite. web/playwright.webkit-scratch.config.tsstays for ad-hoc "run any spec under WebKit" debugging; it is still not part ofnpm test.
CI runs current Firefox and WebKit, not Firefox 114 or Safari 16.4 — it proves engine-family
coverage, not the floor version or a native iOS boot. The declared bundle target has separate drift
coverage, while native iOS 16.4 remains a manual/device concern; see docs/COMPATIBILITY.md for the
exact boundary and hosted-run evidence.
The adult-facing surfaces get an automated axe-core scan (@axe-core/playwright, a
devDependency per ADR-0070 — CI-only tooling, never in the Netlify build) as part of the normal E2E
run — no separate command or workflow:
- What's scanned:
/privacy,/changelog,/admin(logged-out and logged-in, via thetest-admin-secretweb-server key), and Settings dialog opened over/. - What's deliberately not: the drawing canvas and toddler-facing chrome. Toddler UX (giant
wordless buttons, no reading order) isn't WCAG's model, so Settings scan is scoped to
#settingsModalviaAxeBuilder.include()instead of scanning the whole drawing page. - The gate: only violations with impact
seriousorcriticalfail the test, but the failure message prints every violation axe found (id, impact, offending selectors, fix hints), so the moderate/minor tail is visible in any red run. - Adding a surface: add a test to
tests/a11y.spec.tsthat navigates (or opens the overlay), waits for a stable element, and callsexpectNoSeriousViolations(page)— pass a CSS selector as the second argument to scope the scan when the surface is an overlay above toddler chrome. - What axe cannot see inside a modal
<dialog>: itsaria-dialog-namerule matches only[role=dialog], so a native<dialog>with no name scans green, and it reports every text node inside a modal as incomplete (the top-layer backdrop overlaps it), so dialog text contrast goes unchecked.NAMED_DIALOGSin the same spec opens every modal the web build can raise and asserts its accessible name, thenexpectTextContrast(tests/text-contrast.ts) measures each one's visible text against the AA floors in both themes, compositing ink over the backgrounds beneath it. Its doc comment lists what it still cannot see. A new modal gets an entry there. - Fixing vs suppressing: fix violations in the app source. Only suppress (axe
disableRulesor an exclusion) for a genuine false positive or an unfixable-by-design case, each with a comment saying why.
Executable doesn't exist … chromium-<rev>— the env's cached Chromium revision drifted from the one this Playwright version wants.playwright.config.tsand therun-splotchdriver now self-heal: if the pinned binary is missing they fall back to any Chromium underPLAYWRIGHT_BROWSERS_PATH(default/opt/pw-browsers). Override the pick withPLAYWRIGHT_CHROMIUM=/path/to/chrome. Never runnpx playwright installin a cloud session. The permanent fix is keeping.claude/cloud/setup.sh's browser install pinned to this package's@playwright/testversion (it now derives it frompackage.json). Seedocs/CLOUD/Claude-Code.md.DEV_SERVER=1is unreliable in cloud — global-setup has hitwindow is not defined(SSR) //dev/engine never became readythere. Use the default production-build path (justnpm run test:e2e); it's slower per run but works.
The web E2E suite runs in a browser, so it can't tell you whether the shipped native app actually boots. The smoke test fills that gap: it installs the app on a real Android emulator or iOS simulator, launches it, and asserts that the UI renders — proving the Capacitor WebView started and loaded the production web bundle (not a white screen or a crash).
The assertion is a single, meaningful signal: the "Settings" button (the always-present corner
button, web/src/lib/components/SettingsButton.svelte) must become visible. Seeing its
accessibility label means real UI painted, not just that the process launched.
That the flow stops there is a decision, not an omission — ADR-0120. Steps that navigated the UI
broke on a UI change within three releases every time they were added, and because these workflows
are tag-only, each break surfaced as a red release gate instead of a failing PR. Coverage of what
the UI does belongs in Playwright, which runs on every push and can select elements properly. Do
not grow this flow; tools/tests/native-smoke-flow.test.mjs fails on npm test if it selects a
string the app no longer renders, or selects by pattern instead of by exact label.
The test itself is a declarative Maestro flow — .maestro/smoke.yaml:
appId: art.splotch.app
---
- launchApp:
clearState: true
- extendedWaitUntil:
visible: 'Settings'
timeout: 30000
- takeScreenshot: .maestro/screenshots/smoke-launchtakeScreenshot writes smoke-launch.png under .maestro/screenshots/; it's git-ignored. On CI
the whole Maestro report — flow log plus the screenshot taken at a failing step — is uploaded as a
build artifact, and a failure on a tag push files or comments on a GitHub issue, since nobody is
watching a tag-triggered run at the moment it goes red.
The iOS job budgets XCTest driver startup with MAESTRO_DRIVER_STARTUP_TIMEOUT separately from the
flow's app-paint timeout. Its report also retains ~/Library/Logs/maestro/xctest_runner_logs/:
Maestro's flow log alone can show connection refusals without the underlying xcodebuild error. A
failed test:ios command can mean simulator setup, build/install, or driver startup failed before
the app assertion ran. Inspect that phase before calling it a product boot regression. Issue
#1734 was filed after the XCTest driver timed out,
with no flow execution or screenshot.
After a Maestro failure, test:ios also captures a bounded diagnostic relaunch of the installed app
in ~/.maestro/tests/ios-app-relaunch.log before simulator teardown. The console can expose native
or WebView startup errors; it describes a second launch and does not replace the original failure
evidence. A successful relaunch never changes the smoke result.
npm run test:android # headless one-shot: boot → build+install → test → tear down
npm run test:android:device # run against an emulator you already have running
npm run test:ios # one-shot on the iOS simulator (macOS + full Xcode)| Script | What happens |
|---|---|
test:android |
Runs tools/mobile/android/run-smoke-test.mjs: boots a headless Pixel_7_Pro_API_33 emulator (-no-window …), builds + installs (cap:sync then ./gradlew :app:installDebug), runs Maestro, and always kills the emulator afterward — even on failure. Self-contained and self-cleaning. |
test:android:device |
Just maestro test .maestro/smoke.yaml against whatever device is already connected. Fast inner loop — you boot the emulator and install the app yourself. This is what CI uses. |
test:ios |
Runs tools/mobile/ios/run-simulator-smoke-test.mjs: reuses a booted iPhone simulator (or boots the newest available one), builds the debug app with xcodebuild, installs via simctl, runs the same Maestro flow, and shuts the simulator down if the script booted it. No signing required. |
The smoke scripts are device-lifecycle glue only — Maestro does the actual assertions, and both platforms run the same flow file. The Android helper works on macOS and Linux (AVD name and SDK locations resolve per-platform in
tools/mobile/android/lib/android-toolchain.mjs; override the SDK withANDROID_HOME); the iOS helper is macOS-only and fails fast elsewhere. Maestro's install location resolves intools/mobile/lib/maestro.mjs.
For the required iOS floor check, follow the
manual iOS 16.4 floor gate. Its primary path is
Maestro-free; npm run test:ios is only an optional diagnostic on 16.4 because the hosted
experiments could download, boot, and build for that runtime but Maestro's XCTest driver never
became reachable. docs/COMPATIBILITY.md records both experiments and the complementary
current-WebKit CI coverage.
- Android toolchain — the same one used to build the app: Node ≥ 22, full JDK 21, and the
Android SDK. See the
mobileskill and theandroid:*scripts. - An AVD — the scripts default to
Pixel_7_Pro_API_33. List yours withemulator -list-avds. - Maestro CLI — see below. Needs Java 17+ (the JDK 21 above covers it).
On macOS/Linux,
npm run android:setupprovisions all three (system image, thePixel_7_Pro_API_33AVD, and Maestro) and is safe to re-run — it skips whatever is already present.
Maestro is not an npm package, so it can't be a dev dependency — it's a standalone JVM-based CLI installed separately.
macOS / Linux: npm run android:setup installs it automatically (re-run it if Maestro is
missing). To install it on its own:
curl -fsSL "https://get.maestro.mobile.dev" | bash
# adds the binary to ~/.maestro/binUse
get.maestro.mobile.dev—get.maestro.devdoes not work.The smoke scripts resolve Maestro via
tools/mobile/lib/maestro.mjs(PATH first, then~/.maestro/bin), so they run even before you reopen your shell to pick up the PATH entry the installer adds.
# Simplest — one command, nothing to clean up:
npm run test:android
# Fast iteration — boot once, run many times:
npm run android:emulator # boot + build + install (or: boot an AVD, then npm run android:run)
npm run test:android:device # re-run as often as you like| Workflow | Trigger | What it runs |
|---|---|---|
.github/workflows/test.yml |
every push to main, every PR, v* tag push |
quality, browserless, and sharded e2e jobs on branch/PR events, plus parallel Firefox/WebKit smoke jobs; fast WebKit commit gate on pushes to main; full gate on release tags |
.github/workflows/android-deploy.yml |
v* tag push + manual workflow_dispatch |
One test-signed Android Release APK build + Maestro boot-smoke matrix on current API 33 and the API 24 floor |
.github/workflows/ios-deploy.yml |
v* tag push + manual workflow_dispatch |
iOS Release simulator compile without store signing + Debug Maestro boot smoke (macOS runner) |
.github/workflows/native-compile.yml |
PRs and main pushes touching native inputs |
Android Release Java compile (no web build) and iOS Release simulator compile, in parallel with test.yml; no emulator or simulator boots |
.github/workflows/hosted-deploy-smoke.yml |
Daily + manual workflow_dispatch |
Full hosted deploy contract, including ADR-0025 persistence; automatic production runs are read-only, while a manually targeted preview adds the write round-trip |
Inside test.yml, every job runs on its own runner in parallel — runner minutes are free on this
public repo, wall clock is not. The Vitest suites (test:unit:coverage + test:asset-gen +
test:store-drawings + test:tools) run in a browser-free browserless job
(npm run test:browserless locally), and the Playwright e2e suite runs as a matrix in Tests —
each shard builds the app itself (a shared build artifact was measured slower: it serializes shards
behind needs:), and each uploads its own playwright-report-shard-N artifact. The app-driver and
worker-sweep smokes ride shard 1 only. With fullyParallel on, --shard deals out individual tests
(not files) in a deterministic order, balanced by count and blind to duration — so the longest shard
is bounded by the slowest single test, which lives among the deliberately heavy stress tests of
tests/flows-tile-history.spec.ts (the header comment there explains why their cost is intrinsic).
The 2026-08-19 shard-count measurement is preserved in the shard-timing scratchpad, including the expiring report-derived partitions, the fixed build/preview term, and the first real eight-way pull-request run. Four count-based shards kept the adjacent tile-history stress tests concentrated; eight cut the modeled slowest test load roughly in half. Holding the measured 17.1-second build/preview cost out of that scaling projected a roughly 77-second slowest e2e step, which the real warm-cache run matched. At that point checkout, dependency/browser setup, the two shard-1 smokes, and the browserless job dominate the pull-request floor. Cold-cache eight-way contention has not been measured; the warming workflow mitigates it, and the scratchpad records what to inspect on the next runner-image rotation. Further sharding should re-measure setup and the whole workflow, not extrapolate the test-load reduction alone.
The Hosted Deploy Smoke workflow needs a repo secret ADMIN_ACCESS_TOKEN matching production's
admin secret; without it the automatic job fails at the login step. A manually supplied preview must
use that same secret. Production never creates a probe token. A process-level interruption of a
preview check can strand an unguessable but live blobs-smoke-* credential; inspect and remove one
manually from the admin console because an automatic prefix sweep could race a smoke against another
URL. The iOS smoke mirrors Android but on a macos-latest runner — the debug build targets the
simulator, so no signing secrets are involved. Both deploy workflows file a tag failure from a
downstream report-failure job keyed on the build and smoke jobs' results, so a job cut off by its
timeout still files, and only that reporter holds issues: write.
The Native compile workflow (native-compile.yml) is the pre-merge half of those tag gates. Its
path filter is android/**, ios/**, capacitor.config.json, package.json (which holds both
compile commands), pnpm-lock.yaml, the Gradle helper and its imports, and the workflow itself. A
match costs runner minutes, not merge wall clock. Android compiles only the Release Java (the app
and every Capacitor plugin module): cap update regenerates the gitignored Cordova plugin project
that settings.gradle includes, and an empty Android web-assets directory stops it from copying a
web build that javac never reads. iOS runs the tag gate's ios:build:release, web build included,
because Xcode copies the synced public/ and capacitor.config.json as bundle resources. Neither
job is required by a branch rule. ADR-0008's 2026-09-28 amendment records both jobs' measured wall
time against the slowest test.yml job.
ADR-0100 originally split the commit gate into a structural Chromium half and a WebKit timing half.
The structural half asserted that the deleted snapshot/blob history never ran engine.encode inside
a commit. Tiled history (ADR-0085/0086) has no encode path, so that pre-merge job and command
retired instead of passing vacuously.
Post-merge, on pushes to main, the webkit-commit-gate-fast job keeps the COMMIT_GATE_MS
P95 verdict over multi-finger (the sole multi-pointer exerciser) and crayon-scribbles (the sole
mid-stroke pass-split exerciser) on macos-latest. That verdict genuinely needs a faithful engine
and absolute milliseconds, and it is expensive: it was the wall-clock floor of a pull-request run,
so it remains off that path.
That verdict is advisory, as a standing decision (ADR-0173). COMMIT_GATE_ENFORCED
(tools/perf/lib/undo-commit-gate.mjs) is off, because the gate's breaches were bisected to the
WebKit 26.6 browser build rather than the app, and a gate that fails every merge cannot catch a
regression. The commit contract is held by the physical iPad instead, checked once per release (the
iOS release checklist in docs/MOBILE/ios.md). The job still measures, confirms and reports a
breach and still uploads its diagnostics — it exits 0. The retry and filing described next key on
this job failing, so neither runs while it is advisory. A green job with a nonempty failure
fingerprint puts that finding in the run's step summary and keeps the raw distributions in the
diagnostics artifact. A reporter failure is also surfaced there rather than read as an empty
fingerprint. The release-tag full gate is advisory under the same switch but does not yet publish a
fingerprint; its evidence stays in its diagnostics artifact. ADR-0173 records the decision and the
rejected alternatives; issue 1779 has the evidence.
A failure does not file on its own. webkit-commit-gate-fast-retry re-runs the identical gate on a
fresh macos-latest VM, and an issue opens only when the same scenario failed the same way on
both runners — a shared runner can stall long enough to span one whole job, measurement and in-job
confirmation together, so a second VM is the only rerun that changes the environment the failure
happened in. Each runner publishes a failure fingerprint derived from its own undo-scenarios.json
(multi-finger:breach, crayon-scribbles:incomplete, run:no-commit-samples) via
tools/perf/report-undo-gate-failures.mjs, and the retry publishes the intersection to
webkit-commit-gate-fast-report. Comparing job outcomes instead filed a 2026-09-02 run where one
runner skipped crayon-scribbles and the other breached multi-finger — two failures with nothing
in common (ADR-0158). A pair that cannot be compared at all files, which is the fail-closed
direction. The issue comments on the existing open one rather than filing per red commit, and the
first job stays red either way as telemetry.
The first run and retry measure independently across commits. Only the downstream Ubuntu reporter
holds issues: write; it checks out no source and installs or builds nothing. The reporter receives
the retry gate outcome, comparison outcome and reproduced fingerprint through job outputs, including
after a reproduced breach re-fails the retry. Its constant concurrency group serializes the issue
check and create. cancel-in-progress: false and queue: max retain up to 100 pending reporters;
additional pending jobs can still be canceled. This is a bounded queue, not an unlimited guarantee
of per-commit filing. A canceled workflow does not report.
Release tags run all seven scenarios.
A timing breach, an incomplete or unknown requested scenario, or a bundle with no engine.commit
samples fails the job. Every tier attempts to upload undo-scenarios.json and undo-scenarios.md
after a failure; an early build/browser failure may leave no reports, which warns without masking
the original error.
A slow history settle is not an incomplete scenario. After each scenario's batched draw the
harness waits for tiled history to reach its idle steady state — no open command, nothing left for
the fold loop to fold — before it reads the memory table and starts the undo phase. That wait is
diagnostic: the engine.commit samples the gate scores are complete before it begins. So its
deadline (DEFAULT_HISTORY_SETTLE_TIMEOUT_MS in tools/perf/web/run-undo-scenarios.mjs) bounds
wall clock only. An expired wait records settle.settled: false with the whole poll trace on the
scenario result, marks the report row "history unsettled", and the gate scores the scenario as
measured. Through 2026-09-06 that expiry was a thrown skip and fingerprinted as
crayon-scribbles:incomplete on roughly one main push in four, because a shared runner stalls the
page's main thread for 5–10 s after a crayon burst before the first poll can return (ADR-0161).
The workflow's concurrency group folds github.sha in for push events. Pull requests still
collapse per ref so a new push cancels the run it supersedes, but back-to-back merges no longer
cancel each other — which would drop a commit's only WebKit coverage exactly when merge traffic is
highest.
Every tier scores the raw engine.commit P95 against the 25 ms gate — nothing is normalized.
ADR-0140 retired the crayon divisor: its calibration rested on a single run, and Math.max(1, …)
meant it could only ever move a score toward passing. The host slowdown it was derived from is still
measured and reported per run (hostSlowdown in the artifact), so the multi-run macos-latest
distribution a real calibration needs can be collected from ordinary runs; NORMALIZATION_ENABLED
turns it back on once someone has. What replaced it is confirmation: a scenario whose first pass
breached is re-measured through the same runUndoScenario and fails only if it breaches again, and
a confirmation that could not be scored acquits nothing. The JSON and Markdown diagnostics retain
raw P95, maximum, every sample, the measured host slowdown, and the gate value; confirmations
holds each re-measured scenario's full result, so a confirmed breach can be read as a distribution
rather than as two numbers.
FAST_UNDO_SCENARIO_KEYS is the fast set's only declaration. A repo-script unit test derives sole
exercisers from every scenario's declared paths and requires each one in that set. Release runs also
restore and update the durable webkit-undo-full-history artifact: the harness records each
scenario's measured-to-budget ratio, recomputes ideal membership from the three most recent full
runs, and records whether the fast tier would have caught a breach. Membership drift and two
consecutive fast-set misses fail the full job; the updated history and full diagnostics upload even
when that check fails. Invalid restored history falls back to the compatible seed, and a full run
without commit samples is rejected without appending zero-valued evidence.
A timing failure is not product evidence until it is causal. Compare the failing head and its exact base with the same command and runner class; for noisy gates, use repeated or interleaved runs. If the base fails with the same shape or the changed code cannot reach the harness path, fix the test or harness and retry the product PR. Do not quarantine product work for a repository-owned flaky gate.
The native smoke workflows are deliberately tag-only — an emulator/simulator job is the heaviest
thing in CI, and a launch crash is exactly the kind of regression you want caught at release time.
The Android workflow runs on Ubuntu + KVM (the emulator-runner's most reliable path; macOS ARM
runners hit an HVF init failure). Its build job generates a disposable test key, builds the
Release APK once with R8 and resource shrinking, and uploads that shared artifact. Both matrix
legs download and boot the same APK on the current API 33 emulator and the declared API 24 floor.
The matrix reads those levels from their source modules rather than carrying a second floor literal
in YAML. Each leg runs npm run test:android:device through
reactivecircus/android-emulator-runner; the Play upload key never enters CI. The iOS job first
compiles a Release simulator app without store signing, with CODE_SIGNING_ALLOWED=NO, then runs
npm run test:ios on a macOS runner, which boots a simulator, builds the Debug app, and runs the
same Maestro flow. The Release compile catches configuration-only failures while the established
Debug smoke remains the boot signal. Each Maestro leg uploads its own report artifact.
CI uses
test:android:device(nottest:android) because the emulator-runner action already provides a booted emulator — the one-shot would try to boot a second one.