@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 CHANGED
@@ -39,7 +39,7 @@ case "${1:-status}" in
39
39
 
40
40
  echo -e "${DIM}Waiting for DNS convergence...${NC}"
41
41
  for i in $(seq 1 30); do
42
- DNS=$(docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" exec -T comcast-resolver dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2 2>/dev/null || true)
42
+ DNS=$(docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2 2>/dev/null || true)
43
43
  if [ -n "$DNS" ]; then
44
44
  echo -e "${GREEN}Shared infrastructure ready${NC}"
45
45
  echo ""
@@ -72,7 +72,7 @@ case "${1:-status}" in
72
72
  docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" ps --format " {{.Name}}\t{{.Status}}"
73
73
 
74
74
  # Health check
75
- DNS=$(docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" exec -T comcast-resolver dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2 2>/dev/null || true)
75
+ DNS=$(docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2 2>/dev/null || true)
76
76
  echo ""
77
77
  if [ -n "$DNS" ]; then
78
78
  echo -e " DNS: ${GREEN}healthy${NC}"
package/bin/e2e-run CHANGED
@@ -1,354 +1,5 @@
1
1
  #!/bin/bash
2
- #
3
- # E2E Test Runner with Live Progress Reporting
4
- #
5
- # Runs each test file sequentially with real-time progress display.
6
- # Shows sub-steps as they happen (building, deploying, configuring).
7
- # Captures full output per test in e2e/results/<timestamp>/.
8
- #
9
- # Shared infrastructure (DNS, Pebble, apt-cache) is started once
10
- # before the first test and torn down after the last test. Per-test
11
- # containers connect to the shared networks.
12
- #
13
- # Usage:
14
- # ./e2e/bin/e2e-run # run all tests
15
- # ./e2e/bin/e2e-run smoke caddy # run tests matching these patterns
16
- #
17
-
18
- set -e
19
-
2
+ # E2E test runner — delegates to the TypeScript runner
20
3
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
- # Package root (where docker/, config/ live)
22
4
  PKG_DIR="$(dirname "$SCRIPT_DIR")"
23
- # Test directory — can be overridden via E2E_TEST_DIR env var.
24
- # Default: look for tests/ relative to CWD, or fall back to package dir.
25
- E2E_TEST_DIR="${E2E_TEST_DIR:-$(pwd)}"
26
- cd "$PKG_DIR"
27
-
28
- SHARED_PROJECT="celilo-e2e-shared"
29
- SHARED_COMPOSE="docker-compose.shared.yml"
30
-
31
- # Handle Ctrl+C: kill running test, clean up, and exit immediately.
32
- INTERRUPTED=false
33
- cleanup_and_exit() {
34
- INTERRUPTED=true
35
- echo ""
36
- echo -e "${RED}Interrupted. Cleaning up...${NC}"
37
- # Kill any running test process
38
- [ -n "$TEST_PID" ] && kill $TEST_PID 2>/dev/null
39
- # Remove named pipe
40
- [ -n "$PIPE" ] && rm -f "$PIPE"
41
- # Tear down shared infra
42
- echo -e "${DIM} Stopping shared infrastructure...${NC}"
43
- docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" down --volumes --remove-orphans > /dev/null 2>&1 || true
44
- # Clean up Docker
45
- docker network prune -f > /dev/null 2>&1 || true
46
- echo -e "${RED}Aborted.${NC}"
47
- exit 130
48
- }
49
- trap cleanup_and_exit INT TERM
50
-
51
- # Colors
52
- GREEN='\033[0;32m'
53
- RED='\033[0;31m'
54
- DIM='\033[2m'
55
- BOLD='\033[1m'
56
- CYAN='\033[0;36m'
57
- NC='\033[0m'
58
-
59
- # Create results directory (in the test project, not the package)
60
- TIMESTAMP=$(date +%Y%m%d-%H%M%S)
61
- RESULTS_DIR="$E2E_TEST_DIR/results/$TIMESTAMP"
62
- mkdir -p "$RESULTS_DIR"
63
-
64
- # Discover test files from E2E_TEST_DIR
65
- TESTS_PATH="$E2E_TEST_DIR/tests"
66
- if [ ! -d "$TESTS_PATH" ]; then
67
- echo -e "${RED}No tests/ directory found at $TESTS_PATH${NC}"
68
- echo "Set E2E_TEST_DIR to the directory containing your tests/ folder."
69
- exit 1
70
- fi
71
-
72
- if [ $# -gt 0 ]; then
73
- TEST_FILES=()
74
- for pattern in "$@"; do
75
- for f in "$TESTS_PATH"/*${pattern}*.test.ts; do
76
- [ -f "$f" ] && TEST_FILES+=("$f")
77
- done
78
- done
79
- else
80
- TEST_FILES=("$TESTS_PATH"/*.test.ts)
81
- fi
82
-
83
- TOTAL=${#TEST_FILES[@]}
84
- PASSED=0
85
- FAILED=0
86
- RESULTS=()
87
-
88
- echo ""
89
- echo -e "${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}"
90
- echo -e "${BOLD}║ Celilo E2E Tests ║${NC}"
91
- printf "${BOLD}║ %d test(s) queued — %s ║${NC}\n" "$TOTAL" "$(date +%H:%M:%S)"
92
- echo -e "${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}"
93
- echo ""
94
-
95
- SUITE_START=$(date +%s)
96
-
97
- # ─── Shared Infrastructure Startup ────────────────────────────────────
98
- # The first test will call ensureSharedInfra() which starts the shared
99
- # project. But we also pre-check here so the runner can show progress.
100
- echo -e "${BOLD}Shared Infrastructure${NC}"
101
-
102
- # Check if shared infra is already running
103
- SHARED_RUNNING=false
104
- SHARED_CONTAINERS=$(docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" ps -q 2>/dev/null || true)
105
- if [ -n "$SHARED_CONTAINERS" ]; then
106
- # Verify DNS is healthy
107
- DNS_CHECK=$(docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" exec -T comcast-resolver dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2 2>/dev/null || true)
108
- if [ -n "$DNS_CHECK" ]; then
109
- SHARED_RUNNING=true
110
- echo -e " ${GREEN}✔ already running (DNS healthy)${NC}"
111
- else
112
- echo -e " ${DIM}DNS unhealthy, restarting...${NC}"
113
- docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" down --volumes --remove-orphans > /dev/null 2>&1 || true
114
- fi
115
- fi
116
-
117
- if [ "$SHARED_RUNNING" = false ]; then
118
- echo -e " ${DIM}Starting DNS hierarchy, Pebble ACME, apt-cache...${NC}"
119
- # The actual startup happens inside the first test's ensureSharedInfra()
120
- # call. We just report it here for visibility.
121
- fi
122
-
123
- SHARED_END=$(date +%s)
124
- echo ""
125
-
126
- # Track step count per test for percentage
127
- STEP_COUNT=0
128
- ESTIMATED_STEPS=15
129
-
130
- # Parse a line of test output and display as a progress step.
131
- show_progress() {
132
- local line="$1"
133
- local test_start="$2"
134
- local step=""
135
-
136
- # Shared infra progress (from ensureSharedInfra)
137
- if [[ "$line" == *"[e2e] Shared infrastructure ready"* ]]; then
138
- step="✔ shared infrastructure ready"
139
- elif [[ "$line" == *"[e2e] Building shared"* ]]; then
140
- step="building shared infrastructure images"
141
- elif [[ "$line" == *"[e2e] Starting shared"* ]]; then
142
- step="starting shared infrastructure"
143
- elif [[ "$line" == *"[e2e] Cleaning up stale shared"* ]]; then
144
- step="cleaning up stale shared infra"
145
-
146
- # Per-test container manager progress
147
- elif [[ "$line" == *"[e2e] Building images"* ]]; then
148
- step="building test images"
149
- elif [[ "$line" == *"[e2e] Starting containers"* ]]; then
150
- step="starting test containers"
151
- elif [[ "$line" == *"[e2e] Cleaning up"* ]]; then
152
- step="cleaning up stale test resources"
153
- elif [[ "$line" == *"[e2e] Waiting for DNS"* ]]; then
154
- step="waiting for DNS convergence"
155
- elif [[ "$line" == *"[e2e] Verifying routing"* ]]; then
156
- step="verifying routing"
157
- elif [[ "$line" == *"[e2e] Waiting for target"* ]]; then
158
- step="waiting for target machines"
159
- elif [[ "$line" == *"[e2e] Waiting for DHCP"* ]]; then
160
- step="waiting for DHCP"
161
- elif [[ "$line" == *"[e2e] Initializing celilo"* ]]; then
162
- step="initializing celilo"
163
- elif [[ "$line" == *"[e2e] Network ready"* ]]; then
164
- step="network ready"
165
- elif [[ "$line" == *"[e2e] Stopping network"* ]]; then
166
- step="stopping network"
167
-
168
- # Test-level progress (console.log from tests)
169
- elif [[ "$line" == *"Adding machines"* ]]; then
170
- step="adding machines"
171
- elif [[ "$line" == *"Importing modules"* ]]; then
172
- step="importing modules"
173
- elif [[ "$line" == *"Configuring modules"* ]]; then
174
- step="configuring modules"
175
- elif [[ "$line" == *"Prerequisites ready"* ]]; then
176
- step="prerequisites ready"
177
-
178
- # Celilo CLI: "Deploying module: namecheap"
179
- elif [[ "$line" == *"Deploying module:"* ]]; then
180
- local module=$(echo "$line" | sed -n 's/.*Deploying module: \([^ ]*\).*/\1/p')
181
- if [ -n "$module" ]; then
182
- step="deploying $module"
183
- else
184
- step="deploying module"
185
- fi
186
- # Celilo CLI: "Deploying software..."
187
- elif [[ "$line" == *"Deploying software"* ]]; then
188
- step="running Ansible"
189
-
190
- # Test console.log: "[45s] Deploying namecheap..."
191
- elif [[ "$line" =~ Deploying\ ([a-z_-]+) ]]; then
192
- step="deploying ${BASH_REMATCH[1]}"
193
-
194
- # Celilo CLI: "✓ Module 'namecheap' deployed successfully"
195
- elif [[ "$line" == *"deployed successfully"* ]]; then
196
- local module=$(echo "$line" | sed -n "s/.*Module '\([^']*\)' deployed.*/\1/p")
197
- step="✔ ${module:-module} deployed"
198
-
199
- # Celilo CLI: "Successfully imported module: namecheap"
200
- elif [[ "$line" == *"Successfully imported module:"* ]]; then
201
- local module=$(echo "$line" | sed -n 's/.*imported module: \([^ ]*\).*/\1/p')
202
- step="✔ ${module:-module} imported"
203
-
204
- # Celilo CLI: "Infrastructure selected: machine"
205
- elif [[ "$line" == *"Infrastructure selected"* ]]; then
206
- step="infrastructure selected"
207
- # Celilo CLI: "Terraform"
208
- elif [[ "$line" == *"Terraform skipped"* ]]; then
209
- step="terraform skipped (existing machine)"
210
- elif [[ "$line" == *"terraform init"* ]] || [[ "$line" == *"Terraform init"* ]]; then
211
- step="terraform init"
212
- elif [[ "$line" == *"terraform apply"* ]] || [[ "$line" == *"Terraform apply"* ]]; then
213
- step="terraform apply"
214
-
215
- # Ansible health retries
216
- elif [[ "$line" == *"RETRYING"* ]] && [[ "$line" == *"healthy"* ]]; then
217
- local retries=$(echo "$line" | sed -n 's/.*(\([0-9]*\) retries left).*/\1/p')
218
- step="waiting for health check ($retries retries left)"
219
-
220
- # Skip noise
221
- elif [[ "$line" == *"Set config for"* ]]; then return
222
- elif [[ "$line" == *"Set secret for"* ]]; then return
223
- elif [[ "$line" == *"Auto-generated"* ]]; then return
224
- elif [[ "$line" == *"Warning: Permanently added"* ]]; then return
225
-
226
- # Error detection
227
- elif [[ "$line" == *"Error:"* ]] && [[ "$line" != *"vitest"* ]] && [[ "$line" != *"bun test"* ]] && [[ "$line" != *"ipv4: Address already"* ]]; then
228
- local err=$(echo "$line" | sed 's/.*Error: //' | cut -c1-70)
229
- step="✗ $err"
230
- fi
231
-
232
- if [ -n "$step" ]; then
233
- STEP_COUNT=$((STEP_COUNT + 1))
234
- # Update estimate upward if we're exceeding it
235
- if [ $STEP_COUNT -gt $ESTIMATED_STEPS ]; then
236
- ESTIMATED_STEPS=$((STEP_COUNT + 5))
237
- fi
238
- local pct=$(( (STEP_COUNT * 100) / ESTIMATED_STEPS ))
239
- [ $pct -gt 99 ] && pct=99 # Don't show 100% until actually done
240
- local now=$(date +%s)
241
- local elapsed=$((now - test_start))
242
- printf " ${DIM}%3ds${NC} ${CYAN}%2d%%${NC} %s\n" "$elapsed" "$pct" "$step"
243
- fi
244
- }
245
-
246
- for ((i=0; i<TOTAL; i++)); do
247
- FILE="${TEST_FILES[$i]}"
248
- NAME=$(basename "$FILE" .test.ts)
249
- TEST_DIR="$RESULTS_DIR/$NAME"
250
- mkdir -p "$TEST_DIR"
251
-
252
- # Percentage complete (based on test count, not time)
253
- PCT=$(( (i * 100) / TOTAL ))
254
- ELAPSED_TOTAL=$(($(date +%s) - SUITE_START))
255
- if [ $i -gt 0 ]; then
256
- AVG_PER_TEST=$((ELAPSED_TOTAL / i))
257
- REMAINING=$(( (TOTAL - i) * AVG_PER_TEST ))
258
- ETA_MIN=$((REMAINING / 60))
259
- ETA_SEC=$((REMAINING % 60))
260
- ETA_STR=" — ~${ETA_MIN}m ${ETA_SEC}s remaining"
261
- else
262
- ETA_STR=""
263
- fi
264
-
265
- echo -e "${BOLD}[${i}/${TOTAL}] ${CYAN}${PCT}%${NC}${BOLD} ${NAME}${NC}${DIM}${ETA_STR}${NC}"
266
-
267
- STEP_COUNT=0
268
- TEST_START=$(date +%s)
269
- PIPE=$(mktemp -u)
270
- mkfifo "$PIPE"
271
-
272
- # Background: run bun test, merge stdout+stderr, tee to log + pipe
273
- (bun test --timeout 300000 "$FILE" 2>&1 | tee "$TEST_DIR/output.log" > "$PIPE") &
274
- TEST_PID=$!
275
-
276
- # Foreground: read pipe line-by-line and show progress
277
- while IFS= read -r line; do
278
- show_progress "$line" "$TEST_START"
279
- done < "$PIPE"
280
-
281
- # Wait for test to finish and get exit code
282
- wait $TEST_PID 2>/dev/null
283
- EXIT_CODE=$?
284
- rm -f "$PIPE"
285
-
286
- TEST_END=$(date +%s)
287
- DURATION=$((TEST_END - TEST_START))
288
-
289
- if [ $EXIT_CODE -eq 0 ]; then
290
- echo -e " ${GREEN}✔ passed (${DURATION}s)${NC}"
291
- PASSED=$((PASSED + 1))
292
- RESULTS+=("✓|$NAME|${DURATION}s")
293
- echo '{"status":"pass","duration":'$DURATION'}' > "$TEST_DIR/result.json"
294
- else
295
- echo -e " ${RED}✗ failed (${DURATION}s)${NC}"
296
- FAILED=$((FAILED + 1))
297
- ERROR=$(grep -E "Error:|expected.*to be|Timeout" "$TEST_DIR/output.log" 2>/dev/null | grep -v "vitest\|bun test" | head -1 | sed 's/.*Error: //' | cut -c1-80)
298
- RESULTS+=("✗|$NAME|${DURATION}s|$ERROR")
299
- echo '{"status":"fail","duration":'$DURATION',"error":"'"${ERROR//\"/\\\"}"'"}' > "$TEST_DIR/result.json"
300
- echo -e " ${RED} └─ ${ERROR}${NC}"
301
- fi
302
- echo ""
303
-
304
- # Clean up per-test Docker resources between tests (not shared infra)
305
- docker network prune -f > /dev/null 2>&1 || true
306
- done
307
-
308
- # ─── Shared Infrastructure Teardown ───────────────────────────────────
309
- echo -e "${DIM}Stopping shared infrastructure...${NC}"
310
- docker compose -f "$SHARED_COMPOSE" -p "$SHARED_PROJECT" down --volumes --remove-orphans > /dev/null 2>&1 || true
311
- docker network prune -f > /dev/null 2>&1 || true
312
-
313
- SUITE_END=$(date +%s)
314
- SUITE_DURATION=$((SUITE_END - SUITE_START))
315
- SUITE_MINUTES=$((SUITE_DURATION / 60))
316
- SUITE_SECONDS=$((SUITE_DURATION % 60))
317
-
318
- echo ""
319
- echo -e "${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}"
320
- printf "${BOLD}║ Results: ${GREEN}%d passed${NC}${BOLD}, ${RED}%d failed${NC}${BOLD} — %dm %ds total ║${NC}\n" "$PASSED" "$FAILED" "$SUITE_MINUTES" "$SUITE_SECONDS"
321
- echo -e "${BOLD}╠══════════════════════════════════════════════════════════════╣${NC}"
322
-
323
- for result in "${RESULTS[@]}"; do
324
- IFS='|' read -r icon name duration error <<< "$result"
325
- if [ "$icon" = "✓" ]; then
326
- printf "${BOLD}║${NC} ${GREEN}%s${NC} %-28s ${DIM}%6s${NC}\n" "$icon" "$name" "$duration"
327
- else
328
- printf "${BOLD}║${NC} ${RED}%s${NC} %-28s ${DIM}%6s${NC}\n" "$icon" "$name" "$duration"
329
- if [ -n "$error" ]; then
330
- printf "${BOLD}║${NC} ${RED}└─ %s${NC}\n" "$error"
331
- fi
332
- fi
333
- done
334
-
335
- echo -e "${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}"
336
- echo ""
337
- echo -e "Full logs: ${DIM}$RESULTS_DIR${NC}"
338
-
339
- # Write summary
340
- cat > "$RESULTS_DIR/summary.json" <<EOF
341
- {
342
- "timestamp": "$TIMESTAMP",
343
- "total": $TOTAL,
344
- "passed": $PASSED,
345
- "failed": $FAILED,
346
- "duration": $SUITE_DURATION
347
- }
348
- EOF
349
-
350
- # Add results dir to gitignore if not already there
351
- grep -q "^results/" "$E2E_TEST_DIR/.gitignore" 2>/dev/null || echo "results/" >> "$E2E_TEST_DIR/.gitignore"
352
-
353
- # Exit with failure if any tests failed
354
- [ $FAILED -eq 0 ]
5
+ exec bun "$PKG_DIR/src/runner.ts" "$@"
@@ -2,14 +2,14 @@ $ORIGIN iamtheinternet.org.
2
2
  $TTL 300
3
3
 
4
4
  @ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
5
- 2024010686 300 60 604800 300
5
+ 2024010764 300 60 604800 300
6
6
  )
7
7
 
8
8
  @ IN NS ns1.iamtheinternet.org.
9
9
  ns1 IN A 100.64.0.55
10
10
  @ IN A 0.0.0.0
11
11
  caddy IN A 100.100.0.100
12
- www IN A 100.100.0.100
12
+ www IN A 10.0.10.10
13
13
  deathstar IN A 100.100.0.100
14
14
  test IN A 0.0.0.0
15
15
  auth IN A 100.100.0.100
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",
@@ -1,8 +1,9 @@
1
- import { execSync, spawn, type ExecSyncOptions } from 'node:child_process';
1
+ import { execSync, spawn, spawnSync, type ExecSyncOptions } from 'node:child_process';
2
2
  import { existsSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import type { ExecResult, NetworkConfig, NetworkHandle } from './types';
5
- import { generateTestComposeYaml } from './docker-compose-generator';
5
+ import { CeliloCommandError } from './types';
6
+ import { generateTestComposeYaml, SHARED_PROJECT_NAME } from './docker-compose-generator';
6
7
  import { ensureSharedInfra } from './shared-infra';
7
8
 
8
9
  /** Package root — where docker/, config/, simulators/ live */
@@ -68,11 +69,22 @@ function run(cmd: string, opts?: ExecSyncOptions): string {
68
69
  }
69
70
 
70
71
  const COMPOSE_FILE = 'docker-compose.test.yml';
72
+ const SHARED_COMPOSE_FILE = 'docker-compose.shared.yml';
73
+
74
+ /** Containers that live in the shared infra project, not the per-test project */
75
+ const SHARED_CONTAINERS = new Set([
76
+ 'root-dns', 'tld-dns', 'namecheap-dns', 'letsencrypt', 'apt-cache',
77
+ ]);
71
78
 
72
79
  function dockerExec(projectName: string, composeDir: string, container: string, cmd: string, timeoutMs = 60_000): ExecResult {
80
+ // Use the shared infra project for shared containers
81
+ const isShared = SHARED_CONTAINERS.has(container);
82
+ const project = isShared ? SHARED_PROJECT_NAME : projectName;
83
+ const composeFile = isShared ? SHARED_COMPOSE_FILE : COMPOSE_FILE;
84
+
73
85
  try {
74
86
  const stdout = run(
75
- `docker compose -f ${COMPOSE_FILE} -p ${projectName} exec -T ${container} bash -c ${JSON.stringify(cmd)}`,
87
+ `docker compose -f ${composeFile} -p ${project} exec -T ${container} bash -c ${JSON.stringify(cmd)}`,
76
88
  { cwd: composeDir, timeout: timeoutMs },
77
89
  );
78
90
  return { stdout, stderr: '', exitCode: 0 };
@@ -167,7 +179,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
167
179
  await ensureSharedInfra();
168
180
 
169
181
  // Clean up stale per-test containers (self-healing from prior crashes)
170
- console.log('[e2e] Cleaning up stale test resources...');
182
+ console.log('[progress:start] cleaning up stale test resources | cleanup complete');
171
183
  try {
172
184
  // Force-kill per-test containers (not shared infra)
173
185
  try {
@@ -205,18 +217,27 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
205
217
  writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
206
218
 
207
219
  // Build and start per-test containers only
208
- console.log('[e2e] Building images...');
220
+ console.log('[progress:start] building Docker images | images built');
209
221
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} build`, { cwd: composeDir, timeout: 300_000 });
210
222
 
211
- console.log('[e2e] Starting containers...');
223
+ console.log('[progress:start] starting containers | containers running');
212
224
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, { cwd: composeDir, timeout: 120_000 });
213
225
 
214
- // DNS convergence is handled by shared infra — skip it here.
215
- // Just verify routing from per-test containers to shared infra.
226
+ // Wait for DNS convergence (comcast-resolver is per-test, needs to reach shared DNS)
227
+ console.log('[progress:start] waiting for DNS convergence | DNS converged');
228
+ await waitFor(
229
+ async () => {
230
+ const result = dockerExec(projectName, composeDir, 'comcast-resolver',
231
+ 'dig @127.0.0.1 iamtheinternet.org NS +short +timeout=2');
232
+ return result.exitCode === 0 && result.stdout.trim().length > 0;
233
+ },
234
+ 60_000,
235
+ 'DNS convergence',
236
+ );
216
237
 
217
238
  // Optional routing verification
218
239
  if (config.verifyRouting) {
219
- console.log('[e2e] Verifying routing...');
240
+ console.log('[progress:start] verifying routing | routing verified');
220
241
  await waitFor(
221
242
  async () => {
222
243
  const result = dockerExec(projectName, composeDir, 'management',
@@ -231,7 +252,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
231
252
  // Wait for all target machines to complete setup (systemd boot + routing)
232
253
  const allMachines = [...config.dmzMachines, ...config.appMachines, ...config.secureMachines, ...config.internalMachines];
233
254
  if (allMachines.length > 0) {
234
- console.log('[e2e] Waiting for target machines...');
255
+ console.log('[progress:start] waiting for target machines | target machines ready');
235
256
  }
236
257
  for (const machine of allMachines) {
237
258
  await waitFor(
@@ -247,7 +268,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
247
268
 
248
269
  // Wait for DHCP client lease
249
270
  if (config.dhcpClient) {
250
- console.log('[e2e] Waiting for DHCP client lease...');
271
+ console.log('[progress:start] waiting for DHCP client lease | DHCP lease acquired');
251
272
  await waitFor(
252
273
  async () => {
253
274
  const result = dockerExec(projectName, composeDir, 'dhcp-client',
@@ -273,7 +294,7 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
273
294
  }
274
295
 
275
296
  // Initialize celilo
276
- console.log('[e2e] Initializing celilo...');
297
+ console.log('[progress:start] initializing celilo | celilo initialized');
277
298
  const initResult = dockerExec(projectName, composeDir, 'management',
278
299
  `cd /celilo/apps/celilo && bun run src/cli/index.ts system init --accept-defaults \
279
300
  network.dmz.subnet=10.0.10.0/24 \
@@ -286,14 +307,22 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
286
307
  throw new Error(`Celilo init failed: ${initResult.stderr}`);
287
308
  }
288
309
 
289
- console.log(`[e2e] Network ready (project: ${projectName})`);
310
+ console.log('[progress:done] network ready');
290
311
 
291
312
  const handle: NetworkHandle = {
292
313
  projectName,
293
314
 
294
- celilo(cmd: string, timeoutMs = 120_000): Promise<ExecResult> {
295
- return dockerExecAsync(projectName, composeDir, 'management',
315
+ async celilo(cmd: string, optsOrTimeout?: number | { check?: boolean; timeoutMs?: number }): Promise<ExecResult> {
316
+ const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
317
+ const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
318
+
319
+ const result = await dockerExecAsync(projectName, composeDir, 'management',
296
320
  `cd /celilo/apps/celilo && bun run src/cli/index.ts ${cmd}`, timeoutMs);
321
+
322
+ if (check && result.exitCode !== 0) {
323
+ throw new CeliloCommandError(cmd, result);
324
+ }
325
+ return result;
297
326
  },
298
327
 
299
328
  exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
@@ -306,10 +335,32 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
306
335
  return result.stdout.trim();
307
336
  },
308
337
 
338
+ async debug(container = 'management'): Promise<void> {
339
+ // Signal the runner to hand us the terminal
340
+ console.log(`[debug:pause] ${projectName} ${container}`);
341
+
342
+ // The runner intercepts [debug:pause] and spawns the interactive
343
+ // shell itself (since it owns the terminal). It writes a signal
344
+ // file when the user exits the shell.
345
+ const signalFile = join(composeDir, `.debug-resume-${process.pid}`);
346
+
347
+ // Wait for the runner to create the signal file
348
+ const start = Date.now();
349
+ const timeout = 86_400_000; // 24 hour max debug session
350
+ while (!existsSync(signalFile) && Date.now() - start < timeout) {
351
+ await new Promise(r => setTimeout(r, 500));
352
+ }
353
+
354
+ // Clean up
355
+ try { require('node:fs').unlinkSync(signalFile); } catch {}
356
+
357
+ console.log('[debug:resumed]');
358
+ },
359
+
309
360
  waitFor,
310
361
 
311
362
  async stop(): Promise<void> {
312
- console.log(`[e2e] Stopping network (project: ${projectName})...`);
363
+ console.log('[progress:start] stopping network | network stopped');
313
364
  try {
314
365
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
315
366
  { cwd: composeDir, timeout: 60_000 });
@@ -65,7 +65,6 @@ export const SHARED_PROJECT_NAME = 'celilo-e2e-shared';
65
65
 
66
66
  /** Docker network names as created by the shared infrastructure project */
67
67
  export const SHARED_NETWORKS = {
68
- 'isp-external': `${SHARED_PROJECT_NAME}_isp-external`,
69
68
  'internet-external': `${SHARED_PROJECT_NAME}_internet-external`,
70
69
  'real-internet': `${SHARED_PROJECT_NAME}_real-internet`,
71
70
  } as const;
@@ -140,12 +139,14 @@ const TOPOLOGY_SERVICES: Record<TopologyPreset, () => Record<string, unknown>> =
140
139
 
141
140
  /**
142
141
  * Generate the compose YAML for shared infrastructure that runs once
143
- * per test suite. Contains: fw-ext, DNS hierarchy, Pebble ACME, apt-cache.
144
- * Owns the isp-external, internet-external, and real-internet networks.
142
+ * per test suite. Contains: DNS hierarchy, Pebble ACME, apt-cache.
143
+ * Owns the internet-external and real-internet networks.
144
+ *
145
+ * fw-ext and comcast-resolver are per-test because they route to
146
+ * per-test containers (fw-isp/fw-main) and need those to exist first.
145
147
  */
146
148
  export function generateSharedInfraYaml(): string {
147
149
  const networks: Record<string, unknown> = {
148
- 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
149
150
  'internet-external': networkDef('100.64.0.0/24', '100.64.0.250'),
150
151
  'real-internet': {
151
152
  driver: 'bridge',
@@ -159,26 +160,6 @@ export function generateSharedInfraYaml(): string {
159
160
 
160
161
  const services: Record<string, unknown> = {};
161
162
 
162
- services['fw-ext'] = baseService({
163
- build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
164
- networks: {
165
- 'isp-external': { ipv4_address: '100.100.0.101' },
166
- 'internet-external': { ipv4_address: '100.64.0.1' },
167
- 'real-internet': { ipv4_address: '172.30.0.5' },
168
- },
169
- cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
170
- sysctls: ROUTER_SYSCTLS,
171
- });
172
-
173
- services['comcast-resolver'] = baseService({
174
- build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
175
- networks: {
176
- 'isp-external': { ipv4_address: '100.100.0.1' },
177
- 'real-internet': { ipv4_address: '172.30.0.4' },
178
- },
179
- cap_add: ['NET_ADMIN'],
180
- });
181
-
182
163
  services['root-dns'] = baseService({
183
164
  build: { context: '.', dockerfile: 'docker/Dockerfile.authoritative-dns' },
184
165
  networks: { 'internet-external': { ipv4_address: '100.64.0.53' } },
@@ -252,10 +233,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot = '..'
252
233
  dmz: networkDef('10.0.10.0/24', '10.0.10.250'),
253
234
  app: networkDef('10.0.20.0/24', '10.0.20.250'),
254
235
  secure: networkDef('10.0.30.0/24', '10.0.30.250'),
255
- // isp-external is owned by the shared infra project — reference it
256
- 'isp-external': {
236
+ 'isp-external': networkDef('100.100.0.0/24', '100.100.0.250'),
237
+ // internet-external and real-internet are owned by shared infra
238
+ 'internet-external': {
257
239
  external: true,
258
- name: SHARED_NETWORKS['isp-external'],
240
+ name: SHARED_NETWORKS['internet-external'],
241
+ },
242
+ 'real-internet': {
243
+ external: true,
244
+ name: SHARED_NETWORKS['real-internet'],
259
245
  },
260
246
  };
261
247
 
@@ -287,6 +273,27 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot = '..'
287
273
  const topologyFn = TOPOLOGY_SERVICES[config.topology] || TOPOLOGY_SERVICES.default;
288
274
  Object.assign(services, topologyFn());
289
275
 
276
+ // --- Routing infrastructure (bridges per-test to shared infra) ---
277
+ services['fw-ext'] = baseService({
278
+ build: { context: '.', dockerfile: 'docker/Dockerfile.router' },
279
+ networks: {
280
+ 'isp-external': { ipv4_address: '100.100.0.101' },
281
+ 'internet-external': { ipv4_address: '100.64.0.1' },
282
+ 'real-internet': { ipv4_address: '172.30.0.5' },
283
+ },
284
+ cap_add: ['NET_ADMIN', 'SYS_ADMIN'],
285
+ sysctls: ROUTER_SYSCTLS,
286
+ });
287
+
288
+ services['comcast-resolver'] = baseService({
289
+ build: { context: '.', dockerfile: 'docker/Dockerfile.resolver' },
290
+ networks: {
291
+ 'isp-external': { ipv4_address: '100.100.0.1' },
292
+ 'real-internet': { ipv4_address: '172.30.0.4' },
293
+ },
294
+ cap_add: ['NET_ADMIN'],
295
+ });
296
+
290
297
  // --- Dynamic test machines ---
291
298
  const allMachines = [
292
299
  ...config.dmzMachines,