/** * dev-browser-driver — Wrapper around the upstream `dev-browser --headless` CLI. * * Responsibilities: * - Spawn `dev-browser --headless` (or `npx dev-browser --headless` if not on PATH) * - Pipe a rendered JS script via stdin (the upstream CLI has no --script flag) * - Capture stdout (single-line JSON result) + stderr (trace) + exit code * - Enforce a wall-clock timeout via AbortController (upstream has no --timeout flag) * - Parse the final JSON object from stdout * * The script contract: the rendered template MUST emit exactly one * `console.log(JSON.stringify({ status, reason, capture }))` on stdout, with * any trace output going to console.warn/error. Driver ignores everything but * the LAST valid JSON object on stdout (so partial logs don't confuse parsing). * * Upstream: https://github.com/SawyerHood/dev-browser (MIT, v0.2.7). * Pinned in the consumer project's package.json devDependencies. */ import { spawn, type ChildProcess } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadTemplate } from '../../../../../../lib/template-loader.js'; export interface DriverTestSpec { id: string; scenario: 'list' | 'detail' | 'form-submit' | 'edit' | 'delete' | 'permission-negative'; page: string; role: string; fixture?: Record; expectations: { httpStatus?: number[]; noConsoleError?: boolean; rendersTestId?: string; redirectsTo?: string; }; } export interface DriverTestUser { role: string; email: string; password: string; } export interface DriverContext { /** Absolute URL of the running app's frontend (e.g. http://localhost:5173). */ frontendUrl: string; /** Absolute URL of the running app's API (e.g. http://localhost:5142). */ apiUrl: string; /** Filesystem dir (relative to project) where screenshots are written. */ captureDir: string; /** Wall-clock cap per test (default 60_000 ms). */ timeoutMs?: number; /** Override CWD for the dev-browser spawn (defaults to process.cwd()). */ cwd?: string; } export interface DriverHttpRequest { url: string; method: string; status: number; } export interface DriverFailureCapture { httpRequests?: DriverHttpRequest[]; consoleErrors?: string[]; screenshotRelPath?: string; /** Driver-level diagnostic when the script itself crashed. */ stderrTail?: string; exitCode?: number | null; } export interface DriverResult { status: 'pass' | 'fail'; reason: string; capture: DriverFailureCapture; /** Wall-clock duration for the dev-browser invocation (ms). */ durationMs: number; } const DEFAULT_TIMEOUT_MS = 60_000; /** * Run a single UI test via the dev-browser CLI. Returns a typed result. * * Templates live in ../templates/{scenario}.js.hbs and receive the test spec + * user credentials + driver context as Handlebars data. */ export async function runDevBrowserTest( test: DriverTestSpec, user: DriverTestUser, ctx: DriverContext, ): Promise { const startedAt = Date.now(); const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS; let script: string; try { // loadTemplate appends `templates/` to the dir hint. The driver lives at // run-ui-test/lib/dev-browser-driver.ts, so we walk up one dir to point // at run-ui-test/, where templates/ sits. const driverDir = path.dirname(fileURLToPath(import.meta.url)); const cliRoot = path.resolve(driverDir, '..'); const render = await loadTemplate(`${test.scenario}.js.hbs`, cliRoot); script = render({ test, user, frontendUrl: ctx.frontendUrl, apiUrl: ctx.apiUrl, captureDir: ctx.captureDir, // Convenience aliases — Handlebars template can reference {{page}} directly. testId: test.id, page: test.page, expectations: test.expectations, // Pre-stringified so templates can write `const fixture = {{{fixtureJson}}};` // without needing a `json` Handlebars helper. fixture: test.fixture ?? {}, fixtureJson: JSON.stringify(test.fixture ?? {}), }); } catch (err) { return { status: 'fail', reason: `template-error: ${err instanceof Error ? err.message : String(err)}`, capture: { exitCode: null }, durationMs: Date.now() - startedAt, }; } return await spawnAndWait(script, ctx.cwd ?? process.cwd(), timeoutMs, startedAt); } function spawnAndWait( script: string, cwd: string, timeoutMs: number, startedAt: number, ): Promise { return new Promise((resolve) => { // Prefer `npx dev-browser` so the consumer project's devDependencies pin // wins over any global install (which may diverge from v0.2.7). const proc: ChildProcess = spawn('npx', ['--yes', 'dev-browser', '--headless'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: process.platform === 'win32', }); let stdout = ''; let stderr = ''; let killed = false; const watchdog = setTimeout(() => { killed = true; try { if (!proc.killed) proc.kill('SIGTERM'); } catch { /* ignore */ } }, timeoutMs); proc.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); proc.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); proc.on('error', (err) => { clearTimeout(watchdog); resolve({ status: 'fail', reason: `driver-error: spawn failed (${err.message})`, capture: { stderrTail: tail(stderr, 2000), exitCode: null }, durationMs: Date.now() - startedAt, }); }); proc.on('close', (code) => { clearTimeout(watchdog); if (killed) { resolve({ status: 'fail', reason: `driver-error: timeout after ${timeoutMs}ms`, capture: { stderrTail: tail(stderr, 2000), exitCode: code }, durationMs: Date.now() - startedAt, }); return; } const parsed = extractLastJsonObject(stdout); if (!parsed) { resolve({ status: 'fail', reason: `driver-error: no JSON result on stdout (exit ${code ?? '?'})`, capture: { stderrTail: tail(stderr, 2000), exitCode: code }, durationMs: Date.now() - startedAt, }); return; } const status = parsed.status === 'pass' ? 'pass' : 'fail'; resolve({ status, reason: typeof parsed.reason === 'string' ? parsed.reason : (status === 'pass' ? 'ok' : 'unknown'), capture: (parsed.capture as DriverFailureCapture) ?? { exitCode: code }, durationMs: Date.now() - startedAt, }); }); // Pipe the script via stdin. dev-browser reads to EOF before executing. proc.stdin?.write(script); proc.stdin?.end(); }); } function tail(text: string, maxBytes: number): string { if (text.length <= maxBytes) return text; return `…${text.slice(-maxBytes)}`; } /** * Extract the LAST balanced top-level JSON object from a string. Tolerates * preceding logs / ANSI codes / partial fragments. Returns null if no * parseable JSON object is found at any depth. */ function extractLastJsonObject(text: string): Record | null { // Walk from the end, find the last '}' then walk backwards counting braces // (with naive string-skipping) to find the matching '{'. Then JSON.parse. // Tries multiple candidates: each '}' from end is a potential close. for (let end = text.lastIndexOf('}'); end >= 0; end = text.lastIndexOf('}', end - 1)) { let depth = 0; let inString = false; let escape = false; for (let i = end; i >= 0; i--) { const ch = text[i]; if (escape) { escape = false; continue; } if (ch === '\\') { escape = true; continue; } if (ch === '"') { inString = !inString; continue; } if (inString) continue; if (ch === '}') depth++; else if (ch === '{') { depth--; if (depth === 0) { const candidate = text.slice(i, end + 1); try { const parsed = JSON.parse(candidate); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record; } } catch { // fall through, try next end position } break; } } } } return null; }