diff --git a/documentations/refactor/21-mvp-completion-checklist.md b/documentations/refactor/21-mvp-completion-checklist.md index 4440db4..a455319 100644 --- a/documentations/refactor/21-mvp-completion-checklist.md +++ b/documentations/refactor/21-mvp-completion-checklist.md @@ -37,6 +37,8 @@ npm --prefix frontend test -- --run npm --prefix frontend run build npm run test:e2e npm run test:e2e:prod +npm run test:visual +npm run test:visual:prod ``` ### 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 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. - Add persisted live-event capture and reconciliation only after defining the live storage model. diff --git a/documentations/refactor/22-phase-14-visual-regression.md b/documentations/refactor/22-phase-14-visual-regression.md new file mode 100644 index 0000000..3c581cb --- /dev/null +++ b/documentations/refactor/22-phase-14-visual-regression.md @@ -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) diff --git a/documentations/refactor/README.md b/documentations/refactor/README.md index efe8c6f..aa1a64e 100644 --- a/documentations/refactor/README.md +++ b/documentations/refactor/README.md @@ -77,6 +77,8 @@ not implementation tickets yet. for showing local ingestion coverage and next CLI actions. - [21 MVP Completion Checklist](21-mvp-completion-checklist.md): current 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 diff --git a/package.json b/package.json index 2cbd50d..1669ec5 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,11 @@ "test:e2e": "playwright test", "test:e2e:prod": "playwright test --config playwright.prod.config.ts", "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", "devDependencies": { diff --git a/playwright.config.ts b/playwright.config.ts index 88b624b..b6f4165 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ const WEB_PORT = process.env.BOXBOX_WEB_PORT ?? '15173' export default defineConfig({ testDir: './tests', - testIgnore: '**/production-smoke.spec.ts', + testIgnore: ['**/production-smoke.spec.ts', '**/visual/**'], fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/playwright.visual.config.ts b/playwright.visual.config.ts new file mode 100644 index 0000000..7d1bfc4 --- /dev/null +++ b/playwright.visual.config.ts @@ -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, + }, + ], +}) diff --git a/playwright.visual.prod.config.ts b/playwright.visual.prod.config.ts new file mode 100644 index 0000000..bcbee3d --- /dev/null +++ b/playwright.visual.prod.config.ts @@ -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, + }, +}) diff --git a/tests/visual/__snapshots__/desktop/data-library.png b/tests/visual/__snapshots__/desktop/data-library.png new file mode 100644 index 0000000..a9ff516 Binary files /dev/null and b/tests/visual/__snapshots__/desktop/data-library.png differ diff --git a/tests/visual/__snapshots__/desktop/live.png b/tests/visual/__snapshots__/desktop/live.png new file mode 100644 index 0000000..50a353b Binary files /dev/null and b/tests/visual/__snapshots__/desktop/live.png differ diff --git a/tests/visual/__snapshots__/desktop/race-hub.png b/tests/visual/__snapshots__/desktop/race-hub.png new file mode 100644 index 0000000..bc1b06e Binary files /dev/null and b/tests/visual/__snapshots__/desktop/race-hub.png differ diff --git a/tests/visual/__snapshots__/mobile/data-library.png b/tests/visual/__snapshots__/mobile/data-library.png new file mode 100644 index 0000000..484ff3e Binary files /dev/null and b/tests/visual/__snapshots__/mobile/data-library.png differ diff --git a/tests/visual/__snapshots__/mobile/live.png b/tests/visual/__snapshots__/mobile/live.png new file mode 100644 index 0000000..151b816 Binary files /dev/null and b/tests/visual/__snapshots__/mobile/live.png differ diff --git a/tests/visual/__snapshots__/mobile/race-hub.png b/tests/visual/__snapshots__/mobile/race-hub.png new file mode 100644 index 0000000..f353516 Binary files /dev/null and b/tests/visual/__snapshots__/mobile/race-hub.png differ diff --git a/tests/visual/__snapshots__/tablet/data-library.png b/tests/visual/__snapshots__/tablet/data-library.png new file mode 100644 index 0000000..ef7cb1b Binary files /dev/null and b/tests/visual/__snapshots__/tablet/data-library.png differ diff --git a/tests/visual/__snapshots__/tablet/live.png b/tests/visual/__snapshots__/tablet/live.png new file mode 100644 index 0000000..8858f37 Binary files /dev/null and b/tests/visual/__snapshots__/tablet/live.png differ diff --git a/tests/visual/__snapshots__/tablet/race-hub.png b/tests/visual/__snapshots__/tablet/race-hub.png new file mode 100644 index 0000000..fb064ac Binary files /dev/null and b/tests/visual/__snapshots__/tablet/race-hub.png differ diff --git a/tests/visual/helpers.ts b/tests/visual/helpers.ts new file mode 100644 index 0000000..fb56d35 --- /dev/null +++ b/tests/visual/helpers.ts @@ -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 { + await page.evaluate(() => document.fonts.ready) + await page.waitForTimeout(150) +} + +export async function gotoRaceHubReady(page: Page, sessionKey = FULL_SESSION): Promise { + 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 { + 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 { + 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 { + await expect(page).toHaveScreenshot(`${name}.png`, { + fullPage: true, + animations: 'disabled', + caret: 'hide', + mask: options?.mask, + }) +} diff --git a/tests/visual/mvp-screens.spec.ts b/tests/visual/mvp-screens.spec.ts new file mode 100644 index 0000000..0d20c15 --- /dev/null +++ b/tests/visual/mvp-screens.spec.ts @@ -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') + }) +})