diff --git a/.github/workflows/ci-extended.yml b/.github/workflows/ci-extended.yml index 10b56d33c..dcfcc4813 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 + 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'))) 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/ci-required.yml b/.github/workflows/ci-required.yml index a2766a42b..b27b87a57 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) # └── reusable-container-integration.yml (label: testing) — Testcontainers PostgreSQL @@ -28,6 +29,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 # diff --git a/.github/workflows/reusable-e2e-cross-browser.yml b/.github/workflows/reusable-e2e-cross-browser.yml new file mode 100644 index 000000000..4f17239b3 --- /dev/null +++ b/.github/workflows/reusable-e2e-cross-browser.yml @@ -0,0 +1,106 @@ +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: + include: + - project: chromium + browser: chromium + - project: firefox + browser: firefox + - project: webkit + browser: webkit + - project: mobile-chrome + browser: chromium + - project: mobile-safari + browser: webkit + 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 browsers + working-directory: frontend/taskdeck-web + run: npx playwright install --with-deps ${{ matrix.browser }} + + - 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 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/docs/STATUS.md b/docs/STATUS.md index 4e35c5c09..be0976b48 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -882,6 +882,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 @@ -916,6 +917,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` - label/manual-triggered cross-browser E2E matrix lane via `.github/workflows/reusable-e2e-cross-browser.yml` (5-project parallel matrix: Chromium, Firefox, WebKit, mobile-chrome, mobile-safari) - label/manual-triggered visual regression lane via `.github/workflows/reusable-visual-regression.yml` (Playwright `toHaveScreenshot()` with diff artifact upload; `testing`/`visual` label) @@ -945,6 +947,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 diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index e9a1042d0..73f7d75e6 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -596,6 +596,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. + ## Visual Regression Tests Visual regression tests capture baseline screenshots of key UI surfaces and compare them against future renders to catch unintended layout changes. diff --git a/docs/testing/FLAKY_TEST_POLICY.md b/docs/testing/FLAKY_TEST_POLICY.md new file mode 100644 index 000000000..0e3427ac5 --- /dev/null +++ b/docs/testing/FLAKY_TEST_POLICY.md @@ -0,0 +1,125 @@ +# 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 a top-level `grepInvert` in `playwright.config.ts`. The test still runs locally for debugging (pass `--grep="@quarantine"` explicitly to override). + +The top-level exclusion is already configured: + +```typescript +// playwright.config.ts (top level) +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 diff --git a/frontend/taskdeck-web/playwright.config.ts b/frontend/taskdeck-web/playwright.config.ts index ca554ced9..7ebbefc9d 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, @@ -52,10 +52,67 @@ 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', }, + + /* --------------------------------------------------------------------------- + * 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. + * + * 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. + * + * Combined pattern ensures quarantine exclusion is preserved + * (project-level grepInvert overrides the global one). */ + grepInvert: /@mobile|@quarantine/, + }, + { + 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', 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..17b8a2219 --- /dev/null +++ b/frontend/taskdeck-web/tests/e2e/cross-browser.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test' +import { registerAndAttachSession } from './support/authSession' +import { addCard, addColumn, columnByName, createBoard } from './support/boardUiHelpers' + +/** + * 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 (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') +}) + +// --------------------------------------------------------------------------- +// 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 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', exact: true })).toBeVisible() + + // Close with Escape + await page.keyboard.press('Escape') + await expect(page.getByRole('heading', { name: 'Edit Card', exact: true })).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() +}) 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..d0f3bf644 --- /dev/null +++ b/frontend/taskdeck-web/tests/e2e/mobile-responsive.spec.ts @@ -0,0 +1,167 @@ +import { expect, test, type Page } from '@playwright/test' +import { registerAndAttachSession } from './support/authSession' +import { addCard, addColumn, createBoard } from './support/boardUiHelpers' + +/** + * 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') +}) + +async function openMobileNavigation(page: Page) { + const menuButton = page.getByRole('button', { name: 'Open navigation menu' }) + await expect(menuButton).toBeVisible() + await menuButton.click() + + const navigation = page.getByRole('navigation', { name: 'Main navigation' }) + await expect(navigation).toBeVisible() + return navigation +} + +async function navigateWithMobileMenu( + page: Page, + destination: 'Boards' | 'Inbox', + urlPattern: RegExp, +) { + const navigation = await openMobileNavigation(page) + const href = + destination === 'Boards' + ? '/workspace/boards' + : '/workspace/inbox' + await navigation.locator(`a[href="${href}"]`).click() + await expect(page).toHaveURL(urlPattern) +} + +function captureLauncher(page: Page) { + return page + .getByRole('button', { name: 'Open capture modal to add a new inbox item' }) + .first() +} + +// --------------------------------------------------------------------------- +// 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() + + // 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 title area to avoid the drag-handle intercepting the tap. + const card = page.locator('[data-card-id]').filter({ hasText: cardTitle }).first() + await card.getByRole('heading', { name: cardTitle, exact: true }).click() + + const editHeading = page.getByRole('heading', { name: 'Edit Card', exact: true }) + await expect(editHeading).toBeVisible() + + // The edit modal should be within the viewport bounds + const viewportSize = page.viewportSize() + expect(viewportSize).not.toBeNull() + + const modal = page.getByRole('dialog', { name: 'Edit Card' }) + 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 workspace views should render correctly 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 using the mobile hamburger menu rather than bypassing the UI. + await navigateWithMobileMenu(page, 'Boards', /\/workspace\/boards$/) + await expect(page.getByRole('button', { name: '+ New Board' })).toBeVisible() + + await navigateWithMobileMenu(page, 'Inbox', /\/workspace\/inbox$/) + await expect(captureLauncher(page)).toBeVisible() + + // 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/home') + await expect(page.getByRole('heading', { name: 'Home', exact: true })).toBeVisible() + await navigateWithMobileMenu(page, 'Inbox', /\/workspace\/inbox$/) + + const captureText = `Mobile capture ${Date.now()}` + + await captureLauncher(page).click() + 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 through the actual mobile-visible action button. + await captureInput.fill(captureText) + await captureModal.getByRole('button', { name: 'Save Capture' }).click() + + // Inbox should stay visible and show the newly created capture. + await expect(page).toHaveURL(/\/workspace\/inbox$/) + await expect(page.locator('.td-inbox-row__excerpt').first()).toContainText(captureText) +}) 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 }) +}