#!/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); }