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/
This commit is contained in:
parent
46f51d7941
commit
d7dd7722ab
7 changed files with 796 additions and 0 deletions
167
tests/e2e/daemon/dashboard.ts
Normal file
167
tests/e2e/daemon/dashboard.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// 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'));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue