Skip to content

Visual regression: Playwright route-level visual sanity + axe lane

Visual regression: Playwright route-level visual sanity + axe lane

Status: Delivered
CAS: CAS-2742
Delivered: 2026-05-14
PRs: #729, #731

What’s new

A new tests/visual/ Playwright lane provides route-level visual sanity checks and WCAG AA accessibility validation on every PR. This is separate from the existing test/e2e/ smoke tests: it runs against a running dev server, covers five key routes × five viewport sizes, and enforces three hard-blocking layout invariants on every run.

Coverage:

RouteSlug
/transaction/listtransactions
/settings/licensesettings-license
/invoicesinvoices
/settings/syncsettings-sync
/ (no users)onboarding

Viewports: iPhone 14 Pro (375×812), iPhone SE (375×667), iPad (768×1024), MacBook 13 (1280×800), MacBook 16 (1536×960)

What it checks

Hard-blocking layout assertions (fail the PR)

  1. Horizontal overflowdocument.documentElement.scrollWidth > window.innerWidth catches components that overflow the viewport and force horizontal scroll
  2. Fixed-element collision — stacked fixed elements (FABs, nav bars, toolbars) must not overlap each other
  3. Safe-area bottom anchor — on notched viewports (iPhone 14 Pro), bottom-anchored elements must respect env(safe-area-inset-bottom)

Advisory screenshot baseline (advisory only)

expect.soft + toHaveScreenshot captures per-route/per-viewport screenshots. Drift does not block the PR — Loki handles the authoritative visual gate. The screenshots are uploaded as CI artifacts for manual review.

axe-core WCAG AA per route (hard block)

axe violations fail the PR. The meta-viewport rule is suppressed (CAS-2387: conflicts with iOS viewport meta tag; this is a known exception). detailedReport: true means the HTML artifact shows the full tree of violations.

Sanity self-tests

Each hard-blocking assertion has a matching test that injects a hand-crafted violation (overflow element, stacked fixed divs, safe-area anchor missing) and verifies the assertion catches it. These run in the same job and guard against false-confidence in a passing green run.

How to use it

Terminal window
# Run the full visual lane locally (requires a running dev server on :1420)
npm run dev &
deno task test:visual
# Update screenshot baselines after intentional visual changes
deno task test:visual:update
# Run on a specific viewport only
deno task test:visual -- --project=iphone14pro

CI job playwright-visual-sanity runs after the frontend job and uploads the HTML report as an artifact.

What changed under the hood

  • tests/visual/visual-sanity.spec.ts — main spec: 5 routes × 5 viewports, three layout assertions, axe per route, advisory screenshots
  • tests/visual/playwright.config.ts — project matrix (5 viewports), base URL, artifact configuration
  • tests/visual/tauri-mock-shared.ts — shared mock stubs (Tauri command mocks for the routes tested); includes fetch_settings: {} stub added in PR #731
  • src/components/navbar/MobileNavbar.tsx — inactive item opacity 40→60% (contrast fix for WCAG AA)
  • src/components/MobileTransactionList.tsx — date separator color c="dimmed"c="dark.1" (contrast fix)
  • src/pages/InvoiceQueuePage.tsx — status badge variant="light" added
  • .github/workflows/ci.ymlplaywright-visual-sanity job added
  • package.jsonaxe-playwright ^2.2.2 added to devDependencies

Why we built it

Every UI regression in the mobile polish arc (stacked FABs, dead bands, safe-area overlaps, contrast failures) was caught by the regent on a real TestFlight device, not during review. The three hard-blocking assertions in this lane directly target the failure shapes that appeared most often: overflow, collision, and safe-area anchor. A PR that re-introduces a FAB overlap or a viewport overflow will fail the playwright-visual-sanity check before it reaches Eivind or the regent.

Known limitations / follow-on work

  • Screenshot baselines are empty .gitkeep directories on first run — run deno task test:visual:update locally after the first CI green pass, commit the PNGs, and they become the baseline.
  • Baselines are viewport-specific and will need regeneration when intentional layout changes ship.
  • The axe suppression list (meta-viewport, plus the CAS-2387 entries) should be reviewed if the iOS viewport meta tag situation changes.