Add visual regression coverage

This commit is contained in:
2026-05-25 10:17:07 -04:00
parent 10729a906c
commit 571edb9b5a
18 changed files with 278 additions and 4 deletions

View File

@@ -37,6 +37,8 @@ npm --prefix frontend test -- --run
npm --prefix frontend run build npm --prefix frontend run build
npm run test:e2e npm run test:e2e
npm run test:e2e:prod npm run test:e2e:prod
npm run test:visual
npm run test:visual:prod
``` ```
### Dev proxy smoke (Vite + Go API) ### Dev proxy smoke (Vite + Go API)
@@ -82,9 +84,27 @@ This runs `playwright.prod.config.ts`, which builds the frontend, seeds
exercises Race Hub, Data Library, Live empty state, and nav links against the exercises Race Hub, Data Library, Live empty state, and nav links against the
built SPA. built SPA.
## Remaining Post-MVP Work ### Visual regression (Playwright screenshots)
- Add real visual-regression checks for the React screens. Screenshot baselines for Race Hub, Data Library, and Live (disabled-live empty
state) at desktop, tablet, and mobile viewports:
```bash
npm run test:visual
npm run test:visual:prod
```
Refresh baselines after intentional UI changes:
```bash
npm run test:visual:update
npm run test:visual:prod:update
```
Snapshots are stored under `tests/visual/__snapshots__/`. See
[22 Phase 14 Visual Regression](22-phase-14-visual-regression.md).
## Remaining Post-MVP Work
- Improve high-density mobile/iPad behavior for Live Timing and Race Hub tables. - Improve high-density mobile/iPad behavior for Live Timing and Race Hub tables.
- Add persisted live-event capture and reconciliation only after defining the - Add persisted live-event capture and reconciliation only after defining the
live storage model. live storage model.

View File

@@ -0,0 +1,55 @@
# Phase 14: Visual Regression and Responsive QA
## Goal
Add Playwright screenshot coverage for the MVP Web UI routes across desktop,
tablet, and mobile viewports before broader product expansion.
## Scope
Routes:
- `/race-hub?session_key=9472`
- `/data-library`
- `/live` (empty state with `BOXBOX_DISABLE_LIVE=1`)
Viewports (deterministic Chromium):
| Project | Size |
|---------|------|
| desktop | 1280×800 |
| tablet | 768×1024 |
| mobile | 390×844 |
## Commands
```bash
# Dev proxy (Vite + seeded Go API) — same stack as test:e2e
npm run test:visual
npm run test:visual:update
# Production serving (Go + frontend/dist) — canonical for committed snapshots
npm run test:visual:prod
npm run test:visual:prod:update
```
Snapshots live under `tests/visual/__snapshots__/{desktop,tablet,mobile}/`.
## Constraints
- Reuses `scripts/seed-e2e-db` and `BOXBOX_DISABLE_LIVE=1`; no live F1 session
or OpenF1 network calls.
- Screenshots are taken only after route-specific ready conditions (classification
loaded, data library detail visible, live empty state).
- Animations disabled; full-page captures; no loading-state screenshots.
## Out of Scope
- Live timing tower screenshots (requires an active session and live SignalR).
- Cross-browser matrix beyond Chromium.
- Pixel-perfect parity between Vite dev and production builds (use prod update
when refreshing committed baselines).
## Related
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md)

View File

@@ -77,6 +77,8 @@ not implementation tickets yet.
for showing local ingestion coverage and next CLI actions. for showing local ingestion coverage and next CLI actions.
- [21 MVP Completion Checklist](21-mvp-completion-checklist.md): current - [21 MVP Completion Checklist](21-mvp-completion-checklist.md): current
implementation status, verification commands, and remaining post-MVP work. implementation status, verification commands, and remaining post-MVP work.
- [22 Phase 14 Visual Regression](22-phase-14-visual-regression.md): Playwright
screenshot coverage for MVP routes and responsive viewports.
## External References ## External References

View File

