From 19148d553bfeb2b040f171bcd45de66d5af28705 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:25:54 +0100 Subject: [PATCH 01/17] Add Playwright visual regression config Separate config for visual snapshot tests with fixed viewport (1280x720), animations disabled, 0.5% pixel tolerance, and platform-specific baselines. --- .../taskdeck-web/playwright.visual.config.ts | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 frontend/taskdeck-web/playwright.visual.config.ts diff --git a/frontend/taskdeck-web/playwright.visual.config.ts b/frontend/taskdeck-web/playwright.visual.config.ts new file mode 100644 index 000000000..24f0fe4ae --- /dev/null +++ b/frontend/taskdeck-web/playwright.visual.config.ts @@ -0,0 +1,303 @@ +import { defineConfig } from '@playwright/test' +import { + buildHttpOrigin, + defaultFrontendHost, + defaultFrontendPort, + parseFrontendHost, + resolveDefaultFrontendPort, +} from './playwright.port-resolution' +import { resolveDemoBackendLlmEnv, resolvePlaywrightBackendLlmEnv } from './playwright.demo-llm' +import { resolveReuseExistingServer } from './playwright.server-reuse' + +const e2eDbPath = process.env.TASKDECK_E2E_DB ?? 'taskdeck.e2e.visual.db' +const defaultApiBaseUrl = 'http://localhost:5000/api' +const demoBackendLlmEnv = resolveDemoBackendLlmEnv(process.env) +const backendLlmEnv = resolvePlaywrightBackendLlmEnv(process.env) +const reuseExistingServer = resolveReuseExistingServer(process.env, { + requiresFreshServer: Object.keys(demoBackendLlmEnv).length > 0, +}) + +const frontendConfig = resolveFrontendConfig() +const frontendHost = frontendConfig.host +const frontendPort = frontendConfig.port +const frontendBaseUrl = frontendConfig.baseUrl +const apiConfig = resolveApiConfig(process.env.TASKDECK_E2E_API_BASE_URL ?? defaultApiBaseUrl) +const apiBaseUrl = apiConfig.baseUrl + +const backendCorsOrigins = resolveBackendCorsOrigins( + frontendConfig.origin, + process.env.TASKDECK_E2E_API_CORS_ORIGINS, +) +const backendServerEnv: Record = { + ASPNETCORE_ENVIRONMENT: 'Development', + ConnectionStrings__DefaultConnection: `Data Source=${e2eDbPath}`, + ASPNETCORE_URLS: apiConfig.origin, + ...backendLlmEnv, +} + +for (const [index, origin] of backendCorsOrigins.entries()) { + backendServerEnv[`Cors__DevelopmentAllowedOrigins__${index}`] = origin +} + +/** + * Playwright configuration for visual regression tests. + * + * Key differences from the main E2E config: + * - testDir points to tests/visual/ + * - Fixed viewport (1280x720) for deterministic screenshots + * - Animations disabled via reducedMotion to prevent flaky diffs + * - Screenshot comparison thresholds tuned for cross-platform tolerance + * - Snapshot path template includes platform for OS-specific baselines + */ +export default defineConfig({ + testDir: './tests/visual', + forbidOnly: !!process.env.CI, + fullyParallel: false, + workers: 1, + maxFailures: process.env.CI ? 5 : undefined, + globalTimeout: process.env.CI ? 15 * 60_000 : undefined, + timeout: 60_000, + expect: { + timeout: 10_000, + toHaveScreenshot: { + // Allow up to 0.5% pixel difference to absorb font rendering and + // anti-aliasing variance across platforms and CI environments. + maxDiffPixelRatio: 0.005, + // Per-pixel color threshold (0-1). Slightly elevated to handle + // sub-pixel anti-aliasing differences between local and CI. + threshold: 0.3, + // Animation stabilization wait before capture. + animations: 'disabled', + }, + }, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [['line'], ['github'], ['html', { open: 'never' }]] + : 'list', + snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}', + use: { + baseURL: frontendBaseUrl, + trace: 'retain-on-failure', + // Fixed viewport for deterministic screenshots + viewport: { width: 1280, height: 720 }, + // Disable CSS animations and transitions + reducedMotion: 'reduce', + // Consistent color scheme + colorScheme: 'light', + screenshot: 'off', + }, + webServer: [ + { + command: 'dotnet run --no-launch-profile --project ../../backend/src/Taskdeck.Api/Taskdeck.Api.csproj', + url: apiConfig.readinessUrl, + timeout: 120_000, + reuseExistingServer, + stdout: 'pipe', + stderr: 'pipe', + env: backendServerEnv, + }, + { + command: `npm run dev -- --host ${frontendHost} --port ${frontendPort}`, + url: frontendBaseUrl, + timeout: 120_000, + reuseExistingServer, + stdout: 'pipe', + stderr: 'pipe', + env: { + VITE_API_BASE_URL: apiBaseUrl, + }, + }, + ], +}) + +type FrontendConfig = { + baseUrl: string + host: string + origin: string + port: number +} + +type ApiConfig = { + baseUrl: string + origin: string + readinessUrl: string +} + +function resolveFrontendConfig(): FrontendConfig { + const rawFrontendBaseUrl = process.env.TASKDECK_E2E_FRONTEND_BASE_URL + if (rawFrontendBaseUrl && rawFrontendBaseUrl.trim().length > 0) { + return resolveFrontendConfigFromBaseUrl(rawFrontendBaseUrl) + } + + const host = parseFrontendHost( + process.env.TASKDECK_E2E_FRONTEND_HOST ?? defaultFrontendHost, + 'TASKDECK_E2E_FRONTEND_HOST', + ) + const explicitFrontendPort = process.env.TASKDECK_E2E_FRONTEND_PORT + const resolvedFrontendPort = process.env.TASKDECK_E2E_RESOLVED_FRONTEND_PORT + + const port = explicitFrontendPort + ? parsePort(explicitFrontendPort, defaultFrontendPort, 'TASKDECK_E2E_FRONTEND_PORT') + : resolvedFrontendPort + ? parsePort( + resolvedFrontendPort, + defaultFrontendPort, + 'TASKDECK_E2E_RESOLVED_FRONTEND_PORT', + ) + : resolveDefaultFrontendPort(host, { + allowExistingFrontendReuse: reuseExistingServer, + }) + + if (!explicitFrontendPort && !resolvedFrontendPort) { + process.env.TASKDECK_E2E_RESOLVED_FRONTEND_PORT = String(port) + } + + const origin = buildHttpOrigin(host, port) + + return { + baseUrl: origin, + host, + origin, + port, + } +} + +function resolveFrontendConfigFromBaseUrl(rawFrontendBaseUrl: string): FrontendConfig { + const parsedFrontendBaseUrl = parseFrontendBaseUrl(rawFrontendBaseUrl) + if (parsedFrontendBaseUrl.port.length === 0) { + throw new Error( + `[visual config] TASKDECK_E2E_FRONTEND_BASE_URL must include an explicit port (example: "http://localhost:${defaultFrontendPort}"). Received "${rawFrontendBaseUrl}".`, + ) + } + + if (normalizePath(parsedFrontendBaseUrl.pathname).length > 0) { + throw new Error( + `[visual config] TASKDECK_E2E_FRONTEND_BASE_URL cannot include a path segment. Received "${rawFrontendBaseUrl}".`, + ) + } + + if (parsedFrontendBaseUrl.search.length > 0 || parsedFrontendBaseUrl.hash.length > 0) { + throw new Error( + `[visual config] TASKDECK_E2E_FRONTEND_BASE_URL cannot include query or hash fragments. Received "${rawFrontendBaseUrl}".`, + ) + } + + const port = parsePort( + parsedFrontendBaseUrl.port, + defaultFrontendPort, + 'TASKDECK_E2E_FRONTEND_BASE_URL', + ) + + return { + baseUrl: parsedFrontendBaseUrl.origin, + host: parseFrontendHost(parsedFrontendBaseUrl.hostname, 'TASKDECK_E2E_FRONTEND_BASE_URL'), + origin: parsedFrontendBaseUrl.origin, + port, + } +} + +function parseFrontendBaseUrl(rawFrontendBaseUrl: string): URL { + try { + const parsedFrontendBaseUrl = new URL(rawFrontendBaseUrl) + if (parsedFrontendBaseUrl.protocol !== 'http:') { + throw new Error('Only http:// is supported.') + } + + return parsedFrontendBaseUrl + } catch (error) { + const reason = error instanceof Error ? error.message : 'Invalid URL format.' + throw new Error( + `[visual config] TASKDECK_E2E_FRONTEND_BASE_URL must be an absolute http URL. Received "${rawFrontendBaseUrl}". ${reason}`, + { cause: error }, + ) + } +} + +function parsePort(rawPort: string | undefined, fallbackPort: number, source: string): number { + if (!rawPort) { + return fallbackPort + } + + const normalizedPort = rawPort.trim() + if (!/^\d+$/.test(normalizedPort)) { + throw new Error(`[visual config] ${source} must be an integer between 1 and 65535. Received "${rawPort}".`) + } + + const parsedPort = Number.parseInt(normalizedPort, 10) + if (parsedPort < 1 || parsedPort > 65535) { + throw new Error(`[visual config] ${source} must be between 1 and 65535. Received "${rawPort}".`) + } + + return parsedPort +} + +function resolveApiConfig(rawApiBaseUrl: string): ApiConfig { + const parsedApiBaseUrl = parseApiBaseUrl(rawApiBaseUrl) + const apiPath = normalizePath(parsedApiBaseUrl.pathname) + if (apiPath.length === 0) { + throw new Error( + `[visual config] TASKDECK_E2E_API_BASE_URL must include an API path (example: "${defaultApiBaseUrl}"). Received "${rawApiBaseUrl}".`, + ) + } + + const normalizedBaseUrl = `${parsedApiBaseUrl.origin}${apiPath}` + return { + baseUrl: normalizedBaseUrl, + origin: parsedApiBaseUrl.origin, + readinessUrl: `${normalizedBaseUrl}/boards`, + } +} + +function parseApiBaseUrl(rawApiBaseUrl: string): URL { + try { + const parsedApiBaseUrl = new URL(rawApiBaseUrl) + if (parsedApiBaseUrl.protocol !== 'http:') { + throw new Error('Only http:// is supported.') + } + + if (parsedApiBaseUrl.port.length === 0) { + throw new Error('An explicit port is required.') + } + + if (parsedApiBaseUrl.search.length > 0 || parsedApiBaseUrl.hash.length > 0) { + throw new Error('Query and hash fragments are not supported.') + } + + return parsedApiBaseUrl + } catch (error) { + const reason = error instanceof Error ? error.message : 'Invalid URL format.' + throw new Error( + `[visual config] TASKDECK_E2E_API_BASE_URL must be an absolute http URL with explicit port. Received "${rawApiBaseUrl}". ${reason}`, + { cause: error }, + ) + } +} + +function normalizePath(pathname: string): string { + if (!pathname || pathname === '/') { + return '' + } + + return pathname.replace(/\/+$/, '') +} + +function resolveBackendCorsOrigins(frontendOrigin: string, rawOrigins: string | undefined): string[] { + return dedupeOrigins([frontendOrigin, 'http://localhost:5174', ...parseOriginList(rawOrigins)]) +} + +function parseOriginList(rawOrigins: string | undefined): string[] { + if (!rawOrigins) { + return [] + } + + return dedupeOrigins( + rawOrigins + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0), + ) +} + +function dedupeOrigins(origins: string[]): string[] { + return [...new Set(origins)] +} From 5920e63843f7f901d321d14984b9ea4282f5694c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:07 +0100 Subject: [PATCH 02/17] Add visual test helpers for screenshot stability Shared utilities: waitForVisualStability (network idle, image load, paint pause), hideDynamicContent (timestamps, cursors, scrollbars, animations), and prepareForScreenshot (combined preparation sequence). --- .../tests/visual/visual-test-helpers.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 frontend/taskdeck-web/tests/visual/visual-test-helpers.ts diff --git a/frontend/taskdeck-web/tests/visual/visual-test-helpers.ts b/frontend/taskdeck-web/tests/visual/visual-test-helpers.ts new file mode 100644 index 000000000..fc1ccb356 --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/visual-test-helpers.ts @@ -0,0 +1,93 @@ +/** + * Shared helpers for visual regression tests. + * + * These utilities standardize page preparation before screenshot capture + * to minimize false positives from animation timing, lazy loading, and + * dynamic content. + */ +import type { Page } from '@playwright/test' + +/** + * Wait for the page to reach a visually stable state before taking a screenshot. + * + * Steps: + * 1. Wait for network to be idle (no pending fetches) + * 2. Wait for all images to finish loading + * 3. Wait for CSS transitions/animations to settle + * 4. Pause briefly for any remaining paint operations + */ +export async function waitForVisualStability(page: Page): Promise { + // Wait for network idle — all API calls and asset loads should complete + await page.waitForLoadState('networkidle') + + // Wait for all images to be loaded to prevent blank image placeholders + await page.evaluate(async () => { + const images = Array.from(document.querySelectorAll('img')) + await Promise.all( + images + .filter((img) => !img.complete) + .map( + (img) => + new Promise((resolve) => { + img.addEventListener('load', () => resolve()) + img.addEventListener('error', () => resolve()) + }), + ), + ) + }) + + // Brief pause for paint stabilization after all resources loaded. + // This addresses sub-frame rendering differences that can cause + // spurious diffs when a screenshot captures mid-paint. + await page.waitForTimeout(300) +} + +/** + * Hide dynamic content that changes between runs (timestamps, random IDs, etc.) + * to prevent false-positive screenshot diffs. + */ +export async function hideDynamicContent(page: Page): Promise { + await page.evaluate(() => { + const style = document.createElement('style') + style.setAttribute('data-visual-test', 'true') + style.textContent = ` + /* Hide elements that contain timestamps or relative time */ + [data-testid="timestamp"], + [data-testid="relative-time"], + time { + visibility: hidden !important; + } + + /* Freeze blinking cursors */ + * { + caret-color: transparent !important; + } + + /* Disable all animations and transitions for screenshot stability */ + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + + /* Hide scrollbars which may differ across platforms */ + ::-webkit-scrollbar { + display: none !important; + } + * { + scrollbar-width: none !important; + } + ` + document.head.appendChild(style) + }) +} + +/** + * Standard preparation sequence before every visual snapshot. + * Call this after navigating to the target page and before toHaveScreenshot(). + */ +export async function prepareForScreenshot(page: Page): Promise { + await waitForVisualStability(page) + await hideDynamicContent(page) +} From b5baed34e5e37c03f00fad6488032a3fdb34214e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:14 +0100 Subject: [PATCH 03/17] Add visual regression tests for board view Covers empty board and populated board (3 columns, 4 cards) screenshots. --- .../tests/visual/board-view.visual.spec.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts diff --git a/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts b/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts new file mode 100644 index 000000000..0896c3716 --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts @@ -0,0 +1,83 @@ +/** + * Visual regression tests for the Board view. + * + * Captures baseline screenshots of the board in various states: + * - Empty board (freshly created, no columns) + * - Board with columns and cards (populated state) + * + * These tests require a running backend and frontend (configured via + * playwright.visual.config.ts). + */ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from '../e2e/support/authSession' +import { prepareForScreenshot } from './visual-test-helpers' + +function columnByName(page: Page, columnName: string) { + return page + .locator('[data-column-id]') + .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) + .first() +} + +async function createBoard(page: Page, boardName: string) { + await page.goto('/workspace/boards') + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() + await page.getByRole('button', { name: '+ New Board' }).click() + await page.getByPlaceholder('Board name').fill(boardName) + await page.getByRole('button', { name: 'Create', exact: true }).click() + await expect(page).toHaveURL(/\/workspace\/boards\/[a-f0-9-]+$/) + await expect(page.getByRole('heading', { name: boardName })).toBeVisible() +} + +async function addColumn(page: Page, columnName: string) { + await page.getByRole('button', { name: '+ Add Column' }).click() + await page.getByPlaceholder('Column name').fill(columnName) + await page.getByRole('button', { name: 'Create', exact: true }).click() + await expect(page.getByRole('heading', { name: columnName, exact: true })).toBeVisible() +} + +async function addCard(page: Page, columnName: string, cardTitle: string) { + const column = columnByName(page, columnName) + await column.getByRole('button', { name: 'Add Card' }).click() + const addCardInput = column.getByPlaceholder('Enter card title...') + await expect(addCardInput).toBeVisible() + await addCardInput.fill(cardTitle) + const createCardResponse = page.waitForResponse( + (response) => + response.request().method() === 'POST' && + /\/api\/boards\/[a-f0-9-]+\/cards$/i.test(response.url()) && + response.ok(), + ) + await column.getByRole('button', { name: 'Add', exact: true }).click() + await createCardResponse + await expect(page.locator('[data-card-id]').filter({ hasText: cardTitle }).first()).toBeVisible() +} + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'visual-board') +}) + +test('empty board view', async ({ page }) => { + await createBoard(page, 'Visual Test Board') + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('board-empty.png') +}) + +test('board with columns and cards', async ({ page }) => { + await createBoard(page, 'Visual Test Board') + + await addColumn(page, 'Backlog') + await addColumn(page, 'In Progress') + await addColumn(page, 'Done') + + await addCard(page, 'Backlog', 'Design wireframes') + await addCard(page, 'Backlog', 'Write API spec') + await addCard(page, 'In Progress', 'Implement auth') + await addCard(page, 'Done', 'Set up CI pipeline') + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('board-populated.png') +}) From f8d02daa6a2626902095f8cb977321ff7412fdd3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:20 +0100 Subject: [PATCH 04/17] Add visual regression tests for command palette Covers open state and search-filtered state screenshots. --- .../visual/command-palette.visual.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts diff --git a/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts new file mode 100644 index 000000000..f4b084cbb --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts @@ -0,0 +1,51 @@ +/** + * Visual regression tests for the command palette. + * + * Captures the command palette in its open state with the default + * command list visible. The palette is triggered via keyboard shortcut + * (Ctrl+K / Cmd+K). + */ +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from '../e2e/support/authSession' +import { prepareForScreenshot } from './visual-test-helpers' + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'visual-palette') +}) + +test('command palette open state', async ({ page }) => { + await page.goto('/workspace/home') + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() + + // Open command palette via keyboard shortcut + await page.keyboard.press('Control+k') + + // Wait for the palette to be visible (search input) + const paletteInput = page.getByPlaceholder('Search commands, boards, cards...') + await expect(paletteInput).toBeVisible() + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('command-palette-open.png') +}) + +test('command palette with search results', async ({ page }) => { + await page.goto('/workspace/home') + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() + + // Open command palette + await page.keyboard.press('Control+k') + + const paletteInput = page.getByPlaceholder('Search commands, boards, cards...') + await expect(paletteInput).toBeVisible() + + // Type a search query to filter commands + await paletteInput.fill('board') + + // Wait for results to render + await page.waitForTimeout(300) + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('command-palette-search.png') +}) From e731e3018af6bd6fbc99c71b18d22e61cfcb8fff Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:27 +0100 Subject: [PATCH 05/17] Add visual regression tests for archive, inbox, and home views Covers empty-state screenshots for archive, inbox/capture, and home views. --- .../tests/visual/archive-view.visual.spec.ts | 24 +++++++++++++++++++ .../tests/visual/home-view.visual.spec.ts | 22 +++++++++++++++++ .../tests/visual/inbox-capture.visual.spec.ts | 23 ++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts create mode 100644 frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts create mode 100644 frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts diff --git a/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts b/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts new file mode 100644 index 000000000..eeebd2ca2 --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts @@ -0,0 +1,24 @@ +/** + * Visual regression tests for the Archive view. + * + * Captures the archive screen in its empty state (no archived items). + * Testing the populated state would require archiving a board first, + * which is covered in separate E2E tests; the visual baseline here + * ensures the empty-state layout remains stable. + */ +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from '../e2e/support/authSession' +import { prepareForScreenshot } from './visual-test-helpers' + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'visual-archive') +}) + +test('archive view empty state', async ({ page }) => { + await page.goto('/workspace/archive') + await page.waitForLoadState('networkidle') + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('archive-empty.png') +}) diff --git a/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts b/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts new file mode 100644 index 000000000..fdeb48ff8 --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts @@ -0,0 +1,22 @@ +/** + * Visual regression tests for the Home view. + * + * The Home view is the primary landing page after login. Its layout + * stability is critical for first impressions and novice onboarding. + */ +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from '../e2e/support/authSession' +import { prepareForScreenshot } from './visual-test-helpers' + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'visual-home') +}) + +test('home view default state', async ({ page }) => { + await page.goto('/workspace/home') + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('home-default.png') +}) diff --git a/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts b/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts new file mode 100644 index 000000000..155ea49b3 --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts @@ -0,0 +1,23 @@ +/** + * Visual regression tests for the Inbox / Capture view. + * + * Captures the inbox in its empty state. This is a key entry point in + * the capture-review-execute loop and its layout stability is important + * for the novice-first experience. + */ +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from '../e2e/support/authSession' +import { prepareForScreenshot } from './visual-test-helpers' + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'visual-inbox') +}) + +test('inbox view empty state', async ({ page }) => { + await page.goto('/workspace/inbox') + await page.waitForLoadState('networkidle') + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('inbox-empty.png') +}) From 4348c99fbab58ed923af2d5a569d6b387925da73 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:32 +0100 Subject: [PATCH 06/17] Add npm scripts for visual regression tests test:visual runs the suite, test:visual:update regenerates baselines. --- frontend/taskdeck-web/package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/package.json b/frontend/taskdeck-web/package.json index a0881411b..08c9e88e8 100644 --- a/frontend/taskdeck-web/package.json +++ b/frontend/taskdeck-web/package.json @@ -26,7 +26,9 @@ "test:e2e:audit:headed": "node -e \"process.env.TASKDECK_RUN_AUDIT='1';require('child_process').execSync('npx playwright test tests/e2e/manual-audit.spec.ts --headed --reporter=line',{stdio:'inherit',env:process.env})\"", "test:e2e:concurrency": "playwright test tests/e2e/concurrency.spec.ts --reporter=line", "test:e2e:live-llm:headed": "playwright test tests/e2e/live-llm.spec.ts --headed --reporter=line", - "test:e2e:headed": "playwright test --headed" + "test:e2e:headed": "playwright test --headed", + "test:visual": "playwright test --config playwright.visual.config.ts", + "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots" }, "dependencies": { "@microsoft/signalr": "^10.0.0", From 0cc170e04619918f61c9faaa9f72f8541a11eda8 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:39 +0100 Subject: [PATCH 07/17] Add CI workflow for visual regression tests Reusable workflow with Chromium browser setup, diff artifact upload on failure, and 14-day retention. Wired into ci-extended.yml on testing/visual labels or manual dispatch. --- .github/workflows/ci-extended.yml | 8 ++ .../workflows/reusable-visual-regression.yml | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 .github/workflows/reusable-visual-regression.yml diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index ca9912274..e0627beb8 100644 --- a/.github/workflows/ci-extended.yml +++ b/.github/workflows/ci-extended.yml @@ -107,6 +107,14 @@ jobs: dotnet-version: 8.0.x node-version: 24.13.1 + visual-regression: + name: Visual Regression + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && (contains(github.event.pull_request.labels.*.name, 'testing') || contains(github.event.pull_request.labels.*.name, 'visual'))) + uses: ./.github/workflows/reusable-visual-regression.yml + with: + dotnet-version: 8.0.x + node-version: 24.13.1 + load-concurrency-harness: name: Load and Concurrency Harness if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'testing')) diff --git a/.github/workflows/reusable-visual-regression.yml b/.github/workflows/reusable-visual-regression.yml new file mode 100644 index 000000000..9afcbbdce --- /dev/null +++ b/.github/workflows/reusable-visual-regression.yml @@ -0,0 +1,95 @@ +name: Reusable Visual Regression + +on: + workflow_call: + inputs: + dotnet-version: + description: .NET SDK version used for backend setup + required: false + default: "8.0.x" + type: string + node-version: + description: Node.js version used for frontend setup + required: false + default: "24.13.1" + type: string + +permissions: + contents: read + +env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + +jobs: + visual-regression: + name: Visual Regression + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ inputs.dotnet-version }} + cache: true + cache-dependency-path: | + backend/Taskdeck.sln + backend/**/*.csproj + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: frontend/taskdeck-web/package-lock.json + + - name: Restore backend + run: dotnet restore backend/Taskdeck.sln + + - name: Install frontend dependencies + working-directory: frontend/taskdeck-web + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ms-playwright-${{ runner.os }}-${{ hashFiles('frontend/taskdeck-web/package-lock.json') }} + + - name: Install Playwright browser + working-directory: frontend/taskdeck-web + run: npx playwright install --with-deps chromium + + - name: Remove stale visual E2E database + working-directory: frontend/taskdeck-web + run: node -e "require('fs').rmSync('taskdeck.e2e.visual.ci.db',{force:true});" + + - name: Run visual regression tests + timeout-minutes: 12 + working-directory: frontend/taskdeck-web + env: + CI: "true" + TASKDECK_E2E_DB: taskdeck.e2e.visual.ci.db + TASKDECK_RUN_DEMO: "0" + run: npx playwright test --config playwright.visual.config.ts --reporter=line + + - name: Upload visual diff artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: visual-regression-diffs + path: | + frontend/taskdeck-web/test-results/ + if-no-files-found: ignore + retention-days: 14 + + - name: Upload Playwright HTML report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: visual-regression-report + path: frontend/taskdeck-web/playwright-report + if-no-files-found: ignore + retention-days: 14 From 9f300562f6c70986b43c339a7ea76358e7e552ce Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:45 +0100 Subject: [PATCH 08/17] Add visual regression policy document Covers threshold settings, false-positive mitigation (font rendering, animations, dynamic content), baseline management workflow, CI integration, and instructions for adding new visual tests. --- docs/testing/VISUAL_REGRESSION_POLICY.md | 166 +++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docs/testing/VISUAL_REGRESSION_POLICY.md diff --git a/docs/testing/VISUAL_REGRESSION_POLICY.md b/docs/testing/VISUAL_REGRESSION_POLICY.md new file mode 100644 index 000000000..afe2d19e1 --- /dev/null +++ b/docs/testing/VISUAL_REGRESSION_POLICY.md @@ -0,0 +1,166 @@ +# Visual Regression Policy + +Last Updated: 2026-04-09 + +## Purpose + +Visual regression tests capture baseline screenshots of key UI surfaces and compare them against future renders. The goal is to catch unintended layout, color, or structural changes before they reach users, while minimizing false positives from non-deterministic rendering differences. + +## Covered Surfaces + +The visual regression suite covers these critical UI areas: + +| Surface | Test file | Baseline screenshots | +|---------|-----------|---------------------| +| Board (empty) | `board-view.visual.spec.ts` | `board-empty.png` | +| Board (populated) | `board-view.visual.spec.ts` | `board-populated.png` | +| Command palette (open) | `command-palette.visual.spec.ts` | `command-palette-open.png` | +| Command palette (search) | `command-palette.visual.spec.ts` | `command-palette-search.png` | +| Archive (empty) | `archive-view.visual.spec.ts` | `archive-empty.png` | +| Inbox/capture (empty) | `inbox-capture.visual.spec.ts` | `inbox-empty.png` | +| Home view | `home-view.visual.spec.ts` | `home-default.png` | + +## Threshold Settings + +These settings are configured in `playwright.visual.config.ts`: + +| Setting | Value | Rationale | +|---------|-------|-----------| +| `maxDiffPixelRatio` | `0.005` (0.5%) | Allows minor sub-pixel differences while catching real layout shifts | +| `threshold` | `0.3` | Per-pixel color distance tolerance (0-1 scale). Absorbs anti-aliasing differences | +| `animations` | `disabled` | Prevents non-deterministic frame captures | +| Viewport | `1280x720` | Fixed size eliminates responsive layout variance | +| `reducedMotion` | `reduce` | CSS `prefers-reduced-motion` suppresses transitions | +| `colorScheme` | `light` | Forces light mode for consistent color baselines | + +## False-Positive Mitigation + +### Font Rendering + +Font rendering varies significantly across operating systems (macOS, Windows, Linux). The visual tests use: + +- **Platform-specific baselines**: Snapshot paths include the platform identifier so each OS has its own reference images. CI runs on `ubuntu-latest`, so the canonical baselines are Linux-rendered. +- **Elevated color threshold**: The `threshold: 0.3` setting absorbs sub-pixel anti-aliasing differences. +- **maxDiffPixelRatio tolerance**: Up to 0.5% of pixels can differ without failing. + +### Animations and Transitions + +All animations are disabled through multiple layers: + +1. **Playwright `animations: 'disabled'`**: Built-in screenshot option. +2. **`reducedMotion: 'reduce'`**: CSS media query that well-behaved CSS respects. +3. **Injected CSS**: The `hideDynamicContent()` helper forcibly sets `animation-duration: 0s` and `transition-duration: 0s` on all elements. + +### Dynamic Content + +The `hideDynamicContent()` helper hides: + +- Timestamp elements (via `[data-testid="timestamp"]`, `time` tags) +- Blinking cursors (transparent caret color) +- Platform-specific scrollbars + +### Network Stability + +The `waitForVisualStability()` helper: + +1. Waits for `networkidle` state (all API responses received) +2. Waits for all `` elements to load +3. Adds a 300ms paint stabilization pause + +## Baseline Management + +### Where Baselines Live + +Baseline screenshots are stored in: +``` +frontend/taskdeck-web/tests/visual/__screenshots__/ +``` + +These files are **committed to the repository**. This is intentional: +- Baselines are reviewable in PRs (GitHub renders image diffs) +- Changes to baselines require explicit approval +- History is preserved in git + +### Generating Initial Baselines + +When adding a new visual test or running for the first time: + +```bash +cd frontend/taskdeck-web +npm run test:visual:update +``` + +This runs all visual tests and saves the current render as the baseline. Review the generated images before committing. + +### Updating Baselines + +When a legitimate UI change causes visual test failures: + +1. **Verify the change is intentional** by reviewing the diff artifacts from CI +2. **Update baselines locally**: + ```bash + cd frontend/taskdeck-web + npm run test:visual:update + ``` +3. **Review updated baselines** before committing: + - Check that only the expected views changed + - Verify no unintended regressions in other screenshots +4. **Commit baseline changes in a dedicated commit** (separate from code changes) so reviewers can clearly identify what changed visually +5. **PR reviewers should inspect baseline image diffs** using GitHub's image diff viewer + +### CI Baseline Generation + +For CI, baselines must be generated on `ubuntu-latest` to match the CI environment. If baselines were generated on a different OS, CI will fail due to font rendering differences. + +To regenerate CI-compatible baselines: +1. Push a branch with `--update-snapshots` temporarily added to the CI command +2. Download the generated screenshots from the CI artifacts +3. Place them in the correct `__screenshots__` directory +4. Commit and push + +Alternatively, if you have access to an identical Ubuntu environment (Docker, WSL2 with matching fonts), generate baselines there. + +## CI Integration + +Visual regression tests run in the **CI Extended** pipeline: + +- **Trigger**: PRs with `testing` or `visual` labels, or manual `workflow_dispatch` +- **Runner**: `ubuntu-latest` (canonical baseline platform) +- **Artifacts on failure**: `visual-regression-diffs` (test-results with actual/diff images) and `visual-regression-report` (Playwright HTML report) +- **Not a merge gate**: Visual tests run in CI Extended, not CI Required. This prevents font rendering differences from blocking PRs while still providing visual change visibility. + +### Reviewing CI Failures + +When visual tests fail in CI: + +1. Download the `visual-regression-diffs` artifact +2. Look for `*-actual.png` and `*-diff.png` files alongside the expected baselines +3. If the diff shows a legitimate regression: fix the code +4. If the diff shows an intentional change: update baselines (see above) +5. If the diff appears to be a false positive: consider adjusting thresholds and document the finding + +## Running Locally + +```bash +cd frontend/taskdeck-web + +# Run visual tests against current baselines +npm run test:visual + +# Update baselines to current state +npm run test:visual:update + +# Run a single visual test file +npx playwright test --config playwright.visual.config.ts tests/visual/board-view.visual.spec.ts +``` + +Note: Local baselines may differ from CI baselines due to font rendering. The committed baselines should match the CI platform (Ubuntu). + +## Adding New Visual Tests + +1. Create a new `*.visual.spec.ts` file in `frontend/taskdeck-web/tests/visual/` +2. Follow the existing pattern: register session, navigate, prepare, screenshot +3. Use `prepareForScreenshot()` before every `toHaveScreenshot()` call +4. Generate baselines: `npm run test:visual:update` +5. Add the new surface to the table at the top of this document +6. Commit baselines in a separate commit for clear PR review From 06c69f2683d14e15b45ceb577e74366ddb7d7923 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:26:52 +0100 Subject: [PATCH 09/17] Update docs for visual regression harness delivery TESTING_GUIDE.md: add visual regression section with commands and config. STATUS.md: record visual regression harness delivery. IMPLEMENTATION_MASTERPLAN.md: mark #88 as delivered. --- docs/IMPLEMENTATION_MASTERPLAN.md | 2 +- docs/STATUS.md | 1 + docs/TESTING_GUIDE.md | 30 ++++++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 40d1642d7..86bc6616d 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -954,7 +954,7 @@ Seeded from `docs/strategy/00_MASTER_STRATEGY.md` and companion pillar documents ### Priority IV (Expansion Tranche: Platform, Test, UX, Docs Maturity) - Platform and ops maturity: `#84`, `#85`, `#86`, `#101`, `#102`, `#103`, `#104`, `#105`, `#111` -- Test maturity: `#87`, `#88`, `#89` (property/fuzz pilot delivered; extended by `#717`), `#90`, `#91`; rigorous expansion wave tracker at `#721` +- Test maturity: `#87`, `#88` (visual regression harness delivered), `#89` (property/fuzz pilot delivered; extended by `#717`), `#90`, `#91`; rigorous expansion wave tracker at `#721` - UX and onboarding maturity: `#92`, `#93`, `#94`, `#95` - Frontend responsiveness maturity: `#213` - Lower-priority secondary MVP follow-through continuation: diff --git a/docs/STATUS.md b/docs/STATUS.md index ebda157ca..e0c0c9e73 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -28,6 +28,7 @@ Current constraints are mostly hardening and consistency: - LLM flow now supports config-gated `OpenAI` and `Gemini` providers with deterministic `Mock` fallback for safe local/test posture; degraded provider responses are now structurally distinct (`messageType: "degraded"` + `degradedReason`) and the health endpoint supports opt-in probe verification (`?probe=true`); chat-to-proposal pipeline improvements delivered: `LlmIntentClassifier` now uses compiled regex patterns with word-distance matching, stemming/plurals, broader verb coverage, and negative context filtering for negations and other-tool questions (`#571`); parse failures now return structured hint payloads with closest-match suggestions and a frontend hint card with "try this instead" pre-fill (`#572`); dedicated classifier and chat-to-proposal integration test coverage added (`#577`); LLM-assisted instruction extraction now delivered (`#573`): OpenAI and Gemini providers request structured JSON output with a system prompt describing supported instruction patterns, parse the response into `LlmCompletionResult.Instructions`, and fall back to the static `LlmIntentClassifier` when structured parsing fails; `ChatService` iterates LLM-extracted instructions (supporting multiple proposals from a single message) and falls back to raw user message parsing when no instructions are extracted; Mock provider unchanged for deterministic test behavior; multi-instruction batch parsing now delivered (`#574`): `ParseBatchInstructionAsync` splits multiple natural-language instructions into individual planner calls, `ChatService` routes multi-instruction messages through batch parsing to generate multiple proposals from a single chat message; board-context LLM prompting now delivered (`#575`, expanded in `#617`): `BoardContextBuilder` constructs bounded board context (columns, card IDs, titles, labels) grouped per column and appends it to system prompts across OpenAI and Gemini providers via `LlmSystemPromptBuilder`; card IDs are included as first-8 hex chars so the LLM can generate `move card ` instructions; context budget increased to 4000 chars with single-query card fetch; **remaining gap**: conversational refinement (`#576`) remains undelivered; analysis at `docs/analysis/2026-03-29_chat_nlp_proposal_gap.md` - managed-key shared-token abuse-control strategy is now explicitly seeded in `#235` to `#240` before broad external exposure - testing-harness guardrail expansion from `#254` to `#260` is shipped; remaining work is normal follow-up hardening rather than the original wave +- visual regression harness delivered (`#88`): Playwright-based screenshot comparison for 7 key UI surfaces (board empty/populated, command palette open/search, archive, inbox, home); separate `playwright.visual.config.ts` with fixed viewport (1280x720), animations disabled, 0.5% pixel tolerance; CI Extended integration via `reusable-visual-regression.yml` with diff artifact upload on failure; policy document at `docs/testing/VISUAL_REGRESSION_POLICY.md` - rigorous test expansion wave seeded 2026-04-03 (`#721` tracker, 22 issues `#699`–`#726`): systematic codebase audit identified 25+ untested infrastructure repositories, zero tests on the central worker, 6 controllers with untested HTTP surfaces, and no golden-path integration test for the capture → proposal → board pipeline; execution is tracked in `docs/TESTING_GUIDE.md`; first delivery: infrastructure repository integration tests (`#699`/`#730` — 77 tests across 7 repo classes against real SQLite); **major wave delivery 2026-04-04** (PRs `#732`–`#739`, 8 issues, ~300 new tests): SEC-20 ChangePassword fix (`#722`/`#732`), golden-path capture→board integration test (`#703`/`#735` — 7 tests proving full pipeline), cross-user data isolation tests (`#704`/`#733` — 38 tests across all major API boundaries), LlmQueueToProposalWorker integration tests (`#700`/`#734` — 24 tests, previously zero coverage), controller HTTP integration tests (`#702`/`#738` — 67 tests covering 6 untested controllers, found 2 pre-existing bugs), proposal lifecycle edge cases (`#708`/`#736` — 74 tests for state machine/expiry/race conditions), OAuth/auth edge cases (`#707`/`#737` — 44 tests, found and fixed `Substring` overflow bug in `ExternalLoginAsync`), MCP full resource/tool inventory (`#653`/`#739` — 9 resources + 11 tools with 42 tests, GP-06 compliant, user-scoping gap fixed during review); **second wave delivery 2026-04-04** (PRs `#740`–`#755`, 8 issues, ~586 new tests with two rounds of adversarial review, 47 review-fix commits): domain entity state machine exhaustive tests (`#701`/`#740` — 174 tests across 7 entities: CommandRun, ArchiveItem, ChatSession, UserPreference, NotificationPreference, CardLabel, CardCommentMention), SignalR hub and realtime integration tests (`#706`/`#751` — 19 tests covering auth, presence lifecycle, multi-user, authorization, edge cases), LLM provider abstraction and tool-calling edge cases (`#709`/`#747` — 101 tests across orchestrator, provider, classifier, registry), data export/import round-trip integrity tests (`#713`/`#752` — 64 tests covering JSON, CSV, GDPR, database, cross-format validation), API error contract regression and boundary validation (`#714`/`#753` — 57 tests across 7 endpoint families with GP-03 contract enforcement), archive and restore lifecycle integration tests (`#715`/`#755` — 74 tests: 45 domain + 29 API covering state machine, cross-user isolation, conflict detection, audit trail), board metrics and analytics accuracy verification (`#718`/`#749` — 61 tests: 51 service + 10 controller covering throughput, cycle time, WIP, blocked cards, done-column heuristic), notification delivery, deduplication, and preference filtering (`#719`/`#746` — 36 tests covering all 5 notification types, deduplication, preference filtering, cross-user isolation, batch operations) - MVP dogfooding flow now supports canonical checklist bootstrap in chat (proposal-first, board-scoped); broader template coverage remains future work - collaborative editing now includes board/card presence visibility and conflict-hinting guardrails for stale card writes diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 625302733..5d86960c7 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -465,6 +465,36 @@ cd frontend/taskdeck-web npm run test:e2e:audit:headed ``` +## Visual Regression Tests + +Visual regression tests capture baseline screenshots of key UI surfaces and compare them against future renders to catch unintended layout changes. + +**Policy document**: `docs/testing/VISUAL_REGRESSION_POLICY.md` (thresholds, false-positive mitigation, baseline management) + +**Test location**: `frontend/taskdeck-web/tests/visual/` + +**Config**: `frontend/taskdeck-web/playwright.visual.config.ts` + +**Covered surfaces**: board view (empty + populated), command palette (open + search), archive view, inbox/capture view, home view + +Run visual tests: + +```bash +cd frontend/taskdeck-web +npm run test:visual +``` + +Update baselines after intentional UI changes: + +```bash +cd frontend/taskdeck-web +npm run test:visual:update +``` + +Key settings: fixed viewport 1280x720, animations disabled, 0.5% pixel tolerance, platform-specific baselines (CI canonical platform: ubuntu-latest). + +CI integration: runs in CI Extended pipeline with `testing` or `visual` PR labels. Diff artifacts uploaded on failure for review. + ## Demo Tooling Policy Default CI posture: From 6e53cf1fdb31c66219fb33946ee327599049757c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:29:36 +0100 Subject: [PATCH 10/17] Fix self-review findings in visual regression harness - Remove redundant waitForTimeout in command palette search test (prepareForScreenshot already handles this via waitForVisualStability) - Fix policy doc: clarify baselines use single canonical platform (ubuntu) rather than per-OS snapshots, matching actual snapshotPathTemplate config --- docs/testing/VISUAL_REGRESSION_POLICY.md | 4 ++-- .../taskdeck-web/tests/visual/command-palette.visual.spec.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/testing/VISUAL_REGRESSION_POLICY.md b/docs/testing/VISUAL_REGRESSION_POLICY.md index afe2d19e1..848b99557 100644 --- a/docs/testing/VISUAL_REGRESSION_POLICY.md +++ b/docs/testing/VISUAL_REGRESSION_POLICY.md @@ -39,8 +39,8 @@ These settings are configured in `playwright.visual.config.ts`: Font rendering varies significantly across operating systems (macOS, Windows, Linux). The visual tests use: -- **Platform-specific baselines**: Snapshot paths include the platform identifier so each OS has its own reference images. CI runs on `ubuntu-latest`, so the canonical baselines are Linux-rendered. -- **Elevated color threshold**: The `threshold: 0.3` setting absorbs sub-pixel anti-aliasing differences. +- **Single canonical platform**: Baselines are generated on `ubuntu-latest` (matching CI). Local development on other OSes should use `npm run test:visual:update` to generate local baselines, but only ubuntu-generated baselines should be committed. This avoids cross-platform baseline conflicts. +- **Elevated color threshold**: The `threshold: 0.3` setting absorbs sub-pixel anti-aliasing differences that may still occur within the same OS (e.g., different GPU drivers on CI runners). - **maxDiffPixelRatio tolerance**: Up to 0.5% of pixels can differ without failing. ### Animations and Transitions diff --git a/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts index f4b084cbb..6a800914f 100644 --- a/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts +++ b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts @@ -42,9 +42,6 @@ test('command palette with search results', async ({ page }) => { // Type a search query to filter commands await paletteInput.fill('board') - // Wait for results to render - await page.waitForTimeout(300) - await prepareForScreenshot(page) await expect(page).toHaveScreenshot('command-palette-search.png') From 3c298a23035f957f1ed4c8ef0d0cdfdfa57d1172 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:58:35 +0100 Subject: [PATCH 11/17] Fix wrong command palette placeholder text in visual tests The test used 'Search commands, boards, cards...' but the actual ShellCommandPalette.vue renders 'Type a command or search boards and cards...'. Both command palette tests would fail at runtime. --- .../tests/visual/command-palette.visual.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts index 6a800914f..12933d5f5 100644 --- a/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts +++ b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts @@ -21,12 +21,12 @@ test('command palette open state', async ({ page }) => { await page.keyboard.press('Control+k') // Wait for the palette to be visible (search input) - const paletteInput = page.getByPlaceholder('Search commands, boards, cards...') + const paletteInput = page.getByPlaceholder('Type a command or search boards and cards...') await expect(paletteInput).toBeVisible() await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('command-palette-open.png') + await expect(page).toHaveScreenshot('command-palette-open') }) test('command palette with search results', async ({ page }) => { @@ -36,7 +36,7 @@ test('command palette with search results', async ({ page }) => { // Open command palette await page.keyboard.press('Control+k') - const paletteInput = page.getByPlaceholder('Search commands, boards, cards...') + const paletteInput = page.getByPlaceholder('Type a command or search boards and cards...') await expect(paletteInput).toBeVisible() // Type a search query to filter commands @@ -44,5 +44,5 @@ test('command palette with search results', async ({ page }) => { await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('command-palette-search.png') + await expect(page).toHaveScreenshot('command-palette-search') }) From 7050fce391b1112515b7494f372df8cf143ab253 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:58:43 +0100 Subject: [PATCH 12/17] Remove .png extension from toHaveScreenshot() arguments With a custom snapshotPathTemplate using {arg}{ext}, passing 'name.png' produces double extensions (name.png.png). Dropping the extension lets Playwright append it via {ext} correctly. --- .../taskdeck-web/tests/visual/archive-view.visual.spec.ts | 2 +- frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts | 4 ++-- frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts | 2 +- .../taskdeck-web/tests/visual/inbox-capture.visual.spec.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts b/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts index eeebd2ca2..73a71b5a0 100644 --- a/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts +++ b/frontend/taskdeck-web/tests/visual/archive-view.visual.spec.ts @@ -20,5 +20,5 @@ test('archive view empty state', async ({ page }) => { await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('archive-empty.png') + await expect(page).toHaveScreenshot('archive-empty') }) diff --git a/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts b/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts index 0896c3716..d78a33d2b 100644 --- a/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts +++ b/frontend/taskdeck-web/tests/visual/board-view.visual.spec.ts @@ -62,7 +62,7 @@ test('empty board view', async ({ page }) => { await createBoard(page, 'Visual Test Board') await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('board-empty.png') + await expect(page).toHaveScreenshot('board-empty') }) test('board with columns and cards', async ({ page }) => { @@ -79,5 +79,5 @@ test('board with columns and cards', async ({ page }) => { await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('board-populated.png') + await expect(page).toHaveScreenshot('board-populated') }) diff --git a/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts b/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts index fdeb48ff8..e99ccd832 100644 --- a/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts +++ b/frontend/taskdeck-web/tests/visual/home-view.visual.spec.ts @@ -18,5 +18,5 @@ test('home view default state', async ({ page }) => { await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('home-default.png') + await expect(page).toHaveScreenshot('home-default') }) diff --git a/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts b/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts index 155ea49b3..458c0ca15 100644 --- a/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts +++ b/frontend/taskdeck-web/tests/visual/inbox-capture.visual.spec.ts @@ -19,5 +19,5 @@ test('inbox view empty state', async ({ page }) => { await prepareForScreenshot(page) - await expect(page).toHaveScreenshot('inbox-empty.png') + await expect(page).toHaveScreenshot('inbox-empty') }) From 7cc930939ac11dbda07d0da21fcce5e99b007071 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:58:50 +0100 Subject: [PATCH 13/17] Handle missing baselines in visual regression CI workflow When no __screenshots__ directory exists, the CI job now runs with --update-snapshots to generate initial baselines and uploads them as the visual-regression-baselines artifact for manual commit. This prevents guaranteed failures on the first CI run. --- .../workflows/reusable-visual-regression.yml | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-visual-regression.yml b/.github/workflows/reusable-visual-regression.yml index 9afcbbdce..9cee11ebc 100644 --- a/.github/workflows/reusable-visual-regression.yml +++ b/.github/workflows/reusable-visual-regression.yml @@ -66,6 +66,17 @@ jobs: working-directory: frontend/taskdeck-web run: node -e "require('fs').rmSync('taskdeck.e2e.visual.ci.db',{force:true});" + - name: Check for existing baselines + id: baselines + working-directory: frontend/taskdeck-web + run: | + if [ -d "tests/visual/__screenshots__" ] && [ "$(find tests/visual/__screenshots__ -name '*.png' 2>/dev/null | head -1)" ]; then + echo "exist=true" >> "$GITHUB_OUTPUT" + else + echo "exist=false" >> "$GITHUB_OUTPUT" + echo "::warning::No baseline screenshots found. Running with --update-snapshots to generate initial baselines. Download the visual-regression-baselines artifact and commit them." + fi + - name: Run visual regression tests timeout-minutes: 12 working-directory: frontend/taskdeck-web @@ -73,7 +84,21 @@ jobs: CI: "true" TASKDECK_E2E_DB: taskdeck.e2e.visual.ci.db TASKDECK_RUN_DEMO: "0" - run: npx playwright test --config playwright.visual.config.ts --reporter=line + run: | + if [ "${{ steps.baselines.outputs.exist }}" = "false" ]; then + npx playwright test --config playwright.visual.config.ts --update-snapshots --reporter=line + else + npx playwright test --config playwright.visual.config.ts --reporter=line + fi + + - name: Upload generated baselines + if: steps.baselines.outputs.exist == 'false' + uses: actions/upload-artifact@v7 + with: + name: visual-regression-baselines + path: frontend/taskdeck-web/tests/visual/__screenshots__/ + if-no-files-found: warn + retention-days: 30 - name: Upload visual diff artifacts if: failure() From 9e5adb0f1ec037bcff6e411151fcfdc9b453bba5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:58:58 +0100 Subject: [PATCH 14/17] Document that timestamp hiding selectors are forward-looking The hideDynamicContent() helper targets data-testid="timestamp" and