From b9a6257647492711b4995d7e805f15ff6e5ecfe6 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:21:14 +0100 Subject: [PATCH 01/20] Add cross-browser and mobile device projects to Playwright config Expand playwright.config.ts with Firefox, WebKit, and mobile viewport projects (Pixel 7, iPhone 14) using Playwright's built-in device descriptors. Tag-based grep filters ensure: chromium runs all tests except @mobile, Firefox/WebKit run only @cross-browser tests, and mobile projects run only @mobile tests. Existing untagged tests continue to run on chromium unchanged. Refs #87 --- frontend/taskdeck-web/playwright.config.ts | 50 +++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/playwright.config.ts b/frontend/taskdeck-web/playwright.config.ts index ca554ced9..4fa3bd257 100644 --- a/frontend/taskdeck-web/playwright.config.ts +++ b/frontend/taskdeck-web/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from '@playwright/test' +import { defineConfig, devices } from '@playwright/test' import { buildHttpOrigin, defaultFrontendHost, @@ -56,6 +56,54 @@ export default defineConfig({ baseURL: frontendBaseUrl, trace: 'retain-on-failure', }, + + /* --------------------------------------------------------------------------- + * Browser & device projects + * + * Tagging strategy (see docs/testing/FLAKY_TEST_POLICY.md): + * @smoke — quick PR gate (Chromium-only, default) + * @cross-browser — full browser matrix (nightly / manual) + * @mobile — mobile viewport scenarios (nightly / manual) + * + * CI behaviour: + * PR (ci-required) → "chromium" project only (grep excludes @mobile) + * Nightly / manual → all projects via reusable-e2e-cross-browser.yml + * -----------------------------------------------------------------------*/ + projects: [ + /* --- Desktop browsers --- */ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + /* Default project: runs all tests except @mobile-only scenarios. + * Existing untagged tests continue to run here unchanged. */ + grepInvert: /@mobile/, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + /* Only tests explicitly tagged @cross-browser run on Firefox. */ + grep: /@cross-browser/, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + /* Only tests explicitly tagged @cross-browser run on WebKit. */ + grep: /@cross-browser/, + }, + /* --- Mobile viewports --- */ + { + name: 'mobile-chrome', + use: { ...devices['Pixel 7'] }, + /* Only tests tagged @mobile run on mobile viewports. */ + grep: /@mobile/, + }, + { + name: 'mobile-safari', + use: { ...devices['iPhone 14'] }, + grep: /@mobile/, + }, + ], + webServer: [ { command: 'dotnet run --no-launch-profile --project ../../backend/src/Taskdeck.Api/Taskdeck.Api.csproj', From b135caffc02d5fd1583e76c620d7a07127924c24 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:22:00 +0100 Subject: [PATCH 02/20] Add mobile viewport E2E tests for responsive behavior Four @mobile-tagged test scenarios covering: - Board navigation and column visibility on small screens - Card editing modal fitting within mobile viewport - Sidebar navigation accessibility on small screens - Capture modal usability on mobile viewport Tests validate actual responsive behavior (viewport bounds, element visibility, overflow prevention) rather than just re-running desktop tests at a smaller size. Refs #87 --- .../tests/e2e/mobile-responsive.spec.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts diff --git a/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts new file mode 100644 index 000000000..4518373fd --- /dev/null +++ b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts @@ -0,0 +1,199 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from './support/authSession' + +/** + * Mobile-responsive E2E tests. + * + * These tests run only on mobile viewport projects (Pixel 7, iPhone 14) + * and validate that critical workflows remain usable at small screen sizes. + * + * Tag: @mobile — filtered by project grep in playwright.config.ts. + */ + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'mobile') +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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() +} + +function columnByName(page: Page, columnName: string) { + return page + .locator('[data-column-id]') + .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) + .first() +} + +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() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('@mobile board navigation and column visibility on small screen', async ({ page }) => { + const boardName = `Mobile Board ${Date.now()}` + const columnName = `Mobile Col ${Date.now()}` + + await createBoard(page, boardName) + await addColumn(page, columnName) + + // Board heading should be visible on mobile + await expect(page.getByRole('heading', { name: boardName })).toBeVisible() + + // Column heading should be visible and not clipped outside viewport + const columnHeading = page.getByRole('heading', { name: columnName, exact: true }) + await expect(columnHeading).toBeVisible() + const headingBox = await columnHeading.boundingBox() + expect(headingBox).not.toBeNull() + // The heading should have a positive x position (not pushed off-screen) + expect(headingBox!.x).toBeGreaterThanOrEqual(0) + + // The viewport should be small (confirming mobile project is active) + const viewportSize = page.viewportSize() + expect(viewportSize).not.toBeNull() + expect(viewportSize!.width).toBeLessThan(500) + + // Board controls (New Board, Add Column) should still be reachable + await expect(page.getByRole('button', { name: '+ Add Column' })).toBeVisible() +}) + +test('@mobile card editing modal should fit within mobile viewport', async ({ page }) => { + const boardName = `Mobile Edit Board ${Date.now()}` + const columnName = `Mobile Edit Col ${Date.now()}` + const cardTitle = `Mobile Edit Card ${Date.now()}` + + await createBoard(page, boardName) + await addColumn(page, columnName) + await addCard(page, columnName, cardTitle) + + // Click the card to open the edit modal + const card = page.locator('[data-card-id]').filter({ hasText: cardTitle }).first() + await card.click() + + const editHeading = page.getByRole('heading', { name: 'Edit Card' }) + await expect(editHeading).toBeVisible() + + // The edit modal should be within the viewport bounds + const viewportSize = page.viewportSize() + expect(viewportSize).not.toBeNull() + + // The modal/dialog container should not overflow the viewport width + const modal = page.locator('[role="dialog"], .td-card-edit-modal, .td-modal').first() + const modalExists = await modal.count() + if (modalExists > 0) { + const modalBox = await modal.boundingBox() + if (modalBox) { + // Modal should not exceed viewport width + expect(modalBox.x + modalBox.width).toBeLessThanOrEqual(viewportSize!.width + 2) + // Modal should have a reasonable minimum width on mobile + expect(modalBox.width).toBeGreaterThan(200) + } + } + + // Card title field should be visible and editable + const titleInput = page.locator('#card-title, [name="title"], input[type="text"]').first() + if (await titleInput.count() > 0) { + await expect(titleInput).toBeVisible() + } + + // Close the modal + await page.keyboard.press('Escape') + await expect(editHeading).not.toBeVisible() +}) + +test('@mobile sidebar navigation should remain accessible on small screen', async ({ page }) => { + await page.goto('/workspace/home') + await expect(page).toHaveURL(/\/workspace\/home$/) + + const viewportSize = page.viewportSize() + expect(viewportSize).not.toBeNull() + expect(viewportSize!.width).toBeLessThan(500) + + // Home heading should be visible + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() + + // Navigate to boards workspace — use direct URL since sidebar may be + // collapsed or behind a hamburger on mobile + await page.goto('/workspace/boards') + await expect(page).toHaveURL(/\/workspace\/boards$/) + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() + + // Navigate to inbox + await page.goto('/workspace/inbox') + await expect(page).toHaveURL(/\/workspace\/inbox$/) + + // Each workspace view should render its primary content within viewport + const body = page.locator('body') + const bodyBox = await body.boundingBox() + expect(bodyBox).not.toBeNull() + // Body should not be wider than the viewport (no horizontal overflow forcing scroll) + // Allow small tolerance for scrollbar + expect(bodyBox!.width).toBeLessThanOrEqual(viewportSize!.width + 20) +}) + +test('@mobile capture modal should be usable on small screen', async ({ page }) => { + await page.goto('/workspace/boards') + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() + + const captureText = `Mobile capture ${Date.now()}` + + // Open capture modal via keyboard shortcut + await page.keyboard.press('Control+Shift+C') + const captureModal = page.getByRole('dialog', { name: 'Capture item' }) + await expect(captureModal).toBeVisible() + + // The capture textarea should be visible and interactable + const captureInput = captureModal.getByPlaceholder('Capture a thought, task, or follow-up...') + await expect(captureInput).toBeVisible() + + // On mobile the modal should fit the viewport + const viewportSize = page.viewportSize() + expect(viewportSize).not.toBeNull() + + const modalBox = await captureModal.boundingBox() + if (modalBox) { + expect(modalBox.x + modalBox.width).toBeLessThanOrEqual(viewportSize!.width + 2) + } + + // Type and submit + await captureInput.fill(captureText) + await captureInput.press('Control+Enter') + + // Should navigate to inbox with the capture visible + await expect(page).toHaveURL(/\/workspace\/inbox$/) + await expect(page.locator('.td-inbox-row__excerpt').first()).toContainText(captureText) +}) From be05a6aee228633a8589cc4abf08172aa3f7c675 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:22:33 +0100 Subject: [PATCH 03/20] Add cross-browser E2E tests for critical user journeys Five @cross-browser-tagged test scenarios covering: - Board creation, column/card workflow, and persistence after reload - Workspace navigation between Home, Boards, and Inbox views - Card edit modal open/close lifecycle - Capture hotkey submission and inbox routing - Filter panel keyboard shortcut toggle These tests run on Chromium, Firefox, and WebKit to catch browser- specific rendering and event handling differences. Refs #87 --- .../tests/e2e/cross-browser.spec.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts diff --git a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts new file mode 100644 index 000000000..b2562bfa5 --- /dev/null +++ b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts @@ -0,0 +1,161 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from './support/authSession' + +/** + * Cross-browser E2E tests. + * + * These tests run on all desktop browser projects (Chromium, Firefox, WebKit) + * and validate that critical user journeys work consistently across engines. + * + * Tag: @cross-browser — filtered by project grep in playwright.config.ts. + * On Chromium these also run alongside the regular suite. + */ + +test.beforeEach(async ({ page, request }) => { + await registerAndAttachSession(page, request, 'xbrowser') +}) + +// --------------------------------------------------------------------------- +// Helpers (kept local to avoid coupling with smoke.spec helpers) +// --------------------------------------------------------------------------- + +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() +} + +function columnByName(page: Page, columnName: string) { + return page + .locator('[data-column-id]') + .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) + .first() +} + +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() +} + +// --------------------------------------------------------------------------- +// Tests — critical journeys that must work identically across browsers +// --------------------------------------------------------------------------- + +test('@cross-browser board creation and card workflow', async ({ page }) => { + const boardName = `XB Board ${Date.now()}` + const columnName = `XB Col ${Date.now()}` + const cardTitle = `XB Card ${Date.now()}` + + await createBoard(page, boardName) + await addColumn(page, columnName) + await addCard(page, columnName, cardTitle) + + // Card should be visible in the correct column + const column = columnByName(page, columnName) + await expect(column.locator('[data-card-id]').filter({ hasText: cardTitle }).first()).toBeVisible() + + // Page reload should persist + await page.reload() + await expect(page.getByRole('heading', { name: boardName })).toBeVisible() + const reloadedColumn = columnByName(page, columnName) + await expect( + reloadedColumn.locator('[data-card-id]').filter({ hasText: cardTitle }).first(), + ).toBeVisible() +}) + +test('@cross-browser workspace navigation between views', async ({ page }) => { + // Home + await page.goto('/workspace/home') + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() + + // Boards + await page.goto('/workspace/boards') + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() + + // Inbox + await page.goto('/workspace/inbox') + await expect(page).toHaveURL(/\/workspace\/inbox$/) + + // Back to Home + await page.goto('/workspace/home') + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() +}) + +test('@cross-browser card edit modal open and close', async ({ page }) => { + const boardName = `XB Edit Board ${Date.now()}` + const columnName = `XB Edit Col ${Date.now()}` + const cardTitle = `XB Edit Card ${Date.now()}` + + await createBoard(page, boardName) + await addColumn(page, columnName) + await addCard(page, columnName, cardTitle) + + // Click card to open edit modal + const card = page.locator('[data-card-id]').filter({ hasText: cardTitle }).first() + await card.click() + await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() + + // Close with Escape + await page.keyboard.press('Escape') + await expect(page.getByRole('heading', { name: 'Edit Card' })).not.toBeVisible() + + // Card should still be visible after closing modal + await expect( + page.locator('[data-card-id]').filter({ hasText: cardTitle }).first(), + ).toBeVisible() +}) + +test('@cross-browser capture hotkey submits and routes to inbox', async ({ page }) => { + await page.goto('/workspace/boards') + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() + + const captureText = `XB Capture ${Date.now()}` + + await page.keyboard.press('Control+Shift+C') + const captureModal = page.getByRole('dialog', { name: 'Capture item' }) + await expect(captureModal).toBeVisible() + + await captureModal.getByPlaceholder('Capture a thought, task, or follow-up...').fill(captureText) + await captureModal.getByPlaceholder('Capture a thought, task, or follow-up...').press('Control+Enter') + + await expect(page).toHaveURL(/\/workspace\/inbox$/) + await expect(page.locator('.td-inbox-row__excerpt').first()).toContainText(captureText) +}) + +test('@cross-browser filter panel toggle with keyboard shortcut', async ({ page }) => { + const boardName = `XB Filter Board ${Date.now()}` + + await createBoard(page, boardName) + + // Open filter panel with 'f' key + await page.keyboard.press('f') + await expect(page.getByRole('heading', { name: 'Filter Cards' })).toBeVisible() + + // Close with 'f' key + await page.keyboard.press('f') + await expect(page.getByRole('heading', { name: 'Filter Cards' })).not.toBeVisible() +}) From de5c0d651a295ef1e3942513bfc3bc3052a69117 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:23:24 +0100 Subject: [PATCH 04/20] Add cross-browser E2E matrix CI workflow New reusable-e2e-cross-browser.yml runs Playwright tests across all five projects (chromium, firefox, webkit, mobile-chrome, mobile-safari) with fail-fast disabled so all browsers report independently. Wire into ci-nightly.yml for daily regression and ci-extended.yml for on-demand runs via "testing" label or manual dispatch. The PR merge gate (ci-required.yml) remains chromium-only smoke for fast feedback. Refs #87 --- .github/workflows/ci-extended.yml | 10 ++ .github/workflows/ci-nightly.yml | 9 ++ .../workflows/reusable-e2e-cross-browser.yml | 101 ++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 .github/workflows/reusable-e2e-cross-browser.yml diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index ca9912274..2498e606e 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 + e2e-cross-browser: + name: E2E Cross-Browser Matrix + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'testing')) + needs: + - backend-solution + uses: ./.github/workflows/reusable-e2e-cross-browser.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/ci-nightly.yml b/.github/workflows/ci-nightly.yml index fa3d8c498..8d1b4da56 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -60,6 +60,15 @@ jobs: k6-duration: "90s" k6-user-pool: "6" + e2e-cross-browser: + name: E2E Cross-Browser Matrix + needs: + - backend-solution + uses: ./.github/workflows/reusable-e2e-cross-browser.yml + with: + dotnet-version: 8.0.x + node-version: 24.13.1 + container-images: name: Container Images Regression needs: diff --git a/.github/workflows/reusable-e2e-cross-browser.yml b/.github/workflows/reusable-e2e-cross-browser.yml new file mode 100644 index 000000000..46c24dcb6 --- /dev/null +++ b/.github/workflows/reusable-e2e-cross-browser.yml @@ -0,0 +1,101 @@ +name: Reusable E2E Cross-Browser Matrix + +on: + workflow_call: + inputs: + dotnet-version: + description: .NET SDK version used for E2E backend setup + required: false + default: "8.0.x" + type: string + node-version: + description: Node.js version used for E2E frontend setup + required: false + default: "24.13.1" + type: string + +permissions: + contents: read + +env: + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + +jobs: + e2e-cross-browser: + name: E2E (${{ matrix.project }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + project: + - chromium + - firefox + - webkit + - mobile-chrome + - mobile-safari + 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 }}-${{ matrix.project }}-${{ hashFiles('frontend/taskdeck-web/package-lock.json') }} + + - name: Install Playwright browsers + working-directory: frontend/taskdeck-web + run: npx playwright install --with-deps + + - name: Remove stale E2E database + working-directory: frontend/taskdeck-web + run: node -e "require('fs').rmSync('taskdeck.e2e.ci.db',{force:true});" + + - name: Run Playwright tests (${{ matrix.project }}) + timeout-minutes: 15 + working-directory: frontend/taskdeck-web + env: + CI: "true" + TASKDECK_E2E_DB: taskdeck.e2e.ci.db + TASKDECK_RUN_DEMO: "0" + run: npx playwright test --project=${{ matrix.project }} --reporter=line + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-${{ matrix.project }} + path: frontend/taskdeck-web/playwright-report + if-no-files-found: ignore + + - name: Upload Playwright test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-test-results-${{ matrix.project }} + path: frontend/taskdeck-web/test-results + if-no-files-found: ignore From 565a093efcea15b9cc44fd4a0ba1db08166b55c0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:24:18 +0100 Subject: [PATCH 05/20] Add flaky test policy with quarantine process and tagging strategy Documents the E2E test tagging convention (@smoke, @cross-browser, @mobile, @quarantine), CI matrix strategy showing when each project runs, quarantine workflow (identify, tag, investigate, fix), SLA timelines by severity, and prevention guidelines. Refs #87 --- docs/testing/FLAKY_TEST_POLICY.md | 124 ++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/testing/FLAKY_TEST_POLICY.md diff --git a/docs/testing/FLAKY_TEST_POLICY.md b/docs/testing/FLAKY_TEST_POLICY.md new file mode 100644 index 000000000..f5fd87298 --- /dev/null +++ b/docs/testing/FLAKY_TEST_POLICY.md @@ -0,0 +1,124 @@ +# Flaky Test Policy + +Last Updated: 2026-04-09 + +## Purpose + +This document defines how flaky E2E tests are identified, quarantined, and remediated in the Taskdeck test suite. The goal is to maintain CI signal quality: a red build should always mean a real problem. + +## Definition + +A test is **flaky** when it produces inconsistent pass/fail results across runs without any code change. Common causes: + +- Timing-dependent waits or race conditions +- Test isolation failures (shared state between tests or browser profiles) +- Browser-specific rendering timing (especially cross-browser matrix) +- Network/server startup non-determinism + +## Tagging Strategy + +Taskdeck E2E tests use Playwright tag annotations in test titles: + +| Tag | Purpose | Runs in CI | +|-----|---------|------------| +| (no tag) | Default smoke tests | PR gate (chromium only) | +| `@smoke` | Explicit smoke designation | PR gate (chromium only) | +| `@cross-browser` | Critical journeys across all desktop browsers | Nightly + manual (`testing` label) | +| `@mobile` | Mobile viewport responsive tests | Nightly + manual (`testing` label) | +| `@quarantine` | Known flaky, excluded from CI | Never (local debug only) | + +### How to tag a test + +Add the tag to the test title string: + +```typescript +test('@cross-browser board creation workflow', async ({ page }) => { + // ... +}) + +test('@mobile card editing on small screen', async ({ page }) => { + // ... +}) +``` + +Multiple tags can be combined: + +```typescript +test('@cross-browser @mobile responsive navigation', async ({ page }) => { + // ... +}) +``` + +## CI Matrix Strategy + +| CI Lane | Trigger | Projects Run | Tag Filter | +|---------|---------|-------------|------------| +| `ci-required.yml` (PR gate) | Every PR/push | chromium only | All tests except `@mobile` | +| `ci-extended.yml` | `testing` label or manual | All 5 projects | Per-project grep (see config) | +| `ci-nightly.yml` | Daily 03:25 UTC | All 5 projects | Per-project grep (see config) | + +## Quarantine Process + +### Step 1: Identify + +When a test fails intermittently (2+ inconsistent results in nightly or PR runs): + +1. File a GitHub issue with label `flaky-test` and link the failing test file/line +2. Include failure logs, trace artifacts, and which browser(s) are affected + +### Step 2: Quarantine + +Add `@quarantine` tag to the test title: + +```typescript +test('@quarantine @cross-browser flaky board reload test', async ({ page }) => { + // ... +}) +``` + +The Playwright config excludes `@quarantine` from all CI projects via `grepInvert`. The test still runs locally for debugging. + +To add quarantine exclusion to all projects, add this to `playwright.config.ts` in the top-level `use` block or per-project: + +```typescript +grepInvert: /@quarantine/, +``` + +### Step 3: Investigate + +The issue assignee must: + +1. Reproduce locally (run the specific test with `--repeat-each=5`) +2. Check for timing issues (missing `waitFor`, race conditions) +3. Check for test isolation issues (shared state, database leaks) +4. Check for browser-specific behavior (compare across projects) + +### Step 4: Fix and Un-quarantine + +1. Fix the root cause +2. Verify stability: run `npx playwright test --project= --grep="test name" --repeat-each=10` +3. Remove the `@quarantine` tag +4. Close the issue with a link to the fix PR + +## Remediation Timeline + +| Severity | SLA | Escalation | +|----------|-----|------------| +| Blocks PR gate (chromium smoke) | Fix within 24 hours or quarantine | Immediate team notification | +| Nightly cross-browser failure | Fix within 1 week | Review in next standup | +| Nightly mobile-only failure | Fix within 2 weeks | Track in sprint backlog | + +## Prevention Guidelines + +1. **Use explicit waits**: Always `await expect(locator).toBeVisible()` before interacting +2. **Avoid fixed timeouts**: Use `waitForResponse` / `waitForURL` instead of `page.waitForTimeout` +3. **Isolate test state**: Each test gets a fresh user via `registerAndAttachSession` +4. **Use unique names**: Include `Date.now()` in board/card/column names to prevent collisions +5. **Test deterministically**: Avoid tests that depend on animation timing or CSS transitions +6. **Keep browser profiles independent**: Never share cookies, localStorage, or database state across browser projects + +## Monitoring + +- Nightly CI results are reviewed daily for new failures +- Flaky test issues are prioritized alongside regular bugs +- A test that has been quarantined for more than 30 days without progress should be escalated or removed From 42a589d66b502f512a0a4b58d45d33972bccad3e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:25:11 +0100 Subject: [PATCH 06/20] Update TESTING_GUIDE.md with cross-browser and mobile testing section Add comprehensive section documenting browser projects, test tagging strategy, local run commands, CI configuration details, guidance for writing new tagged E2E tests, and link to the flaky test policy. Refs #87 --- docs/TESTING_GUIDE.md | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 625302733..a5b5ad4bd 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -465,6 +465,80 @@ cd frontend/taskdeck-web npm run test:e2e:audit:headed ``` +## Cross-Browser and Mobile E2E Testing + +### Browser Projects + +The Playwright config defines five projects: + +| Project | Device Descriptor | When It Runs | +|---------|------------------|--------------| +| `chromium` | Desktop Chrome | Every PR (ci-required), nightly, manual | +| `firefox` | Desktop Firefox | Nightly, manual dispatch, `testing` label | +| `webkit` | Desktop Safari | Nightly, manual dispatch, `testing` label | +| `mobile-chrome` | Pixel 7 | Nightly, manual dispatch, `testing` label | +| `mobile-safari` | iPhone 14 | Nightly, manual dispatch, `testing` label | + +### Test Tagging + +Tests use tag annotations in their title strings to control which projects run them: + +- **(no tag)** or `@smoke` — runs on chromium only (PR gate default) +- `@cross-browser` — runs on chromium, firefox, and webkit +- `@mobile` — runs on mobile-chrome and mobile-safari only +- `@quarantine` — excluded from all CI (see `docs/testing/FLAKY_TEST_POLICY.md`) + +### Running Cross-Browser Tests Locally + +Install all browsers (one-time): + +```bash +cd frontend/taskdeck-web +npx playwright install --with-deps +``` + +Run a specific project: + +```bash +npx playwright test --project=firefox --reporter=line +npx playwright test --project=mobile-safari --reporter=line +``` + +Run all projects: + +```bash +npx playwright test --reporter=line +``` + +Run only cross-browser tagged tests across all desktop browsers: + +```bash +npx playwright test --grep="@cross-browser" --reporter=line +``` + +Run only mobile tests: + +```bash +npx playwright test --grep="@mobile" --reporter=line +``` + +### CI Configuration + +- **PR gate** (`ci-required.yml`): calls `reusable-e2e-smoke.yml` which installs and runs chromium only. This keeps PR feedback fast (~12 min timeout). +- **Nightly** (`ci-nightly.yml`): calls `reusable-e2e-cross-browser.yml` which runs all 5 projects in a matrix with `fail-fast: false`. +- **Extended/manual** (`ci-extended.yml`): calls `reusable-e2e-cross-browser.yml` on `testing` label or manual dispatch. + +### Writing New E2E Tests + +1. **Default tests** (no tag): run on chromium in PR gate. Use for most new tests. +2. **Critical journeys** that must work cross-browser: add `@cross-browser` tag. These will also run on chromium in PR gate. +3. **Mobile-specific behavior** (viewport responsiveness, touch targets, overflow): add `@mobile` tag. These only run on mobile projects. +4. **Flaky or unstable tests**: add `@quarantine` tag and file an issue. See `docs/testing/FLAKY_TEST_POLICY.md`. + +### Flaky Test Policy + +See `docs/testing/FLAKY_TEST_POLICY.md` for the full quarantine/remediation process, SLA timelines, and prevention guidelines. + ## Demo Tooling Policy Default CI posture: From 65267dad73394c4da15b0817f38981d99aee9dc1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:27:04 +0100 Subject: [PATCH 07/20] Update STATUS.md and IMPLEMENTATION_MASTERPLAN.md for cross-browser E2E Record delivery of #87 in both canonical docs: cross-browser/mobile E2E matrix, CI workflow wiring, and flaky test policy. Refs #87 --- docs/IMPLEMENTATION_MASTERPLAN.md | 1 + docs/STATUS.md | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index 40d1642d7..fbf453702 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -1215,6 +1215,7 @@ Additional P1 issues from the same session (tracked in `#510`–`#515`) cover ex 10. Keep issue `#107` synchronized as the single wave index and maintain one-priority-label-per-issue discipline (`Priority I` to `Priority V`). 11. Treat the demo-expansion migration wave (`#297` -> `#302`) as delivered; route any further demo-tooling work through normal scoped follow-up issues such as `#311`, `#354`, `#355`, and `#369` instead of reopening the migration batches. 12. Test suite baseline counts recertified 2026-04-08: backend ~3,460+ passing, frontend ~1,891 passing, combined ~5,370+. Rigorous test expansion wave (`#721`) fully delivered (25/25 issues). +20. **Cross-browser and mobile E2E matrix expansion (2026-04-09)**: `#87` delivered — Playwright config expanded with 5 projects (chromium, firefox, webkit, mobile-chrome/Pixel 7, mobile-safari/iPhone 14); tag-based test filtering (`@cross-browser`, `@mobile`, `@quarantine`); 5 cross-browser tests + 4 mobile viewport tests; `reusable-e2e-cross-browser.yml` workflow runs full matrix nightly and on `testing` label; PR gate stays chromium-only for fast feedback; flaky test policy at `docs/testing/FLAKY_TEST_POLICY.md`; `docs/TESTING_GUIDE.md` updated with cross-browser section. ## Documentation Operating Model Active docs: diff --git a/docs/STATUS.md b/docs/STATUS.md index ebda157ca..152b439f3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # Taskdeck Status (Source of Truth) -Last Updated: 2026-04-08 +Last Updated: 2026-04-09
Status Owner: Repository maintainers Authoritative Scope: Current implementation, verified test execution, and active phase progress @@ -828,6 +828,7 @@ Result: - backend Playwright startup stays on deterministic `Mock` provider mode unless the run is an explicit demo flow that injects live-provider overrides. - Investigation record remains at `docs/analysis/2026-02-25_frontend-gate-port-bind-and-cors-blockers.md`. - 2026-03-26 manual audit confirmed the previously published raw API/E2E counts were stale; the next full end-to-end suite recertification should refresh discovery/pass totals rather than continuing to repeat the older 2026-03-06 figures. +- 2026-04-09 cross-browser and mobile E2E matrix delivered (`#87`): Playwright config now defines 5 projects (chromium, firefox, webkit, mobile-chrome/Pixel 7, mobile-safari/iPhone 14); tag-based filtering (`@cross-browser`, `@mobile`, `@quarantine`) controls which tests run per project; 5 cross-browser + 4 mobile viewport tests added; PR gate stays chromium-only; full matrix runs nightly and on `testing` label; flaky test policy documented at `docs/testing/FLAKY_TEST_POLICY.md` ### Demo Director Smoke @@ -862,6 +863,7 @@ Extended/non-blocking workflow: `.github/workflows/ci-extended.yml` - `dependency-review` (PR dependency risk check) - label/manual-triggered backend solution + E2E smoke lanes (`testing` label or `workflow_dispatch`) for PRs that touch `.github/workflows/**`, `backend/**`, `frontend/**`, `deploy/**`, or `scripts/**` - label/manual-triggered demo director smoke lane (`automation` label or `workflow_dispatch`) via `.github/workflows/reusable-demo-director-smoke.yml`; docs-only PRs still need manual dispatch because `ci-extended.yml` path filters do not watch `docs/**` +- label/manual-triggered E2E cross-browser matrix lane via `.github/workflows/reusable-e2e-cross-browser.yml` (`testing` label or `workflow_dispatch`); runs all 5 browser/device projects in parallel with `fail-fast: false` - label/manual-triggered load/concurrency harness lane via `.github/workflows/reusable-load-concurrency-harness.yml` Release workflow: `.github/workflows/ci-release.yml` @@ -882,6 +884,7 @@ Nightly workflow: `.github/workflows/ci-nightly.yml` - scheduled/manual backend solution regression - scheduled/manual E2E smoke (reuses `.github/workflows/reusable-e2e-smoke.yml`) +- scheduled/manual E2E cross-browser matrix (reuses `.github/workflows/reusable-e2e-cross-browser.yml`; 5 projects: chromium, firefox, webkit, mobile-chrome, mobile-safari) - scheduled/manual load/concurrency harness (reuses `.github/workflows/reusable-load-concurrency-harness.yml`) - scheduled/manual container image regression From 13ebebcedc85152dc032a3febabb9fde09a34b0b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:27:53 +0100 Subject: [PATCH 08/20] Pin smoke CI to chromium project and add quarantine exclusion Update reusable-e2e-smoke.yml to explicitly pass --project=chromium so the PR gate does not accidentally run all browser projects. Add top-level grepInvert for @quarantine tag in playwright.config.ts to exclude quarantined tests from all projects globally. Refs #87 --- .github/workflows/reusable-e2e-smoke.yml | 2 +- frontend/taskdeck-web/playwright.config.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-e2e-smoke.yml b/.github/workflows/reusable-e2e-smoke.yml index c774c8d87..df903957c 100644 --- a/.github/workflows/reusable-e2e-smoke.yml +++ b/.github/workflows/reusable-e2e-smoke.yml @@ -73,7 +73,7 @@ jobs: CI: "true" TASKDECK_E2E_DB: taskdeck.e2e.ci.db TASKDECK_RUN_DEMO: "0" - run: npx playwright test --reporter=line + run: npx playwright test --project=chromium --reporter=line - name: Upload Playwright report if: failure() diff --git a/frontend/taskdeck-web/playwright.config.ts b/frontend/taskdeck-web/playwright.config.ts index 4fa3bd257..dcd2a7bd1 100644 --- a/frontend/taskdeck-web/playwright.config.ts +++ b/frontend/taskdeck-web/playwright.config.ts @@ -52,6 +52,8 @@ export default defineConfig({ }, retries: process.env.CI ? 0 : 0, reporter: process.env.CI ? [['line'], ['github'], ['html', { open: 'never' }]] : 'list', + /* Exclude quarantined tests from all projects (see docs/testing/FLAKY_TEST_POLICY.md). */ + grepInvert: /@quarantine/, use: { baseURL: frontendBaseUrl, trace: 'retain-on-failure', From 7a61784627eba8dd94fee9c6842615fa49eb2484 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:28:21 +0100 Subject: [PATCH 09/20] Update CI topology comment to include cross-browser workflow Add reusable-e2e-cross-browser.yml entries to the topology diagram in the ci-required.yml header comment for ci-extended and ci-nightly. Refs #87 --- .github/workflows/ci-required.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index 8e9bd1514..54cdd063f 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -20,6 +20,7 @@ # ├── reusable-openapi-guardrail.yml # ├── reusable-backend-solution.yml (label: testing) # ├── reusable-e2e-smoke.yml (label: testing) +# ├── reusable-e2e-cross-browser.yml (label: testing) # ├── reusable-demo-director-smoke.yml (label: automation) # └── reusable-load-concurrency-harness.yml (label: testing) # @@ -27,6 +28,7 @@ # ├── reusable-openapi-guardrail.yml # ├── reusable-backend-solution.yml # ├── reusable-e2e-smoke.yml +# ├── reusable-e2e-cross-browser.yml # ├── reusable-load-concurrency-harness.yml # └── reusable-container-images.yml # From b0a913152cfd324edf791d9722a0e066425bdcd5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 03:30:39 +0100 Subject: [PATCH 10/20] Fix review findings: deduplicate browser cache key and clarify quarantine docs Remove matrix.project from Playwright browser cache key in the cross-browser workflow since all browsers are installed in every matrix job. Update flaky test policy to accurately describe the top-level grepInvert configuration. Refs #87 --- .github/workflows/reusable-e2e-cross-browser.yml | 2 +- docs/testing/FLAKY_TEST_POLICY.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-e2e-cross-browser.yml b/.github/workflows/reusable-e2e-cross-browser.yml index 46c24dcb6..24929da3e 100644 --- a/.github/workflows/reusable-e2e-cross-browser.yml +++ b/.github/workflows/reusable-e2e-cross-browser.yml @@ -65,7 +65,7 @@ jobs: uses: actions/cache@v5 with: path: ~/.cache/ms-playwright - key: ms-playwright-${{ runner.os }}-${{ matrix.project }}-${{ hashFiles('frontend/taskdeck-web/package-lock.json') }} + key: ms-playwright-${{ runner.os }}-${{ hashFiles('frontend/taskdeck-web/package-lock.json') }} - name: Install Playwright browsers working-directory: frontend/taskdeck-web diff --git a/docs/testing/FLAKY_TEST_POLICY.md b/docs/testing/FLAKY_TEST_POLICY.md index f5fd87298..0e3427ac5 100644 --- a/docs/testing/FLAKY_TEST_POLICY.md +++ b/docs/testing/FLAKY_TEST_POLICY.md @@ -76,11 +76,12 @@ test('@quarantine @cross-browser flaky board reload test', async ({ page }) => { }) ``` -The Playwright config excludes `@quarantine` from all CI projects via `grepInvert`. The test still runs locally for debugging. +The Playwright config excludes `@quarantine` from all CI projects via a top-level `grepInvert` in `playwright.config.ts`. The test still runs locally for debugging (pass `--grep="@quarantine"` explicitly to override). -To add quarantine exclusion to all projects, add this to `playwright.config.ts` in the top-level `use` block or per-project: +The top-level exclusion is already configured: ```typescript +// playwright.config.ts (top level) grepInvert: /@quarantine/, ``` From 0264b0008113a05e02f4caf63c83ade8f6f305b9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 04:03:05 +0100 Subject: [PATCH 11/20] Add shared UI-level board helpers to reduce test duplication Extract createBoard, addColumn, columnByName, and addCard helpers to a shared boardUiHelpers.ts module. These were duplicated identically across cross-browser.spec.ts and mobile-responsive.spec.ts. The addCard helper now uses a 15s timeout for card visibility to handle slow CI re-render timing that caused the PR gate failure. Refs #87 --- .../tests/e2e/support/boardUiHelpers.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 frontend/taskdeck-web/tests/e2e/support/boardUiHelpers.ts diff --git a/frontend/taskdeck-web/tests/e2e/support/boardUiHelpers.ts b/frontend/taskdeck-web/tests/e2e/support/boardUiHelpers.ts new file mode 100644 index 000000000..e058d58e6 --- /dev/null +++ b/frontend/taskdeck-web/tests/e2e/support/boardUiHelpers.ts @@ -0,0 +1,52 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +/** + * UI-level board helpers for E2E tests. + * + * These interact with the actual UI (clicking buttons, filling inputs) + * rather than using the API directly. Shared across cross-browser and + * mobile-responsive spec files to avoid duplication. + */ + +export 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() +} + +export 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() +} + +export function columnByName(page: Page, columnName: string) { + return page + .locator('[data-column-id]') + .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) + .first() +} + +export 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 + // CI can be slow to re-render after card creation; extend the default expect timeout. + await expect( + page.locator('[data-card-id]').filter({ hasText: cardTitle }).first(), + ).toBeVisible({ timeout: 15_000 }) +} From d5db85bb45c96c4c92d998710e85d4a8a6c9acd4 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 04:03:14 +0100 Subject: [PATCH 12/20] Use shared board helpers in cross-browser spec and document PR gate impact Replace duplicated local helpers with imports from boardUiHelpers.ts. Add note in file header that @cross-browser tests run in PR gate on chromium and to be mindful of count. Refs #87 --- .../tests/e2e/cross-browser.spec.ts | 50 ++----------------- 1 file changed, 3 insertions(+), 47 deletions(-) diff --git a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts index b2562bfa5..4d7eb3b27 100644 --- a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts @@ -1,6 +1,6 @@ -import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { registerAndAttachSession } from './support/authSession' +import { addCard, addColumn, columnByName, createBoard } from './support/boardUiHelpers' /** * Cross-browser E2E tests. @@ -9,58 +9,14 @@ import { registerAndAttachSession } from './support/authSession' * and validate that critical user journeys work consistently across engines. * * Tag: @cross-browser — filtered by project grep in playwright.config.ts. - * On Chromium these also run alongside the regular suite. + * On Chromium these also run alongside the regular suite (PR gate includes + * @cross-browser tests; be mindful of count to avoid slowing PR feedback). */ test.beforeEach(async ({ page, request }) => { await registerAndAttachSession(page, request, 'xbrowser') }) -// --------------------------------------------------------------------------- -// Helpers (kept local to avoid coupling with smoke.spec helpers) -// --------------------------------------------------------------------------- - -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() -} - -function columnByName(page: Page, columnName: string) { - return page - .locator('[data-column-id]') - .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) - .first() -} - -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() -} - // --------------------------------------------------------------------------- // Tests — critical journeys that must work identically across browsers // --------------------------------------------------------------------------- From 435b278190ab4b77aeb41a2207c3df551baddebb Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 04:03:21 +0100 Subject: [PATCH 13/20] Fix mobile spec: use shared helpers, rename misleading test, remove conditional assertions - Import board helpers from shared boardUiHelpers.ts instead of duplicating them locally. - Rename sidebar navigation test to accurately reflect what it validates (workspace views render correctly, not sidebar interaction). - Remove conditional if-guards around modal assertions so regressions in modal rendering are caught instead of silently passing. Refs #87 --- .../tests/e2e/mobile-responsive.spec.ts | 75 +++---------------- 1 file changed, 11 insertions(+), 64 deletions(-) diff --git a/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts index 4518373fd..3d9851790 100644 --- a/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts @@ -1,6 +1,6 @@ -import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { registerAndAttachSession } from './support/authSession' +import { addCard, addColumn, columnByName, createBoard } from './support/boardUiHelpers' /** * Mobile-responsive E2E tests. @@ -15,51 +15,6 @@ test.beforeEach(async ({ page, request }) => { await registerAndAttachSession(page, request, 'mobile') }) -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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() -} - -function columnByName(page: Page, columnName: string) { - return page - .locator('[data-column-id]') - .filter({ has: page.getByRole('heading', { name: columnName, exact: true }) }) - .first() -} - -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() -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -111,31 +66,23 @@ test('@mobile card editing modal should fit within mobile viewport', async ({ pa const viewportSize = page.viewportSize() expect(viewportSize).not.toBeNull() - // The modal/dialog container should not overflow the viewport width + // The modal/dialog container should not overflow the viewport width. + // Use the dialog role locator which must exist since "Edit Card" heading is visible. const modal = page.locator('[role="dialog"], .td-card-edit-modal, .td-modal').first() - const modalExists = await modal.count() - if (modalExists > 0) { - const modalBox = await modal.boundingBox() - if (modalBox) { - // Modal should not exceed viewport width - expect(modalBox.x + modalBox.width).toBeLessThanOrEqual(viewportSize!.width + 2) - // Modal should have a reasonable minimum width on mobile - expect(modalBox.width).toBeGreaterThan(200) - } - } - - // Card title field should be visible and editable - const titleInput = page.locator('#card-title, [name="title"], input[type="text"]').first() - if (await titleInput.count() > 0) { - await expect(titleInput).toBeVisible() - } + await expect(modal).toBeVisible() + const modalBox = await modal.boundingBox() + expect(modalBox).not.toBeNull() + // Modal should not exceed viewport width + expect(modalBox!.x + modalBox!.width).toBeLessThanOrEqual(viewportSize!.width + 2) + // Modal should have a reasonable minimum width on mobile + expect(modalBox!.width).toBeGreaterThan(200) // Close the modal await page.keyboard.press('Escape') await expect(editHeading).not.toBeVisible() }) -test('@mobile sidebar navigation should remain accessible on small screen', async ({ page }) => { +test('@mobile workspace views should render correctly on small screen', async ({ page }) => { await page.goto('/workspace/home') await expect(page).toHaveURL(/\/workspace\/home$/) From 8deb464079d1a7755f42bc8e6291a327b2bb9e49 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 04:03:27 +0100 Subject: [PATCH 14/20] Document @cross-browser PR gate impact in chromium project config Add comment warning that @cross-browser tests run in the PR gate on chromium and to keep count lean for fast feedback. Refs #87 --- frontend/taskdeck-web/playwright.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/playwright.config.ts b/frontend/taskdeck-web/playwright.config.ts index dcd2a7bd1..9829099b8 100644 --- a/frontend/taskdeck-web/playwright.config.ts +++ b/frontend/taskdeck-web/playwright.config.ts @@ -77,7 +77,11 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, /* Default project: runs all tests except @mobile-only scenarios. - * Existing untagged tests continue to run here unchanged. */ + * Existing untagged tests continue to run here unchanged. + * + * NOTE: @cross-browser tests also run here (in PR gate via ci-required). + * Adding more @cross-browser tests will increase PR gate time. + * Keep @cross-browser count lean to preserve fast PR feedback. */ grepInvert: /@mobile/, }, { From 9759af7db3df4055da6aa86f0f4c685c7fdfb95b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 04:03:33 +0100 Subject: [PATCH 15/20] Fix IMPLEMENTATION_MASTERPLAN.md numbering for cross-browser entry Renumber from 20 to 13 to follow the previous item 12 in the Operating Notes list. The jump to 20 was a numbering error. Refs #87 --- docs/IMPLEMENTATION_MASTERPLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_MASTERPLAN.md b/docs/IMPLEMENTATION_MASTERPLAN.md index fbf453702..a250f954a 100644 --- a/docs/IMPLEMENTATION_MASTERPLAN.md +++ b/docs/IMPLEMENTATION_MASTERPLAN.md @@ -1215,7 +1215,7 @@ Additional P1 issues from the same session (tracked in `#510`–`#515`) cover ex 10. Keep issue `#107` synchronized as the single wave index and maintain one-priority-label-per-issue discipline (`Priority I` to `Priority V`). 11. Treat the demo-expansion migration wave (`#297` -> `#302`) as delivered; route any further demo-tooling work through normal scoped follow-up issues such as `#311`, `#354`, `#355`, and `#369` instead of reopening the migration batches. 12. Test suite baseline counts recertified 2026-04-08: backend ~3,460+ passing, frontend ~1,891 passing, combined ~5,370+. Rigorous test expansion wave (`#721`) fully delivered (25/25 issues). -20. **Cross-browser and mobile E2E matrix expansion (2026-04-09)**: `#87` delivered — Playwright config expanded with 5 projects (chromium, firefox, webkit, mobile-chrome/Pixel 7, mobile-safari/iPhone 14); tag-based test filtering (`@cross-browser`, `@mobile`, `@quarantine`); 5 cross-browser tests + 4 mobile viewport tests; `reusable-e2e-cross-browser.yml` workflow runs full matrix nightly and on `testing` label; PR gate stays chromium-only for fast feedback; flaky test policy at `docs/testing/FLAKY_TEST_POLICY.md`; `docs/TESTING_GUIDE.md` updated with cross-browser section. +13. **Cross-browser and mobile E2E matrix expansion (2026-04-09)**: `#87` delivered — Playwright config expanded with 5 projects (chromium, firefox, webkit, mobile-chrome/Pixel 7, mobile-safari/iPhone 14); tag-based test filtering (`@cross-browser`, `@mobile`, `@quarantine`); 5 cross-browser tests + 4 mobile viewport tests; `reusable-e2e-cross-browser.yml` workflow runs full matrix nightly and on `testing` label; PR gate stays chromium-only for fast feedback; flaky test policy at `docs/testing/FLAKY_TEST_POLICY.md`; `docs/TESTING_GUIDE.md` updated with cross-browser section. ## Documentation Operating Model Active docs: From 97f801c6eb27735052796ea3fc28a6a03b9ed142 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 23:21:57 +0100 Subject: [PATCH 16/20] Guard proposal decisions with EF concurrency --- .../Services/AutomationProposalService.cs | 8 ++--- .../AutomationProposalConfiguration.cs | 3 +- .../Repositories/UnitOfWork.cs | 9 ++++++ .../ConcurrencyRaceConditionStressTests.cs | 32 +++++++++++-------- .../AutomationProposalServiceEdgeCaseTests.cs | 21 ++++++++++++ 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs index 6422581a1..cad17948f 100644 --- a/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs +++ b/backend/src/Taskdeck.Application/Services/AutomationProposalService.cs @@ -155,10 +155,10 @@ public async Task> ApproveProposalAsync(Guid id, Guid decide return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.Approve(decidedByUserId); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "approved", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } @@ -177,10 +177,10 @@ public async Task> RejectProposalAsync(Guid id, Guid decided return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.Reject(decidedByUserId, dto.Reason); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "rejected", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } @@ -199,10 +199,10 @@ public async Task> MarkAsAppliedAsync(Guid id, CancellationT return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.MarkAsApplied(); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "applied", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } @@ -221,10 +221,10 @@ public async Task> MarkAsFailedAsync(Guid id, string failure return Result.Failure(ErrorCodes.NotFound, $"Proposal with ID {id} not found"); proposal.MarkAsFailed(failureReason); + await _unitOfWork.SaveChangesAsync(cancellationToken); var notifyResult = await PublishProposalOutcomeNotificationAsync(proposal, "failed", cancellationToken); if (!notifyResult.IsSuccess) return Result.Failure(notifyResult.ErrorCode, notifyResult.ErrorMessage); - await _unitOfWork.SaveChangesAsync(cancellationToken); return Result.Success(MapToDto(proposal)); } diff --git a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs index 59a6f452b..5e621579b 100644 --- a/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs +++ b/backend/src/Taskdeck.Infrastructure/Persistence/Configurations/AutomationProposalConfiguration.cs @@ -66,7 +66,8 @@ public void Configure(EntityTypeBuilder builder) .IsRequired(); builder.Property(ap => ap.UpdatedAt) - .IsRequired(); + .IsRequired() + .IsConcurrencyToken(); builder.HasMany(ap => ap.Operations) .WithOne(o => o.Proposal) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index 9cd2cf84a..4ad27f4a4 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -1,7 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; using Taskdeck.Infrastructure.Persistence; namespace Taskdeck.Infrastructure.Repositories; @@ -102,6 +104,13 @@ public async Task SaveChangesAsync(CancellationToken cancellationToken = de { return await _context.SaveChangesAsync(cancellationToken); } + catch (DbUpdateConcurrencyException ex) + { + throw new DomainException( + ErrorCodes.Conflict, + "The requested change conflicted with a concurrent update.", + ex); + } catch (DbUpdateException ex) when (TryResolveRecoverableUniqueConflicts(ex)) { return await _context.SaveChangesAsync(cancellationToken); diff --git a/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs b/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs index a19b80681..01edc17dd 100644 --- a/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/ConcurrencyRaceConditionStressTests.cs @@ -497,15 +497,15 @@ public async Task ProposalApprove_ConcurrentDoubleApprove_ExactlyOneSucceeds() barrier.Release(2); await Task.WhenAll(approveTasks); - var codes = statusCodes.ToList(); - var successCount = codes.Count(s => s == HttpStatusCode.OK); - var failCount = codes.Count(s => s != HttpStatusCode.OK); - - // Exactly one should succeed, one should fail - successCount.Should().Be(1, - "exactly one concurrent approve should succeed"); - failCount.Should().Be(1, - "the second concurrent approve should fail"); + var codes = statusCodes.ToList(); + var successCount = codes.Count(s => s == HttpStatusCode.OK); + var conflictCount = codes.Count(s => s == HttpStatusCode.Conflict); + + // Exactly one should succeed, one should fail + successCount.Should().Be(1, + "exactly one concurrent approve should succeed"); + conflictCount.Should().Be(1, + "the losing concurrent approve should return 409 conflict"); } /// @@ -573,11 +573,15 @@ public async Task ProposalDecision_ConcurrentApproveAndReject_ExactlyOneWins() barrier.Release(2); await Task.WhenAll(approveTask, rejectTask); - // Exactly one should succeed - var successCount = (results["approve"] == HttpStatusCode.OK ? 1 : 0) - + (results["reject"] == HttpStatusCode.OK ? 1 : 0); - successCount.Should().Be(1, - "exactly one of approve/reject should succeed in a race"); + // Exactly one should succeed + var successCount = (results["approve"] == HttpStatusCode.OK ? 1 : 0) + + (results["reject"] == HttpStatusCode.OK ? 1 : 0); + var conflictCount = (results["approve"] == HttpStatusCode.Conflict ? 1 : 0) + + (results["reject"] == HttpStatusCode.Conflict ? 1 : 0); + successCount.Should().Be(1, + "exactly one of approve/reject should succeed in a race"); + conflictCount.Should().Be(1, + "the losing proposal decision should return 409 conflict"); // Verify final state is consistent var proposalResp = await client.GetAsync($"/api/automation/proposals/{proposalId}"); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs index 08d64b4ac..66933b6b1 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/AutomationProposalServiceEdgeCaseTests.cs @@ -74,6 +74,27 @@ public async Task ApproveProposalAsync_ShouldReturnFailure_WhenProposalAlreadyAp result.ErrorMessage.Should().Contain("Approved"); } + [Fact] + public async Task ApproveProposalAsync_ShouldReturnConflict_WhenConcurrentDecisionWins() + { + var proposal = CreatePendingProposal(); + + _proposalRepoMock + .Setup(r => r.GetByIdAsync(proposal.Id, It.IsAny())) + .ReturnsAsync(proposal); + _unitOfWorkMock + .Setup(u => u.SaveChangesAsync(It.IsAny())) + .ThrowsAsync(new DomainException(ErrorCodes.Conflict, "The requested change conflicted with a concurrent update.")); + + var result = await _service.ApproveProposalAsync(proposal.Id, Guid.NewGuid()); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + _notificationServiceMock.Verify( + s => s.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task ApproveProposalAsync_ShouldReturnNotFound_WhenProposalDoesNotExist() { From a1f7c3d679c288c2a084da8ae9c13d9a628d938a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 9 Apr 2026 23:22:13 +0100 Subject: [PATCH 17/20] Remove dead mobile e2e helper import --- frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts index 3d9851790..b74099e8f 100644 --- a/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { registerAndAttachSession } from './support/authSession' -import { addCard, addColumn, columnByName, createBoard } from './support/boardUiHelpers' +import { addCard, addColumn, createBoard } from './support/boardUiHelpers' /** * Mobile-responsive E2E tests. From 5369c6aa46c748993531973309e61e6321191199 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 12 Apr 2026 01:20:42 +0100 Subject: [PATCH 18/20] Fix cross-browser test click target and preserve quarantine exclusion - Click card title instead of card body to avoid drag-handle intercept - Combine @mobile|@quarantine in chromium project grepInvert to prevent project-level override from losing global quarantine exclusion --- frontend/taskdeck-web/playwright.config.ts | 7 +++++-- frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/taskdeck-web/playwright.config.ts b/frontend/taskdeck-web/playwright.config.ts index 9829099b8..7ebbefc9d 100644 --- a/frontend/taskdeck-web/playwright.config.ts +++ b/frontend/taskdeck-web/playwright.config.ts @@ -81,8 +81,11 @@ export default defineConfig({ * * NOTE: @cross-browser tests also run here (in PR gate via ci-required). * Adding more @cross-browser tests will increase PR gate time. - * Keep @cross-browser count lean to preserve fast PR feedback. */ - grepInvert: /@mobile/, + * Keep @cross-browser count lean to preserve fast PR feedback. + * + * Combined pattern ensures quarantine exclusion is preserved + * (project-level grepInvert overrides the global one). */ + grepInvert: /@mobile|@quarantine/, }, { name: 'firefox', diff --git a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts index 4d7eb3b27..ab49b3a1b 100644 --- a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts @@ -70,9 +70,9 @@ test('@cross-browser card edit modal open and close', async ({ page }) => { await addColumn(page, columnName) await addCard(page, columnName, cardTitle) - // Click card to open edit modal + // Click the card title to open edit modal (avoid drag-handle intercepting click) const card = page.locator('[data-card-id]').filter({ hasText: cardTitle }).first() - await card.click() + await card.getByRole('heading', { name: cardTitle, exact: true }).click() await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() // Close with Escape From 9b1555da54d56dccb41cd06313b2c65ef279b357 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 12 Apr 2026 01:25:49 +0100 Subject: [PATCH 19/20] Trigger CI From c1af1723a65275a401a907964c803a90481d2d12 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 12 Apr 2026 01:57:14 +0100 Subject: [PATCH 20/20] Fix cross-browser E2E test strict mode violation Add exact: true to Edit Card heading selector to avoid matching both the modal heading and cards containing "Edit Card" in their title. --- frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts index ab49b3a1b..17b8a2219 100644 --- a/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts +++ b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts @@ -73,11 +73,11 @@ test('@cross-browser card edit modal open and close', async ({ page }) => { // Click the card title to open edit modal (avoid drag-handle intercepting click) const card = page.locator('[data-card-id]').filter({ hasText: cardTitle }).first() await card.getByRole('heading', { name: cardTitle, exact: true }).click() - await expect(page.getByRole('heading', { name: 'Edit Card' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Edit Card', exact: true })).toBeVisible() // Close with Escape await page.keyboard.press('Escape') - await expect(page.getByRole('heading', { name: 'Edit Card' })).not.toBeVisible() + await expect(page.getByRole('heading', { name: 'Edit Card', exact: true })).not.toBeVisible() // Card should still be visible after closing modal await expect(