agent-orchestrator/tests/e2e/daemon/dashboard.ts
Hibryda d7dd7722ab feat(e2e): add test daemon CLI with ANSI dashboard and Agent SDK bridge
- index.ts: CLI entry point (--full, --spec, --watch, --agent flags)
- runner.ts: programmatic WDIO launcher with result streaming
- dashboard.ts: ANSI terminal UI (pass/fail/skip/running icons, summary)
- agent-bridge.ts: NDJSON stdin/stdout for Agent SDK queries
  (status, rerun, failures, reset-cache)
- Standalone package at tests/e2e/daemon/
2026-03-18 05:17:17 +01:00

167 lines
5.3 KiB
TypeScript

// 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<TestStatus, string> = {
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<typeof setInterval> | 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'));
}
}