Files
skills/skills/playwright/SKILL.md
T
mathias c6787e04d7
CI / Check / Test (push) Failing after 1s
CI / Tag release (push) Skipped
feat(index): generate the skills table from frontmatter
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
2026-08-29 11:23:14 +02:00

7.1 KiB

name, surfaces, summary, use_when, description
name surfaces summary use_when description
playwright
claude-code
library
Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring Adding browser tests to any project with a web UI 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

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)

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

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

// 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

await page.locator('#result').waitFor({ state: 'visible' });
await expect(page.locator('#result')).toContainText('expected text');

Avoid timing races

// 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

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:

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):

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

// 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:

npx playwright show-report e2e/playwright-report

CI wiring (Gitea workflow)

  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')