- 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/
167 lines
5.3 KiB
TypeScript
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'));
|
|
}
|
|
}
|