// WDIO programmatic runner — launches specs and streams results to a callback // Uses @wdio/cli Launcher for test execution, reads results-db for smart caching. import { resolve, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, readdirSync, writeFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import type { TestStatus } from './dashboard.ts'; import { ResultsDb } from '../infra/results-db.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = resolve(__dirname, '../../..'); const WDIO_CONF = resolve(PROJECT_ROOT, 'tests/e2e/wdio.conf.js'); const SPECS_DIR = resolve(PROJECT_ROOT, 'tests/e2e/specs'); const RESULTS_PATH = resolve(PROJECT_ROOT, 'test-results/results.json'); export interface TestResult { name: string; specFile: string; status: TestStatus; durationMs?: number; error?: string; } export type ResultCallback = (result: TestResult) => void; export interface RunOptions { pattern?: string; full?: boolean; onResult?: ResultCallback; } // ── Spec discovery ── export function discoverSpecs(pattern?: string): string[] { const files = readdirSync(SPECS_DIR) .filter((f) => f.endsWith('.test.ts')) .sort(); if (pattern) { const lp = pattern.toLowerCase(); return files.filter((f) => f.toLowerCase().includes(lp)); } return files; } export function specDisplayName(specFile: string): string { return basename(specFile, '.test.ts'); } // ── Smart cache ── function getPassedSpecs(db: ResultsDb): Set { const passed = new Set(); for (const run of db.getRecentRuns(5)) { if (run.status !== 'passed' && run.status !== 'failed') continue; for (const step of db.getStepsForRun(run.run_id)) { if (step.status === 'passed') passed.add(step.scenario_name); } } return passed; } function filterByCache(specs: string[], db: ResultsDb): { run: string[]; skipped: string[] } { const cached = getPassedSpecs(db); const run: string[] = []; const skipped: string[] = []; for (const spec of specs) { (cached.has(specDisplayName(spec)) ? skipped : run).push(spec); } return { run, skipped }; } // ── Git info ── function getGitInfo(): { branch: string | null; sha: string | null } { try { const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: PROJECT_ROOT, encoding: 'utf-8', }).trim(); const sha = execSync('git rev-parse --short HEAD', { cwd: PROJECT_ROOT, encoding: 'utf-8', }).trim(); return { branch, sha }; } catch { return { branch: null, sha: null }; } } // ── Runner ── export async function runSpecs(opts: RunOptions = {}): Promise { const db = new ResultsDb(); const allSpecs = discoverSpecs(opts.pattern); const results: TestResult[] = []; let specsToRun: string[]; let skippedSpecs: string[] = []; if (opts.full) { specsToRun = allSpecs; } else { const filtered = filterByCache(allSpecs, db); specsToRun = filtered.run; skippedSpecs = filtered.skipped; } // Emit skipped specs immediately for (const spec of skippedSpecs) { const result: TestResult = { name: specDisplayName(spec), specFile: spec, status: 'skipped' }; results.push(result); opts.onResult?.(result); } if (specsToRun.length === 0) { return results; } // Build absolute spec paths const specPaths = specsToRun.map((s) => resolve(SPECS_DIR, s)); // Generate run ID and record const git = getGitInfo(); const runId = `daemon-${Date.now()}`; db.startRun(runId, git.branch ?? undefined, git.sha ?? undefined); // Mark specs as running for (const spec of specsToRun) { opts.onResult?.({ name: specDisplayName(spec), specFile: spec, status: 'running' }); } // Run via WDIO CLI Launcher const startTime = Date.now(); let exitCode = 1; try { const { Launcher } = await import('@wdio/cli'); const launcher = new Launcher(WDIO_CONF, { specs: specPaths, }); exitCode = await launcher.run(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); for (const spec of specsToRun) { const name = specDisplayName(spec); const result: TestResult = { name, specFile: spec, status: 'failed', error: `Launcher error: ${msg}` }; results.push(result); opts.onResult?.(result); db.recordStep({ run_id: runId, scenario_name: name, step_name: 'launcher', status: 'error', duration_ms: null, error_message: msg, screenshot_path: null, agent_cost_usd: null }); } db.finishRun(runId, 'error', Date.now() - startTime); return results; } const totalDuration = Date.now() - startTime; const perSpecDuration = Math.round(totalDuration / specsToRun.length); // WDIO Launcher returns 0 for all passed, non-zero for failures. // Without per-test reporter hooks, we infer per-spec status from exit code. const specStatus: TestStatus = exitCode === 0 ? 'passed' : 'failed'; const errMsg = exitCode !== 0 ? `WDIO exited with code ${exitCode}` : null; for (const spec of specsToRun) { const name = specDisplayName(spec); const result: TestResult = { name, specFile: spec, status: specStatus, durationMs: perSpecDuration, error: errMsg ?? undefined }; results.push(result); opts.onResult?.(result); db.recordStep({ run_id: runId, scenario_name: name, step_name: 'spec', status: specStatus, duration_ms: perSpecDuration, error_message: errMsg, screenshot_path: null, agent_cost_usd: null }); } db.finishRun(runId, exitCode === 0 ? 'passed' : 'failed', totalDuration); return results; } export function clearCache(): void { if (existsSync(RESULTS_PATH)) { writeFileSync(RESULTS_PATH, JSON.stringify({ runs: [], steps: [] }, null, 2)); } }