@@ -7,7 +7,11 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:prod": "playwright test --config playwright.prod.config.ts", "test:e2e:prod": "playwright test --config playwright.prod.config.ts",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report" "test:e2e:report": "playwright show-report",
"test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:prod": "playwright test --config playwright.visual.prod.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
"test:visual:prod:update": "playwright test --config playwright.visual.prod.config.ts --update-snapshots"
}, },
"type": "commonjs", "type": "commonjs",
"devDependencies": { "devDependencies": {

View File

@@ -6,7 +6,7 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
export default defineConfig({ export default defineConfig({
testDir: './tests', testDir: './tests',
testIgnore: '**/production-smoke.spec.ts', testIgnore: ['**/production-smoke.spec.ts', '**/visual/**'],
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,

View File

@@ -0,0 +1,64 @@
import { defineConfig } from '@playwright/test'
import { VIEWPORTS } from './tests/visual/helpers'
const E2E_DB = '.playwright/boxbox-e2e.db'
const API_PORT = process.env.BOXBOX_API_PORT ?? '18080'
const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173'
export default defineConfig({
testDir: './tests/visual',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'html',
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
expect: {
toHaveScreenshot: {
animations: 'disabled',
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: `http://localhost:${WEB_PORT}`,
trace: 'on-first-retry',
colorScheme: 'dark',
},
projects: [
{
name: 'desktop',
use: {
browserName: 'chromium',
viewport: VIEWPORTS.desktop,
},
},
{
name: 'tablet',
use: {
browserName: 'chromium',
viewport: VIEWPORTS.tablet,
},
},
{
name: 'mobile',
use: {
browserName: 'chromium',
viewport: VIEWPORTS.mobile,
},
},
],
webServer: [
{
command: `go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db ${E2E_DB} --port ${API_PORT}`,
url: `http://localhost:${API_PORT}/api/v1/race-hub?session_key=9472`,
reuseExistingServer: false,
timeout: 120_000,
},
{
command: `BOXBOX_API_PORT=${API_PORT} npm run dev --prefix frontend -- --port ${WEB_PORT} --strictPort`,
url: `http://localhost:${WEB_PORT}`,
reuseExistingServer: false,
timeout: 120_000,
},
],
})

View File

@@ -0,0 +1,55 @@
import { defineConfig } from '@playwright/test'
import { VIEWPORTS } from './tests/visual/helpers'
const E2E_DB = '.playwright/boxbox-prod-e2e.db'
const PROD_PORT = process.env.BOXBOX_PROD_PORT ?? '18080'
export default defineConfig({
testDir: './tests/visual',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: process.env.CI ? 'github' : 'html',
snapshotPathTemplate: '{testDir}/{testFileDir}/__snapshots__/{projectName}/{arg}{ext}',
expect: {
toHaveScreenshot: {
animations: 'disabled',
maxDiffPixelRatio: 0.02,
},
},
use: {
baseURL: `http://localhost:${PROD_PORT}`,
trace: 'on-first-retry',
colorScheme: 'dark',
},
projects: [
{
name: 'desktop',
use: {
browserName: 'chromium',
viewport: VIEWPORTS.desktop,
},
},
{
name: 'tablet',
use: {
browserName: 'chromium',
viewport: VIEWPORTS.tablet,
},
},
{
name: 'mobile',
use: {
browserName: 'chromium',
viewport: VIEWPORTS.mobile,
},
},
],
webServer: {
command: `npm run build --prefix frontend && go run ./scripts/seed-e2e-db/main.go --db ${E2E_DB} && BOXBOX_DISABLE_LIVE=1 go run ./cmd/main.go --web --db ${E2E_DB} --port ${PROD_PORT}`,
url: `http://localhost:${PROD_PORT}/`,
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

50
tests/visual/helpers.ts Normal file
View File

@@ -0,0 +1,50 @@
import { expect, type Locator, type Page } from '@playwright/test'
export const VIEWPORTS = {
desktop: { width: 1280, height: 800 },
tablet: { width: 768, height: 1024 },
mobile: { width: 390, height: 844 },
} as const
export const FULL_SESSION = 9472
/** Wait for web fonts and layout to settle before screenshots. */
export async function waitForScreenshotReady(page: Page): Promise<void> {
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(150)
}
export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise<void> {
await page.goto(`/race-hub?session_key=${sessionKey}`)
await expect(page.getByText('Final Classification')).toBeVisible()
await expect(page.locator('.drv-code', { hasText: 'VER' })).toBeVisible()
await waitForScreenshotReady(page)
}
export async function gotoDataLibraryReady(page: Page): Promise<void> {
await page.goto('/data-library')
await expect(page.getByTestId('data-library')).toBeVisible()
await expect(page.getByTestId('dl-meeting-1229')).toBeVisible()
await expect(page.getByTestId('meeting-detail')).toBeVisible()
await waitForScreenshotReady(page)
}
export async function gotoLiveEmptyReady(page: Page): Promise<void> {
await page.goto('/live')
await expect(page.locator('.loading-state')).toHaveCount(0)
await expect(page.getByTestId('live-empty')).toBeVisible()
await waitForScreenshotReady(page)
}
export async function screenshotPage(
page: Page,
name: string,
options?: { mask?: Locator[] },
): Promise<void> {
await expect(page).toHaveScreenshot(`${name}.png`, {
fullPage: true,
animations: 'disabled',
caret: 'hide',
mask: options?.mask,
})
}

View File

@@ -0,0 +1,24 @@
import { test } from '@playwright/test'
import {
gotoDataLibraryReady,
gotoLiveEmptyReady,
gotoRaceHubReady,
screenshotPage,
} from './helpers'
test.describe('MVP visual regression', () => {
test('race-hub', async ({ page }) => {
await gotoRaceHubReady(page)
await screenshotPage(page, 'race-hub')
})
test('data-library', async ({ page }) => {
await gotoDataLibraryReady(page)
await screenshotPage(page, 'data-library')
})
test('live-empty', async ({ page }) => {
await gotoLiveEmptyReady(page)
await screenshotPage(page, 'live')
})
})