@celilo/e2e 0.5.1 → 0.6.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.
Files changed (59) hide show
  1. package/README.md +2 -1
  2. package/bin/cele2e +14 -0
  3. package/bin/e2e-cache-images +57 -0
  4. package/bin/e2e-shell +36 -13
  5. package/bin/e2e-status +8 -2
  6. package/bin/e2e-up +5 -5
  7. package/completions/install-zsh-completion.sh +78 -0
  8. package/config/dns/celilo.computer.zone +16 -0
  9. package/config/dns/iamtheinternet.org.zone +7 -6
  10. package/config/dns/knot-namecheap.conf +8 -0
  11. package/config/dns/knot-tld.conf +4 -0
  12. package/config/dns/lunacycle.net.zone +16 -0
  13. package/config/dns/net.zone +11 -0
  14. package/config/dns/org.zone +5 -0
  15. package/config/dns/root.zone +2 -0
  16. package/config/dns/templates/iamtheinternet.org.zone +16 -0
  17. package/config/dns/templates/lunacycle.net.zone +16 -0
  18. package/config/resolver/unbound.conf +10 -0
  19. package/config/routing/docker-image-preload.service +14 -0
  20. package/config/routing/docker-image-preload.sh +24 -0
  21. package/config/routing/management-routes.sh +24 -2
  22. package/config/routing/resolver-internal-routes.sh +59 -0
  23. package/docker/Dockerfile.internal-resolver +16 -0
  24. package/docker/Dockerfile.isitup +37 -0
  25. package/docker/Dockerfile.management +2 -0
  26. package/docker/Dockerfile.registry +34 -0
  27. package/docker/Dockerfile.target-machine +20 -0
  28. package/docker/Dockerfile.target-machine-docker +11 -19
  29. package/docker/Dockerfile.target-technitium +97 -0
  30. package/netapps/authentik.netapp +0 -0
  31. package/netapps/caddy.netapp +0 -0
  32. package/netapps/greenwave.netapp +0 -0
  33. package/netapps/homebridge.netapp +0 -0
  34. package/netapps/iptables.netapp +0 -0
  35. package/netapps/knot-unbound-internal.netapp +0 -0
  36. package/netapps/namecheap.netapp +0 -0
  37. package/netapps/technitium.netapp +0 -0
  38. package/netapps/test-ansible-output.netapp +0 -0
  39. package/package.json +8 -1
  40. package/simulators/greenwave/state.ts +7 -12
  41. package/simulators/isitup/Caddyfile +11 -0
  42. package/simulators/isitup/entrypoint.sh +47 -0
  43. package/simulators/isitup/server.ts +112 -0
  44. package/simulators/namecheap-ddns/server.ts +31 -2
  45. package/src/cli/build.ts +214 -0
  46. package/src/cli/command-registry.ts +100 -0
  47. package/src/cli/command-tree-parser.ts +52 -0
  48. package/src/cli/completion.ts +195 -0
  49. package/src/cli/index.ts +273 -0
  50. package/src/cli/module-discovery.ts +120 -0
  51. package/src/cli/scaffold.ts +108 -0
  52. package/src/container-manager.ts +373 -76
  53. package/src/docker-compose-generator.ts +127 -23
  54. package/src/network-builder.ts +21 -0
  55. package/src/parse-line.ts +147 -0
  56. package/src/runner.ts +197 -247
  57. package/src/simulator-ips.ts +42 -0
  58. package/src/types.ts +28 -0
  59. package/bin/cache-docker-images +0 -46
