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:
92
.claude/skills/run-teamforge/SKILL.md
Normal file
92
.claude/skills/run-teamforge/SKILL.md
Normal 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.
|
||||
179
.claude/skills/run-teamforge/driver.mjs
Normal file
179
.claude/skills/run-teamforge/driver.mjs
Normal 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);
|
||||
}
|
||||
56
package-lock.json
generated
Normal file
56
package-lock.json
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "TeamForge",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user