Add visual regression coverage
@@ -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.
|
||||||
|
|||||||
55
documentations/refactor/22-phase-14-visual-regression.md
Normal 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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
64
playwright.visual.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
55
playwright.visual.prod.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
tests/visual/__snapshots__/desktop/data-library.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
tests/visual/__snapshots__/desktop/live.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tests/visual/__snapshots__/desktop/race-hub.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
tests/visual/__snapshots__/mobile/data-library.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
tests/visual/__snapshots__/mobile/live.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
tests/visual/__snapshots__/mobile/race-hub.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
tests/visual/__snapshots__/tablet/data-library.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
tests/visual/__snapshots__/tablet/live.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
tests/visual/__snapshots__/tablet/race-hub.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
50
tests/visual/helpers.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
24
tests/visual/mvp-screens.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||