feat(e2e): visual snapshots — route×theme matrix in the pinned container
18 baselines (9 manifest routes × light/dark) compared only inside mcr.microsoft.com/playwright:v<version>-noble, so pixels are identical on every machine and CI; the wrapper derives the tag from the installed @playwright/test version, making the pin drift-proof, and a CI guard fails loudly if the workflow's container tag falls behind. macOS hosts cannot run the visual project directly (parallel darwin baselines) nor mount their node_modules into the container (native vite binaries): the wrapper builds dist on the host and the container serves it with a dependency-free static server — route mocks intercept API calls before the network, so no proxy is needed. The xterm canvas is masked (SwiftShader pixels are driver-dependent). Determinism proven by back-to-back container runs. The e2e-visual CI job is advisory, never a required check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -73,6 +73,53 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Visual snapshots: pinned container = pixel-stable baselines. Advisory —
|
||||
# never a required check.
|
||||
e2e-visual:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Verify container tag matches @playwright/test
|
||||
run: |
|
||||
node -e "
|
||||
const v = require('./frontend/package.json').devDependencies['@playwright/test'];
|
||||
if (v !== '1.61.1') {
|
||||
console.error('::error::bump the e2e-visual container tag to v' + v + '-noble (and regenerate baselines)');
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run visual snapshots
|
||||
working-directory: frontend
|
||||
run: E2E_VISUAL=1 npx playwright test -c e2e/playwright.config.ts
|
||||
|
||||
- name: Upload diffs
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-visual-diffs
|
||||
path: frontend/e2e/test-results
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Tier 2: branch-built image + isolated compose stack + the mock-LLM agent
|
||||
# loop. Not part of the PR gate — nightly and on-demand only.
|
||||
e2e-local:
|
||||
|
||||
@@ -29,6 +29,9 @@ CI=1 pnpm e2e # byte-identical reproduction of a CI run
|
||||
./e2e/tools/run-local-tier.sh # Tier 2: branch image + isolated docker
|
||||
# stack + mock LLM; runs specs/real/**
|
||||
E2E_TIER=stand E2E_BASE_URL=https://… pnpm e2e # against a live stand
|
||||
|
||||
pnpm e2e:visual # visual snapshots (pinned container)
|
||||
pnpm e2e:visual:update # regenerate baselines after a UI change
|
||||
```
|
||||
|
||||
Tips for the mock tier: `vite preview` is reused between runs if you keep it
|
||||
@@ -49,6 +52,18 @@ docker daemon, and removes those sandboxes on exit. The deterministic agent
|
||||
transcript lives in `e2e/mock-llm/scenario.mjs`. Iterating: `E2E_SKIP_BUILD=1`
|
||||
reuses the image, `E2E_KEEP_STACK=1` leaves the stack up.
|
||||
|
||||
## Visual snapshots
|
||||
|
||||
Baselines live in the repo (`e2e/specs/visual/*-snapshots/`, linux-suffixed)
|
||||
and are generated ONLY inside the pinned `mcr.microsoft.com/playwright`
|
||||
container — never run the visual project on the host: macOS pixels produce
|
||||
parallel baselines that will never match CI. `pnpm e2e:visual` derives the
|
||||
image tag from the installed `@playwright/test` version, builds `dist` on the
|
||||
host, and compares inside the container; `pnpm e2e:visual:update` regenerates
|
||||
baselines (commit them with the UI change that caused the diff). The xterm
|
||||
canvas is masked — WebGL rendering is driver-dependent. The CI `e2e-visual`
|
||||
job is advisory and never a required check.
|
||||
|
||||
## Debugging a red CI run
|
||||
|
||||
1. Open the failed run (link in the PR comment) and download the `e2e-report`
|
||||
|
||||
@@ -7,6 +7,10 @@ export type BackendTier = 'local' | 'mock' | 'stand';
|
||||
|
||||
const tier = (process.env.E2E_TIER ?? 'mock') as BackendTier;
|
||||
const isCI = Boolean(process.env.CI);
|
||||
// Visual snapshots only ever run inside the pinned Playwright Linux container
|
||||
// (e2e/tools/run-visual.sh) — a darwin run would generate parallel baselines
|
||||
// that never match CI pixels.
|
||||
const isVisual = process.env.E2E_VISUAL === '1';
|
||||
|
||||
// `vite preview` listens on VITE_PORT + 100 and reuses the dev proxy config.
|
||||
const PREVIEW_PORT = 8100;
|
||||
@@ -38,11 +42,17 @@ export default defineConfig<BackendOptions>({
|
||||
projects:
|
||||
tier === 'mock'
|
||||
? [
|
||||
{
|
||||
name: 'mock-chromium',
|
||||
testIgnore: '**/specs/real/**',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
isVisual
|
||||
? {
|
||||
name: 'visual',
|
||||
testMatch: '**/specs/visual/**',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
}
|
||||
: {
|
||||
name: 'mock-chromium',
|
||||
testIgnore: ['**/specs/real/**', '**/specs/visual/**'],
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
@@ -83,7 +93,9 @@ export default defineConfig<BackendOptions>({
|
||||
webServer:
|
||||
tier === 'mock'
|
||||
? {
|
||||
command: 'pnpm run build && pnpm exec vite preview',
|
||||
// The visual container cannot load host-built native vite
|
||||
// binaries — it serves a pre-built dist with plain Node.
|
||||
command: isVisual ? 'node e2e/tools/serve-dist.mjs' : 'pnpm run build && pnpm exec vite preview',
|
||||
cwd: fileURLToPath(new URL('..', import.meta.url)),
|
||||
env: { VITE_PORT: '8000', VITE_USE_HTTPS: 'false' },
|
||||
reuseExistingServer: !isCI,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from '../../fixtures/test.ts';
|
||||
import { ROUTE_MANIFEST } from '../../routes.ts';
|
||||
|
||||
const THEMES = ['light', 'dark'] as const;
|
||||
|
||||
const slugify = (path: string) => path.replaceAll('/', '-').replace(/^-/, '') || 'root';
|
||||
|
||||
test.describe('visual', { tag: '@visual' }, () => {
|
||||
for (const entry of ROUTE_MANIFEST) {
|
||||
for (const theme of THEMES) {
|
||||
test.describe(`${entry.path} ${theme}`, () => {
|
||||
test.use({ cassette: entry.cassette() });
|
||||
|
||||
test('matches the baseline', async ({ page }) => {
|
||||
await page.addInitScript((value) => window.localStorage.setItem('theme', String(value)), theme);
|
||||
await page.goto(entry.path);
|
||||
await expect(entry.ready(page)).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
|
||||
await expect(page).toHaveScreenshot(`${slugify(entry.path)}-${theme}.png`, {
|
||||
fullPage: true,
|
||||
// The terminal is a WebGL canvas: SwiftShader pixels differ
|
||||
// between runners, so its region is masked, not compared.
|
||||
mask: [page.locator('.xterm')],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Visual snapshots run ONLY inside the pinned Playwright container so baselines
|
||||
# are pixel-stable across every machine and CI. The image tag is derived from
|
||||
# the installed @playwright/test version — the pin cannot drift by construction.
|
||||
# Usage: run-visual.sh [--update] (E2E_SKIP_BUILD=1 reuses the existing dist)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
FRONTEND="$REPO_ROOT/frontend"
|
||||
|
||||
PLAYWRIGHT_VERSION="$(node -p "require('$FRONTEND/node_modules/@playwright/test/package.json').version")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
|
||||
|
||||
if [[ "${E2E_SKIP_BUILD:-}" != "1" ]]; then
|
||||
(cd "$FRONTEND" && corepack pnpm run build)
|
||||
fi
|
||||
|
||||
UPDATE_ARGS=()
|
||||
if [[ "${1:-}" == "--update" ]]; then
|
||||
UPDATE_ARGS=(--update-snapshots)
|
||||
shift
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v "$REPO_ROOT:/work" \
|
||||
-w /work/frontend \
|
||||
-e E2E_VISUAL=1 \
|
||||
-e CI="${CI:-}" \
|
||||
--ipc=host \
|
||||
"$IMAGE" \
|
||||
npx playwright test -c e2e/playwright.config.ts ${UPDATE_ARGS[@]+"${UPDATE_ARGS[@]}"} "$@"
|
||||
@@ -0,0 +1,36 @@
|
||||
// Static server for the built dist with an SPA fallback. The visual project
|
||||
// runs inside the pinned Playwright container where the host-built (darwin)
|
||||
// native vite binaries cannot load — this replaces `vite preview` with
|
||||
// dependency-free Node. API calls never reach it: Playwright route mocks
|
||||
// intercept them in the browser.
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DIST = fileURLToPath(new URL('../../dist', import.meta.url));
|
||||
const PORT = Number(process.env.PORT ?? 8100);
|
||||
|
||||
const MIME = {
|
||||
'.css': 'text/css',
|
||||
'.html': 'text/html',
|
||||
'.ico': 'image/x-icon',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
createServer((request, response) => {
|
||||
const pathname = normalize(new URL(request.url ?? '/', 'http://localhost').pathname).replace(/^(\.\.[/\\])+/, '');
|
||||
let filePath = join(DIST, pathname);
|
||||
|
||||
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
||||
filePath = join(DIST, 'index.html');
|
||||
}
|
||||
|
||||
response.writeHead(200, { 'content-type': MIME[extname(filePath)] ?? 'application/octet-stream' });
|
||||
createReadStream(filePath).pipe(response);
|
||||
}).listen(PORT, () => console.log(`[serve-dist] ${DIST} on :${PORT}`));
|
||||
@@ -8,6 +8,8 @@
|
||||
"e2e": "playwright test -c e2e/playwright.config.ts",
|
||||
"e2e:setup": "playwright install chromium",
|
||||
"e2e:ui": "playwright test -c e2e/playwright.config.ts --ui",
|
||||
"e2e:visual": "./e2e/tools/run-visual.sh",
|
||||
"e2e:visual:update": "./e2e/tools/run-visual.sh --update",
|
||||
"graphql:generate": "graphql-codegen --config graphql-codegen.ts",
|
||||
"lint": "eslint \"{src,e2e}/**/*.{ts,tsx,js,jsx}\"",
|
||||
"lint:fix": "eslint \"{src,e2e}/**/*.{ts,tsx,js,jsx}\" --fix",
|
||||
|
||||