page-visual-tests
ethereum/ethereum-org-website/.claude/skills/page-visual-tests/SKILL.md
Playwright + Chromatic full-page visual tests for ethereum.org. Trigger on "add a page to the visual suite", "the snapshot keeps changing", "chromatic pages", "chromatic playwright", or edits to `tests/visual/`, `playwright.visual.config.ts`, or the `page-visual-tests` job in `.github/workflows/ci.yml`. Skip for Storybook Chromatic (the `visual-tests` job in ci.yml), e2e (`tests/e2e/`), unit (`tests/unit/`).
- Reads credentials
What's in it
- Page Visual Tests (Playwright + Chromatic)
- Files that matter
- Non-obvious constraints
- Canonical test
- Common situations
---
name: page-visual-tests
description: Playwright + Chromatic full-page visual tests for ethereum.org. Trigger on "add a page to the visual suite", "the snapshot keeps changing", "chromatic pages", "chromatic playwright", or edits to `tests/visual/`, `playwright.visual.config.ts`, or the `page-visual-tests` job in `.github/workflows/ci.yml`. Skip for Storybook Chromatic (the `visual-tests` job in ci.yml), e2e (`tests/e2e/`), unit (`tests/unit/`).
---
# 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; **no `webServer`**)
- `tests/visual/pages.spec.ts` — page list + readiness pattern
- `.github/workflows/ci.yml` — CI (the `page-visual-tests` job)
- `src/components/ui/skeleton.tsx`, `src/components/ui/spinner.tsx` — loading primitives
- `package.json` scripts: `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
```ts
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`.
More agent context in ethereum/ethereum-org-website
9 other files this repository gives its agents.
AGENTS.md
CLAUDE.md
Skill
- data-layer.claude/skills/data-layer/SKILL.md
- design-system.claude/skills/design-system/SKILL.md
- intl-pipeline.claude/skills/intl-pipeline/SKILL.md
- intl-review.claude/skills/intl-review/SKILL.md
- new-page.claude/skills/new-page/SKILL.md
- performance.claude/skills/performance/SKILL.md
- review-page.claude/skills/review-page/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

