--- name: playwright description: Add and run Playwright E2E tests for any project with a web UI — covers setup, HTMX-specific patterns, SSE/streaming, Taskfile integration, and CI wiring. --- # Playwright E2E Testing ## When to use - Project has a web UI (HTMX, React, plain HTML — any stack) - You want to verify the golden path in a real browser, not just unit-test handlers - Chat/SSE streams, HTMX swaps, or dynamic DOM need testing that Go tests cannot cover ## Setup (first time in a project) ### 1. Install ```bash npm init -y npm install --save-dev @playwright/test npx playwright install chromium # chromium only — enough for CI ``` ### 2. Directory layout ``` / e2e/ playwright.config.ts tests/ smoke.spec.ts chat.spec.ts package.json Taskfile.yml ← add e2e task here ``` Keep `e2e/` at repo root. Add to `.gitignore`: ``` node_modules/ e2e/test-results/ e2e/playwright-report/ ``` ### 3. Config (`e2e/playwright.config.ts`) ```typescript import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30_000, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? 'github' : 'list', use: { baseURL: process.env.BASE_URL ?? 'http://localhost:8080', screenshot: 'only-on-failure', trace: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, ], }); ``` ### 4. Taskfile task ```yaml tasks: e2e: desc: "Run Playwright E2E tests against running dev server" dir: e2e cmds: - npx playwright test {{.CLI_ARGS}} env: BASE_URL: "http://localhost:8080" e2e:ui: desc: "Open Playwright UI mode" dir: e2e cmds: - npx playwright test --ui ``` Run with: `task e2e` or `task e2e -- --headed` or `task e2e -- tests/chat.spec.ts` --- ## HTMX-specific patterns HTMX updates the DOM asynchronously. Never use `waitForSelector` alone — wait for HTMX to finish the swap. ### Wait for HTMX swap to complete ```typescript // After triggering an action that causes an HTMX request: await page.locator('[hx-target]').waitFor({ state: 'visible' }); // Better: wait for htmx:afterSwap event on the target element await page.evaluate(() => new Promise(resolve => document.body.addEventListener('htmx:afterSwap', () => resolve(), { once: true }) ) ); ``` ### Wait for specific content to appear after swap ```typescript await page.locator('#result').waitFor({ state: 'visible' }); await expect(page.locator('#result')).toContainText('expected text'); ``` ### Avoid timing races ```typescript // BAD — may grab stale content before swap completes await page.click('button'); const text = await page.locator('#output').textContent(); // GOOD — assert after waiting await page.click('button'); await expect(page.locator('#output')).toContainText('expected text', { timeout: 5000 }); ``` ### Forms with HTMX submit ```typescript await page.fill('input[name="query"]', 'hello'); await page.keyboard.press('Enter'); // or click submit button await expect(page.locator('#response')).not.toBeEmpty({ timeout: 10_000 }); ``` --- ## SSE / streaming patterns For chat UIs or any endpoint using `text/event-stream`: ```typescript test('chat stream delivers response', async ({ page }) => { await page.goto('/chat'); // Type and submit await page.fill('#message-input', 'what is 2+2?'); await page.click('#send-btn'); // SSE response appears incrementally — wait for streaming to stop // Strategy: wait until the response element stops growing const responseEl = page.locator('#chat-response'); await responseEl.waitFor({ state: 'visible', timeout: 15_000 }); // Poll until content stabilises (streaming done) let prev = ''; let stable = 0; while (stable < 3) { const cur = await responseEl.textContent() ?? ''; if (cur === prev && cur.length > 0) stable++; else { stable = 0; prev = cur; } await page.waitForTimeout(300); } expect(prev.length).toBeGreaterThan(0); }); ``` For a done-signal in the DOM (e.g. a `[data-streaming="false"]` attribute set when stream ends): ```typescript await page.locator('[data-streaming="false"]').waitFor({ timeout: 20_000 }); const text = await page.locator('#chat-response').textContent(); expect(text).toBeTruthy(); ``` --- ## Golden path smoke test template ```typescript // e2e/tests/smoke.spec.ts import { test, expect } from '@playwright/test'; test.describe('smoke', () => { test('home page loads', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/.+/); await expect(page.locator('body')).not.toBeEmpty(); }); test('main nav is present', async ({ page }) => { await page.goto('/'); await expect(page.locator('nav')).toBeVisible(); }); }); ``` --- ## Screenshot on failure Already configured via `screenshot: 'only-on-failure'` in the config. Artifacts land in `e2e/test-results/`. View them with: ```bash npx playwright show-report e2e/playwright-report ``` --- ## CI wiring (Gitea workflow) ```yaml e2e: runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@v4 - name: Install Node deps working-directory: e2e run: npm ci - name: Install Playwright browsers working-directory: e2e run: npx playwright install --with-deps chromium - name: Start server run: ./bin/server & env: PORT: 8080 - name: Wait for server run: | for i in $(seq 1 20); do curl -sf http://localhost:8080/ && break sleep 1 done - name: Run E2E tests working-directory: e2e run: npx playwright test env: BASE_URL: http://localhost:8080 - name: Upload test artifacts if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report path: e2e/playwright-report/ ``` --- ## Checklist When adding Playwright to a project: - [ ] `npm init -y && npm install --save-dev @playwright/test` in `e2e/` - [ ] `npx playwright install chromium` - [ ] `e2e/playwright.config.ts` with baseURL and screenshot on failure - [ ] At least one smoke test covering the golden path - [ ] `.gitignore` entries: `node_modules/`, `test-results/`, `playwright-report/` - [ ] `task e2e` wired in Taskfile.yml - [ ] CI step added to Gitea workflow ## Failure triage | Symptom | Likely cause | Fix | |---------|-------------|-----| | `TimeoutError` on HTMX element | Swap not finished | Use `htmx:afterSwap` event or increase timeout | | Stale content asserted | Race between click and swap | Move assertion after explicit wait | | SSE test flaky | Stream not done yet | Poll for stable content or use done-signal attribute | | CI fails, local passes | Server not ready | Add wait-for-server loop in CI | | Screenshots empty | Screenshot taken before render | Add `await page.waitForLoadState('networkidle')` |