Page Visual Tests (Playwright + Chromatic)
This skill covers the page visual tests Chromatic project only (the page-visual-tests job in ci.yml publishes via chromaui/action; local publish is pnpm chromatic:pages with CHROMATIC_PAGES_TOKEN) — not Storybook Chromatic.
The Playwright suite captures DOM archives (not PNGs) per page × viewport; Chromatic re-renders them in the cloud to diff. A green local pnpm test:visual just means archives were produced — the diff happens after upload.
Files that matter
playwright.visual.config.ts— visual-only config (3 viewports +webServer)playwright.config.ts— base (e2e + unit; nowebServer)tests/visual/pages.spec.ts— page list + readiness pattern.github/workflows/ci.yml— CI (thepage-visual-testsjob)src/components/ui/skeleton.tsx,src/components/ui/spinner.tsx— loading primitivespackage.jsonscripts:test:visual*,chromatic:pages
Non-obvious constraints
Dual Playwright config. webServer lives only in playwright.visual.config.ts. Moving it into the base config breaks pnpm test:unit and pnpm test:e2e in CI — they try to start Next against a missing .next build.
Desktop viewport is 1024, not 1280. Chromatic caps snapshots at width × height ≤ 25M px. The tallest tested pages reach ~22.5k px; 1280 overflows, 1024 fits. Measure document.documentElement.scrollHeight before raising the viewport or adding a long page.
Loading contract: data-slot="loading". The shared Skeleton and Spinner primitives carry this attribute. Each test waits until document.querySelectorAll('[data-slot="loading"]').length === 0 before snapshotting. Any bespoke loader — raw animate-pulse-light, a local Skeleton copy, a custom spinner — is invisible to the wait and will silently flake. Fix by routing through the shared primitive or adding data-slot="loading" to the bespoke loader's root.
Imports come from @chromatic-com/playwright, not @playwright/test. The two packages re-export expect with skewed types, so expect(...).toHaveCount(0) misbehaves — prefer page.waitForFunction for the loading wait.
Environment. USE_MOCK_DATA=true and NEXT_PUBLIC_BUILD_LOCALES are required at build and test time. The local test:visual:build script sets NEXT_PUBLIC_BUILD_LOCALES=en; CI's shared build in ci.yml uses NEXT_PUBLIC_BUILD_LOCALES: "en,es,zh,ar" because one build artifact serves the e2e, lighthouse, and visual jobs. Paths in the spec are unprefixed (/wallets/, not /en/wallets/) because localePrefix: "as-needed" serves English at the root — adding /en would just trigger a redirect.
Random ordering: safeShuffle. Lodash shuffle and .sort(() => Math.random() - 0.5) flake snapshots independently of loaders. Wrap them with safeShuffle from src/lib/utils/random.ts — it returns the list unchanged when IS_VISUAL_TEST=true. Current call sites: src/lib/utils/wallets.ts, src/lib/utils/apps.ts (Highlights/Discover/AppOfTheWeek), src/components/Staking/StakingProductsCardGrid/index.tsx. The env var is exposed to the client bundle via next.config.js's env block; without that, process.env.IS_VISUAL_TEST evaluates to undefined in client components and the shuffle still runs.
Use domcontentloaded, not networkidle. Analytics and background fetches keep the network perpetually busy.
Canonical test
import { takeSnapshot, test } from "@chromatic-com/playwright"
const pages: Array<{ name: string; path: string }> = [
{ name: "Homepage", path: "/" },
{ name: "Docs - Smart Contracts", path: "/developers/docs/smart-contracts/" },
// ...
]
test.describe("Page Visual Tests", () => {
for (const { name, path } of pages) {
test(name, async ({ page }, testInfo) => {
await page.goto(path, { waitUntil: "domcontentloaded" })
await page.waitForFunction(
() => document.querySelectorAll('[data-slot="loading"]').length === 0
)
// FeedbackWidget is dynamic({ ssr: false }); waiting for its button proves
// hydration finished and dynamic chunks landed — same pattern other
// ssr:false components (Emoji/Twemoji) rely on to render.
await page.waitForSelector('[data-testid="feedback-widget-button"]')
await takeSnapshot(page, testInfo)
})
}
})
Common situations
Adding a page. Each entry costs three snapshots (one per viewport) against Chromatic's budget, so check whether the page's layout (under src/layouts/) is already covered before adding. Scan the page subtree for bespoke loaders — they're the single biggest flake cause — and confirm full-page height stays under the 25M-pixel budget. Local loop: pnpm test:visual:build once, then pnpm test:visual:desktop for iteration, pnpm test:visual for the full sweep.
Flaky snapshot. Two main causes. (1) A loader without data-slot="loading" — run with --trace=on and inspect the waitForFunction step; ~0 ms duration means it isn't being waited on. (2) Random ordering — grep the page subtree for shuffle(, Math.random(), or .sort(() => and route through safeShuffle. If dynamic content is drifting, double-check USE_MOCK_DATA=true is set in both build and test steps.
Local pnpm dev masks a regression. playwright.visual.config.ts sets reuseExistingServer: true, which is correct for CI but means a pnpm dev already running on :3000 will be used silently in place of the production build the suite assumes. If a snapshot diff doesn't reproduce in CI, kill the dev server and run pnpm test:visual:build to rebuild against the production output before retrying.
Pixel-limit error. Measure the page's full-page height at 1024 px; if it exceeds ~24,400 px, the page needs shortening or removal from the suite. Don't crop to viewport — below-the-fold coverage is the point of this suite.
Works locally, fails in CI. Usually HOME: /root missing from the test step — GitHub Actions overrides HOME inside containers, and Playwright can no longer find the browsers baked into the mcr.microsoft.com/playwright image. Also check that the image tag matches @playwright/test in package.json.