@celilo/e2e 0.3.0 → 0.4.0
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-down +40 -15
- package/config/dns/iamtheinternet.org.zone +1 -1
- package/package.json +1 -1
- package/src/container-manager.ts +95 -0
- package/src/index.ts +3 -0
- package/src/runner.ts +50 -3
package/bin/e2e-down
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# Tear down
|
|
2
|
+
# Tear down E2E test artifacts:
|
|
3
|
+
# - Persistent test networks (from `e2e-run --keep`)
|
|
4
|
+
# - Interactive sessions (from `e2e-up`)
|
|
5
|
+
# - Orphaned test runs
|
|
3
6
|
#
|
|
4
7
|
# Usage:
|
|
5
|
-
# ./bin/e2e-down #
|
|
6
|
-
# ./bin/e2e-down --keep #
|
|
7
|
-
# ./bin/e2e-down --all #
|
|
8
|
+
# ./bin/e2e-down # tear down everything e2e-related
|
|
9
|
+
# ./bin/e2e-down --keep # stop containers but keep volumes
|
|
10
|
+
# ./bin/e2e-down --all # also stop module containers that compete for resources
|
|
8
11
|
|
|
9
12
|
set -e
|
|
10
13
|
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
@@ -19,20 +22,42 @@ for arg in "$@"; do
|
|
|
19
22
|
esac
|
|
20
23
|
done
|
|
21
24
|
|
|
22
|
-
# 1.
|
|
25
|
+
# 1. Tear down any persistent test networks (look for marker files in common test dirs)
|
|
26
|
+
for dir in "$(pwd)" "$(pwd)/../../e2e" "$(pwd)/../../../e2e"; do
|
|
27
|
+
MARKER="$dir/.e2e-persistent.json"
|
|
28
|
+
if [ -f "$MARKER" ]; then
|
|
29
|
+
PROJECT=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('$MARKER', 'utf-8')).projectName)" 2>/dev/null || echo "")
|
|
30
|
+
if [ -n "$PROJECT" ]; then
|
|
31
|
+
echo "Tearing down persistent network: $PROJECT"
|
|
32
|
+
docker compose -f docker-compose.test.yml -p "$PROJECT" down --volumes --remove-orphans 2>/dev/null || true
|
|
33
|
+
fi
|
|
34
|
+
rm -f "$MARKER"
|
|
35
|
+
fi
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
# Also check E2E_TEST_DIR if set
|
|
39
|
+
if [ -n "$E2E_TEST_DIR" ] && [ -f "$E2E_TEST_DIR/.e2e-persistent.json" ]; then
|
|
40
|
+
PROJECT=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('$E2E_TEST_DIR/.e2e-persistent.json', 'utf-8')).projectName)" 2>/dev/null || echo "")
|
|
41
|
+
if [ -n "$PROJECT" ]; then
|
|
42
|
+
echo "Tearing down persistent network: $PROJECT"
|
|
43
|
+
docker compose -f docker-compose.test.yml -p "$PROJECT" down --volumes --remove-orphans 2>/dev/null || true
|
|
44
|
+
fi
|
|
45
|
+
rm -f "$E2E_TEST_DIR/.e2e-persistent.json"
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# 2. Stop the interactive session (if running)
|
|
23
49
|
echo "Stopping interactive session..."
|
|
24
|
-
docker compose -p "
|
|
50
|
+
docker compose -p "celilo-e2e-interactive" down --remove-orphans 2>/dev/null || true
|
|
25
51
|
|
|
26
|
-
#
|
|
27
|
-
CONTAINERS=$(docker ps -a --filter "name=
|
|
52
|
+
# 3. Kill ALL containers matching celilo-e2e-* (orphaned test runs)
|
|
53
|
+
CONTAINERS=$(docker ps -a --filter "name=celilo-e2e" --format "{{.ID}}" 2>/dev/null)
|
|
28
54
|
if [ -n "$CONTAINERS" ]; then
|
|
29
55
|
COUNT=$(echo "$CONTAINERS" | wc -l | tr -d ' ')
|
|
30
56
|
echo "Removing $COUNT orphaned e2e containers..."
|
|
31
57
|
echo "$CONTAINERS" | xargs docker rm -f 2>/dev/null || true
|
|
32
58
|
fi
|
|
33
59
|
|
|
34
|
-
#
|
|
35
|
-
# These come from local docker-compose runs, not from e2e, but starve the VM.
|
|
60
|
+
# 4. Stop module containers that compete for resources (--all flag)
|
|
36
61
|
if [ "$STOP_ALL" = true ]; then
|
|
37
62
|
MODULE_PATTERNS="authentik- caddy- build-your-own-internet-"
|
|
38
63
|
for pattern in $MODULE_PATTERNS; do
|
|
@@ -45,23 +70,23 @@ if [ "$STOP_ALL" = true ]; then
|
|
|
45
70
|
done
|
|
46
71
|
fi
|
|
47
72
|
|
|
48
|
-
#
|
|
49
|
-
NETWORKS=$(docker network ls --filter "name=
|
|
73
|
+
# 5. Remove all celilo-e2e networks
|
|
74
|
+
NETWORKS=$(docker network ls --filter "name=celilo-e2e" --format "{{.ID}}" 2>/dev/null)
|
|
50
75
|
if [ -n "$NETWORKS" ]; then
|
|
51
76
|
echo "Removing e2e networks..."
|
|
52
77
|
echo "$NETWORKS" | xargs docker network rm 2>/dev/null || true
|
|
53
78
|
fi
|
|
54
79
|
|
|
55
|
-
#
|
|
80
|
+
# 6. Remove volumes (unless --keep)
|
|
56
81
|
if [ "$KEEP_VOLUMES" = false ]; then
|
|
57
|
-
VOLUMES=$(docker volume ls --filter "name=
|
|
82
|
+
VOLUMES=$(docker volume ls --filter "name=celilo-e2e" --format "{{.Name}}" 2>/dev/null)
|
|
58
83
|
if [ -n "$VOLUMES" ]; then
|
|
59
84
|
echo "Removing e2e volumes..."
|
|
60
85
|
echo "$VOLUMES" | xargs docker volume rm 2>/dev/null || true
|
|
61
86
|
fi
|
|
62
87
|
fi
|
|
63
88
|
|
|
64
|
-
#
|
|
89
|
+
# 7. Clean up generated compose files
|
|
65
90
|
rm -f docker-compose.test.yml
|
|
66
91
|
|
|
67
92
|
echo "E2E environment cleaned."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/container-manager.ts
CHANGED
|
@@ -310,6 +310,8 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
console.log('[progress:done] network ready');
|
|
313
|
+
// Emit project name so the runner can persist it if --keep is set
|
|
314
|
+
console.log(`[e2e:project] ${projectName}`);
|
|
313
315
|
|
|
314
316
|
const handle: NetworkHandle = {
|
|
315
317
|
projectName,
|
|
@@ -362,6 +364,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
362
364
|
waitFor,
|
|
363
365
|
|
|
364
366
|
async stop(): Promise<void> {
|
|
367
|
+
// Respect --keep / --reuse: don't tear down the network
|
|
368
|
+
if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
|
|
369
|
+
console.log(`[progress:done] network kept alive (project: ${projectName})`);
|
|
370
|
+
activeProjects.delete(projectName); // don't re-teardown on exit
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
365
373
|
console.log('[progress:start] stopping network | network stopped');
|
|
366
374
|
try {
|
|
367
375
|
run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
|
|
@@ -375,3 +383,90 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
|
|
|
375
383
|
|
|
376
384
|
return handle;
|
|
377
385
|
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Reconnect to an existing test network (previously kept alive with --keep).
|
|
389
|
+
* Returns a NetworkHandle pointing to the existing Docker project. Skips
|
|
390
|
+
* all the setup (image build, container start, celilo init) — assumes the
|
|
391
|
+
* network is already running and healthy.
|
|
392
|
+
*
|
|
393
|
+
* If the project doesn't exist, throws with an actionable error.
|
|
394
|
+
*/
|
|
395
|
+
export function reconnectNetwork(projectName: string): NetworkHandle {
|
|
396
|
+
const composeDir = PACKAGE_ROOT;
|
|
397
|
+
|
|
398
|
+
// Verify the project actually exists
|
|
399
|
+
try {
|
|
400
|
+
const result = run(
|
|
401
|
+
`docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`,
|
|
402
|
+
{ cwd: composeDir, timeout: 10_000 },
|
|
403
|
+
);
|
|
404
|
+
if (!result.trim()) {
|
|
405
|
+
throw new Error(`No containers found for project ${projectName}`);
|
|
406
|
+
}
|
|
407
|
+
} catch (err) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
|
|
410
|
+
`Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
activeProjects.add(projectName);
|
|
415
|
+
console.log(`[progress:done] reconnected to existing network (${projectName})`);
|
|
416
|
+
console.log(`[e2e:project] ${projectName}`);
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
projectName,
|
|
420
|
+
|
|
421
|
+
async celilo(cmd: string, optsOrTimeout?: number | { check?: boolean; timeoutMs?: number }): Promise<ExecResult> {
|
|
422
|
+
const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
|
|
423
|
+
const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
|
|
424
|
+
|
|
425
|
+
const result = await dockerExecAsync(projectName, composeDir, 'management',
|
|
426
|
+
`celilo ${cmd}`, timeoutMs);
|
|
427
|
+
|
|
428
|
+
if (check && result.exitCode !== 0) {
|
|
429
|
+
throw new CeliloCommandError(cmd, result);
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
|
|
435
|
+
return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
async dig(name: string): Promise<string> {
|
|
439
|
+
const result = dockerExec(projectName, composeDir, 'management',
|
|
440
|
+
`dig +short ${name}`);
|
|
441
|
+
return result.stdout.trim();
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
async debug(container = 'management'): Promise<void> {
|
|
445
|
+
console.log(`[debug:pause] ${projectName} ${container}`);
|
|
446
|
+
const signalFile = join(composeDir, `.debug-resume-${process.pid}`);
|
|
447
|
+
const start = Date.now();
|
|
448
|
+
const timeout = 86_400_000;
|
|
449
|
+
while (!existsSync(signalFile) && Date.now() - start < timeout) {
|
|
450
|
+
await new Promise(r => setTimeout(r, 500));
|
|
451
|
+
}
|
|
452
|
+
try { require('node:fs').unlinkSync(signalFile); } catch {}
|
|
453
|
+
console.log('[debug:resumed]');
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
waitFor,
|
|
457
|
+
|
|
458
|
+
async stop(): Promise<void> {
|
|
459
|
+
// Respect --keep / --reuse: don't tear down
|
|
460
|
+
if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
|
|
461
|
+
console.log(`[progress:done] network kept alive (project: ${projectName})`);
|
|
462
|
+
activeProjects.delete(projectName);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
|
|
467
|
+
{ cwd: composeDir, timeout: 60_000 });
|
|
468
|
+
} catch {}
|
|
469
|
+
activeProjects.delete(projectName);
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
// Builder API
|
|
25
25
|
export { network, NetworkBuilder } from './network-builder';
|
|
26
26
|
|
|
27
|
+
// Reconnect to an existing network (for --reuse mode)
|
|
28
|
+
export { reconnectNetwork } from './container-manager';
|
|
29
|
+
|
|
27
30
|
// Progress reporting (emit signals the test runner displays)
|
|
28
31
|
export { progress } from './progress';
|
|
29
32
|
|
package/src/runner.ts
CHANGED
|
@@ -27,7 +27,13 @@ const PKG_DIR = resolve(import.meta.dir, '..');
|
|
|
27
27
|
const testDir = process.env.E2E_TEST_DIR || process.cwd();
|
|
28
28
|
const testsPath = join(testDir, 'tests');
|
|
29
29
|
const timingFile = join(testDir, '.e2e-timing.json');
|
|
30
|
-
const
|
|
30
|
+
const persistentFile = join(testDir, '.e2e-persistent.json');
|
|
31
|
+
|
|
32
|
+
// Parse flags out of argv
|
|
33
|
+
const rawArgs = process.argv.slice(2);
|
|
34
|
+
const flagKeep = rawArgs.includes('--keep');
|
|
35
|
+
const flagReuse = rawArgs.includes('--reuse');
|
|
36
|
+
const patterns = rawArgs.filter((a) => !a.startsWith('--'));
|
|
31
37
|
|
|
32
38
|
// ─── Colors ──────────────────────────────────────────────────────────
|
|
33
39
|
|
|
@@ -309,6 +315,7 @@ interface TestResult {
|
|
|
309
315
|
duration: number;
|
|
310
316
|
error?: string;
|
|
311
317
|
hadDebugPause?: boolean;
|
|
318
|
+
projectName?: string;
|
|
312
319
|
}
|
|
313
320
|
|
|
314
321
|
async function runTest(
|
|
@@ -323,13 +330,29 @@ async function runTest(
|
|
|
323
330
|
|
|
324
331
|
display.reset(start, expectedDuration);
|
|
325
332
|
let hadDebugPause = false;
|
|
333
|
+
let capturedProjectName: string | undefined;
|
|
326
334
|
|
|
327
335
|
return new Promise<TestResult>((resolveTest) => {
|
|
336
|
+
// Env vars let the test detect --keep / --reuse modes
|
|
337
|
+
const testEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
338
|
+
if (flagKeep) testEnv.CELILO_E2E_KEEP = '1';
|
|
339
|
+
if (flagReuse) {
|
|
340
|
+
// Read the persistent project and pass it to the test
|
|
341
|
+
try {
|
|
342
|
+
const data = JSON.parse(readFileSync(persistentFile, 'utf-8'));
|
|
343
|
+
testEnv.CELILO_E2E_REUSE = '1';
|
|
344
|
+
testEnv.CELILO_E2E_PROJECT = data.projectName;
|
|
345
|
+
} catch {
|
|
346
|
+
console.error(`${red}✗ --reuse: no persistent network found. Run with --keep first.${reset}`);
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
328
351
|
// 1 hour timeout per test (debug sessions can be long)
|
|
329
352
|
const proc = spawn('bun', ['test', '--timeout', '3600000', file], {
|
|
330
353
|
cwd: PKG_DIR,
|
|
331
354
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
332
|
-
env:
|
|
355
|
+
env: testEnv,
|
|
333
356
|
});
|
|
334
357
|
|
|
335
358
|
let stdout = '';
|
|
@@ -393,6 +416,13 @@ async function runTest(
|
|
|
393
416
|
continue;
|
|
394
417
|
}
|
|
395
418
|
|
|
419
|
+
// Capture project name for persistent network tracking
|
|
420
|
+
const projectMatch = trimmed.match(/\[e2e:project\] (\S+)/);
|
|
421
|
+
if (projectMatch) {
|
|
422
|
+
capturedProjectName = projectMatch[1];
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
|
|
396
426
|
parseLine(trimmed, display);
|
|
397
427
|
}
|
|
398
428
|
}
|
|
@@ -423,10 +453,11 @@ async function runTest(
|
|
|
423
453
|
status: 'suspicious',
|
|
424
454
|
duration,
|
|
425
455
|
hadDebugPause,
|
|
456
|
+
projectName: capturedProjectName,
|
|
426
457
|
error: '0 assertions — test likely exited before reaching assertions',
|
|
427
458
|
});
|
|
428
459
|
} else {
|
|
429
|
-
resolveTest({ name, status: 'pass', duration, hadDebugPause });
|
|
460
|
+
resolveTest({ name, status: 'pass', duration, hadDebugPause, projectName: capturedProjectName });
|
|
430
461
|
}
|
|
431
462
|
} else {
|
|
432
463
|
resolveTest({
|
|
@@ -434,6 +465,7 @@ async function runTest(
|
|
|
434
465
|
status: 'fail',
|
|
435
466
|
duration,
|
|
436
467
|
hadDebugPause,
|
|
468
|
+
projectName: capturedProjectName,
|
|
437
469
|
error: extractFailureMessage(lines, code ?? 1),
|
|
438
470
|
});
|
|
439
471
|
}
|
|
@@ -556,6 +588,21 @@ async function main() {
|
|
|
556
588
|
history[name] = result.duration;
|
|
557
589
|
}
|
|
558
590
|
|
|
591
|
+
// Write persistent network info if --keep was set and we captured a project
|
|
592
|
+
if (flagKeep && result.projectName) {
|
|
593
|
+
writeFileSync(
|
|
594
|
+
persistentFile,
|
|
595
|
+
JSON.stringify({
|
|
596
|
+
projectName: result.projectName,
|
|
597
|
+
testFile: file,
|
|
598
|
+
createdAt: new Date().toISOString(),
|
|
599
|
+
}, null, 2) + '\n',
|
|
600
|
+
);
|
|
601
|
+
console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
|
|
602
|
+
console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
|
|
603
|
+
console.log(`${dim} Tear down: e2e-down${reset}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
559
606
|
if (result.status === 'pass') {
|
|
560
607
|
console.log(` ${green}✔ passed (${result.duration}s)${reset}`);
|
|
561
608
|
passed++;
|