// WDIO programmatic runner — launches specs and streams results to a callback // Uses @wdio/cli Launcher for test execution, reads results-db for smart caching. // Supports --stack flag: tauri (default), electrobun, or both. 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 SPECS_DIR = resolve(PROJECT_ROOT, 'tests/e2e/specs'); const RESULTS_PATH = resolve(PROJECT_ROOT, 'test-results/results.json'); export type StackTarget = 'tauri' | 'electrobun' | 'both'; /** Resolve the WDIO config file for a given stack */ function getWdioConf(stack: StackTarget): string { switch (stack) { case 'tauri': return resolve(PROJECT_ROOT, 'tests/e2e/wdio.tauri.conf.js'); case 'electrobun': return resolve(PROJECT_ROOT, 'tests/e2e/wdio.electrobun.conf.js'); default: return resolve(PROJECT_ROOT, 'tests/e2e/wdio.tauri.conf.js'); } } // Legacy fallback — original config const WDIO_CONF_LEGACY = resolve(PROJECT_ROOT, 'tests/e2e/wdio.conf.js'); export interface TestResult { name: string; specFile: string; status: TestStatus; durationMs?: number; error?: string; stack?: string; } export type ResultCallback = (result: TestResult) => void; export interface RunOptions { pattern?: string; full?: boolean; onResult?: ResultCallback; stack?: StackTarget; } // ── 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 }; } } // ── Single-stack runner ── async function runSingleStack( stack: StackTarget, opts: RunOptions, specsToRun: string[], db: ResultsDb, runId: string, ): Promise { const results: TestResult[] = []; const confPath = getWdioConf(stack); // Fall back to legacy config if new one doesn't exist const wdioConf = existsSync(confPath) ? confPath : WDIO_CONF_LEGACY; const stackLabel = stack === 'both' ? 'tauri' : stack; // Mark specs as running for (const spec of specsToRun) { opts.onResult?.({ name: `[${stackLabel}] ${specDisplayName(spec)}`, specFile: spec, status: 'running', stack: stackLabel }); } const specPaths = specsToRun.map((s) => resolve(SPECS_DIR, s)); const startTime = Date.now(); let exitCode = 1; const capturedLines: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); process.stdout.write = function (chunk: any, ...args: any[]) { const str = typeof chunk === 'string' ? chunk : chunk.toString(); capturedLines.push(str); return origWrite(chunk, ...args); } as typeof process.stdout.write; try { const { Launcher } = await import('@wdio/cli'); const launcher = new Launcher(wdioConf, { 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: `[${stackLabel}] ${name}`, specFile: spec, status: 'failed', error: `Launcher error: ${msg}`, stack: stackLabel, }; results.push(result); opts.onResult?.(result); db.recordStep({ run_id: runId, scenario_name: `[${stackLabel}] ${name}`, step_name: 'launcher', status: 'error', duration_ms: null, error_message: msg, screenshot_path: null, agent_cost_usd: null, }); } return results; } finally { process.stdout.write = origWrite; } const totalDuration = Date.now() - startTime; const perSpecDuration = Math.round(totalDuration / specsToRun.length); const passedSet = new Set(); const failedSet = new Set(); const output = capturedLines.join(''); for (const spec of specsToRun) { if (output.includes('PASSED') && output.includes(spec)) { passedSet.add(spec); } else if (output.includes('FAILED') && output.includes(spec)) { failedSet.add(spec); } else if (exitCode === 0) { passedSet.add(spec); } else { failedSet.add(spec); } } for (const spec of specsToRun) { const name = specDisplayName(spec); const passed = passedSet.has(spec); const status: TestStatus = passed ? 'passed' : 'failed'; const errMsg = passed ? null : 'Spec run had failures (check WDIO output above)'; const result: TestResult = { name: `[${stackLabel}] ${name}`, specFile: spec, status, durationMs: perSpecDuration, error: errMsg ?? undefined, stack: stackLabel, }; results.push(result); opts.onResult?.(result); db.recordStep({ run_id: runId, scenario_name: `[${stackLabel}] ${name}`, step_name: 'spec', status, duration_ms: perSpecDuration, error_message: errMsg, screenshot_path: null, agent_cost_usd: null, }); } return results; } // ── Main runner ── export async function runSpecs(opts: RunOptions = {}): Promise { const db = new ResultsDb(); const allSpecs = discoverSpecs(opts.pattern); const results: TestResult[] = []; const stack = opts.stack ?? 'tauri'; 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; } const git = getGitInfo(); const runId = `daemon-${stack}-${Date.now()}`; db.startRun(runId, git.branch ?? undefined, git.sha ?? undefined); if (stack === 'both') { // Run against Tauri first, then Electrobun console.log('\n=== Running specs against TAURI stack ===\n'); const tauriResults = await runSingleStack('tauri', opts, specsToRun, db, runId); results.push(...tauriResults); console.log('\n=== Running specs against ELECTROBUN stack ===\n'); const ebunResults = await runSingleStack('electrobun', opts, specsToRun, db, runId); results.push(...ebunResults); const allPassed = [...tauriResults, ...ebunResults].every(r => r.status === 'passed'); const totalDuration = results.reduce((sum, r) => sum + (r.durationMs ?? 0), 0); db.finishRun(runId, allPassed ? 'passed' : 'failed', totalDuration); } else { const startTime = Date.now(); const stackResults = await runSingleStack(stack, opts, specsToRun, db, runId); results.push(...stackResults); const allPassed = stackResults.every(r => r.status === 'passed'); db.finishRun(runId, allPassed ? 'passed' : 'failed', Date.now() - startTime); } return results; } export function clearCache(): void { if (existsSync(RESULTS_PATH)) { writeFileSync(RESULTS_PATH, JSON.stringify({ runs: [], steps: [] }, null, 2)); } }