Files
skills/skills/playwright/SKILL.md
T
mathiasandClaude Opus 5 56a947b2a0 refactor(plugin)!: ship as a Claude Code plugin, one dir link per harness
Skills move to skills/<name>/SKILL.md and the repo gains both manifests, so
it is now a marketplace hosting exactly one plugin whose root is the repo
root (source: "."). Claude Code installs it from git; no symlinks involved.

Why: install.sh wired the library into every consuming repo as an ABSOLUTE
symlink to $CHECKOUT_DIR. The checkout moved three times, most recently when
local-dev@1966d5a dropped the embedded .skills/ copy, and nothing migrated
the links — 243 dangling links across 12 repos, hidden from git by the
**/.claude/skills/ ignore rule. Worse, ~/dev spans two path roots
(/Users/mathias on the iCloud-shared Macs, /home/mathias on koala), so an
absolute link stored there is broken on one side by construction. Re-running
the installer cannot fix that; only removing the mechanism can.

install.sh keeps the harnesses with no plugin system and drops from 42 links
per harness to one directory link, via the convergent ~/.agents/skills
convention that Pi and OpenCode both read:

  ~/.agents/skills          -> $CHECKOUT_DIR/skills
  ~/.config/crush/skills    -> ~/.agents/skills
  ~/.config/opencode/skills -> ~/.agents/skills

wire_claude_repo is deleted outright rather than gated behind a flag — on a
mixed-root fleet it cannot be made correct. Antigravity and Vibe targets go
too; both unused. CHECKOUT_DIR keeps its host-local default deliberately:
pointing it into ~/dev would put a git working tree inside iCloud with two
concurrent writers.

Taskfile drops five near-identical wiring loops (they had already drifted)
and delegates to install.sh. `task check` gains frontmatter validation, a
stray-root-entry gate, and manifest/tag version parity — the loose
secret-hygiene.md sat unloadable for months because nothing checked layout.

Verified: claude plugin validate . passes clean; a probe skill loaded as
skills:zzprobe through --plugin-dir; task check green at 42 skills.

Refs: #23
Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:29:51 +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')