@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/bin/e2e-infra +2 -2
- package/bin/e2e-run +2 -351
- package/config/dns/iamtheinternet.org.zone +2 -2
- package/package.json +1 -1
- package/src/container-manager.ts +67 -16
- package/src/docker-compose-generator.ts +34 -27
- package/src/index.ts +4 -1
- package/src/progress.ts +39 -0
- package/src/runner.ts +637 -0
- package/src/shared-infra.ts +20 -9
- package/src/types.ts +26 -1
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 {
|
package/src/progress.ts
ADDED
|
@@ -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
|
+
});
|