package/src/runner.ts CHANGED
@@ -20,12 +20,37 @@
20
20
  import { spawn, spawnSync, execSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
22
22
  import { basename, join, resolve } from 'node:path';
23
+ import { parse as parseYaml } from 'yaml';
24
+ import { ProgressDisplay } from '@celilo/cli-display';
25
+ import { parseLine } from './parse-line';
23
26
 
24
27
  // ─── Config ──────────────────────────────────────────────────────────
25
28
 
26
29
  const PKG_DIR = resolve(import.meta.dir, '..');
27
30
  const testDir = process.env.E2E_TEST_DIR || process.cwd();
28
- const testsPath = join(testDir, 'tests');
31
+ /** Set by `cele2e run <module-path>` to run tests from that module's e2e/ dir */
32
+ const moduleDir = process.env.E2E_MODULE_DIR;
33
+ /** Set by `cele2e run` (no args) when invoked outside a module — runs every discovered module's tests. */
34
+ const moduleDirs = process.env.E2E_MODULE_DIRS
35
+ ? process.env.E2E_MODULE_DIRS.split('\n').filter(Boolean)
36
+ : moduleDir
37
+ ? [moduleDir]
38
+ : [];
39
+
40
+ function resolveModuleTestsDir(dir: string): string {
41
+ const manifestPath = join(dir, 'manifest.yml');
42
+ if (existsSync(manifestPath)) {
43
+ try {
44
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { e2e?: { tests_dir?: string } };
45
+ if (parsed?.e2e?.tests_dir) return resolve(dir, parsed.e2e.tests_dir);
46
+ } catch {}
47
+ }
48
+ return join(dir, 'e2e');
49
+ }
50
+
51
+ const testsPath = moduleDir
52
+ ? resolveModuleTestsDir(moduleDir)
53
+ : (process.env.E2E_TESTS_PATH ?? join(testDir, 'tests'));
29
54
  const timingFile = join(testDir, '.e2e-timing.json');
30
55
  const persistentFile = join(testDir, '.e2e-persistent.json');
31
56
 
@@ -33,6 +58,9 @@ const persistentFile = join(testDir, '.e2e-persistent.json');
33
58
  const rawArgs = process.argv.slice(2);
34
59
  const flagKeep = rawArgs.includes('--keep');
35
60
  const flagReuse = rawArgs.includes('--reuse');
61
+ const flagLive = rawArgs.includes('--live');
62
+ const flagPublished = rawArgs.includes('--published');
63
+ const flagVerbose = rawArgs.includes('--verbose');
36
64
  const patterns = rawArgs.filter((a) => !a.startsWith('--'));
37
65
 
38
66
  // ─── Colors ──────────────────────────────────────────────────────────
@@ -44,11 +72,7 @@ const red = '\x1b[31m';
44
72
  const cyan = '\x1b[36m';
45
73
  const reset = '\x1b[0m';
46
74
 
47
- // Terminal control for interactive mode
48
- const CLEAR_LINE = '\x1b[2K\r';
49
- const MOVE_UP = '\x1b[A';
50
-
51
- const interactive = process.stdout.isTTY && !process.argv.includes('--no-interactive');
75
+ const interactive = process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose;
52
76
 
53
77
  // ─── Timing ──────────────────────────────────────────────────────────
54
78
 
@@ -71,240 +95,58 @@ function formatDuration(secs: number): string {
71
95
  return `${secs}s`;
72
96
  }
73
97
 
74
- /** Format countdown: positive = remaining, negative = over estimate */
75
- function formatCountdown(elapsedSecs: number, expectedSecs: number | undefined): string {
76
- if (!expectedSecs) return `${dim}${String(elapsedSecs).padStart(4)}s${reset}`;
77
- const remaining = expectedSecs - elapsedSecs;
78
- if (remaining >= 0) {
79
- return `${dim}${String(remaining).padStart(4)}s${reset}`;
80
- }
81
- return `${red}${String(remaining).padStart(4)}s${reset}`;
82
- }
83
-
84
- // ─── Progress Display ────────────────────────────────────────────────
85
-
86
- interface PendingStep {
87
- doingMsg: string;
88
- doneMsg: string;
89
- startTime: number;
90
- }
91
-
92
- class ProgressDisplay {
93
- private pending: PendingStep | null = null;
94
- private testStart = 0;
95
- private expectedDuration: number | undefined;
96
- /** Number of sub-event lines printed below the current "… doing" line */
97
- private subEventCount = 0;
98
-
99
- reset(testStart: number, expectedDuration: number | undefined) {
100
- this.pending = null;
101
- this.testStart = testStart;
102
- this.expectedDuration = expectedDuration;
103
- this.subEventCount = 0;
104
- }
105
-
106
- private time(): string {
107
- const elapsed = Math.floor((Date.now() - this.testStart) / 1000);
108
- return formatCountdown(elapsed, this.expectedDuration);
109
- }
110
-
111
- /**
112
- * In interactive mode: erase the "… doing" line and all sub-event
113
- * lines below it, then print the "✔ done" line in their place.
114
- * In non-interactive mode: just append the done line.
115
- */
116
- private finalizePending() {
117
- if (!this.pending) return;
118
- if (interactive) {
119
- // Erase sub-event lines + the pending "…" line
120
- const linesToErase = this.subEventCount + 1;
121
- for (let i = 0; i < linesToErase; i++) {
122
- process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
123
- }
124
- }
125
- process.stdout.write(` ${this.time()} ${green}✔${reset} ${this.pending.doneMsg}\n`);
126
- this.pending = null;
127
- this.subEventCount = 0;
128
- }
129
-
130
- private finalizeFailed(message: string) {
131
- if (interactive && this.pending) {
132
- const linesToErase = this.subEventCount + 1;
133
- for (let i = 0; i < linesToErase; i++) {
134
- process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
135
- }
136
- }
137
- process.stdout.write(` ${this.time()} ${red}✗${reset} ${message}\n`);
138
- this.pending = null;
139
- this.subEventCount = 0;
140
- }
141
-
142
- startStep(doingMsg: string, doneMsg: string) {
143
- this.finalizePending();
144
- this.pending = { doingMsg, doneMsg, startTime: Date.now() };
145
- this.subEventCount = 0;
146
- process.stdout.write(` ${this.time()} ${dim}…${reset} ${doingMsg}\n`);
147
- }
148
-
149
- doneStep(overrideMessage?: string) {
150
- if (this.pending && overrideMessage) {
151
- this.pending.doneMsg = overrideMessage;
152
- }
153
- this.finalizePending();
154
- }
155
-
156
- failStep(message: string) {
157
- this.finalizeFailed(message);
158
- }
159
-
160
- instantEvent(message: string) {
161
- this.finalizePending();
162
- process.stdout.write(` ${this.time()} ${message}\n`);
163
- }
164
-
165
- /**
166
- * Show a sub-event under the current "… doing" step.
167
- * Interactive: replaces the "… doing" line with the sub-event,
168
- * then reprints "… doing" below it — so the pending line is always last.
169
- * Non-interactive: just appends indented.
170
- */
171
- subEvent(message: string) {
172
- if (interactive && this.pending) {
173
- // Replace the "… doing" line with this sub-event (permanent)
174
- process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
175
- process.stdout.write(` ${this.time()} ${message}\n`);
176
- // Reprint the "… doing" line as the new bottom line
177
- process.stdout.write(` ${this.time()} ${dim}…${reset} ${this.pending.doingMsg}\n`);
178
- this.subEventCount++;
179
- } else {
180
- process.stdout.write(` ${this.time()} ${message}\n`);
181
- if (this.pending) this.subEventCount++;
182
- }
183
- }
184
-
185
- /** Finalize pending on success */
186
- flush() {
187
- this.finalizePending();
188
- }
98
+ // ─── Error Extraction ────────────────────────────────────────────────
189
99
 
190
- /** Abandon pending step on failure (erase the … line in interactive mode) */
191
- abandon() {
192
- if (!this.pending) return;
193
- if (interactive) {
194
- const linesToErase = this.subEventCount + 1;
195
- for (let i = 0; i < linesToErase; i++) {
196
- process.stdout.write(`${MOVE_UP}${CLEAR_LINE}`);
197
- }
198
- }
199
- this.pending = null;
200
- this.subEventCount = 0;
201
- }
100
+ // Strips ANSI escape sequences so pattern matching works on bun's coloured output
101
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
102
+ function stripAnsi(s: string): string {
103
+ return s.replace(ANSI_RE, '');
202
104
  }
203
105
 
204
- // ─── Line Parsing ────────────────────────────────────────────────────
205
-
206
- function parseLine(line: string, display: ProgressDisplay): void {
207
- // Protocol: [progress:start] doing | done
208
- const startMatch = line.match(/\[progress:start\] (.*?) \| (.*)/);
209
- if (startMatch) {
210
- display.startStep(startMatch[1], startMatch[2]);
211
- return;
212
- }
213
-
214
- // Protocol: [progress:done] optional message
215
- const doneMatch = line.match(/\[progress:done\](.*)/);
216
- if (doneMatch) {
217
- const msg = doneMatch[1].trim();
218
- display.doneStep(msg || undefined);
219
- return;
220
- }
221
-
222
- // Protocol: [progress:fail] message
223
- const failMatch = line.match(/\[progress:fail\] (.*)/);
224
- if (failMatch) {
225
- display.failStep(failMatch[1]);
226
- return;
227
- }
228
-
229
- // Legacy: [progress] message (treat as instant event)
230
- const legacyMatch = line.match(/\[progress\] (.*)/);
231
- if (legacyMatch) {
232
- display.instantEvent(legacyMatch[1]);
233
- return;
234
- }
235
-
236
- // Celilo CLI signals — shown as sub-events under the current step.
237
- // These must NOT finalize the test's progress step.
238
- const deployMatch = line.match(/Module '([^']+)' deployed successfully/);
239
- if (deployMatch) {
240
- display.subEvent(`${green}✔${reset} ${deployMatch[1]} deployed`);
241
- return;
242
- }
243
-
244
- const importMatch = line.match(/Successfully imported module: (\S+)/);
245
- if (importMatch) {
246
- display.subEvent(`${green}✔${reset} ${importMatch[1]} imported`);
247
- return;
248
- }
249
-
250
- if (line.includes('Deploying software')) {
251
- display.subEvent('running Ansible');
252
- return;
253
- }
254
- if (line.includes('Infrastructure selected')) {
255
- display.subEvent('infrastructure selected');
256
- return;
257
- }
258
- if (line.includes('Terraform skipped')) {
259
- display.subEvent('terraform skipped (existing machine)');
260
- return;
261
- }
262
- if (line.includes('terraform init') || line.includes('Terraform init')) {
263
- display.subEvent('running terraform init');
264
- return;
265
- }
266
- if (line.includes('terraform apply') || line.includes('Terraform apply')) {
267
- display.subEvent('running terraform apply');
268
- return;
269
- }
270
-
271
- const retryMatch = line.match(/RETRYING.*?(\d+) retries left/);
272
- if (retryMatch) {
273
- display.subEvent(`health check (${retryMatch[1]} retries left)`);
274
- return;
106
+ function extractFailureMessage(lines: string[], exitCode: number): string {
107
+ const plain = lines.map(stripAnsi);
108
+
109
+ // bun's own test failure: "✗ test name [Xms]" — extract the failing test name
110
+ const bunFailIdx = plain.findIndex((l) => /^\s*✗\s+\S/.test(l) && !l.includes('failed ('));
111
+ if (bunFailIdx >= 0) {
112
+ const testName = plain[bunFailIdx].replace(/^\s*✗\s+/, '').replace(/\s*\[\d+ms\].*$/, '').trim();
113
+ // Grab the error detail on the next few lines (bun indents it under the test)
114
+ const detail = plain
115
+ .slice(bunFailIdx + 1, bunFailIdx + 6)
116
+ .map((l) => l.trim())
117
+ .filter((l) => l.length > 0 && !l.startsWith('at ') && !l.startsWith('│') && l !== '└─')
118
+ .slice(0, 2)
119
+ .join(' — ');
120
+ return detail ? `${testName}: ${detail}` : testName;
275
121
  }
276
122
 
277
- // Error lines
278
- if (line.includes('Error:') && !line.includes('bun test') && !line.includes('ipv4: Address already')) {
279
- const err = line.replace(/.*Error: /, '').slice(0, 70);
280
- display.subEvent(`${red}✗${reset} ${err}`);
281
- }
282
- }
283
-
284
- // ─── Error Extraction ────────────────────────────────────────────────
285
-
286
- function extractFailureMessage(lines: string[], exitCode: number): string {
287
- const errorIdx = lines.findIndex(
288
- (l) => l.startsWith('error:') && !l.includes('bun test'),
123
+ // "error: ..." lines (may be indented in bun output)
124
+ const errorIdx = plain.findIndex(
125
+ (l) => l.trimStart().startsWith('error:') && !l.includes('bun test'),
289
126
  );
290
-
291
127
  if (errorIdx >= 0) {
292
- const context = lines
128
+ const context = plain
293
129
  .slice(errorIdx, errorIdx + 5)
294
130
  .map((l) => l.trim())
295
131
  .filter((l) => l.length > 0 && !l.startsWith('at '));
296
132
  return context.join('\n ');
297
133
  }
298
134
 
299
- const celiloError = lines.find(
135
+ const celiloError = plain.find(
300
136
  (l) => l.includes('CeliloCommandError') || (l.includes('celilo') && l.includes('failed')),
301
137
  );
302
138
  if (celiloError) return celiloError.slice(0, 120);
303
139
 
304
- const timeout = lines.find((l) => l.includes('Timeout') || l.includes('timed out'));
140
+ const timeout = plain.find((l) => l.includes('Timeout') || l.includes('timed out'));
305
141
  if (timeout) return timeout.slice(0, 120);
306
142
 
307
- return `exit code ${exitCode}`;
143
+ // Last resort: print the last few non-empty, non-progress lines from the output
144
+ const tail = plain
145
+ .filter((l) => l.length > 0 && !l.startsWith('[progress') && !l.startsWith('[e2e:') && !l.startsWith('[ansible:'))
146
+ .slice(-3)
147
+ .map((l) => l.trim())
148
+ .join(' | ');
149
+ return tail || `exit code ${exitCode}`;
308
150
  }
309
151
 
310
152
  // ─── Test Execution ──────────────────────────────────────────────────
@@ -314,6 +156,8 @@ interface TestResult {
314
156
  status: 'pass' | 'fail' | 'suspicious';
315
157
  duration: number;
316
158
  error?: string;
159
+ /** Last ~30 lines of raw test output, shown on failure so the user doesn't have to dig in logs */
160
+ rawTail?: string;
317
161
  hadDebugPause?: boolean;
318
162
  projectName?: string;
319
163
  }
@@ -333,9 +177,19 @@ async function runTest(
333
177
  let capturedProjectName: string | undefined;
334
178
 
335
179
  return new Promise<TestResult>((resolveTest) => {
336
- // Env vars let the test detect --keep / --reuse modes
180
+ // Env vars let the test detect --keep / --reuse / --live / --published modes
337
181
  const testEnv: NodeJS.ProcessEnv = { ...process.env };
338
182
  if (flagKeep) testEnv.CELILO_E2E_KEEP = '1';
183
+ if (flagLive) {
184
+ // Resolve infra root relative to the test directory (sibling repo convention)
185
+ const inferredRoot = resolve(testDir, '../../infra');
186
+ testEnv.CELILO_E2E_INFRA_ROOT = existsSync(join(inferredRoot, 'apps', 'celilo'))
187
+ ? inferredRoot
188
+ : resolve(PKG_DIR, '..', '..'); // fallback: @celilo/e2e is inside the infra monorepo
189
+ }
190
+ if (flagPublished) {
191
+ testEnv.CELILO_E2E_INFRA_ROOT = ''; // empty = force published mode
192
+ }
339
193
  if (flagReuse) {
340
194
  // Read the persistent project and pass it to the test
341
195
  try {
@@ -364,9 +218,9 @@ async function runTest(
364
218
 
365
219
  const signalFile = join(PKG_DIR, `.debug-resume-${proc.pid}`);
366
220
  const border = '═'.repeat(56);
367
- process.stdout.write(`\n${bold}\x1b[33m╔${border}╗${reset}\n`);
368
- process.stdout.write(`${bold}\x1b[33m║ DEBUG: test paused ║${reset}\n`);
369
- process.stdout.write(`${bold}\x1b[33m╠${border}╣${reset}\n`);
221
+ process.stdout.write(`\n${bold}\x1b[33m╔${border}${reset}\n`);
222
+ process.stdout.write(`${bold}\x1b[33m║ DEBUG: test paused${reset}\n`);
223
+ process.stdout.write(`${bold}\x1b[33m╠${border}${reset}\n`);
370
224
  process.stdout.write(`${bold}\x1b[33m║${reset} Project: ${bold}${project}${reset}\n`);
371
225
  process.stdout.write(`${bold}\x1b[33m║${reset} Container: ${bold}${container}${reset}\n`);
372
226
  process.stdout.write(`${bold}\x1b[33m║${reset}\n`);
@@ -377,7 +231,7 @@ async function runTest(
377
231
 
378
232
  if (interactive) {
379
233
  process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Or get a shell: type 'exit' to resume the test.${reset}\n`);
380
- process.stdout.write(`${bold}\x1b[33m╚${border}╝${reset}\n\n`);
234
+ process.stdout.write(`${bold}\x1b[33m╚${border}${reset}\n\n`);
381
235
 
382
236
  spawnSync('docker', [
383
237
  'compose', '-f', 'docker-compose.test.yml', '-p', project,
@@ -388,7 +242,7 @@ async function runTest(
388
242
  });
389
243
  } else {
390
244
  process.stdout.write(`${bold}\x1b[33m║${reset} ${dim}Resume: touch ${signalFile}${reset}\n`);
391
- process.stdout.write(`${bold}\x1b[33m╚${border}╝${reset}\n\n`);
245
+ process.stdout.write(`${bold}\x1b[33m╚${border}${reset}\n\n`);
392
246
 
393
247
  // Non-interactive: just wait for the signal file (no shell)
394
248
  // The caller creates the file when they're done investigating.
@@ -423,6 +277,11 @@ async function runTest(
423
277
  continue;
424
278
  }
425
279
 
280
+ // Detect bun test failure so pending progress steps show ✗ instead of ✔
281
+ if (/^\s*\(fail\)\s/.test(trimmed) || /^\s*✗\s+.+\[/.test(trimmed)) {
282
+ display.markTestFailing();
283
+ }
284
+
426
285
  parseLine(trimmed, display);
427
286
  }
428
287
  }
@@ -438,7 +297,7 @@ async function runTest(
438
297
  if (code === 0) {
439
298
  display.flush();
440
299
  } else {
441
- display.abandon();
300
+ display.abandonFailed();
442
301
  }
443
302
 
444
303
  writeFileSync(logFile, stdout + '\n---STDERR---\n' + stderr);
@@ -460,6 +319,20 @@ async function runTest(
460
319
  resolveTest({ name, status: 'pass', duration, hadDebugPause, projectName: capturedProjectName });
461
320
  }
462
321
  } else {
322
+ // Collect a raw tail: strip ANSI, drop internal protocol lines, keep last 30
323
+ const rawTail = (stdout + stderr)
324
+ .split('\n')
325
+ .map(stripAnsi)
326
+ .map((l) => l.trimEnd())
327
+ .filter((l) =>
328
+ l.length > 0 &&
329
+ !l.startsWith('[progress') &&
330
+ !l.startsWith('[e2e:') &&
331
+ !l.startsWith('[ansible:') &&
332
+ !l.startsWith('[debug:'),
333
+ )
334
+ .slice(-30)
335
+ .join('\n');
463
336
  resolveTest({
464
337
  name,
465
338
  status: 'fail',
@@ -467,6 +340,7 @@ async function runTest(
467
340
  hadDebugPause,
468
341
  projectName: capturedProjectName,
469
342
  error: extractFailureMessage(lines, code ?? 1),
343
+ rawTail,
470
344
  });
471
345
  }
472
346
  });
@@ -499,18 +373,63 @@ function stopSharedInfra(): void {
499
373
  try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
500
374
  }
501
375
 
502
- // ─── Main ────────────────────────────────────────────────────────────
376
+ // ─── Docker Check ────────────────────────────────────────────────────
503
377
 
504
- async function main() {
505
- if (!existsSync(testsPath)) {
506
- console.error(`${red}No tests/ directory found at ${testsPath}${reset}`);
378
+ function assertDockerAvailable(): void {
379
+ try {
380
+ execSync('docker info', { stdio: 'pipe', timeout: 5_000 });
381
+ } catch {
382
+ console.error(`\n${red}✗ Docker is not running.${reset}`);
383
+ console.error(` Start it with: ${bold}colima start${reset}`);
384
+ console.error(` Then retry: ${bold}cele2e run${reset}\n`);
507
385
  process.exit(1);
508
386
  }
387
+ }
388
+
389
+ // ─── Main ────────────────────────────────────────────────────────────
390
+
391
+ async function main() {
392
+ assertDockerAvailable();
509
393
 
510
- let testFiles = readdirSync(testsPath)
511
- .filter((f) => f.endsWith('.test.ts'))
512
- .sort()
513
- .map((f) => resolve(testsPath, f));
394
+ let testFiles: string[] = [];
395
+
396
+ if (moduleDirs.length > 0) {
397
+ // One or more modules — collect tests from each module's e2e dir.
398
+ for (const dir of moduleDirs) {
399
+ if (!existsSync(join(dir, 'manifest.yml'))) {
400
+ console.error(`${red}Not a celilo module: no manifest.yml found at ${dir}${reset}`);
401
+ process.exit(1);
402
+ }
403
+ const tp = resolveModuleTestsDir(dir);
404
+ if (!existsSync(tp)) {
405
+ console.error(`${red}No tests directory found at ${tp}${reset}`);
406
+ console.error(`${red}Set e2e.tests_dir in ${join(dir, 'manifest.yml')} to point to your test files.${reset}`);
407
+ process.exit(1);
408
+ }
409
+ // Auto-install e2e/ dependencies if package.json exists but node_modules is missing
410
+ const e2ePkg = join(tp, 'package.json');
411
+ const e2eNodeModules = join(tp, 'node_modules');
412
+ if (existsSync(e2ePkg) && !existsSync(e2eNodeModules)) {
413
+ process.stdout.write(`${dim}Installing e2e dependencies for ${basename(dir)}...${reset}\n`);
414
+ execSync('bun install', { cwd: tp, stdio: 'inherit' });
415
+ process.stdout.write('\n');
416
+ }
417
+ const files = readdirSync(tp)
418
+ .filter((f) => f.endsWith('.test.ts'))
419
+ .sort()
420
+ .map((f) => resolve(tp, f));
421
+ testFiles.push(...files);
422
+ }
423
+ } else {
424
+ if (!existsSync(testsPath)) {
425
+ console.error(`${red}No tests/ directory found at ${testsPath}${reset}`);
426
+ process.exit(1);
427
+ }
428
+ testFiles = readdirSync(testsPath)
429
+ .filter((f) => f.endsWith('.test.ts'))
430
+ .sort()
431
+ .map((f) => resolve(testsPath, f));
432
+ }
514
433
 
515
434
  if (patterns.length > 0) {
516
435
  testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
@@ -521,20 +440,40 @@ async function main() {
521
440
  process.exit(1);
522
441
  }
523
442
 
443
+ // --keep keeps a single test network alive after the run; only one network
444
+ // can live in `.e2e-persistent.json` at a time, so multiple tests would
445
+ // either fight for that slot or run with no functional --keep at all. Bail
446
+ // early with a clear message instead of silently doing the wrong thing.
447
+ if (flagKeep && testFiles.length > 1) {
448
+ console.error(
449
+ `${red}--keep matched ${testFiles.length} tests; --keep only works with a single test.${reset}`,
450
+ );
451
+ console.error(
452
+ `${dim}Narrow the pattern (e.g. \`cele2e run ${basename(testFiles[0], '.test.ts')} --keep\`) or drop --keep.${reset}`,
453
+ );
454
+ process.exit(1);
455
+ }
456
+
524
457
  const history = loadTiming();
525
458
  const testNames = testFiles.map((f) => basename(f, '.test.ts'));
526
459
  const hasHistory = testNames.some((n) => history[n]);
527
460
  const suiteExpected = testNames.reduce((sum, n) => sum + (history[n] || 0), 0);
528
461
 
462
+ const moduleLabel =
463
+ moduleDirs.length === 1
464
+ ? ` — ${basename(moduleDirs[0])}`
465
+ : moduleDirs.length > 1
466
+ ? ` — ${moduleDirs.length} modules`
467
+ : '';
529
468
  console.log();
530
- console.log(`${bold}╔══════════════════════════════════════════════════════════════╗${reset}`);
531
- console.log(`${bold}║ Celilo E2E Tests ║${reset}`);
469
+ console.log(`${bold}╔══════════════════════════════════════════════════════════════${reset}`);
470
+ console.log(`${bold}║ Celilo E2E Tests${moduleLabel}${reset}`);
532
471
  if (hasHistory) {
533
- console.log(`${bold}║ ${testFiles.length} test(s) — expected ~${formatDuration(suiteExpected).padEnd(30)}║${reset}`);
472
+ console.log(`${bold}║ ${testFiles.length} test(s) — expected ~${formatDuration(suiteExpected)}${reset}`);
534
473
  } else {
535
- console.log(`${bold}║ ${testFiles.length} test(s) — first run, no timing history ║${reset}`);
474
+ console.log(`${bold}║ ${testFiles.length} test(s) — first run, no timing history${reset}`);
536
475
  }
537
- console.log(`${bold}╚══════════════════════════════════════════════════════════════╝${reset}`);
476
+ console.log(`${bold}╚══════════════════════════════════════════════════════════════${reset}`);
538
477
  console.log();
539
478
 
540
479
  const suiteStart = Date.now();
@@ -542,7 +481,10 @@ async function main() {
542
481
  const resultsDir = join(testDir, 'results', timestamp);
543
482
  mkdirSync(resultsDir, { recursive: true });
544
483
 
545
- const display = new ProgressDisplay();
484
+ const display = new ProgressDisplay({
485
+ mode: 'render',
486
+ out: { write: process.stdout.write.bind(process.stdout), isTTY: interactive },
487
+ });
546
488
  let passed = 0;
547
489
  let failed = 0;
548
490
  const results: TestResult[] = [];
@@ -600,7 +542,7 @@ async function main() {
600
542
  );
601
543
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
602
544
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
603
- console.log(`${dim} Tear down: e2e-down${reset}`);
545
+ console.log(`${dim} Tear down: cele2e down${reset}`);
604
546
  }
605
547
 
606
548
  if (result.status === 'pass') {
@@ -615,6 +557,14 @@ async function main() {
615
557
  if (result.error) {
616
558
  console.log(` ${red} └─ ${result.error}${reset}`);
617
559
  }
560
+ if (result.rawTail) {
561
+ console.log();
562
+ console.log(`${dim} ── test output ──────────────────────────────────────${reset}`);
563
+ for (const line of result.rawTail.split('\n')) {
564
+ console.log(` ${dim}${line}${reset}`);
565
+ }
566
+ console.log(`${dim} ─────────────────────────────────────────────────────${reset}`);
567
+ }
618
568
  failed++;
619
569
  }
620
570
  console.log();
@@ -635,11 +585,11 @@ async function main() {
635
585
  const suiteDuration = Math.floor((Date.now() - suiteStart) / 1000);
636
586
 
637
587
  console.log();
638
- console.log(`${bold}╔══════════════════════════════════════════════════════════════╗${reset}`);
588
+ console.log(`${bold}╔══════════════════════════════════════════════════════════════${reset}`);
639
589
  console.log(
640
- `${bold}║ Results: ${green}${passed} passed${reset}${bold}, ${red}${failed} failed${reset}${bold} — ${formatDuration(suiteDuration)} total${' '.repeat(20)}║${reset}`,
590
+ `${bold}║ Results: ${green}${passed} passed${reset}${bold}, ${red}${failed} failed${reset}${bold} — ${formatDuration(suiteDuration)} total${reset}`,
641
591
  );
642
- console.log(`${bold}╠══════════════════════════════════════════════════════════════╣${reset}`);
592
+ console.log(`${bold}╠══════════════════════════════════════════════════════════════${reset}`);
643
593
 
644
594
  for (const r of results) {
645
595
  const icon = r.status === 'pass' ? '✓' : '✗';
@@ -650,7 +600,7 @@ async function main() {
650
600
  }
651
601
  }
652
602
 
653
- console.log(`${bold}╚══════════════════════════════════════════════════════════════╝${reset}`);
603
+ console.log(`${bold}╚══════════════════════════════════════════════════════════════${reset}`);
654
604
  console.log();
655
605
  console.log(`${dim}Full logs: ${resultsDir}${reset}`);
656
606
 
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Single source of truth for the IPs of e2e simulator containers on
3
+ * the `internet-external` Docker network.
4
+ *
5
+ * Renumbering one of these used to mean tracking down the value in
6
+ * eight-ish places (compose files, generator, zone files, resolver
7
+ * configs, routing scripts). This module is the canonical record for
8
+ * code; non-code files (Knot zones, shell scripts) still hard-code the
9
+ * values, but `scripts/check-simulator-ips.sh` flags any drift in CI.
10
+ *
11
+ * To renumber a simulator:
12
+ * 1. Update the constant here.
13
+ * 2. Run `bash packages/e2e/scripts/check-simulator-ips.sh` — it
14
+ * lists every non-code file that still has the old value.
15
+ * 3. Edit those files. Re-run the check until it passes.
16
+ * 4. `cele2e build-infra` to rebake the images.
17
+ */
18
+
19
+ export const SIMULATOR_IPS = {
20
+ /** Authoritative root DNS (`.` zone). */
21
+ ROOT_DNS: '100.64.0.53',
22
+ /** Authoritative TLD DNS for `com.`, `org.`, `net.`. */
23
+ TLD_DNS: '100.64.0.54',
24
+ /** Authoritative DNS for the customer domains; also serves the DDNS update endpoint at port 8080. */
25
+ NAMECHEAP_DNS: '100.64.0.55',
26
+ /** Internal e2e module registry (replaces production celilo.computer in tests). */
27
+ REGISTRY: '100.64.0.56',
28
+ /** External-website prober simulator (mimics isitup.org). */
29
+ ISITUP: '100.64.0.57',
30
+ /** Pebble ACME server (replaces production Let's Encrypt). */
31
+ PEBBLE: '100.64.0.100',
32
+ } as const;
33
+
34
+ export type SimulatorIpName = keyof typeof SIMULATOR_IPS;
35
+
36
+ /**
37
+ * Iterable list of `[name, ip]` pairs — useful for the sanity check
38
+ * script and any future consumer that wants to enumerate the set.
39
+ */
40
+ export const SIMULATOR_IP_ENTRIES: ReadonlyArray<readonly [SimulatorIpName, string]> = (
41
+ Object.entries(SIMULATOR_IPS) as Array<[SimulatorIpName, string]>
42
+ ).map(([k, v]) => [k, v] as const);