@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.
- package/README.md +2 -1
- package/bin/cele2e +14 -0
- package/bin/e2e-cache-images +57 -0
- package/bin/e2e-shell +36 -13
- package/bin/e2e-status +8 -2
- package/bin/e2e-up +5 -5
- package/completions/install-zsh-completion.sh +78 -0
- package/config/dns/celilo.computer.zone +16 -0
- package/config/dns/iamtheinternet.org.zone +7 -6
- package/config/dns/knot-namecheap.conf +8 -0
- package/config/dns/knot-tld.conf +4 -0
- package/config/dns/lunacycle.net.zone +16 -0
- package/config/dns/net.zone +11 -0
- package/config/dns/org.zone +5 -0
- package/config/dns/root.zone +2 -0
- package/config/dns/templates/iamtheinternet.org.zone +16 -0
- package/config/dns/templates/lunacycle.net.zone +16 -0
- package/config/resolver/unbound.conf +10 -0
- package/config/routing/docker-image-preload.service +14 -0
- package/config/routing/docker-image-preload.sh +24 -0
- package/config/routing/management-routes.sh +24 -2
- package/config/routing/resolver-internal-routes.sh +59 -0
- package/docker/Dockerfile.internal-resolver +16 -0
- package/docker/Dockerfile.isitup +37 -0
- package/docker/Dockerfile.management +2 -0
- package/docker/Dockerfile.registry +34 -0
- package/docker/Dockerfile.target-machine +20 -0
- package/docker/Dockerfile.target-machine-docker +11 -19
- package/docker/Dockerfile.target-technitium +97 -0
- package/netapps/authentik.netapp +0 -0
- package/netapps/caddy.netapp +0 -0
- package/netapps/greenwave.netapp +0 -0
- package/netapps/homebridge.netapp +0 -0
- package/netapps/iptables.netapp +0 -0
- package/netapps/knot-unbound-internal.netapp +0 -0
- package/netapps/namecheap.netapp +0 -0
- package/netapps/technitium.netapp +0 -0
- package/netapps/test-ansible-output.netapp +0 -0
- package/package.json +8 -1
- package/simulators/greenwave/state.ts +7 -12
- package/simulators/isitup/Caddyfile +11 -0
- package/simulators/isitup/entrypoint.sh +47 -0
- package/simulators/isitup/server.ts +112 -0
- package/simulators/namecheap-ddns/server.ts +31 -2
- package/src/cli/build.ts +214 -0
- package/src/cli/command-registry.ts +100 -0
- package/src/cli/command-tree-parser.ts +52 -0
- package/src/cli/completion.ts +195 -0
- package/src/cli/index.ts +273 -0
- package/src/cli/module-discovery.ts +120 -0
- package/src/cli/scaffold.ts +108 -0
- package/src/container-manager.ts +373 -76
- package/src/docker-compose-generator.ts +127 -23
- package/src/network-builder.ts +21 -0
- package/src/parse-line.ts +147 -0
- package/src/runner.ts +197 -247
- package/src/simulator-ips.ts +42 -0
- package/src/types.ts +28 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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}
|
|
368
|
-
process.stdout.write(`${bold}\x1b[33m║ DEBUG: test paused
|
|
369
|
-
process.stdout.write(`${bold}\x1b[33m╠${border}
|
|
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}
|
|
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}
|
|
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.
|
|
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
|
-
// ───
|
|
376
|
+
// ─── Docker Check ────────────────────────────────────────────────────
|
|
503
377
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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 =
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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}
|
|
531
|
-
console.log(`${bold}║ Celilo E2E Tests
|
|
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)
|
|
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
|
|
474
|
+
console.log(`${bold}║ ${testFiles.length} test(s) — first run, no timing history${reset}`);
|
|
536
475
|
}
|
|
537
|
-
console.log(`${bold}
|
|
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:
|
|
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}
|
|
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${
|
|
590
|
+
`${bold}║ Results: ${green}${passed} passed${reset}${bold}, ${red}${failed} failed${reset}${bold} — ${formatDuration(suiteDuration)} total${reset}`,
|
|
641
591
|
);
|
|
642
|
-
console.log(`${bold}
|
|
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}
|
|
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);
|