diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index eb731d4a0..10b56d33c 100644 --- a/.github/workflows/ci-extended.yml +++ b/.github/workflows/ci-extended.yml @@ -107,6 +107,16 @@ 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'))) + needs: + - backend-solution + 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..9cee11ebc --- /dev/null +++ b/.github/workflows/reusable-visual-regression.yml @@ -0,0 +1,120 @@ +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: 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 + env: + CI: "true" + TASKDECK_E2E_DB: taskdeck.e2e.visual.ci.db + TASKDECK_RUN_DEMO: "0" + 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() + 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 diff --git a/docs/STATUS.md b/docs/STATUS.md index af3ee9c3f..222c3817a 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 eb0010ab6..e9a1042d0 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -596,6 +596,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: diff --git a/docs/testing/VISUAL_REGRESSION_POLICY.md b/docs/testing/VISUAL_REGRESSION_POLICY.md new file mode 100644 index 000000000..49916306b --- /dev/null +++ b/docs/testing/VISUAL_REGRESSION_POLICY.md @@ -0,0 +1,173 @@ +# 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: + +- **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 + +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 applies the following rules: + +- **Timestamp selectors** (forward-looking): `[data-testid="timestamp"]`, `[data-testid="relative-time"]`, `time` tags are hidden via `visibility: hidden`. Note: the current codebase renders timestamps as inline text in plain ``/`

` tags without these attributes, so these selectors are not yet effective. When adding visual tests for populated views, add `data-testid="timestamp"` to the relevant Vue components. +- **Blinking cursors**: transparent caret color on all elements +- **Platform-specific scrollbars**: hidden via `::-webkit-scrollbar` and `scrollbar-width: none` + +### 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. + +The CI workflow automatically detects when no baselines exist and runs with `--update-snapshots` to generate them. The generated baselines are uploaded as the `visual-regression-baselines` artifact. To bootstrap baselines for the first time or after a full reset: + +1. Push the branch and trigger the visual regression CI job +2. Download the `visual-regression-baselines` artifact from the CI run +3. Place the files in `frontend/taskdeck-web/tests/visual/__screenshots__/` +4. Commit and push + +To regenerate CI-compatible baselines after intentional UI changes: +1. Download the `visual-regression-diffs` artifact from the failing CI run +2. Review the `*-actual.png` images to verify the changes are intentional +3. Download the actual images and place them as the new baselines in `__screenshots__/` +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 diff --git a/frontend/taskdeck-web/package.json b/frontend/taskdeck-web/package.json index 0ea11e728..1de0cd569 100644 --- a/frontend/taskdeck-web/package.json +++ b/frontend/taskdeck-web/package.json @@ -27,6 +27,8 @@ "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:visual": "playwright test --config playwright.visual.config.ts", + "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", "mutation:test": "npx stryker run" }, "dependencies": { 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)] +} 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..73a71b5a0 --- /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') +}) 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..d78a33d2b --- /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') +}) + +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') +}) 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..12933d5f5 --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/command-palette.visual.spec.ts @@ -0,0 +1,48 @@ +/** + * 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('Type a command or search boards and cards...') + await expect(paletteInput).toBeVisible() + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('command-palette-open') +}) + +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('Type a command or search boards and cards...') + await expect(paletteInput).toBeVisible() + + // Type a search query to filter commands + await paletteInput.fill('board') + + await prepareForScreenshot(page) + + await expect(page).toHaveScreenshot('command-palette-search') +}) 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..e99ccd832 --- /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') +}) 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..458c0ca15 --- /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') +}) 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..d65d8da1a --- /dev/null +++ b/frontend/taskdeck-web/tests/visual/visual-test-helpers.ts @@ -0,0 +1,101 @@ +/** + * 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 to prevent false-positive + * screenshot diffs. Also applies global animation/transition suppression + * and hides platform-specific scrollbars. + * + * Note: The timestamp selectors below ([data-testid="timestamp"], time, etc.) + * are forward-looking — the current codebase renders timestamps as inline text + * in plain /

tags without data-testid attributes or