// Terminal dashboard — ANSI escape code UI for E2E test status // No external deps. Renders test list with status icons, timing, and summary. export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'; export interface TestEntry { name: string; specFile: string; status: TestStatus; durationMs?: number; error?: string; } // ── ANSI helpers ── const ESC = '\x1b['; const CLEAR_SCREEN = `${ESC}2J${ESC}H`; const HIDE_CURSOR = `${ESC}?25l`; const SHOW_CURSOR = `${ESC}?25h`; const BOLD = `${ESC}1m`; const DIM = `${ESC}2m`; const RESET = `${ESC}0m`; const fg = { red: `${ESC}31m`, green: `${ESC}32m`, yellow: `${ESC}33m`, blue: `${ESC}34m`, magenta: `${ESC}35m`, cyan: `${ESC}36m`, white: `${ESC}37m`, gray: `${ESC}90m`, }; const STATUS_ICONS: Record = { pending: `${fg.white}\u00b7${RESET}`, // centered dot running: `${fg.yellow}\u27f3${RESET}`, // clockwise arrow passed: `${fg.green}\u2713${RESET}`, // check mark failed: `${fg.red}\u2717${RESET}`, // cross mark skipped: `${fg.gray}\u23ed${RESET}`, // skip icon }; function formatDuration(ms: number | undefined): string { if (ms === undefined) return ''; if (ms < 1000) return `${fg.gray}${ms}ms${RESET}`; return `${fg.gray}${(ms / 1000).toFixed(1)}s${RESET}`; } function truncate(str: string, max: number): string { return str.length > max ? str.slice(0, max - 1) + '\u2026' : str; } // ── Dashboard ── export class Dashboard { private tests: TestEntry[] = []; private startTime: number = Date.now(); private refreshTimer: ReturnType | null = null; private running = false; private lastRunStatus: 'idle' | 'running' | 'passed' | 'failed' = 'idle'; setTests(specs: Array<{ name: string; specFile: string }>): void { this.tests = specs.map((s) => ({ name: s.name, specFile: s.specFile, status: 'pending' as TestStatus, })); this.startTime = Date.now(); this.lastRunStatus = 'running'; } updateTest(name: string, status: TestStatus, durationMs?: number, error?: string): void { const entry = this.tests.find((t) => t.name === name); if (entry) { entry.status = status; entry.durationMs = durationMs; entry.error = error; } } startRefresh(): void { if (this.refreshTimer) return; this.running = true; process.stdout.write(HIDE_CURSOR); this.render(); this.refreshTimer = setInterval(() => this.render(), 500); } stopRefresh(): void { this.running = false; if (this.refreshTimer) { clearInterval(this.refreshTimer); this.refreshTimer = null; } // Final render with cursor restored this.render(); process.stdout.write(SHOW_CURSOR); } markComplete(): void { const failed = this.tests.filter((t) => t.status === 'failed').length; this.lastRunStatus = failed > 0 ? 'failed' : 'passed'; } stop(): void { this.stopRefresh(); } getTests(): TestEntry[] { return this.tests; } render(): void { const cols = process.stdout.columns || 80; const lines: string[] = []; // ── Header ── const title = 'Agent Orchestrator \u2014 E2E Test Daemon'; const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); const statusColor = this.lastRunStatus === 'failed' ? fg.red : this.lastRunStatus === 'passed' ? fg.green : this.lastRunStatus === 'running' ? fg.yellow : fg.gray; const statusLabel = this.running ? 'RUNNING' : this.lastRunStatus.toUpperCase(); lines.push(''); lines.push(` ${BOLD}${fg.cyan}${title}${RESET} ${statusColor}${statusLabel}${RESET} ${DIM}${elapsed}s${RESET}`); lines.push(` ${fg.gray}${'─'.repeat(Math.min(cols - 4, 76))}${RESET}`); // ── Test list ── const nameWidth = Math.min(cols - 20, 60); for (const t of this.tests) { const icon = STATUS_ICONS[t.status]; const name = truncate(t.name, nameWidth); const dur = formatDuration(t.durationMs); lines.push(` ${icon} ${name} ${dur}`); if (t.status === 'failed' && t.error) { const errLine = truncate(t.error, cols - 8); lines.push(` ${fg.red}${DIM}${errLine}${RESET}`); } } // ── Summary footer ── const passed = this.tests.filter((t) => t.status === 'passed').length; const failed = this.tests.filter((t) => t.status === 'failed').length; const skipped = this.tests.filter((t) => t.status === 'skipped').length; const running = this.tests.filter((t) => t.status === 'running').length; const pending = this.tests.filter((t) => t.status === 'pending').length; const totalTime = this.tests.reduce((sum, t) => sum + (t.durationMs ?? 0), 0); lines.push(''); lines.push(` ${fg.gray}${'─'.repeat(Math.min(cols - 4, 76))}${RESET}`); const parts: string[] = []; if (passed > 0) parts.push(`${fg.green}${passed} passed${RESET}`); if (failed > 0) parts.push(`${fg.red}${failed} failed${RESET}`); if (skipped > 0) parts.push(`${fg.gray}${skipped} skipped${RESET}`); if (running > 0) parts.push(`${fg.yellow}${running} running${RESET}`); if (pending > 0) parts.push(`${fg.white}${pending} pending${RESET}`); parts.push(`${DIM}${formatDuration(totalTime)}${RESET}`); lines.push(` ${parts.join(' \u2502 ')}`); lines.push(''); process.stdout.write(CLEAR_SCREEN + lines.join('\n')); } }