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>
180 lines
5.8 KiB
JavaScript
180 lines
5.8 KiB
JavaScript
#!/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);
|
|
}
|