@celilo/e2e 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@
24
24
  // Builder API
25
25
  export { network, NetworkBuilder } from './network-builder';
26
26
 
27
+ // Progress reporting (emit signals the test runner displays)
28
+ export { progress } from './progress';
29
+
27
30
  // Types
28
31
  export type {
29
32
  NetworkConfig,
@@ -33,7 +36,7 @@ export type {
33
36
  Zone,
34
37
  TopologyPreset,
35
38
  } from './types';
36
- export { ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
39
+ export { CeliloCommandError, ZONE_GATEWAYS, ZONE_SUBNETS } from './types';
37
40
 
38
41
  // Common fixtures
39
42
  export {
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Emit progress signals that the e2e test runner displays.
3
+ *
4
+ * Each step has a "while doing" message and a "when done" message.
5
+ * The runner shows `… deploying caddy` while in progress, then
6
+ * replaces it with `✔ caddy deployed` when the next step starts
7
+ * or done() is called.
8
+ *
9
+ * Usage:
10
+ * import { progress } from '@celilo/e2e';
11
+ *
12
+ * progress('deploying caddy', 'caddy deployed with HTTPS');
13
+ * await net.celilo('module deploy caddy');
14
+ * // Next progress() auto-finalizes the previous step.
15
+ *
16
+ * progress('verifying DNS', 'DNS resolves');
17
+ * await net.waitFor(...);
18
+ * progress.done(); // Explicit finalize (optional — next progress() also works)
19
+ */
20
+ export function progress(doing: string, done?: string): void {
21
+ // Protocol: [progress:start] doing | done
22
+ // The runner shows "… doing" and replaces with "✔ done" when finalized.
23
+ const doneMsg = done ?? doing;
24
+ console.log(`[progress:start] ${doing} | ${doneMsg}`);
25
+ }
26
+
27
+ /** Explicitly finalize the current step. Optional override message. */
28
+ progress.done = (message?: string): void => {
29
+ if (message) {
30
+ console.log(`[progress:done] ${message}`);
31
+ } else {
32
+ console.log('[progress:done]');
33
+ }
34
+ };
35
+
36
+ /** Mark the current step as failed with a message. */
37
+ progress.fail = (message: string): void => {
38
+ console.log(`[progress:fail] ${message}`);
39
+ };
package/src/runner.ts ADDED
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * E2E Test Runner
4
+ *
5
+ * Runs each test file sequentially with live progress display.
6
+ * Tracks historical timing in .e2e-timing.json for countdown ETAs.
7
+ * Detects false passes (0 assertions).
8
+ *
9
+ * Progress protocol:
10
+ * [progress:start] doing message | done message
11
+ * [progress:done] optional override
12
+ * [progress:fail] error message
13
+ *
14
+ * Display:
15
+ * While in progress: " -45s … deploying caddy"
16
+ * When done: " -45s ✔ caddy deployed"
17
+ * Countdown goes negative when over estimate.
18
+ */
19
+
20
+ import { spawn, spawnSync, execSync } from 'node:child_process';
21
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
22
+ import { basename, join, resolve } from 'node:path';
23
+
24
+ // ─── Config ──────────────────────────────────────────────────────────
25
+
26
+ const PKG_DIR = resolve(import.meta.dir, '..');
27
+ const testDir = process.env.E2E_TEST_DIR || process.cwd();
28
+ const testsPath = join(testDir, 'tests');
29
+ const timingFile = join(testDir, '.e2e-timing.json');
30
+ const patterns = process.argv.slice(2);
31
+
32
+ // ─── Colors ──────────────────────────────────────────────────────────
33
+
34
+ const bold = '\x1b[1m';
35
+ const dim = '\x1b[2m';
36
+ const green = '\x1b[32m';
37
+ const red = '\x1b[31m';
38
+ const cyan = '\x1b[36m';
39
+ const reset = '\x1b[0m';
40
+
41
+ // Terminal control for interactive mode
42
+ const CLEAR_LINE = '\x1b[2K\r';
43
+ const MOVE_UP = '\x1b[A';
44
+
45
+ const interactive = process.stdout.isTTY && !process.argv.includes('--no-interactive');
46
+
47
+ // ─── Timing ──────────────────────────────────────────────────────────
48
+
49
+ type TimingHistory = Record<string, number>;
50
+
51
+ function loadTiming(): TimingHistory {
52
+ try {
53
+ return JSON.parse(readFileSync(timingFile, 'utf-8'));
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ function saveTiming(history: TimingHistory): void {
60
+ writeFileSync(timingFile, JSON.stringify(history, Object.keys(history).sort(), 2) + '\n');
61
+ }
62
+
63
+ function formatDuration(secs: number): string {
64
+ if (secs >= 60) return `${Math.floor(secs / 60)}m${Math.abs(secs) % 60}s`;
65
+ return `${secs}s`;
66
+ }
67
+
68
+ /** Format countdown: positive = remaining, negative = over estimate */
69
+ function formatCountdown(elapsedSecs: number, expectedSecs: number | undefined): string {
70
+ if (!expectedSecs) return `${dim}${String(elapsedSecs).padStart(4)}s${reset}`;
71
+ const remaining = expectedSecs - elapsedSecs;
72
+ if (remaining >= 0) {
73
+ return `${dim}${String(remaining).padStart(4)}s${reset}`;
74
+ }
75
+ return `${red}${String(remaining).padStart(4)}s${reset}`;
76
+ }
77
+
78
+ // ─── Progress Display ────────────────────────────────────────────────
79
+
80
+ interface PendingStep {
81
+ doingMsg: string;
82
+ doneMsg: string;
83
+ startTime: number;
84
+ }
85
+
86
+ class ProgressDisplay {
87
+ private pending: PendingStep | null = null;
88
+ private testStart = 0;
89
+ private expectedDuration: number | undefined;
90
+ /** Number of sub-event lines printed below the current "… doing" line */
91
+ private subEventCount = 0;
92
+
93
+ reset(testStart: number, expectedDuration: number | undefined) {
94
+ this.pending = null;
95
+ this.testStart = testStart;
96
+ this.expectedDuration = expectedDuration;
97
+ this.subEventCount = 0;
98
+ }
99
+
100
+ private time(): string {
101
+ const elapsed = Math.floor((Date.now() - this.testStart) / 1000);
102
+ return formatCountdown(elapsed, this.expectedDuration);
103
+ }
104
+
105
+ /**
106
+ * In interactive mode: erase the "… doing" line and all sub-event
107
+ * lines below it, then print the "✔ done" line in their place.
108
+ * In non-interactive mode: just append the done line.
109
+ */
110
+ private finalizePending() {
111
+ if (!this.pending) return;
112
+ if (interactive) {
113
+ // Erase sub-event lines + the pending "…" line
114
+ const linesToErase = this.subEventCount + 1;
115
+ for (let i = 0; i < linesToErase; i++) {
116
+ process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
117
+ }
118
+ }
119
+ process.stdout.write(` ${this.time()} ${green}✔${reset} ${this.pending.doneMsg}\n`);
120
+ this.pending = null;
121
+ this.subEventCount = 0;
122
+ }
123
+
124
+ private finalizeFailed(message: string) {
125
+ if (interactive && this.pending) {
126
+ const linesToErase = this.subEventCount + 1;
127
+ for (let i = 0; i < linesToErase; i++) {
128
+ process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
129
+ }
130
+ }
131
+ process.stdout.write(` ${this.time()} ${red}✗${reset} ${message}\n`);
132
+ this.pending = null;
133
+ this.subEventCount = 0;
134
+ }
135
+
136
+ startStep(doingMsg: string, doneMsg: string) {
137
+ this.finalizePending();
138
+ this.pending = { doingMsg, doneMsg, startTime: Date.now() };
139
+ this.subEventCount = 0;
140
+ process.stdout.write(` ${this.time()} ${dim}…${reset} ${doingMsg}\n`);
141
+ }
142
+
143
+ doneStep(overrideMessage?: string) {
144
+ if (this.pending && overrideMessage) {
145
+ this.pending.doneMsg = overrideMessage;
146
+ }
147
+ this.finalizePending();
148
+ }
149
+
150
+ failStep(message: string) {
151
+ this.finalizeFailed(message);
152
+ }
153
+
154
+ instantEvent(message: string) {
155
+ this.finalizePending();
156
+ process.stdout.write(` ${this.time()} ${message}\n`);
157
+ }
158
+
159
+ /**
160
+ * Show a sub-event under the current "… doing" step.
161
+ * Interactive: replaces the "… doing" line with the sub-event,
162
+ * then reprints "… doing" below it — so the pending line is always last.
163
+ * Non-interactive: just appends indented.
164
+ */
165
+ subEvent(message: string) {
166
+ if (interactive && this.pending) {
167
+ // Replace the "… doing" line with this sub-event (permanent)
168
+ process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
169
+ process.stdout.write(` ${this.time()} ${message}\n`);
170
+ // Reprint the "… doing" line as the new bottom line
171
+ process.stdout.write(` ${this.time()} ${dim}…${reset} ${this.pending.doingMsg}\n`);
172
+ this.subEventCount++;
173
+ } else {
174
+ process.stdout.write(` ${this.time()} ${message}\n`);
175
+ if (this.pending) this.subEventCount++;
176
+ }
177
+ }
178
+
179
+ /** Finalize pending on success */
180
+ flush() {
181
+ this.finalizePending();
182
+ }
183
+
184
+ /** Abandon pending step on failure (erase the … line in interactive mode) */
185
+ abandon() {
186
+ if (!this.pending) return;
187
+ if (interactive) {
188
+ const linesToErase = this.subEventCount + 1;
189
+ for (let i = 0; i < linesToErase; i++) {
190
+ process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
191
+ }
192
+ }
193
+ this.pending = null;
194
+ this.subEventCount = 0;
195
+ }
196
+ }
197
+
198
+ // ─── Line Parsing ────────────────────────────────────────────────────
199
+
200
+ function parseLine(line: string, display: ProgressDisplay): void {
201
+ // Protocol: [progress:start] doing | done
202
+ const startMatch = line.match(/\[progress:start\] (.*?) \| (.*)/);
203
+ if (startMatch) {
204
+ display.startStep(startMatch[1], startMatch[2]);
205
+ return;
206
+ }
207
+
208
+ // Protocol: [progress:done] optional message
209
+ const doneMatch = line.match(/\[progress:done\](.*)/);
210
+ if (doneMatch) {
211
+ const msg = doneMatch[1].trim();
212
+ display.doneStep(msg || undefined);
213
+ return;
214
+ }
215
+
216
+ // Protocol: [progress:fail] message
217
+ const failMatch = line.match(/\[progress:fail\] (.*)/);
218
+ if (failMatch) {
219
+ display.failStep(failMatch[1]);
220
+ return;
221
+ }
222
+
223
+ // Legacy: [progress] message (treat as instant event)
224
+ const legacyMatch = line.match(/\[progress\] (.*)/);
225
+ if (legacyMatch) {
226
+ display.instantEvent(legacyMatch[1]);
227
+ return;
228
+ }
229
+
230
+ // Celilo CLI signals — shown as sub-events under the current step.
231
+ // These must NOT finalize the test's progress step.
232
+ const deployMatch = line.match(/Module '([^']+)' deployed successfully/);
233
+ if (deployMatch) {
234
+ display.subEvent(`${green}✔${reset} ${deployMatch[1]} deployed`);
235
+ return;
236
+ }
237
+
238
+ const importMatch = line.match(/Successfully imported module: (\S+)/);
239
+ if (importMatch) {
240
+ display.subEvent(`${green}✔${reset} ${importMatch[1]} imported`);
241
+ return;
242
+ }
243
+
244
+ if (line.includes('Deploying software')) {
245
+ display.subEvent('running Ansible');
246
+ return;
247
+ }
248
+ if (line.includes('Infrastructure selected')) {
249
+ display.subEvent('infrastructure selected');
250
+ return;
251
+ }
252
+ if (line.includes('Terraform skipped')) {
253
+ display.subEvent('terraform skipped (existing machine)');
254
+ return;
255
+ }
256
+ if (line.includes('terraform init') || line.includes('Terraform init')) {
257
+ display.subEvent('running terraform init');
258
+ return;
259
+ }
260
+ if (line.includes('terraform apply') || line.includes('Terraform apply')) {
261
+ display.subEvent('running terraform apply');
262
+ return;
263
+ }
264
+
265
+ const retryMatch = line.match(/RETRYING.*?(\d+) retries left/);
266
+ if (retryMatch) {
267
+ display.subEvent(`health check (${retryMatch[1]} retries left)`);
268
+ return;
269
+ }
270
+
271
+ // Error lines
272
+ if (line.includes('Error:') && !line.includes('bun test') && !line.includes('ipv4: Address already')) {
273
+ const err = line.replace(/.*Error: /, '').slice(0, 70);
274
+ display.subEvent(`${red}✗${reset} ${err}`);
275
+ }
276
+ }
277
+
278
+ // ─── Error Extraction ────────────────────────────────────────────────
279
+
280
+ function extractFailureMessage(lines: string[], exitCode: number): string {
281
+ const errorIdx = lines.findIndex(
282
+ (l) => l.startsWith('error:') && !l.includes('bun test'),
283
+ );
284
+
285
+ if (errorIdx >= 0) {
286
+ const context = lines
287
+ .slice(errorIdx, errorIdx + 5)
288
+ .map((l) => l.trim())
289
+ .filter((l) => l.length > 0 && !l.startsWith('at '));
290
+ return context.join('\n ');
291
+ }
292
+
293
+ const celiloError = lines.find(
294
+ (l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
295
+ );
296
+ if (celiloError) return celiloError.slice(0, 120);
297
+
298
+ const timeout = lines.find((l) => l.includes('Timeout') || l.includes('timed out'));
299
+ if (timeout) return timeout.slice(0, 120);
300
+
301
+ return `exit code ${exitCode}`;
302
+ }
303
+
304
+ // ─── Test Execution ──────────────────────────────────────────────────
305
+
306
+ interface TestResult {
307
+ name: string;
308
+ status: 'pass' | 'fail' | 'suspicious';
309
+ duration: number;
310
+ error?: string;
311
+ hadDebugPause?: boolean;
312
+ }
313
+
314
+ async function runTest(
315
+ file: string,
316
+ name: string,
317
+ expectedDuration: number | undefined,
318
+ logDir: string,
319
+ display: ProgressDisplay,
320
+ ): Promise<TestResult> {
321
+ const start = Date.now();
322
+ const logFile = join(logDir, 'output.log');
323
+
324
+ display.reset(start, expectedDuration);
325
+ let hadDebugPause = false;
326
+
327
+ return new Promise<TestResult>((resolveTest) => {
328
+ // 1 hour timeout per test (debug sessions can be long)
329
+ const proc = spawn('bun', ['test', '--timeout', '3600000', file], {
330
+ cwd: PKG_DIR,
331
+ stdio: ['pipe', 'pipe', 'pipe'],
332
+ env: { ...process.env },
333
+ });
334
+
335
+ let stdout = '';
336
+ let stderr = '';
337
+ const lines: string[] = [];
338
+
339
+ function handleDebugPause(project: string, container: string) {
340
+ display.instantEvent(`${bold}\x1b[33m⏸ DEBUG: paused at ${container}${reset}`);
341
+
342
+ const signalFile = join(PKG_DIR, `.debug-resume-${proc.pid}`);
343
+ const border = '═'.repeat(56);
344
+ process.stdout.write(`\n${bold}\x1b[33m╔${border}╗${reset}\n`);
345
+ process.stdout.write(`${bold}\x1b[33m║ DEBUG: test paused ║${reset}\n`);
346
+ process.stdout.write(`${bold}\x1b[33m╠${border}╣${reset}\n`);
347
+ process.stdout.write(`${bold}\x1b[33m║${reset} Project: ${bold}${project}${reset}\n`);
348
+ process.stdout.write(`${bold}\x1b[33m║${reset} Container: ${bold}${container}${reset}\n`);
349
+ process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
350
+ process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Run commands:${reset}\n`);
351
+ process.stdout.write(`${bold}\x1b[33m║${reset} docker compose -f docker-compose.test.yml \\\n`);
352
+ process.stdout.write(`${bold}\x1b[33m║${reset} -p ${project} exec ${container} bash -c '<cmd>'\n`);
353
+ process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
354
+
355
+ if (interactive) {
356
+ process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Or get a shell: type 'exit' to resume the test.${reset}\n`);
357
+ process.stdout.write(`${bold}\x1b[33m╚${border}╝${reset}\n\n`);
358
+
359
+ spawnSync('docker', [
360
+ 'compose', '-f', 'docker-compose.test.yml', '-p', project,
361
+ 'exec', container, 'bash',
362
+ ], {
363
+ cwd: PKG_DIR,
364
+ stdio: 'inherit',
365
+ });
366
+ } else {
367
+ process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Resume: touch ${signalFile}${reset}\n`);
368
+ process.stdout.write(`${bold}\x1b[33m╚${border}╝${reset}\n\n`);
369
+
370
+ // Non-interactive: just wait for the signal file (no shell)
371
+ // The caller creates the file when they're done investigating.
372
+ return;
373
+ }
374
+
375
+ process.stdout.write(`\n${dim}Resuming test...${reset}\n\n`);
376
+ writeFileSync(signalFile, 'resume');
377
+ }
378
+
379
+ function processChunk(chunk: Buffer) {
380
+ const text = chunk.toString();
381
+ stdout += text;
382
+
383
+ for (const part of text.split('\n')) {
384
+ const trimmed = part.trim();
385
+ if (!trimmed) continue;
386
+ lines.push(trimmed);
387
+
388
+ // Check for debug pause signal: [debug:pause] <project> <container>
389
+ const debugMatch = trimmed.match(/\[debug:pause\] (\S+) (\S+)/);
390
+ if (debugMatch) {
391
+ hadDebugPause = true;
392
+ handleDebugPause(debugMatch[1], debugMatch[2]);
393
+ continue;
394
+ }
395
+
396
+ parseLine(trimmed, display);
397
+ }
398
+ }
399
+
400
+ proc.stdout.on('data', processChunk);
401
+ proc.stderr.on('data', (chunk: Buffer) => {
402
+ stderr += chunk.toString();
403
+ processChunk(chunk);
404
+ });
405
+
406
+ proc.on('close', (code) => {
407
+ const duration = Math.floor((Date.now() - start) / 1000);
408
+ if (code === 0) {
409
+ display.flush();
410
+ } else {
411
+ display.abandon();
412
+ }
413
+
414
+ writeFileSync(logFile, stdout + '\n---STDERR---\n' + stderr);
415
+
416
+ const expectMatch = stdout.match(/(\d+) expect\(\) calls/);
417
+ const expectCount = expectMatch ? parseInt(expectMatch[1], 10) : undefined;
418
+
419
+ if (code === 0) {
420
+ if (expectCount === 0) {
421
+ resolveTest({
422
+ name,
423
+ status: 'suspicious',
424
+ duration,
425
+ hadDebugPause,
426
+ error: '0 assertions — test likely exited before reaching assertions',
427
+ });
428
+ } else {
429
+ resolveTest({ name, status: 'pass', duration, hadDebugPause });
430
+ }
431
+ } else {
432
+ resolveTest({
433
+ name,
434
+ status: 'fail',
435
+ duration,
436
+ hadDebugPause,
437
+ error: extractFailureMessage(lines, code ?? 1),
438
+ });
439
+ }
440
+ });
441
+ });
442
+ }
443
+
444
+ // ─── Shared Infrastructure ───────────────────────────────────────────
445
+
446
+ function dockerComposeShared(cmd: string): string {
447
+ try {
448
+ return execSync(
449
+ `docker compose -f docker-compose.shared.yml -p celilo-e2e-shared ${cmd}`,
450
+ { cwd: PKG_DIR, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60_000 },
451
+ ).trim();
452
+ } catch {
453
+ return '';
454
+ }
455
+ }
456
+
457
+ function isSharedInfraHealthy(): boolean {
458
+ const containers = dockerComposeShared('ps -q');
459
+ if (!containers) return false;
460
+ return dockerComposeShared(
461
+ 'exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2',
462
+ ).length > 0;
463
+ }
464
+
465
+ function stopSharedInfra(): void {
466
+ dockerComposeShared('down --volumes --remove-orphans');
467
+ try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
468
+ }
469
+
470
+ // ─── Main ────────────────────────────────────────────────────────────
471
+
472
+ async function main() {
473
+ if (!existsSync(testsPath)) {
474
+ console.error(`${red}No tests/ directory found at ${testsPath}${reset}`);
475
+ process.exit(1);
476
+ }
477
+
478
+ let testFiles = readdirSync(testsPath)
479
+ .filter((f) => f.endsWith('.test.ts'))
480
+ .sort()
481
+ .map((f) => resolve(testsPath, f));
482
+
483
+ if (patterns.length > 0) {
484
+ testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
485
+ }
486
+
487
+ if (testFiles.length === 0) {
488
+ console.error(`${red}No test files matched${reset}`);
489
+ process.exit(1);
490
+ }
491
+
492
+ const history = loadTiming();
493
+ const testNames = testFiles.map((f) => basename(f, '.test.ts'));
494
+ const hasHistory = testNames.some((n) => history[n]);
495
+ const suiteExpected = testNames.reduce((sum, n) => sum + (history[n] || 0), 0);
496
+
497
+ console.log();
498
+ console.log(`${bold}╔══════════════════════════════════════════════════════════════╗${reset}`);
499
+ console.log(`${bold}║ Celilo E2E Tests ║${reset}`);
500
+ if (hasHistory) {
501
+ console.log(`${bold}║ ${testFiles.length} test(s) — expected ~${formatDuration(suiteExpected).padEnd(30)}║${reset}`);
502
+ } else {
503
+ console.log(`${bold}║ ${testFiles.length} test(s) — first run, no timing history ║${reset}`);
504
+ }
505
+ console.log(`${bold}╚══════════════════════════════════════════════════════════════╝${reset}`);
506
+ console.log();
507
+
508
+ // Shared infra check
509
+ console.log(`${bold}Shared Infrastructure${reset}`);
510
+ if (isSharedInfraHealthy()) {
511
+ console.log(` ${green}✔ already running (DNS healthy)${reset}`);
512
+ } else {
513
+ console.log(` ${dim}Will start on first test...${reset}`);
514
+ }
515
+ console.log();
516
+
517
+ const suiteStart = Date.now();
518
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
519
+ const resultsDir = join(testDir, 'results', timestamp);
520
+ mkdirSync(resultsDir, { recursive: true });
521
+
522
+ const display = new ProgressDisplay();
523
+ let passed = 0;
524
+ let failed = 0;
525
+ const results: TestResult[] = [];
526
+
527
+ let interrupted = false;
528
+ process.on('SIGINT', () => {
529
+ if (interrupted) process.exit(130);
530
+ interrupted = true;
531
+ console.log(`\n${red}Interrupted. Stopping shared infrastructure...${reset}`);
532
+ stopSharedInfra();
533
+ saveTiming(history);
534
+ process.exit(130);
535
+ });
536
+
537
+ for (let i = 0; i < testFiles.length; i++) {
538
+ if (interrupted) break;
539
+
540
+ const file = testFiles[i];
541
+ const name = testNames[i];
542
+ const logDir = join(resultsDir, name);
543
+ mkdirSync(logDir, { recursive: true });
544
+
545
+ const expected = history[name];
546
+
547
+ // Remaining ETA from history
548
+ const remainingExpected = testNames.slice(i).reduce((sum, n) => sum + (history[n] || 0), 0);
549
+ const hasRemaining = testNames.slice(i).some((n) => history[n]);
550
+
551
+ let header = `${bold}[${i + 1}/${testFiles.length}] ${name}${reset}`;
552
+ if (expected) {
553
+ header += ` ${dim}(~${formatDuration(expected)})${reset}`;
554
+ }
555
+ if (hasRemaining && i > 0) {
556
+ header += ` ${dim}— ~${formatDuration(remainingExpected)} remaining${reset}`;
557
+ }
558
+ console.log(header);
559
+
560
+ const result = await runTest(file, name, expected, logDir, display);
561
+ results.push(result);
562
+ // Only save meaningful durations — crashes (<10s) and debug sessions
563
+ // would poison future ETAs
564
+ if (result.duration >= 10 && !result.hadDebugPause) {
565
+ history[name] = result.duration;
566
+ }
567
+
568
+ if (result.status === 'pass') {
569
+ console.log(` ${green}✔ passed (${result.duration}s)${reset}`);
570
+ passed++;
571
+ } else if (result.status === 'suspicious') {
572
+ console.log(` ${red}✗ suspicious pass — 0 assertions (${result.duration}s)${reset}`);
573
+ console.log(` ${red} └─ ${result.error}${reset}`);
574
+ failed++;
575
+ } else {
576
+ console.log(` ${red}✗ failed (${result.duration}s)${reset}`);
577
+ if (result.error) {
578
+ console.log(` ${red} └─ ${result.error}${reset}`);
579
+ }
580
+ failed++;
581
+ }
582
+ console.log();
583
+
584
+ writeFileSync(
585
+ join(logDir, 'result.json'),
586
+ JSON.stringify({ status: result.status, duration: result.duration, error: result.error }),
587
+ );
588
+
589
+ try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
590
+ }
591
+
592
+ saveTiming(history);
593
+
594
+ console.log(`${dim}Stopping shared infrastructure...${reset}`);
595
+ stopSharedInfra();
596
+
597
+ const suiteDuration = Math.floor((Date.now() - suiteStart) / 1000);
598
+
599
+ console.log();
600
+ console.log(`${bold}╔══════════════════════════════════════════════════════════════╗${reset}`);
601
+ console.log(
602
+ `${bold}║ Results: ${green}${passed} passed${reset}${bold}, ${red}${failed} failed${reset}${bold} — ${formatDuration(suiteDuration)} total${' '.repeat(20)}║${reset}`,
603
+ );
604
+ console.log(`${bold}╠══════════════════════════════════════════════════════════════╣${reset}`);
605
+
606
+ for (const r of results) {
607
+ const icon = r.status === 'pass' ? '✓' : '✗';
608
+ const color = r.status === 'pass' ? green : red;
609
+ console.log(`${bold}║${reset} ${color}${icon}${reset} ${r.name.padEnd(28)} ${dim}${(r.duration + 's').padStart(6)}${reset}`);
610
+ if (r.error) {
611
+ console.log(`${bold}║${reset} ${red}└─ ${r.error}${reset}`);
612
+ }
613
+ }
614
+
615
+ console.log(`${bold}╚══════════════════════════════════════════════════════════════╝${reset}`);
616
+ console.log();
617
+ console.log(`${dim}Full logs: ${resultsDir}${reset}`);
618
+
619
+ writeFileSync(
620
+ join(resultsDir, 'summary.json'),
621
+ JSON.stringify({ timestamp, total: testFiles.length, passed, failed, duration: suiteDuration }, null, 2),
622
+ );
623
+
624
+ const gitignore = join(testDir, '.gitignore');
625
+ const content = existsSync(gitignore) ? readFileSync(gitignore, 'utf-8') : '';
626
+ const adds: string[] = [];
627
+ if (!content.includes('results/')) adds.push('results/');
628
+ if (!content.includes('.e2e-timing.json')) adds.push('.e2e-timing.json');
629
+ if (adds.length) writeFileSync(gitignore, content.trimEnd() + '\n' + adds.join('\n') + '\n');
630
+
631
+ process.exit(failed > 0 ? 1 : 0);
632
+ }
633
+
634
+ main().catch((err) => {
635
+ console.error(err);
636
+ process.exit(1);
637
+ });