Playwright Visual QA
Playwright visual regression is the primary mechanism for catching unintended UI changes before they reach customers. It captures screenshots of key UI states and compares them against approved baselines.
Why Visual Regression Matters
TypeScript compiles, tests pass, and the UI still looks broken. Visual regression exists because:
- CSS changes have side effects across the component tree that tests cannot see
- Font loading, animation states, and layout shifts are invisible to functional tests
- The GOVERN Living Visualization doctrine requires specific visual properties (energy layers, ambient motion, orb-first interface) that cannot be unit tested
The Playwright visual suite is the only mechanism that can verify these properties automatically.
Setup
# Install Playwright and browsers (run once per machine)cd packages/govern-internal-docsnpx playwright install --with-deps chromium
# Or from the monorepo rootpnpm add -D @playwright/testnpx playwright install chromiumplaywright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ testDir: './e2e', use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:4321', screenshot: 'only-on-failure', }, projects: [ { name: 'chromium-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } }, }, { name: 'chromium-mobile', use: { ...devices['Pixel 5'] }, }, ], snapshotDir: './e2e/snapshots', updateSnapshots: 'missing',});Taking and Comparing Screenshots
Writing a visual test
import { test, expect } from '@playwright/test';
test('dashboard home renders correctly', async ({ page }) => { await page.goto('/'); await page.waitForLoadState('networkidle');
// Wait for ambient animation to reach steady state await page.waitForTimeout(2000);
await expect(page).toHaveScreenshot('dashboard-home.png', { maxDiffPixels: 100, threshold: 0.02, });});
test('assessment panel with active probe', async ({ page }) => { await page.goto('/assessments/test-assessment-id'); await page.waitForSelector('[data-testid="probe-status"]');
await expect( page.locator('[data-testid="assessment-panel"]') ).toHaveScreenshot('assessment-panel-active.png');});Updating baselines
When a UI change is intentional, update the baseline screenshots:
# Update all baselinesnpx playwright test --update-snapshots
# Update a specific test filenpx playwright test e2e/visual/dashboard.spec.ts --update-snapshotsAlways review updated baselines before committing. The Testing Dashboard shows a side-by-side diff of old vs new baseline.
Screenshot Inventory
These are the canonical screenshots maintained as visual regression baselines:
Customer Dashboard
| Screenshot | Page | State |
|---|---|---|
dashboard-home.png | / | Idle, ambient animation at 2s |
assessments-list.png | /assessments | 3 assessments, mixed status |
assessment-detail.png | /assessments/:id | Running probe, live telemetry |
report-preview.png | /reports/:id | PDF preview, all sections loaded |
settings-org.png | /settings/org | Org settings form |
Internal Dashboard
| Screenshot | Page | State |
|---|---|---|
internal-home.png | / | All widgets loaded, 24h view |
pipeline-monitor.png | /pipeline | Active pipeline, recent events |
build-events.png | /build-events | Last 20 events, skill graph |
cost-governor.png | /cost | Current period, spend trend |
deploy-watchdog.png | /deploys | Last 5 deploys, health badges |
CI Integration
Visual regression runs in GitHub Actions on every PR that touches a UI package:
name: Visual Regressionon: pull_request: paths: - 'packages/dashboard/**' - 'packages/govern-dashboard/**' - 'packages/govern-app/**'
jobs: visual-qa: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: pnpm install - run: npx playwright install --with-deps chromium - run: pnpm build - run: npx playwright test - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/Reviewing Failures
When visual regression fails, the Testing Dashboard shows:
- Expected — the approved baseline screenshot
- Actual — the screenshot from the failing test run
- Diff — pixel-level diff with changed areas highlighted in red
- Threshold info — how far the diff exceeds the allowed threshold
Common failure causes
| Failure type | Likely cause | Resolution |
|---|---|---|
| Entire page shifted | Viewport size mismatch | Check playwright.config.ts viewport settings |
| Animation mid-frame | waitForTimeout too short | Increase wait before screenshot |
| Font rendering difference | Different OS font rendering | Run tests in Docker to standardize |
| Data-driven text changed | Test database seeding changed | Reset test database seeds |
| Intentional redesign | UI deliberately changed | Review + approve, then --update-snapshots |
Living Visualization Checks
For GOVERN UI surfaces that implement the Living Visualization doctrine, visual tests include specific checks:
test('energy layers are visible at idle', async ({ page }) => { await page.goto('/'); await page.waitForTimeout(3000); // Let ambient animation stabilize
// L1 — base energy layer const l1 = page.locator('[data-layer="energy-l1"]'); await expect(l1).toBeVisible();
// L2 — mid energy layer const l2 = page.locator('[data-layer="energy-l2"]'); await expect(l2).toBeVisible();
// L3 — surface energy layer const l3 = page.locator('[data-layer="energy-l3"]'); await expect(l3).toBeVisible();
// Full page screenshot for visual baseline await expect(page).toHaveScreenshot('idle-energy-layers.png');});
test('orb is the dominant interface element', async ({ page }) => { await page.goto('/'); const orb = page.locator('[data-testid="civilization-orb"]'); await expect(orb).toBeVisible();
// Orb bounding box should occupy center of viewport const orbBox = await orb.boundingBox(); const viewportSize = page.viewportSize(); expect(orbBox?.x).toBeGreaterThan(viewportSize!.width * 0.3); expect(orbBox?.x).toBeLessThan(viewportSize!.width * 0.7);});Snapshot Storage
Baseline snapshots are committed to the repository in e2e/snapshots/. They are versioned alongside the code they test.
- Do not use
.gitignoreto exclude snapshot files - Do review snapshot changes in PRs the same way you review code changes
- Do delete snapshots for removed UI components to keep the baseline lean