Add /run-teamforge skill with Playwright driver

Adds a run skill that starts the Django dev server, creates a test
superuser, logs in via /admin/login/, and screenshots any page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 17:01:53 +02:00
parent 198d68e6ff
commit e169d83311
4 changed files with 332 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
---
name: run-teamforge
description: Build, run, and drive TeamForge. Use when asked to start TeamForge, run its tests, take a screenshot of its UI, verify a feature works, or interact with the running app.
---
TeamForge is a Django 6 club-management web app (members + teams). Drive it via `.claude/skills/run-teamforge/driver.mjs` under Playwright. The driver starts the dev server, creates a test superuser, logs in via `/admin/login/`, and screenshots any page.
All paths below are relative to the repo root (`/Users/bernard/Code/PycharmProjects/TeamForge/`).
## Prerequisites
Python (≥3.12) and `uv` must be present. Node.js (≥18) is required for the Playwright driver.
```bash
uv sync # install Python deps
npm install playwright # install Playwright (one-time, at repo root)
npx playwright install chromium # download Chromium browser (one-time, ~90 MB)
```
The app connects to a PostgreSQL database. Connection is in `.env`:
```
DJANGO_DATABASE_URL=postgres://...
DJANGO_DEBUG=True
```
If the database isn't reachable, the server still boots but all page requests fail with 500. The `.env` file must exist; there is no fallback to SQLite in production mode.
## Setup
```bash
uv sync
uv run python manage.py migrate # apply migrations (idempotent)
```
## Run (agent path)
The driver handles everything — server startup, test-user creation, login, and screenshots.
```bash
# Smoke test: starts server, screenshots members + configuration pages
node .claude/skills/run-teamforge/driver.mjs smoke
# Screenshot any page (path or full URL)
node .claude/skills/run-teamforge/driver.mjs screenshot /backend/members/
node .claude/skills/run-teamforge/driver.mjs screenshot /backend/configuration
# Stop the background server
node .claude/skills/run-teamforge/driver.mjs stop
```
Screenshots land in `/tmp/teamforge-shots/<name>.png`. Server log → `/tmp/teamforge-server.log`.
Auth is via a local `skilltest` superuser (password: `skilltest123!`) which the driver creates automatically on first run. The driver logs in through `/admin/login/` — the app does not register `django.contrib.auth` URLs, so `/accounts/login/` returns 404.
## Run (human path)
```bash
uv run python manage.py runserver # → http://localhost:8000
python manage.py tailwind start # separate terminal — required for CSS
```
Log in at `/admin/login/` or create a superuser with `uv run python manage.py createsuperuser`.
## Test
```bash
uv run python manage.py test # → 43 tests, all pass
```
---
## Gotchas
- **`/accounts/login/` returns 404** — the URL conf (`TeamForge/urls.py`) only has `backend/` and `admin/`. The `@login_required` decorator redirects there by default, but `django.contrib.auth.urls` is not included. Always log in via `/admin/login/`. The session cookie then works for all `/backend/` views.
- **`avatar` templatetag crashes on empty names** — `theme/templatetags/avatar.py:41` does `first_name[0]` which raises `IndexError: string index out of range` when either name field is blank. Every Django User used as a logged-in session must have `first_name` and `last_name` set. The driver sets them on the test user automatically.
- **`/backend/` dashboard is a placeholder** — `templates/backend/index.html` contains only "TEST FILE". This is expected during development; the real UI lives at `/backend/members/` and `/backend/configuration`.
- **Tailwind CSS not served in dev** — running `manage.py runserver` without `manage.py tailwind start` in a second terminal means pages render with no styles. The Playwright driver screenshots may look unstyled when Tailwind isn't running. For visual verification, start `tailwind start` first (or accept the unstyled capture).
- **PostgreSQL required** — `DJANGO_DATABASE_URL` in `.env` points to a remote PostgreSQL instance at `192.168.1.100`. If that host isn't reachable, every request returns 500. There is no in-repo SQLite fallback for normal runs; the test suite creates a separate test DB automatically.
- **`npm install playwright` must run at repo root** — the driver imports `playwright` via Node's resolution from `node_modules/` at the repo root. Running it from another directory will cause `ERR_MODULE_NOT_FOUND`.
## Troubleshooting
- **`Cannot find package 'playwright'`**: run `npm install playwright` at the repo root, then retry.
- **`Server did not start within 20s`**: check `/tmp/teamforge-server.log`. Most common cause: PostgreSQL unreachable (connection refused or timeout).
- **500 on every page after login**: the logged-in user has no `first_name`/`last_name` — the avatar templatetag crashes on the base template. Fix: set names on the user via `uv run python manage.py shell -c "from django.contrib.auth.models import User; u=User.objects.get(username='skilltest'); u.first_name='Skill'; u.last_name='Test'; u.save()"`.
- **`page.waitForURL` timeout on login**: admin login redirect failed — wrong password or user doesn't exist. The driver's `ensureTestUser()` recreates the user each run; if it fails, check that `manage.py shell` works at all.

View File

