
How this site is tested
Every layer of testing behind this site, from static analysis to mutation testing: the question each answers, why it exists, and how to run and debug it.
Every layer answers a question the others cannot, and each one had to earn its place the same way: by catching a real defect in this codebase before it counted as done. Those defects, and what fixed each one, are listed at the end of this document. Every command below is the one CI runs, so if it passes locally it passes in CI. The plan this implements is issue #92 in the repository.
The layers
| Layer | Question it answers | Tool | Where | Command |
|---|---|---|---|---|
| L1 Static | Does the code follow the rules we can check without running it? | ESLint + full jsx-a11y |
.eslintrc.json |
npm run lint |
| L1 Fitness | Does the architecture still hold? | Vitest | tests/fitness/ |
npm run test:fitness |
| L2 Unit | Does each module do what it says? | Vitest | tests/unit/ |
npm run test:unit |
| L2 Property | Does it hold for every input, not just the ones we thought of? | fast-check | tests/property/ |
npm run test:property |
| L3 Component | Does the UI behave, driven the way a person drives it? | Testing Library + user-event + MSW, jsdom | tests/component/ |
npm run test:component |
| L4 API | Does each route handler behave, including when its dependencies fail? | Vitest + MSW + a stateful fake Redis | tests/api/ |
npm run test:api |
| L5 E2E | Do real journeys work in real browsers? | Playwright (Chromium, WebKit, Firefox, Pixel 7, iPhone 14) | tests/e2e/ |
npm run test:e2e |
| L6 A11y | Can everyone use it? | axe-core (WCAG 2.2 AA), keyboard traversal, motion audit | tests/a11y/ |
npm run test:a11y |
| L7 Visual | Does it still look right? | Playwright screenshots, Linux-rendered | tests/visual/ |
npm run test:visual |
| L8 Perf | Is it still fast and stable? | PerformanceObserver (CLS/LCP), bundle budget, Lighthouse CI | tests/perf/, scripts/check-bundle-budget.mjs |
npm run test:perf, npm run test:bundle |
| L9 Security | Is it hard to attack, and do secrets stay secret? | Header and API-hardening specs, client-bundle scan, CodeQL, OSV, Gitleaks, audit gate | tests/security/, scripts/scan-client-bundle.mjs, security.yml |
npm run test:security, npm run test:audit |
| L10 Meta | Are the tests themselves any good? | V8 coverage, Stryker mutation testing, flake burn-in | vitest.config.mjs, stryker.config.mjs, ci.yml |
npm run test:coverage, npm run test:mutation |
npm run test:all runs the whole PR gate locally: lint, every Vitest project
with coverage, a production build, the bundle checks, then the browser layers.
Principles
- The pyramid is honoured. Well over a thousand fast Vitest tests, fewer DOM tests, a few browser journeys, a handful of screenshots.
- Determinism beats coverage. The network is sealed at every layer (MSW fails on any unhandled request; the Playwright server runs inside a fetch sandbox; the browser is hermetic). Fixtures are frozen, credentials are minted per run, and a suite that flakes is treated as a defect.
- One source of truth per concern.
tests/support/fixtures/api.jsfeeds both the MSW handlers (Vitest) andpage.route(Playwright). The browser route list is checked against the sitemap registry by a fitness test. - Test behaviour, not implementation. Components are found by role and label, as a screen reader would find them.
- No skipped tests on
main.tests/fitness/testHygiene.test.jsfails on a committed.only,.skip,.todoorfixme. - Secrets stay out, by construction. Test credentials are random per run
(
tests/helpers/secrets.js,playwright.config.mjs), and the build output is scanned for any credential that reached a browser.
Layer by layer
L1 · Static analysis and fitness functions
ESLint runs next/core-web-vitals plus the full plugin:jsx-a11y/recommended
set. Every exception is disabled at the line, with the reason written beside
it. The one global override allows role="list" on <ul>/<ol>, because
Safari's VoiceOver drops list semantics from a list styled list-style: none
unless the role is restated.
Fitness functions read the source tree and enforce architectural rules no unit test would think to check:
| Suite | Rule |
|---|---|
envHygiene |
Every process.env.X is documented in .env.example; no credential is NEXT_PUBLIC_, read from a "use client" module, or given a literal fallback. |
apiSurface |
The method map of every route is pinned; routes export only verbs and segment config; Node-only imports pin runtime; every request body is read through a byte ceiling; CRON_SECRET is compared only in constant time. |
contactContract |
The contact form's client limits are never looser than the server's, and both read src/lib/contactLimits.js. |
routeRegistry |
The browser suites' route list equals the sitemap registry. |
testHygiene |
No focused or skipped tests; every @quarantine links an issue. |
browserScripts |
Every npm run script that runs playwright test builds first; the CI JUnit reporter keeps includeRetries, so the run summary can name flaky tests. |
routeSegmentConfig |
No file under src/app re-exports route segment config (dynamicParams, revalidate, …) from a sibling: Next 15 reads it only from the file itself and silently applies the default otherwise. |
escapeOwnership |
Every source that acts on Escape claims it with claimEscape (src/lib/escapeKey.js), and the privacy notice decides only after dispatch, so one press never both closes something and declines analytics. |
L2 · Unit and property-based tests
The property suites generate thousands of inputs per run and shrink any
failure to a minimal counterexample. fast-check prints the seed, so a CI
failure replays locally with fc.assert(..., { seed, path }).
The centrepiece is guestbookPaging.property.test.js, a model-based test.
It runs random histories of posts, deletes, polls and "load older" (bursts
larger than a page, same-millisecond ties) against a model server that uses
the real paging helpers and the real cursor codec. After every step it
asserts the wall never shows a duplicate, never breaks order, and never loses
a message. It proves that under one assumption, that a new message sorts above
everything already on the wall, which the model holds by construction (its
same-millisecond ids increase). Production does not enforce it: two visitors
posting in one millisecond get random id tails, so they sort either way round.
paging.js records the assumption and what breaks without it.
Soaking. FC_RUNS raises every property in the layer to at least that
many runs, never fewer than its everyday count:
FC_RUNS=20000 npm run test:property
It reaches every suite because none of them names a raw count:
tests/support/setup-property.js sets fast-check's global default, and a
suite that needs more than the default uses runs(n) from
tests/support/fastCheckRuns.js. A literal numRuns: 5000 would opt that
property out of the soak silently, so testHygiene fails on one. (Until
2026-10-04 only the paging test read FC_RUNS, so the nightly "soak" deepened
one file. The first real soak of all of them found a test bug within seconds:
see the defects table.)
L3 · Component tests
tests/support/setup-component.js adds jest-dom matchers, the smallest
possible polyfills for APIs jsdom lacks, and the shared MSW server. Framer
Motion animations are skipped so a visibility assertion never depends on how
far a tween had run.
L4 · API integration tests
Route handlers are imported and called directly. Outbound HTTP goes to MSW.
Redis is tests/support/fakeRedis.js, a stateful fake with an atomic
SET NX, so the contact route's idempotency state machine runs against real
state. Ten concurrent requests with one key produce exactly one email.
L5 · End-to-end
Playwright boots next start on port 3100 with every credential blanked and
every gate secret minted at random. NODE_OPTIONS preloads
tests/support/network-sandbox.cjs, which refuses any server-side fetch to a
host other than localhost, and below fetch any TCP connection to one: the
contact route sends mail through nodemailer over raw sockets, which a fetch
wrapper never sees. Mail is also pointed at a local port nothing serves
(SMTP_HOST=127.0.0.1, SMTP_PORT=1, no account, no recipient), so an
un-stubbed send fails before it leaves; api-hardening.spec.js checks that.
New specs import test from tests/support/test.js, which adds:
- a hermetic browser: foreign origins are answered locally;
- an error sentinel: any uncaught exception or
console.errorfails the test. Expected errors are declared withtest.use({ allowedErrors })or the per-testexpectErrors()fixture; - WebKit on localhost: strips the production-only
upgrade-insecure-requestsCSP directive, which real WebKit applies even tohttp://localhost.
Tags: @cross-browser specs run on every engine and device; everything else
runs on Chromium. PRs run chromium and mobile-chrome; the nightly run adds
webkit, firefox and mobile-safari.
The image warm-up. chromium, mobile-chrome, a11y, security and
perf each depend on a warm-up
project (tests/warmup/images.setup.js) that visits every route once, with the
same viewport and pixel ratio, before any spec runs. a11y has two:
warm-desktop for the keyboard and motion specs, and warm-axe-desktop at
axe's 1440×900 with reduced motion, because the viewport decides which image
width the browser asks for (/qualifications' cards want w=384 at 1280 px
and w=640 at 1440 px). The test server is a
fresh next start, so /_next/image encodes each image width on its first
request, and AVIF encoding is slow: on a 2-vCPU runner a Pixel 7's first visit
to /qualifications queued fifteen encodes at 11 to 43 seconds each, and the
page's load event missed the 60-second test limit. The retry passed against
the now-warm cache, so it looked like a flaky page when it was really a cold
cache. Production has no such queue (Vercel's image cache is shared and
persistent), so the fix warms the cache instead of loosening the specs. The
warm-up asserts nothing about the page (an HTTP error still fires load, and
the real specs report it), but it does require load to arrive: a timed-out
visit is retried once, and a second timeout fails the setup, so no suite runs
against a cache known to be cold. The warm-ups import hermeticTest from
tests/support/test.js: the same browser interception as every spec, so a
warm-up visit reaches no outside host either, without the error sentinel,
whose findings belong to the suites that depend on it.
Debugging a failure. CI uploads playwright-report/ and test-results/
on failure, from the burn-in job too. Retries record a trace, so the burn-in
(retries off) has screenshots, video and error context but no trace:
npx playwright show-report # the HTML report
npx playwright show-trace test-results/<test>/trace.zip # step-by-step replay
npx playwright test tests/e2e/contact.spec.js --project=chromium --debug
npm run test:e2e:ui # UI mode
To iterate against a server you started yourself (with the same environment):
E2E_REUSE_SERVER=1 npx playwright test.
L6 · Accessibility
axe.spec.js: every route plus the 404 page, at 1440 px and 390 px, under WCAG 2.0/2.1/2.2 A and AA. Full results are attached to the report.keyboard.spec.js: Tabs through every page. At each stop it measures that the element looks different focused (2.4.7), is on screen and not covered (2.4.11), and that focus keeps moving (2.1.2).motion.spec.js: underprefers-reduced-motion: reduce, nothing may loop forever (2.2.2): CSS and Web Animations, SMIL, looping video.
A Playwright detail that cost a run: in this version reducedMotion must go
inside contextOptions. A top-level reducedMotion key is silently ignored.
L7 · Visual regression
Screenshots of the views whose look is stable (/cv, /privacy, /notes,
/contact, the 404 page), with animations off, reduced motion on and live
regions masked.
Rendered on every push, committed back automatically.
.github/workflows/visual-baselines.yml runs on each push to any branch: it
builds the site, renders every view on ubuntu-latest with the same Chromium
install the CI comparison job uses, and commits the images to the branch.
There are two directories, and the difference matters:
| Directory | What it is | Updated |
|---|---|---|
tests/visual/__latest__/ |
The current render of every view, plus a README.md gallery saying which views differ from their baseline. Open it on GitHub to see the site as the last commit renders it. |
On every push, automatically. |
tests/visual/__screenshots__/ |
The reviewed baselines ci.yml compares against. |
Seeded automatically the first time (which arms the layer). After that, only deliberately: run the workflow by hand with promote: true, or commit the PNGs in a PR. |
Baselines are not overwritten on every push because a baseline that silently becomes whatever today's code renders could never catch a regression. The gallery shows what changed; promoting it is the review.
Every render starts from an empty snapshot directory, in CI and in
npm run test:visual:update alike. Playwright rewrites the snapshots the
current tests reference but never deletes one whose test was removed or
renamed, so rendering over the old tree would list that orphan in the gallery
as a current view and keep it through a promotion. The local updater replaces
tests/visual/__screenshots__/ only after its run succeeds.
The image commit gets its own checks. A commit pushed with the workflow's
GITHUB_TOKEN starts no workflows, so the image commit used to head the
branch with no CI at all, and a PR's required checks would wait on it forever.
workflow_dispatch is the one event that token may still raise, so after
pushing, the workflow dispatches ci.yml and security.yml on the branch, and
their checks land on the image commit. Both key their concurrency group on the
branch name, so the dispatch cancels the superseded pull_request and push runs
rather than running beside them. Not lighthouse.yml: the image commit changes
only PNGs, so the pages are the ones the push already audited. A render whose
only change is the gallery README's commit line is not committed, so it
dispatches nothing. It never commits to main: main only changes through a
pull request, and the merged PR already carried its latest render.
Never on a Mac: macOS rasterises glyphs differently, and Docker on Apple
silicon cannot run Chromium under amd64 emulation, which is why rendering lives
in CI. With a Linux Docker host, npm run test:visual:update renders locally in
the official Playwright image. The visual project only exists once baselines
do (playwright.config.mjs), so a branch without them never fails on a missing
snapshot.
L8 · Performance
web-vitals.spec.jsmeasures CLS through load and a full scroll on every route, enforced at 0.1. It is scored as Core Web Vitals scores it: the largest session window (shifts under 1 s apart, at most 5 s long), not the sum of every shift in the page's life. LCP is recorded as an annotation only, because runner timing is noise.scripts/check-bundle-budget.mjscomputes first-load JS per route (gzip) from the build manifest, againstbundle-budget.json(measured + 10%). After a deliberate change, runnpm run test:bundle:updateand explain the new number in review.- Lighthouse CI (
lighthouserc.json) is unchanged: a11y, best practices and SEO enforced at 100 on six routes.
L9 · Security
headers.spec.jsasserts the served headers on every route: CSP (frame-ancestors,object-src,base-uri,form-action),nosniff,Referrer-Policy,Permissions-Policy, noX-Powered-By.api-hardening.spec.jssends hostile requests over HTTP: forged and missing bearers, header injection, oversized and malformed bodies, anonymous writes, forged webhook signatures, and checks no error leaks a stack trace.scripts/scan-client-bundle.mjsscans everything a browser can download for the values of configured credentials, server-only credential names in client JS, and token shapes (GitHub, OpenAI, Anthropic, AWS, Slack, PEM). It reports variable names, never values. A value already committed in public source (the contact address) is reported as public by design.security.yml: CodeQLsecurity-extended, OSV-Scanner (informational), Gitleaks over full history, andscripts/audit-gate.mjs.- OSV is informational, and looks it. It reads the whole lockfile, dev tooling included, so it always lists the advisories the audit gate already waives. Its scan step may fail without failing the job; a notice and the run summary say findings exist. The audit job is the gate.
- Gitleaks runs the CLI, not the action. The pinned release binary (checksum-verified) scans every commit on every event. The marketplace action needed a token to list a PR's commits, then scanned only those, so on a pull request it was neither working nor "full history".
- On a private repository. GitHub code scanning is not available on a private personal repository, so CodeQL cannot upload alerts there. The workflow skips the upload while the repository is private and judges the SARIF itself instead: any finding with a security severity of 7.0 or more (high or critical) fails the job, and every finding is listed in the run summary. Made public, the results upload as code-scanning alerts as well.
- Gitleaks false positives are recorded in
.gitleaksignore, one fingerprint (commit, file, rule, line) per reviewed finding, never a path or a rule. A real secret added to the same file later is still caught.
The audit gate. When it was written, npm audit on main reported 45
advisories whose fixes were all major upgrades, so a plain
--audit-level=high gate could never pass and would have been switched off.
(Those upgrades have since landed: sharp 0.35, then Next 15, React 19 and
nodemailer 10 on 2026-10-11, which left the production audit at zero and the
allowlist empty.)
Instead .github/audit-allowlist.json lists each known advisory with a
written exposure assessment and an expiry date. The gate fails on any new
high or critical advisory, on any expired entry, and on any entry that no
longer matches (so upgrades clean it up). Renewing an entry means re-reading
the advisory.
L10 · Tests that test the tests
- Coverage (
npm run test:coverage) is measured over the code Vitest can reach: utils, lib, API routes, hooks and the contact components. The thresholds invitest.config.mjs(measured 2026-09-29: lines 58.96%, branches 48.68%) are a ratchet set just under the measured figure, so coverage can rise freely but can only fall in a reviewed diff. - Mutation testing (
npm run test:mutation, nightly) makes deliberate bugs in the critical modules and checks a test catches each one. Measured 2026-09-29: 87.2% over eight modules (79.6% on the first run, which exposed the two gaps the rank oracle and the contact-client suite now close). Thebreakscore of 85 instryker.config.mjsis the ratchet. - Flake burn-in: on every push to a branch, each browser spec that push
changed runs five times with retries off (capped at 30 minutes). The range is
the push's own
before, from whichever run survives the concurrency group: the pull_request run, the branch's push run, or the screenshot commit's dispatch, which passes it asburn_in_base. A newly opened PR compares againstmain. Elsewhere CI retries twice and reports tests that passed on retry as flaky in the run summary. Two pushes in quick succession cancel the first run, so the first push's specs skip burn-in. That is the cost of not re-running every spec the PR has ever touched on each push, which took 130 billed minutes a push on PR #160. - Quarantine: a browser test tagged
@quarantine(with its issue linked, enforced) is left out of every run bygrepInvertinplaywright.config.mjs, so it no longer gates a merge. The nightlyquarantinejob setsPW_INCLUDE_QUARANTINE=1and runs only the tagged tests, so a parked test still runs every night until it is fixed.
CI
| Workflow | When | What |
|---|---|---|
ci.yml |
every push and PR, and dispatched onto each screenshot commit | lint + the browser-test count check · Vitest + coverage · build → bundle scan + budget · E2E (chromium, mobile-chrome) · browser quality (a11y, security, perf, then visual, each with its own report and summary) · burn-in |
security.yml |
PRs, main, weekly |
CodeQL · OSV · Gitleaks · audit gate |
nightly.yml |
daily | WebKit, Firefox, iPhone 14 · quarantined tests · mutation testing · every property at ≥ 20,000 runs |
lighthouse.yml |
main; a PR only while it carries the lighthouse label |
Lighthouse CI budgets |
visual-baselines.yml |
every push (and manual, with promote) |
render every view, commit tests/visual/__latest__/ (and seed or promote the baselines) on any branch but main when a screenshot changed, then dispatch ci.yml and security.yml onto that commit |
Each job writes a Markdown section to the run summary
(scripts/test-summary.mjs). A job that runs Playwright twice gives the second
run its own JUnit file, HTML report and output directory
(PLAYWRIGHT_JUNIT_FILE, PLAYWRIGHT_HTML_OUTPUT_DIR, --output): Playwright
empties its output directory when it starts, so sharing one meant the visual
run replaced the a11y/security/perf report and discarded its traces.
Both test counts /uses publishes come from the runners' own collected
lists, not from reading the test files: npm run test:count writes
tests/browser-cases.json from playwright test --list and
tests/vitest-cases.json from vitest list. Most browser tests are generated
in loops, and on the Vitest side an it.each over a list built at collection
time, or a table held in a variable, runs many cases from one line; a text
count expands neither. The lint job fails a PR that changes either set of tests
without regenerating its file.
Known defects
None open. Every finding the suites tracked has been fixed (table below). The
keyboard spec keeps its KNOWN list, empty, as the documented way to track a
future finding that genuinely needs its owner; anything not on it fails.
Defects the suites caught and this work fixed
| Layer | Defect | Fix |
|---|---|---|
| Fitness | Nine environment variables read by the code but documented nowhere in .env.example. |
Documented, with defaults. |
| Fitness | /api/work-status compared CRON_SECRET with ===, which returns at the first differing byte (a timing side channel). |
Switched to the shared constant-time safeBearerEqual. |
| Fitness / API | /api/send-mail, the one public unauthenticated POST, parsed any body in full before validating it, and answered malformed JSON with a 500, which the client retries forever. |
16 KB counted-read ceiling (413) and a 400 for malformed JSON. |
| Fitness | The contact form's field limits existed twice, held together by a comment. | One shared module; the client is pinned never looser than the server. |
| Property | The guestbook paging algorithm silently depends on messages arriving in time order. | The assumption is now documented in paging.js; 20,000 generated histories hold under it. It is not enforced: same-millisecond posts from two visitors sort by a random id tail (corrected 2026-10-18). |
| Property | The rank badge could disagree with the percentile printed beside it ("62.50%" ranked B-). | The level is derived from the displayed percentile. |
| Component + axe | The ⌘K palette's empty state sat inside role="listbox" and was never announced. |
Moved outside as a polite status. |
Lint (jsx-a11y) |
Language rows on /about opened their popover by focus, but Enter/Space could not reopen it after Escape. |
Keyboard handler and button role. |
| E2E (chaos) | A failed deferred chunk load (the three.js backdrops) blanked /contact, /about and project pages with Next's "Application error". |
optionalChunk(): a decorative chunk that fails renders nothing. |
| E2E (hermetic network) | Every project page fetched its HDR map from raw.githack.com, an undisclosed third party. |
Self-hosted in public/hdri/. |
| Security | The site sent no X-Content-Type-Options, Referrer-Policy or Permissions-Policy, and advertised X-Powered-By: Next.js. |
All added; the powered-by header removed. |
| Axe | Four text colours on /uses under 4.5:1; scrollable code blocks, tables and the pipeline diagram unreachable by keyboard at phone width; overlapping carousel links under 24 px. |
Colours raised minimally; scrollers focusable; only the centred card's link is live. |
| Keyboard | Page content stayed focusable behind the opaque intro loader; the privacy notice hid focused controls; two /about controls had no visible focus. |
inert behind the loader; scroll-padding-bottom and a footer spacer from the notice's measured height; a focus ring; a focused orbit button rises above the laptop. |
| Motion | The project-title bloom pulsed forever under prefers-reduced-motion. |
Gated. |
| Mutation | The property suites checked the rank calculator's shape, not its values (73.8% mutation score). | A differential oracle with an independent erf; targeted survivor cases. |
| Error sentinel (reduced motion) | React hydration errors #418/#423/#425 on every page for visitors with reduced motion: framer-motion's useReducedMotion returns the real preference on the first client render. |
src/hooks/useReducedMotion.js rebuilt on useSyncExternalStore (server snapshot = motion on); 42 components moved onto it; a root <MotionConfig reducedMotion="user">; a fitness test bans the direct import. |
| Keyboard (K1) | On the non-scrolling homepage the privacy notice could cover focused controls, and closed on Escape only with focus inside it. | Page-wide Escape as a last resort, yielding to the palette, open dialogs and text fields. The keyboard spec verifies WCAG 2.4.11's dismiss-in-place exception instead of excusing it. |
| Keyboard (K2) | Tab reached /about skill icons that had not scrolled into the strip, and every inactive category's icons. |
Focus drives the scrub: the scroll position that reveals the focused icon is computed from the scrub's own geometry. |
| Keyboard (K3) | "Live Demo" and "GitHub" on every project page did nothing: GlowButton was a <button> that ignored href. The 3D-projected copies also sat where they were not painted. |
Real links from each project's demoLink/repo; the in-scene copies pointer-only and aria-hidden; an accessible pair in the page intro. |
| Keyboard | On every project page the footer started ~160 px down and covered the 3D scene, because <main> held only fixed-position content. |
<main> takes a full viewport on project pages. |
| Audit gate | A new high-severity nodemailer advisory (GHSA-v53p-9fqp-m79j, quadratic backtracking in its address parser) was reachable through the contact form's email field, which had no length limit beyond the 16 KB body ceiling. The patched nodemailer is a major version next-auth does not yet allow. | The address is capped at 254 characters (the RFC 5321 maximum) on both client and server, from the shared limits module; the waiver records why the remaining cost is bounded. |
| Review (E2E) | Projects 8–11 are private: true, but the links fix above derived "Source on GitHub" from every repo, so their pages linked to repositories that answer 404. |
source is withheld for private records, and a demo equal to the repo URL is dropped too; the e2e fixture now includes a private project and asserts no link anywhere on its page points at the repo. |
| Review (keyboard) | FloatingSoundToggle and NowPlaying rendered beside the intro loader's inert boundary, not inside it, so the sound button could take focus under the opaque loader on a touch device. |
Both moved inside LoaderWrapper. loaderInertBoundary (fitness) pins the layout, because the hermetic server renders neither control; loader-inert.spec.js checks in the browser that nothing outside the loader is focusable while it covers the page. |
Review (/uses) |
The browser-test figure /uses publishes was counted from the spec text, which cannot expand generated tests: axe.spec.js counted 2 of its 32. |
The figure comes from Playwright's own collected list (tests/browser-cases.json), checked in CI. |
| Review (sitemap) | The project pages' scene loads its environment map by URL, which no import walk reaches, so replacing it would not move the eleven pages' lastModified. |
Listed in PROJECT_SOURCES; the drift test now recognises public assets loaded by URL. |
| Review (chaos, the test) | The lazy-chunk test armed its fault after settle(), which waits for the intro loader, and the aurora and project-scene chunks start loading on that signal or earlier. It passed while aborting nothing. |
The fault is armed by a load listener registered before navigation, any chunk carrying three.js is aborted whenever it is requested (found by its body, since the names are hashed), and the test fails if nothing was aborted. |
| Review (perf, the test) | web-vitals.spec.js summed every layout shift across a multi-second scroll, so a long page could fail the 0.1 budget while its real CLS passed. |
Session windows, maximum kept. |
CI (security.yml) |
Gitleaks failed every pull request with a 403 (the action lists the PR's commits through the API, which this workflow's token may not), and on a PR it would have scanned only the PR's commits anyway. | The pinned gitleaks CLI over the full history, no token. Verified locally: 385 commits, no leaks, .gitleaksignore honoured. |
CI (security.yml) |
OSV-Scanner, meant to be informational, showed as a failed check on every PR: continue-on-error on the job keeps the workflow green but not the job. |
Moved to the step, with a notice when findings exist. |
| Review (warm-up, the test) | The image warm-up swallowed its own timeout and reported success, sending the dependent suites into the cold-cache state it exists to prevent. | One bounded retry, then the setup fails. |
| Property soak (the test) | The first real 20,000-run soak drew the secret "SWo" and failed redaction, but the redaction was correct: the test searched the whole output for the secret, and "password" contains "swo". | The check now compares whole tokens. A defect in the test, recorded because the soak that found it had never actually run. |
| Review (scripts) | npm run test:a11y, test:security, test:perf, test:visual and test:e2e:ui start next start without building, so on a fresh checkout they failed before collecting a test. |
Each builds first. browserScripts.test.js fails on any script that runs playwright test unbuilt. |
| Review (CI summary) | The JUnit reporter's includeRetries defaults to off, so a test that passed on retry left no trace in the XML and the run summary's flaky list was always empty. |
Turned on for the CI reporter, and pinned by browserScripts.test.js. |
| Review (CI summary) | Once retries were recorded, the summary still named a test flaky if the word "flaky" appeared anywhere in it, so a hard failure whose message said "flaky upstream" was reported as passed on retry. | scripts/junit-flaky.mjs matches only <flakyFailure>/<flakyError> elements; junitFlaky.test.js. |
| Review (hermetic network) | The test server blanked every outbound credential but SMTP's, and the fetch sandbox cannot see nodemailer's raw sockets, so an un-stubbed valid contact POST could send real mail. The image warm-ups, on the base Playwright fixture, also reached third-party hosts from the browser. | SMTP pointed at a dead local port; the sandbox refuses non-local TCP; the warm-ups use hermeticTest. |
| Review (hermetic network, the test) | The socket sandbox allowed any host starting 127., so a DNS name such as 127.example.com reached the real connect and could resolve to a public address. |
127/8 is allowed only as an IPv4 literal; networkSandbox.test.js refuses 127.-prefixed names and checks the socket refuses before resolving. |
| Review (e2e coverage) | The privacy notice's four Escape journeys carried no @cross-browser tag, so they ran on desktop Chromium only, and the palette test's WebKit (⌘K) branch never ran. |
One tagged test.describe: the journeys now run on WebKit, Firefox and both phones too. |
| Review (screen reader) | The ⌘K palette's "Nothing matches" status was inserted with its text already inside, which screen readers often do not announce, so an unmatched query could still be silent. | The status is mounted empty with the palette and only its text changes, as the privacy notice's live region does (CommandPalette.test.jsx). |
| Review (keyboard) | When the track had a Spotify URL, the Now Playing card was a link with no key handler, so Escape on it while expanded was claimed by nobody: with the privacy notice open it declined analytics instead of collapsing the console. | The link passes Escape, and only Escape, to the card's handler; Enter and Space stay native (NowPlaying.test.jsx). |
| Review (contact form) | The message field accepts exactly 500 and exactly 50 characters, but its errors said "less than 500" and "more than 50". | "At most" and "at least", matching the other fields. |
| Burn-in (warm-up) | The first desktop axe scan of /qualifications timed out: axe scans at 1440 px, where the cards ask for w=640, but the warm-up ran at 1280 px, which asks for w=384. So the scan met a cold cache (35 s in the quality job, over 60 s in burn-in; about 11 s once warm). |
A warm-axe-desktop project at axe's exact viewport and motion setting, which a11y depends on. |
| Burn-in (the tests) | /notes/discoverability, the longest page, outran two flat limits when other tests ran beside it: the keyboard walk took the full 180 s on all five repeats and failed once, and the CLS scroll failed three times out of five at 60 s. Alone they take 55 s and 37 s. |
The keyboard limit is now 5 s per stop (MAX_STOPS × 5 s), and the CLS test's limit is 120 s. Neither measures speed, so the limits only have to catch a hang. |
| E2E (the test) | The ⌘K palette test failed whenever the CI runner was under load. The runner draws the Ember Passage's WebGL swarm in software, and /about committed about 18 s after Enter, past the 15 s limit. |
The test runs under reduced motion, so the passage takes its static branch. The test covers the palette and history, not the passage. |
Review (/about) |
The Enter/Space fix above gave every language row role="button" and a Tab stop, so a language with no repository breakdown was announced as a disabled button and still took focus. |
Button role and Tab stop only on rows that open a breakdown; the rest stay plain statistics. tests/component/LanguagesCard.test.jsx. |
| Upgrade (keyboard) | After the React 19 upgrade the page was never inert under the intro loader: LoaderWrapper passed inert="", the form React 18 needed, and React 19 reads an empty string as false and drops the attribute. All four loader-inert.spec.js cases failed. |
inert={covered}, a real boolean. |
| Upgrade (build) | After the Next 15 upgrade both twitter-image.js routes re-exported dynamicParams from their sibling; Next 15 ignores a re-exported segment config with one build warning, so the param sets reopened while the build stayed green. |
Declared in each file; the routeSegmentConfig fitness test fails on any re-export. |
| Review (keyboard) | The privacy notice's page-wide Escape inferred "nobody else owns this" from dialog markup and read defaultPrevented on the spot, from a document listener. Owners on window run after it, and most owners never prevented default: on the 404 page one press declined analytics and went home, and the about-page popovers and the orbit tip double-dismissed the same way. |
Every owner claims the key (claimEscape); the notice decides after dispatch (afterUnclaimedEscape). escapeKey.test.js covers both listener orders, escapeOwnership (fitness) fails on an unclaimed handler, and the e2e spec checks the 404 case (it fails without the fix with an analytics:false record). |
| Review (visual) | Both visual renderers wrote over the existing snapshot tree, so a removed or renamed view's PNG survived: listed in __latest__ as current, kept on promotion, and left behind by npm run test:visual:update. |
Both render into an empty tree; the local updater clears its output only after a successful run. |
Review (/uses) |
After the Vitest layers were split, the published Vitest case total still came from a lexical scanner: the text read routeSegmentConfig (an it.each(files) over every app source file) as 2 cases of 80 and contactContract as 3 of 9, publishing 1,195 against Vitest's 1,356. |
Taken from vitest list into tests/vitest-cases.json, checked in CI like the browser count; the scanner is gone. |
| Review (keyboard) | The page left inert when the loader's reveal began, but the overlay stays mounted and mostly opaque for the 520 ms wipe (or reduced-motion cross-fade), so Tab in that window reached controls still hidden behind it. |
inert until the overlay is removed. loader-inert.spec.js samples every frame until then; before the fix it found 6 exposed frames. |
| Review (keyboard) | ⌘K during the intro loader opened the palette while the page was inert, so its input could not take focus and nothing retried: Esc and the arrow keys did nothing. |
LoaderWrapper fires loaderuncovered once inert is gone, and the palette defers a covered ⌘K to it (useCommandPalette.test.js). |
| Review (security) | The audit gate read an error-only npm audit response as a clean one: it kept npm's stdout, defaulted the missing vulnerabilities field to an empty map, and with the allowlist empty reported zero advisories and passed. A registry outage would have turned the gate green. |
scripts/audit-gate.mjs refuses a report with an error, no JSON, or a missing or malformed vulnerabilities map. tests/unit/auditGate.test.js runs the real script against a fake npm on PATH. |
| Review (keyboard) | Tabbing to an /about skill icon that had not scrolled into the stage dismissed its repository panel at once: focus opened the panel from the icon's off-stage rect, then the stage's own reveal scroll reached the panel's close-on-scroll listener. Writing the test found a second defect underneath: the browser's focus-scroll also scrolled the row's overflow: hidden wrapper sideways, on top of the scrub's translate, so the "revealed" icon landed a full row-width off the stage. |
Keyboard focus now opens the panel once the focus-driven scrolling has settled, from the icon's final rect, and the reveal resets that sideways scroll. tests/e2e/skills-keyboard-popover.spec.js, in full motion. |
| Review (e2e) | Three seo.spec.js focus tests waited for __loaderDone, which fires as the reveal starts, while the page stays inert for the 520 ms wipe. A fast run focused an inert link (a no-op) and read the still-clipped panel. One of them had no loader wait at all, and passed only because a 1px nav overlaps nothing. |
Each now also waits for the [inert] boundary to detach before focusing. |
Owner report (/journey atlas) |
A role started in the current month got an 8px bar closed on the NOW line, and its caption was printed outside the bar with no border. The newest role also sat on a lower lane, because lanes were packed oldest-first. | Open bars too short for their caption now widen left to hold it, with the right edge still on the NOW line, and lanes pack newest-first. tests/unit/timelineAtlasLayout.test.js (its two atlas cases fail on the old code). |
Maintenance
- Every new issue re-considers this plan. This document has to cover what
the project is now, not what it was when #92 closed. Each issue checks,
while planning and again before its commit, which layer each new page,
route, interaction, journey, animation, piece of logic or outside host
belongs to, and lands those tests and any update here in the same commit.
The checklist is §3 of the repository's
CLAUDE.md. Much of it is not enforced by CI, so it is a habit, not a gate. - Flake: fix it or quarantine it with
@quarantineand an issue link (enforced). Never.skipit. - Snapshots: reviewed, never rubber-stamped.
- Budgets and ratchets: raise freely; lower only in a reviewed diff with a reason.
- Audit waivers: each expires. Re-assess, upgrade or renew with fresh reasoning.
Source: docs/testing.md · 440 lines