diff --git a/.claude/skills/run-teamforge/SKILL.md b/.claude/skills/run-teamforge/SKILL.md new file mode 100644 index 0000000..f3655f7 --- /dev/null +++ b/.claude/skills/run-teamforge/SKILL.md @@ -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/.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. diff --git a/.claude/skills/run-teamforge/driver.mjs b/.claude/skills/run-teamforge/driver.mjs new file mode 100644 index 0000000..4a5a1cb --- /dev/null +++ b/.claude/skills/run-teamforge/driver.mjs @@ -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 '); 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 |stop]'); + process.exit(1); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..df16fdf --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f2eab14 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "playwright": "^1.60.0" + } +}