@@ -0,0 +1,179 @@
#!/usr/bin/env node
/**
* TeamForge driver — launch server, login, drive pages, screenshot.
*
* Usage (run from repo root):
* node .claude/skills/run-teamforge/driver.mjs smoke
* node .claude/skills/run-teamforge/driver.mjs screenshot /backend/members/
* node .claude/skills/run-teamforge/driver.mjs stop
*
* Prerequisites: `npm install playwright` at repo root (one-time).
* Screenshots land in /tmp/teamforge-shots/
* Server log → /tmp/teamforge-server.log
*
* Auth note: logs in via /admin/login/ with a local "skilltest" superuser
* (created automatically on first smoke run). The skilltest user must have
* first_name and last_name set — the avatar templatetag crashes without them.
*/
import { chromium } from 'playwright';
import { execSync, spawn } from 'child_process';
import { writeFileSync, readFileSync, existsSync, openSync, mkdirSync, writeSync, closeSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { fileURLToPath } from 'url';
const PID_FILE = '/tmp/teamforge.pid';
const LOG_FILE = '/tmp/teamforge-server.log';
const SHOTS_DIR = '/tmp/teamforge-shots';
const PORT = 8765;
const BASE_URL = `http://localhost:${PORT}`;
const SKILL_DIR = fileURLToPath(new URL('.', import.meta.url));
const REPO_ROOT = join(SKILL_DIR, '..', '..', '..');
function log(msg) {
process.stderr.write(`[driver] ${msg}\n`);
}
function ensureTestUser() {
const script = [
'from django.contrib.auth.models import User',
"u, _ = User.objects.get_or_create(username='skilltest', defaults={'email': 'test@test.com', 'is_staff': True, 'is_superuser': True, 'first_name': 'Skill', 'last_name': 'Test'})",
"u.first_name = u.first_name or 'Skill'",
"u.last_name = u.last_name or 'Test'",
"u.set_password('skilltest123!')",
'u.save()',
].join('; ');
execSync(`uv run python manage.py shell -c "${script}"`, { cwd: REPO_ROOT, stdio: 'pipe' });
log('skilltest user ready');
}
function pollReady(url, timeoutMs = 20000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
execSync(`curl -sf ${url} -o /dev/null`, { stdio: 'ignore' });
return true;
} catch {}
const end = Date.now() + 400;
while (Date.now() < end) {} // busy-wait
}
return false;
}
function startServer() {
if (existsSync(PID_FILE)) {
try {
const pid = readFileSync(PID_FILE, 'utf8').trim();
execSync(`kill ${pid} 2>/dev/null || true`, { stdio: 'ignore' });
} catch {}
}
log(`Starting dev server on port ${PORT}...`);
const out = openSync(LOG_FILE, 'w');
const proc = spawn('uv', ['run', 'python', 'manage.py', 'runserver', String(PORT)], {
cwd: REPO_ROOT,
detached: true,
stdio: ['ignore', out, out],
});
proc.unref();
writeFileSync(PID_FILE, String(proc.pid));
// Wait for /backend/ (redirects to login when not authed = server is up)
if (!pollReady(`${BASE_URL}/backend/`)) {
throw new Error(`Server did not start within 20s — check ${LOG_FILE}`);
}
log('Server ready.');
}
function stopServer() {
if (!existsSync(PID_FILE)) { log('No PID file — nothing to stop.'); return; }
const pid = readFileSync(PID_FILE, 'utf8').trim();
try {
execSync(`kill ${pid}`, { stdio: 'ignore' });
log(`Killed PID ${pid}`);
} catch {
log(`PID ${pid} already gone`);
}
execSync(`rm -f ${PID_FILE}`, { stdio: 'ignore' });
}
async function getLoggedInPage(browser) {
const context = await browser.newContext();
const page = await context.newPage();
// /accounts/login/ isn't in urlpatterns; use /admin/login/ which is always present
await page.goto(`${BASE_URL}/admin/login/`);
await page.fill('input[name="username"]', 'skilltest');
await page.fill('input[name="password"]', 'skilltest123!');
await page.click('input[type="submit"]');
await page.waitForURL(`${BASE_URL}/admin/`);
log('Logged in.');
return page;
}
async function shot(page, name) {
mkdirSync(SHOTS_DIR, { recursive: true });
const path = join(SHOTS_DIR, `${name}.png`);
await page.screenshot({ path, fullPage: false });
log(`Screenshot → ${path}`);
return path;
}
async function smoke() {
ensureTestUser();
startServer();
const browser = await chromium.launch({ headless: true });
try {
const page = await getLoggedInPage(browser);
// Members list — the main functional page
await page.goto(`${BASE_URL}/backend/members/`);
await page.waitForLoadState('networkidle');
const s1 = await shot(page, 'members');
// Configuration page (superuser only)
await page.goto(`${BASE_URL}/backend/configuration`);
await page.waitForLoadState('networkidle');
const s2 = await shot(page, 'configuration');
log('Smoke test passed.');
console.log(JSON.stringify({ ok: true, screenshots: [s1, s2] }));
} finally {
await browser.close();
}
}
async function screenshotUrl(url) {
ensureTestUser();
startServer();
const browser = await chromium.launch({ headless: true });
try {
const page = await getLoggedInPage(browser);
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
await page.goto(fullUrl);
await page.waitForLoadState('networkidle');
const name = url.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_+|_+$/g, '') || 'screenshot';
const path = await shot(page, name);
console.log(JSON.stringify({ ok: true, screenshot: path }));
} finally {
await browser.close();
}
}
const [,, cmd, ...rest] = process.argv;
switch (cmd) {
case 'smoke':
smoke().catch(e => { console.error(e.message); process.exit(1); });
break;
case 'screenshot':
if (!rest[0]) { console.error('Usage: driver.mjs screenshot <path-or-url>'); process.exit(1); }
screenshotUrl(rest[0]).catch(e => { console.error(e.message); process.exit(1); });
break;
case 'stop':
stopServer();
break;
default:
console.error('Usage: driver.mjs [smoke|screenshot <url>|stop]');
process.exit(1);
}