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
This commit is contained in:
2026-08-23 14:29:51 +02:00
co-authored by Claude Opus 5
parent 21be3ea245
commit 56a947b2a0
88 changed files with 182 additions and 253 deletions
+273
View File
@@ -0,0 +1,273 @@
---
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
```
<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')` |