Files
skills/playwright/SKILL.md
Mathias d6a71e370e
Some checks failed
release / tag (push) Has been cancelled
chore: bootstrap skills library — 19 skills + installer + CI auto-tag
Phase 1 of mathias/skills extraction (infra#62 Track D — homelab
next-step plan addendum). Imports ~/dev/.skills/ verbatim (19 skill
dirs + SKILLS_INDEX.md) and adds the installation surface:

- Taskfile.yml — install / update / list / release / check targets
- install.sh — bootstrap installer for hosts without Task. Idempotent
  symlink wirer; default checkout at ~/.local/share/skills/ on every
  host; SKILLS_REF env var pins a tag (default: main).
- .gitea/workflows/release.yml — auto-tag every push to main by
  Bump-Type footer (major/minor/patch, default patch). Skipped when
  commit contains [skip-release].
- README — usage, versioning, contribution flow, secret-hygiene rule.

Phase 1 wires Claude Code only (~/.claude/skills/<name> global +
<repo>/.claude/skills/<name> per-repo). Phase 2 adds Crush, opencode,
antigravity, and gitea-resident agents (cobalt-dingo, agentsquad)
once their skill conventions are researched.

Public repo, markdown-only — no secrets, no client names. Verified
via pre-push grep before initial push.

[skip-release]
2026-05-24 14:59:54 +02:00

6.9 KiB

name, description
name description
playwright 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')