- Test files live in
test/*.test.ts; NOT alongside source. - To unit-test a route action directly (no browser), type the args object as
Parameters<typeof action>[0]: the generated+types/<route>module only resolves via tsconfigrootDirsfor relative imports insideapp/, so~/routes/+types/…does NOT resolve fromtest/. Seetest/api-inbound-push.test.tsfor the pattern (mock the route's network-facing collaborators withvi.mock, keep real decryption). - The Fastmail flows are unit-tested offline:
test/fastmail-send.test.tsdrivessendEmailViaJmapthrough its injectableJmapSendDeps(identity match → upload → import → submit; the submit step retries once on a transient failure with the same email id; failures return false), andtest/api-inbound-push.test.tsforges RFC 8291 payloads encrypted to throwaway keys from avi.mock'd~/lib/env(generated lazily in getters, becausevi.hoistedruns beforenode:cryptoinitializes), so the decrypt cases run everywhere, CI included (PushVerification echo, StateChange drain, unknown type, undecryptable body). - Browser tests via Playwright (vitest provider); helpers in
test/helpers/(launchBrowser.ts→goto/signIn,seedTestData.ts→testPrisma+ seeded constants,launchServer.ts).gotoreuses one browser + signed-in context per test file (each file runs in its own vitest fork) and skips the /login flow when theexpense_sessioncookie is present; tests that need a logged-out or multi-account session drive their own browser directly. The post-hydration settle sleep is 150ms (was 500ms); the 100mswaitForEditorSettlebeat intest/expenses.test.tsis separate. - Use
testPrismafor DB assertions; seed with the account/user constants. - Requires local Postgres (
expense_test); the schema is force-reset each run. - The suite runs on a pinned clock:
2026-07-15T12:00:00Z, ticking in real time from that base, shared by the test process, browser pages, and the test server (test/helpers/frozen-time.ts,pinned-time.ts,pinned-clock.mjs,launchBrowser.ts::freezePageClock). Consequence for test writers:Date.now()/new Date()return pinned base + elapsed; NEVER use them for real-time deadlines or polling loops (they hang); useperformance.now()(seelaunchServer.ts,auth.test.tspolling).- Derive webhook timestamps (
svix-timestamp) fromDate.now(), not absolute wall-clock values, so they stay inside the server's replay guard. - The browser pin is a
context.addInitScriptDate override, NOT Playwright'spage.clock(setFixedTimenever fires page timers;install()doesn't survive navigations). - The server pin loads via
NODE_OPTIONS=--import ./test/helpers/pinned-clock.mjsinlaunchServer.ts; keep the pinned instant in sync across all files. - The test server blanks
PUBLIC_URLin its env (like the live-service keys) so the OAuth metadata issuer follows the request/forwarded origin. A.envPUBLIC_URL(set for the Fastmail push URL) changed the issuer to the prod origin locally and prompted a bad hardcode in531e814; keep OAuth test expectations origin-derived, never hardcoded.
- React Router remounts route components when a data navigation settles
(~1ms after the URL changes): the create-editor mounts, unmounts, and
remounts, replacing the file input in between. A Playwright
setInputFileslanding in that window sets files on the instance about to be torn down and the change event is silently lost (no request ever fires; the suite caught this as a flaky 30swaitForResponsetimeout). Upload tests must wait for the settle before setting files:waitForEditorSettle(page)(test/expenses.test.ts), a 100ms beat that lands well past the ~1ms remount. Drag-drop uploads are exempt (the editor'suseEffectupload fires from mount #1 anddraftUploadsInFlightdedupes the remount).
- The
unitproject carries property-based suites (fast-check) alongside its hand-written cases. A property lives in the mirror-named test file for the module (app/lib/csv.ts→test/csv.test.ts), inside one appendeddescribe("… properties")block, so the hand-written cases stay as they are and a new file still has to be registered in bothvitest.unit.config.tsincludeandvitest.main.config.tsexclude. - Every property goes through
assertPropertyfromtest/helpers/property.ts, so the run count and the seed live in one place: 200 runs, seed 1,endOnFailure: true. A failure prints the seed and a shrunk counterexample and replays identically from the seed alone. DropPROPERTY_RUNSto 50 for a slow property, with a comment saying why; never raise the 10s timeout. - Give every
fc.string()an explicitmaxLength. fast-check does not bound string length by default, and an unbounded input toparseCsvorparseMarkdownis how a property test eats the per-test timeout. - Use the helper's arbitraries rather than a local one:
text(n)for general input,binary(n)for the full code-point range (what breaks a naive char loop),validDatefor a real calendar date, andmoneyAmountfor a money-shaped decimal.textmixes in ASCII on purpose —unit: "binary"alone draws uniformly from all 1.1M code points, so a digit lands in roughly 1 character in 100,000 and a property keyed on digits, quotes or commas passes vacuously. The same trap is a matter of frequency, not alphabet: a generated row matched another about 4% of the time, so the duplicate-detection properties ran mostly against empty match lists. When a property needs a specific shape to exist at all (a real link, a bracketed email, a parseable amount, a row that genuinely duplicates another), seed it into the input or derive it from the module's own output rather than hoping the generator produces one. - Assert the contract, not the implementation. A property that
reimplements the module's branch (deriving the same regex, or the same
accept/reject decision, and comparing) only proves the code was copied
correctly. State the guarantee instead: a fixed point, an agreement
between two surfaces, a bound that holds at its edges. When a case is a
table over one field with no generated input involved, write it as a plain
loop rather than wrapping it in
assertPropertyfor decoration. - A property suite that asserts nothing is worse than none. When you add one, break a single assertion, confirm the file fails with a shrunk counterexample, and put it back.
- When a property fails, decide in this order: the counterexample violates a
contract the module documents → fix the app in the same change and
keep the property; the property is wrong about the code (it contradicts a
documented behavior, or asserts something never promised) → fix the
property and say why in a comment. Never delete or weaken a property to
reach green. (
parseAmount's finiteness guard is the worked example: the exponent bound could not see"NaN"or"Infinity", which decimal.js accepts witheset to NaN.)
- The home-page hero is a video of the app filing a receipt:
public/demo-receipt.mp4,public/demo-receipt.webm, andpublic/demo-receipt-poster.webp(the still the page paints first and shows instead when a visitor asks for reduced motion), all committed.pnpm demorebuilds them: it seeds the stills' dataset, drives the real flow in Chromium, and encodes with ffmpeg, so ffmpeg has to be onPATH. - The capture is a frame sequence with explicit holds rather than a screen
recording (
test/helpers/demoRecord.ts): each state gets the frames it deserves and ffmpeg joins them from a concat list, which is what makes the cut reproducible. Everyshot()waits two animation frames first, because a state React has committed is not necessarily painted: an earlier cut was missing the drop highlight entirely, its one held frame having beaten the paint. - The demo asserts what it shows rather than trusting the eye: 11 list rows
before the drop and 12 after, the outline class up while the file hovers,
and the merchant and amount the extraction filled. The page it records is
the same seeded dataset the stills use (
seedScreenshotData). - The receipt is a generated PDF with a text layer and its extraction is a
warmed cache row (
warmDemoExtraction). The model call is the one step the suite cannot make (no outbound network, seeapp/lib/env.ts), and the cache is what the app reads on a re-upload anyway. The key comes from the app's own reader,sha256("txt:" + extractPdfText(pdf)), so a drifted key would leave the demo's fields empty; the warm reads back before recording. - The landing baseline in
screenshots/landing.pngis captured withreducedMotion: "reduce": a playing video is never the same twice, so the suite compares the still the page falls back to, which keeps that path covered as well. - The demo's duration is the sum of its holds, so tune the storyboard in
test/screenshot.test.ts, not the encoder. Both formats are kept because Safari still prefers the mp4; the poster is a WebP of the video's first frame, so the still and frame one are the same image.
- Every
pnpm testrun captures the app's important screens and the emails it sends (test/screenshot.test.ts, "suite screenshots" block) and compares each against the committed baseline inscreenshots/viamatchBaseline(test/helpers/toMatchScreenshot.ts, looks-same, ΔE tolerance 2.3) — a plain function that throws, not anexpect.extendmatcher: a matcher name the runner does not know resolves to a native no-op, which passed without comparing anything. Any drift fails the run and leavesscreenshots/<name>.new.png(the new capture) andscreenshots/<name>.diff.pngnext to the baseline; all drifted screens are reported in one failure, not just the first. - Baselines are committed. When a capture changes on purpose, review the
drift with
pnpm screenshots:review(adapted from rentail'sscripts/screenshots.ts): it serves a side-by-side UI on :3456, keyboard driven (A accept / S skip / Esc close), and Accept replaces the baseline with the new capture; the server stops itself after the last item. Baselines that were edited by hand show up in the review as "modified" (compared against the Git HEAD version, Revert/Keep). - Comparisons are skipped when
CIis set, so CI never fails on environment-rendering noise; they only run locally. A shell that exportsCI(some local setups do) therefore passes the whole block without comparing anything — runCI= pnpm exec vp test run test/screenshot.test.tswhen you actually want the check. - Determinism guards: the test server pins the random content on captured
screens (
SCREENSHOT_HIGHLIGHT_PIN, set bylaunchServer.ts): the home page's "Did you know?" highlight pick and the insights page's opening starter both read it, so both capture the first eligible entry. Any new random content on a captured screen must read it too; the rotation is untouched in every other environment. The matcher also waits fordocument.fonts.ready(a late-loading webfont otherwise flags phantom text drift), and the suite's pinned clock keeps client-rendered dates stable. If you add a screen capture, expect a baseline-creation pass first (Baseline screenshot created) and matching runs after.