Second half of #29. The index was hand-maintained and had already drifted once: dream and experiment-spec shipped with no row, their rows lost to a squash-merge after a directory reorg. bin/check-index caught that after the fact; generating removes the class. The curated summary and trigger move into each SKILL.md as `summary:` and `use_when:`, carried over verbatim from the existing table rather than derived from `description:` — description is a paragraph written for model selection, and truncating it would have produced a worse table than the one it replaced. Knowledge now lives once, next to the skill it describes, which is the argument that made a hand-maintained index the wrong shape to begin with. The table is written between markers, so the wiring docs, the relationships tree and the trigger table below it are untouched — those are prose, not data. Rows are alphabetical now; the conceptual grouping the old order carried lives in the relationships tree, which is the better place for it. A fourth column shows each skill's surfaces, which is what makes #29's field visible rather than merely enforced. bin/check-index and its test are deleted: a generated table cannot drift in either direction, so `generate-index --check` subsumes both of its assertions. Verified by mutation — deleting the tdd row turns `task check` red with "SKILLS_INDEX.md is out of date". Closes #29 Bump-Type: minor
277 lines
7.1 KiB
Markdown
277 lines
7.1 KiB
Markdown
---
|
|
name: playwright
|
|
surfaces: [claude-code, library]
|
|
summary: "Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring"
|
|
use_when: "Adding browser tests to any project with a web UI"
|
|
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
|
|
|
|
```
|
|
<repo>/
|
|
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<void>(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')` |
|