Skip to content

Commit f1ed565

Browse files
authored
Merge pull request #18009 from ethereum/feat/playwright-chromatic-page-visual-tests
test(visual): add Playwright + Chromatic page visual regression suite
2 parents 4a12cb9 + d64d2f3 commit f1ed565

23 files changed

Lines changed: 460 additions & 1035 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
name: page-visual-tests
3+
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 `.github/workflows/chromatic-pages.yml`. Skip for Storybook Chromatic (`chromatic.yml`), e2e (`tests/e2e/`), unit (`tests/unit/`).
4+
---
5+
6+
# Page Visual Tests (Playwright + Chromatic)
7+
8+
This repo has two Chromatic projects: Storybook (`chromatic.yml` + `pnpm chromatic`) and **page visual tests** (`chromatic-pages.yml` + `pnpm chromatic:pages`). This skill is the second one only.
9+
10+
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.
11+
12+
## Files that matter
13+
14+
- `playwright.visual.config.ts` — visual-only config (3 viewports + `webServer`)
15+
- `playwright.config.ts` — base (e2e + unit; **no `webServer`**)
16+
- `tests/visual/pages.spec.ts` — page list + readiness pattern
17+
- `.github/workflows/chromatic-pages.yml` — CI
18+
- `src/components/ui/skeleton.tsx`, `src/components/ui/spinner.tsx` — loading primitives
19+
- `package.json` scripts: `test:visual*`, `chromatic:pages`
20+
21+
## Non-obvious constraints
22+
23+
**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.
24+
25+
**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.
26+
27+
**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.
28+
29+
**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.
30+
31+
**Environment.** `USE_MOCK_DATA=true` and `NEXT_PUBLIC_BUILD_LOCALES=en` are required at build and test time. 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.
32+
33+
**Random ordering: `maybeShuffle`.** Lodash `shuffle` and `.sort(() => Math.random() - 0.5)` flake snapshots independently of loaders. Wrap them with `maybeShuffle` from `src/lib/utils/random.ts` — it returns the list unchanged when `IS_VISUAL_TEST=true`. Current call sites: `wallets.ts`, `apps.ts` (Highlights/Discover/AppOfTheWeek), `useStakingProductsCardGrid.ts`. 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.
34+
35+
**Use `domcontentloaded`, not `networkidle`.** Analytics and background fetches keep the network perpetually busy.
36+
37+
## Canonical test
38+
39+
```ts
40+
import { takeSnapshot, test } from "@chromatic-com/playwright"
41+
42+
const pages: Array<{ name: string; path: string }> = [
43+
{ name: "Homepage", path: "/" },
44+
{ name: "Docs - Smart Contracts", path: "/developers/docs/smart-contracts/" },
45+
// ...
46+
]
47+
48+
test.describe("Page Visual Tests", () => {
49+
for (const { name, path } of pages) {
50+
test(name, async ({ page }, testInfo) => {
51+
await page.goto(path, { waitUntil: "domcontentloaded" })
52+
await page.waitForFunction(
53+
() => document.querySelectorAll('[data-slot="loading"]').length === 0
54+
)
55+
await takeSnapshot(page, testInfo)
56+
})
57+
}
58+
})
59+
```
60+
61+
## Common situations
62+
63+
**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.
64+
65+
**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.
66+
67+
**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.
68+
69+
**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. Cropping to viewport was considered and rejected — it defeats the below-the-fold regression coverage that justifies using Playwright over Storybook here.
70+
71+
**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`.
72+
73+
Branch: `feat/playwright-chromatic-page-visual-tests` · PR: <https://github.com/ethereum/ethereum-org-website/pull/18009>
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Playwright + Chromatic full-page visual regression tests
2+
# Separate from chromatic.yml (Storybook component snapshots)
3+
name: "Chromatic: Page Visual Tests"
4+
5+
on:
6+
pull_request:
7+
branches: [master, staging, "test/**"]
8+
types: [opened, synchronize, ready_for_review]
9+
workflow_dispatch:
10+
11+
jobs:
12+
playwright-visual:
13+
name: Build & Capture Snapshots
14+
runs-on: ubuntu-latest
15+
container:
16+
image: mcr.microsoft.com/playwright:v1.53.1-noble
17+
steps:
18+
- name: Checkout repo
19+
uses: actions/checkout@v6
20+
with:
21+
fetch-depth: 0
22+
23+
- name: Setup pnpm
24+
uses: pnpm/action-setup@v4
25+
26+
- name: Setup Node.js
27+
uses: actions/setup-node@v6
28+
with:
29+
node-version: 20
30+
cache: pnpm
31+
32+
- name: Install dependencies
33+
run: pnpm install --frozen-lockfile
34+
35+
- name: Build Next.js with mock data (English only)
36+
run: pnpm build
37+
env:
38+
USE_MOCK_DATA: "true"
39+
IS_VISUAL_TEST: "true"
40+
NEXT_PUBLIC_BUILD_LOCALES: "en"
41+
42+
- name: Run visual tests
43+
run: pnpm test:visual
44+
env:
45+
HOME: /root
46+
USE_MOCK_DATA: "true"
47+
IS_VISUAL_TEST: "true"
48+
49+
- name: Upload test results
50+
if: always()
51+
uses: actions/upload-artifact@v4
52+
with:
53+
name: chromatic-archives
54+
path: test-results/
55+
retention-days: 1
56+
57+
chromatic-upload:
58+
name: Upload to Chromatic
59+
needs: playwright-visual
60+
runs-on: ubuntu-latest
61+
steps:
62+
- name: Checkout repo
63+
uses: actions/checkout@v6
64+
with:
65+
fetch-depth: 0
66+
67+
- name: Setup pnpm
68+
uses: pnpm/action-setup@v4
69+
70+
- name: Setup Node.js
71+
uses: actions/setup-node@v6
72+
with:
73+
node-version: 20
74+
cache: pnpm
75+
76+
- name: Install dependencies
77+
run: pnpm install --frozen-lockfile
78+
79+
- name: Download test results
80+
uses: actions/download-artifact@v4
81+
with:
82+
name: chromatic-archives
83+
path: test-results/
84+
85+
- name: Publish to Chromatic
86+
uses: chromaui/action@v16
87+
with:
88+
projectToken: ${{ secrets.CHROMATIC_PAGES_TOKEN }}
89+
playwright: true
90+
exitZeroOnChanges: true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
/coverage
1111
tests/__results__/
1212
tests/__report__/
13+
test-results/
1314

1415
# next.js
1516
/.next/
@@ -64,6 +65,7 @@ src/data/crowdin/bucketsAwaitingReviewReport.csv
6465
build-storybook.log
6566
build-archive.log
6667
storybook-static
68+
storybook-static-pages
6769

6870
# Trigger
6971
.trigger

app/[locale]/_components/HomepageLazy.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const Skeleton = ({
1313
}) => (
1414
<Section className={className}>
1515
<div
16+
data-slot="loading"
1617
className={`w-full animate-pulse rounded-2xl bg-background-highlight ${heightClass}`}
1718
/>
1819
</Section>

app/api/gas-eth-price/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ export async function GET() {
2020
return NextResponse.json({
2121
gasPrice: gasPriceData.gasPrice,
2222
ethPriceUSD: ethPriceData.value,
23+
ethPercentChange24h: ethPriceData.percentChange24h,
2324
})
2425
}

next.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ module.exports = (phase) => {
4242
process.env.DEPLOY_URL ||
4343
process.env.URL ||
4444
"https://ethereum.org",
45+
// Inline IS_VISUAL_TEST into the client bundle so client-side shuffles
46+
// (e.g. useStakingProductsCardGrid) can opt out of randomization during
47+
// visual test builds. Server code reads it from process.env directly.
48+
IS_VISUAL_TEST: process.env.IS_VISUAL_TEST,
4549
},
4650
webpack: (config) => {
4751
config.module.rules.push({

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"build-storybook": "storybook build",
1818
"build-storybook:chromatic": "storybook build --test",
1919
"chromatic": "chromatic --project-token fee8e66c9916",
20+
"chromatic:pages": "chromatic --playwright --zip --exit-zero-on-changes",
2021
"lint:md": "markdownlint-cli2 \"public/content/**/*.md\" \"!public/content/translations/**\"",
2122
"lint:md:fix": "markdownlint-cli2 --fix \"public/content/**/*.md\" \"!public/content/translations/**\"",
2223
"update-tutorials": "ts-node -O '{ \"module\": \"commonjs\" }' src/scripts/update-tutorials-list.ts",
@@ -27,6 +28,9 @@
2728
"test:e2e:ui": "playwright test --project=e2e --ui",
2829
"test:e2e:debug": "playwright test --project=e2e --debug",
2930
"test:e2e:report": "playwright show-report tests/__report__",
31+
"test:visual": "playwright test --config playwright.visual.config.ts",
32+
"test:visual:desktop": "playwright test --config playwright.visual.config.ts --project=chromatic-desktop",
33+
"test:visual:build": "USE_MOCK_DATA=true IS_VISUAL_TEST=true NEXT_PUBLIC_BUILD_LOCALES=en pnpm build",
3034
"trigger:dev": "trigger dev --env-file src/data-layer/.env.local",
3135
"trigger:deploy": "trigger deploy --env-file src/data-layer/.env.local"
3236
},
@@ -108,6 +112,7 @@
108112
"yaml-loader": "^0.8.0"
109113
},
110114
"devDependencies": {
115+
"@chromatic-com/playwright": "^0.13.1",
111116
"@chromatic-com/storybook": "5.1.1",
112117
"@google/genai": "^1.46.0",
113118
"@netlify/plugin-nextjs": "^5.15.9",

playwright.visual.config.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ChromaticConfig } from "@chromatic-com/playwright"
2+
import { defineConfig } from "@playwright/test"
3+
4+
import baseConfig from "./playwright.config"
5+
6+
const visualUse: ChromaticConfig = {
7+
disableAutoSnapshot: true,
8+
assetDomains: ["s3-dcl1.ethquokkaops.io"],
9+
}
10+
11+
// Append "Chromatic" to the default UA so `isChromatic()` returns true client-side.
12+
// Several components (QuizWidget, Simulator) use this signal to skip randomization.
13+
const userAgent =
14+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Chromatic"
15+
16+
export default defineConfig<ChromaticConfig>({
17+
...baseConfig,
18+
19+
projects: [
20+
{
21+
name: "chromatic-desktop",
22+
testDir: "./tests/visual",
23+
outputDir: "./test-results",
24+
// 1024 (Tailwind `lg`) keeps full-page snapshots under Chromatic's 25M pixel limit on our longest pages.
25+
use: { ...visualUse, userAgent, viewport: { width: 1024, height: 720 } },
26+
},
27+
{
28+
name: "chromatic-tablet",
29+
testDir: "./tests/visual",
30+
outputDir: "./test-results",
31+
use: { ...visualUse, userAgent, viewport: { width: 768, height: 1024 } },
32+
},
33+
{
34+
name: "chromatic-mobile",
35+
testDir: "./tests/visual",
36+
outputDir: "./test-results",
37+
use: { ...visualUse, userAgent, viewport: { width: 375, height: 812 } },
38+
},
39+
],
40+
41+
webServer: {
42+
command: "pnpm start",
43+
port: 3000,
44+
reuseExistingServer: true,
45+
},
46+
})

0 commit comments

Comments
 (0)