@celilo/e2e 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/e2e-build CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/bin/bash
2
2
  #
3
- # Pre-build all E2E Docker images.
3
+ # Pre-build all E2E Docker images and package standard modules as .netapps.
4
4
  #
5
5
  # Run once before the test suite. Subsequent test runs use the cached
6
6
  # images and skip the build phase entirely (~5s vs ~3min per test).
7
7
  #
8
8
  # Usage:
9
- # ./e2e/bin/e2e-build # build all images
9
+ # ./e2e/bin/e2e-build # build netapps + all images
10
10
  # ./e2e/bin/e2e-build --save # build + save to tarball for colima restarts
11
11
  #
12
12
 
@@ -14,6 +14,11 @@ set -e
14
14
 
15
15
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
16
  E2E_DIR="$(dirname "$SCRIPT_DIR")"
17
+ PKG_DIR="$E2E_DIR"
18
+ REPO_ROOT="$(cd "$E2E_DIR/../.." && pwd)"
19
+ MODULES_DIR="$REPO_ROOT/modules"
20
+ NETAPPS_DIR="$E2E_DIR/netapps"
21
+
17
22
  cd "$E2E_DIR"
18
23
 
19
24
  GREEN='\033[0;32m'
@@ -21,6 +26,43 @@ DIM='\033[2m'
21
26
  BOLD='\033[1m'
22
27
  NC='\033[0m'
23
28
 
29
+ # ── Step 1: Package standard modules as .netapp files ─────────────────────────
30
+
31
+ STANDARD_MODULES=(namecheap greenwave iptables caddy authentik)
32
+
33
+ echo -e "${BOLD}Packaging standard modules...${NC}"
34
+ echo ""
35
+
36
+ mkdir -p "$NETAPPS_DIR"
37
+
38
+ for MODULE in "${STANDARD_MODULES[@]}"; do
39
+ SRC="$MODULES_DIR/$MODULE"
40
+ OUT="$NETAPPS_DIR/$MODULE.netapp"
41
+
42
+ if [ ! -d "$SRC" ]; then
43
+ echo -e " ${DIM}skip${NC} $MODULE ${DIM}(not found at $SRC)${NC}"
44
+ continue
45
+ fi
46
+
47
+ printf " %-20s " "$MODULE"
48
+ BUILD_START=$(date +%s)
49
+
50
+ # Use the wrapper script if available, else bun directly
51
+ if [ -x "$REPO_ROOT/celilo" ]; then
52
+ "$REPO_ROOT/celilo" package "$SRC" --output "$OUT" > /dev/null 2>&1
53
+ else
54
+ bun run "$REPO_ROOT/apps/celilo/src/cli/index.ts" package "$SRC" --output "$OUT" > /dev/null 2>&1
55
+ fi
56
+
57
+ BUILD_END=$(date +%s)
58
+ SIZE=$(du -h "$OUT" 2>/dev/null | cut -f1)
59
+ echo -e "${GREEN}✔${NC} ${DIM}$((BUILD_END - BUILD_START))s ${SIZE}${NC}"
60
+ done
61
+
62
+ echo ""
63
+
64
+ # ── Step 2: Build Docker images ───────────────────────────────────────────────
65
+
24
66
  echo -e "${BOLD}Building E2E Docker images...${NC}"
25
67
  echo ""
26
68
 
@@ -48,7 +90,8 @@ TOTAL_TIME=$((END - START))
48
90
  echo ""
49
91
  echo -e "${GREEN}All $TOTAL images built in ${TOTAL_TIME}s${NC}"
50
92
 
51
- # Optionally save to tarball
93
+ # ── Step 3: Optionally save images to tarball ─────────────────────────────────
94
+
52
95
  if [ "$1" = "--save" ]; then
53
96
  echo ""
54
97
  echo -e "${BOLD}Saving images to tarball...${NC}"
package/bin/e2e-down CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/bin/bash
2
- # Tear down ALL E2E test artifacts — interactive sessions AND orphaned test runs.
2
+ # Tear down E2E test artifacts:
3
+ # - Persistent test networks (from `e2e-run --keep`)
4
+ # - Interactive sessions (from `e2e-up`)
5
+ # - Orphaned test runs
3
6
  #
4
7
  # Usage:
5
- # ./bin/e2e-down # Stop and remove everything
6
- # ./bin/e2e-down --keep # Stop containers but keep volumes (faster restart)
7
- # ./bin/e2e-down --all # Also stop module containers (authentik, etc.) that compete for resources
8
+ # ./bin/e2e-down # tear down everything e2e-related
9
+ # ./bin/e2e-down --keep # stop containers but keep volumes
10
+ # ./bin/e2e-down --all # also stop module containers that compete for resources
8
11
 
9
12
  set -e
10
13
  SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
@@ -19,20 +22,42 @@ for arg in "$@"; do
19
22
  esac
20
23
  done
21
24
 
22
- # 1. Stop the interactive session (if running)
25
+ # 1. Tear down any persistent test networks (look for marker files in common test dirs)
26
+ for dir in "$(pwd)" "$(pwd)/../../e2e" "$(pwd)/../../../e2e"; do
27
+ MARKER="$dir/.e2e-persistent.json"
28
+ if [ -f "$MARKER" ]; then
29
+ PROJECT=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('$MARKER', 'utf-8')).projectName)" 2>/dev/null || echo "")
30
+ if [ -n "$PROJECT" ]; then
31
+ echo "Tearing down persistent network: $PROJECT"
32
+ docker compose -f docker-compose.test.yml -p "$PROJECT" down --volumes --remove-orphans 2>/dev/null || true
33
+ fi
34
+ rm -f "$MARKER"
35
+ fi
36
+ done
37
+
38
+ # Also check E2E_TEST_DIR if set
39
+ if [ -n "$E2E_TEST_DIR" ] && [ -f "$E2E_TEST_DIR/.e2e-persistent.json" ]; then
40
+ PROJECT=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('$E2E_TEST_DIR/.e2e-persistent.json', 'utf-8')).projectName)" 2>/dev/null || echo "")
41
+ if [ -n "$PROJECT" ]; then
42
+ echo "Tearing down persistent network: $PROJECT"
43
+ docker compose -f docker-compose.test.yml -p "$PROJECT" down --volumes --remove-orphans 2>/dev/null || true
44
+ fi
45
+ rm -f "$E2E_TEST_DIR/.e2e-persistent.json"
46
+ fi
47
+
48
+ # 2. Stop the interactive session (if running)
23
49
  echo "Stopping interactive session..."
24
- docker compose -p "conductor-e2e-interactive" down --remove-orphans 2>/dev/null || true
50
+ docker compose -p "celilo-e2e-interactive" down --remove-orphans 2>/dev/null || true
25
51
 
26
- # 2. Kill ALL containers matching conductor-e2e-* (orphaned test runs)
27
- CONTAINERS=$(docker ps -a --filter "name=conductor-e2e" --format "{{.ID}}" 2>/dev/null)
52
+ # 3. Kill ALL containers matching celilo-e2e-* (orphaned test runs)
53
+ CONTAINERS=$(docker ps -a --filter "name=celilo-e2e" --format "{{.ID}}" 2>/dev/null)
28
54
  if [ -n "$CONTAINERS" ]; then
29
55
  COUNT=$(echo "$CONTAINERS" | wc -l | tr -d ' ')
30
56
  echo "Removing $COUNT orphaned e2e containers..."
31
57
  echo "$CONTAINERS" | xargs docker rm -f 2>/dev/null || true
32
58
  fi
33
59
 
34
- # 3. Stop module containers that compete for resources (--all flag)
35
- # These come from local docker-compose runs, not from e2e, but starve the VM.
60
+ # 4. Stop module containers that compete for resources (--all flag)
36
61
  if [ "$STOP_ALL" = true ]; then
37
62
  MODULE_PATTERNS="authentik- caddy- build-your-own-internet-"
38
63
  for pattern in $MODULE_PATTERNS; do
@@ -45,23 +70,23 @@ if [ "$STOP_ALL" = true ]; then
45
70
  done
46
71
  fi
47
72
 
48
- # 3. Remove all conductor-e2e networks
49
- NETWORKS=$(docker network ls --filter "name=conductor-e2e" --format "{{.ID}}" 2>/dev/null)
73
+ # 5. Remove all celilo-e2e networks
74
+ NETWORKS=$(docker network ls --filter "name=celilo-e2e" --format "{{.ID}}" 2>/dev/null)
50
75
  if [ -n "$NETWORKS" ]; then
51
76
  echo "Removing e2e networks..."
52
77
  echo "$NETWORKS" | xargs docker network rm 2>/dev/null || true
53
78
  fi
54
79
 
55
- # 4. Remove volumes (unless --keep)
80
+ # 6. Remove volumes (unless --keep)
56
81
  if [ "$KEEP_VOLUMES" = false ]; then
57
- VOLUMES=$(docker volume ls --filter "name=conductor-e2e" --format "{{.Name}}" 2>/dev/null)
82
+ VOLUMES=$(docker volume ls --filter "name=celilo-e2e" --format "{{.Name}}" 2>/dev/null)
58
83
  if [ -n "$VOLUMES" ]; then
59
84
  echo "Removing e2e volumes..."
60
85
  echo "$VOLUMES" | xargs docker volume rm 2>/dev/null || true
61
86
  fi
62
87
  fi
63
88
 
64
- # 5. Clean up generated compose file
89
+ # 7. Clean up generated compose files
65
90
  rm -f docker-compose.test.yml
66
91
 
67
92
  echo "E2E environment cleaned."
@@ -2,7 +2,7 @@ $ORIGIN iamtheinternet.org.
2
2
  $TTL 300
3
3
 
4
4
  @ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
5
- 2024010769 300 60 604800 300
5
+ 2024010778 300 60 604800 300
6
6
  )
7
7
 
8
8
  @ IN NS ns1.iamtheinternet.org.
@@ -22,9 +22,19 @@ RUN update-ca-certificates
22
22
  # Point apt at the simulated apt cache proxy (for runtime apt usage)
23
23
  RUN echo 'Acquire::http::Proxy "http://100.64.0.2:3142";' > /etc/apt/apt.conf.d/00proxy
24
24
 
25
- # Install celilo CLI from npm — provides the `celilo` command
25
+ # Install celilo CLI from npm — provides the fallback `celilo` command
26
26
  RUN bun add -g @celilo/cli@0.1.4
27
27
 
28
+ # Shim: prefer /celilo source when mounted (dev loop), fall back to npm install.
29
+ # This means `celilo ...` runs the monorepo's source code during e2e tests
30
+ # instead of the pinned npm version — critical for iterating on fixes.
31
+ RUN printf '#!/bin/bash\nif [ -d /celilo/apps/celilo ]; then\n exec bun run /celilo/apps/celilo/src/cli/index.ts "$@"\nfi\nexec /root/.bun/bin/celilo "$@"\n' > /usr/local/bin/celilo \
32
+ && chmod +x /usr/local/bin/celilo
33
+
34
+ # Standard modules pre-packaged as .netapp files — built by e2e-build.
35
+ # Tests import from /netapps/<module>.netapp instead of the monorepo mount.
36
+ COPY netapps/ /netapps/
37
+
28
38
  COPY config/ssh/generate-keys.sh /config/ssh/generate-keys.sh
29
39
  COPY config/routing/management-routes.sh /startup.sh
30
40
  RUN chmod +x /startup.sh /config/ssh/generate-keys.sh
@@ -12,6 +12,9 @@ RUN apt-get update && apt-get install -y \
12
12
  dnsutils \
13
13
  procps \
14
14
  python3 \
15
+ python3-apt \
16
+ sqlite3 \
17
+ jq \
15
18
  sudo \
16
19
  curl \
17
20
  ca-certificates \
@@ -16,6 +16,9 @@ RUN apt-get update && apt-get install -y \
16
16
  dnsutils \
17
17
  procps \
18
18
  python3 \
19
+ python3-apt \
20
+ sqlite3 \
21
+ jq \
19
22
  sudo \
20
23
  curl \
21
24
  ca-certificates \
File without changes
Binary file
Binary file
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/e2e",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -21,6 +21,7 @@
21
21
  "config/",
22
22
  "simulators/",
23
23
  "bin/",
24
+ "netapps/",
24
25
  "README.md"
25
26
  ],
26
27
  "dependencies": {
@@ -158,6 +158,66 @@ async function waitFor(
158
158
  throw new Error(`Timeout waiting for ${label} after ${timeoutMs}ms`);
159
159
  }
160
160
 
161
+ /**
162
+ * Run `docker compose build` with per-service progress signals.
163
+ * Uses --progress=plain so output is parseable on non-TTY.
164
+ * Emits one [progress:start] per service being built so the runner
165
+ * shows "building <service>" as each image is compiled.
166
+ */
167
+ async function streamingBuild(
168
+ projectName: string,
169
+ composeDir: string,
170
+ composeFile: string,
171
+ ): Promise<void> {
172
+ return new Promise<void>((resolve, reject) => {
173
+ const proc = spawn(
174
+ 'docker',
175
+ ['compose', '-f', composeFile, '-p', projectName, 'build', '--progress=plain'],
176
+ { cwd: composeDir },
177
+ );
178
+
179
+ const seenServices = new Set<string>();
180
+ let stderr = '';
181
+
182
+ function handleLine(line: string) {
183
+ // --progress=plain lines look like: #5 [management 2/4] RUN apt-get ...
184
+ const svcMatch = line.match(/\[([a-z][a-z0-9-]*)\s+\d+\/\d+\]/);
185
+ if (svcMatch) {
186
+ const svc = svcMatch[1];
187
+ if (!seenServices.has(svc)) {
188
+ seenServices.add(svc);
189
+ // Each new service auto-finalizes the previous [progress:start] in the runner.
190
+ console.log(`[progress:start] building image: ${svc} | ${svc} image ready`);
191
+ }
192
+ }
193
+ }
194
+
195
+ let buf = '';
196
+ function onData(chunk: Buffer) {
197
+ buf += chunk.toString();
198
+ const lines = buf.split('\n');
199
+ buf = lines.pop() ?? '';
200
+ for (const line of lines) handleLine(line.trim());
201
+ }
202
+
203
+ proc.stdout.on('data', onData);
204
+ proc.stderr.on('data', (chunk: Buffer) => {
205
+ stderr += chunk.toString();
206
+ onData(chunk);
207
+ });
208
+
209
+ proc.on('close', (code) => {
210
+ if (buf.trim()) handleLine(buf.trim());
211
+ if (code === 0) {
212
+ console.log('[progress:done] images built');
213
+ resolve();
214
+ } else {
215
+ reject(new Error(`docker compose build failed (exit ${code}):\n${stderr.slice(-500)}`));
216
+ }
217
+ });
218
+ });
219
+ }
220
+
161
221
  export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle> {
162
222
  // Pre-flight: fail if live containers are running that will compete for resources.
163
223
  try {
@@ -218,9 +278,9 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
218
278
  const yaml = generateTestComposeYaml(config, celiloRoot);
219
279
  writeFileSync(join(composeDir, COMPOSE_FILE), yaml);
220
280
 
221
- // Build and start per-test containers only
222
- console.log('[progress:start] building Docker images | images built');
223
- run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} build`, { cwd: composeDir, timeout: 600_000 });
281
+ // Build and start per-test containers only — stream output so the runner
282
+ // can show which service is currently being built (can take minutes on cold cache).
283
+ await streamingBuild(projectName, composeDir, COMPOSE_FILE);
224
284
 
225
285
  console.log('[progress:start] starting containers | containers running');
226
286
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} up -d`, { cwd: composeDir, timeout: 120_000 });
@@ -310,6 +370,8 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
310
370
  }
311
371
 
312
372
  console.log('[progress:done] network ready');
373
+ // Emit project name so the runner can persist it if --keep is set
374
+ console.log(`[e2e:project] ${projectName}`);
313
375
 
314
376
  const handle: NetworkHandle = {
315
377
  projectName,
@@ -362,6 +424,12 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
362
424
  waitFor,
363
425
 
364
426
  async stop(): Promise<void> {
427
+ // Respect --keep / --reuse: don't tear down the network
428
+ if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
429
+ console.log(`[progress:done] network kept alive (project: ${projectName})`);
430
+ activeProjects.delete(projectName); // don't re-teardown on exit
431
+ return;
432
+ }
365
433
  console.log('[progress:start] stopping network | network stopped');
366
434
  try {
367
435
  run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
@@ -375,3 +443,90 @@ export async function startNetwork(config: NetworkConfig): Promise<NetworkHandle
375
443
 
376
444
  return handle;
377
445
  }
446
+
447
+ /**
448
+ * Reconnect to an existing test network (previously kept alive with --keep).
449
+ * Returns a NetworkHandle pointing to the existing Docker project. Skips
450
+ * all the setup (image build, container start, celilo init) — assumes the
451
+ * network is already running and healthy.
452
+ *
453
+ * If the project doesn't exist, throws with an actionable error.
454
+ */
455
+ export function reconnectNetwork(projectName: string): NetworkHandle {
456
+ const composeDir = PACKAGE_ROOT;
457
+
458
+ // Verify the project actually exists
459
+ try {
460
+ const result = run(
461
+ `docker compose -f ${COMPOSE_FILE} -p ${projectName} ps -q`,
462
+ { cwd: composeDir, timeout: 10_000 },
463
+ );
464
+ if (!result.trim()) {
465
+ throw new Error(`No containers found for project ${projectName}`);
466
+ }
467
+ } catch (err) {
468
+ throw new Error(
469
+ `Cannot reconnect to network '${projectName}': ${err instanceof Error ? err.message : String(err)}\n` +
470
+ `Fix: Run without --reuse to create a fresh network, or check if containers were torn down.`,
471
+ );
472
+ }
473
+
474
+ activeProjects.add(projectName);
475
+ console.log(`[progress:done] reconnected to existing network (${projectName})`);
476
+ console.log(`[e2e:project] ${projectName}`);
477
+
478
+ return {
479
+ projectName,
480
+
481
+ async celilo(cmd: string, optsOrTimeout?: number | { check?: boolean; timeoutMs?: number }): Promise<ExecResult> {
482
+ const check = typeof optsOrTimeout === 'object' ? (optsOrTimeout.check ?? true) : true;
483
+ const timeoutMs = typeof optsOrTimeout === 'number' ? optsOrTimeout : (optsOrTimeout?.timeoutMs ?? 120_000);
484
+
485
+ const result = await dockerExecAsync(projectName, composeDir, 'management',
486
+ `celilo ${cmd}`, timeoutMs);
487
+
488
+ if (check && result.exitCode !== 0) {
489
+ throw new CeliloCommandError(cmd, result);
490
+ }
491
+ return result;
492
+ },
493
+
494
+ exec(container: string, cmd: string, timeoutMs = 60_000): Promise<ExecResult> {
495
+ return Promise.resolve(dockerExec(projectName, composeDir, container, cmd, timeoutMs));
496
+ },
497
+
498
+ async dig(name: string): Promise<string> {
499
+ const result = dockerExec(projectName, composeDir, 'management',
500
+ `dig +short ${name}`);
501
+ return result.stdout.trim();
502
+ },
503
+
504
+ async debug(container = 'management'): Promise<void> {
505
+ console.log(`[debug:pause] ${projectName} ${container}`);
506
+ const signalFile = join(composeDir, `.debug-resume-${process.pid}`);
507
+ const start = Date.now();
508
+ const timeout = 86_400_000;
509
+ while (!existsSync(signalFile) && Date.now() - start < timeout) {
510
+ await new Promise(r => setTimeout(r, 500));
511
+ }
512
+ try { require('node:fs').unlinkSync(signalFile); } catch {}
513
+ console.log('[debug:resumed]');
514
+ },
515
+
516
+ waitFor,
517
+
518
+ async stop(): Promise<void> {
519
+ // Respect --keep / --reuse: don't tear down
520
+ if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') {
521
+ console.log(`[progress:done] network kept alive (project: ${projectName})`);
522
+ activeProjects.delete(projectName);
523
+ return;
524
+ }
525
+ try {
526
+ run(`docker compose -f ${COMPOSE_FILE} -p ${projectName} down --volumes --remove-orphans`,
527
+ { cwd: composeDir, timeout: 60_000 });
528
+ } catch {}
529
+ activeProjects.delete(projectName);
530
+ },
531
+ };
532
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,9 @@
24
24
  // Builder API
25
25
  export { network, NetworkBuilder } from './network-builder';
26
26
 
27
+ // Reconnect to an existing network (for --reuse mode)
28
+ export { reconnectNetwork } from './container-manager';
29
+
27
30
  // Progress reporting (emit signals the test runner displays)
28
31
  export { progress } from './progress';
29
32
 
package/src/runner.ts CHANGED
@@ -27,7 +27,13 @@ const PKG_DIR = resolve(import.meta.dir, '..');
27
27
  const testDir = process.env.E2E_TEST_DIR || process.cwd();
28
28
  const testsPath = join(testDir, 'tests');
29
29
  const timingFile = join(testDir, '.e2e-timing.json');
30
- const patterns = process.argv.slice(2);
30
+ const persistentFile = join(testDir, '.e2e-persistent.json');
31
+
32
+ // Parse flags out of argv
33
+ const rawArgs = process.argv.slice(2);
34
+ const flagKeep = rawArgs.includes('--keep');
35
+ const flagReuse = rawArgs.includes('--reuse');
36
+ const patterns = rawArgs.filter((a) => !a.startsWith('--'));
31
37
 
32
38
  // ─── Colors ──────────────────────────────────────────────────────────
33
39
 
@@ -309,6 +315,7 @@ interface TestResult {
309
315
  duration: number;
310
316
  error?: string;
311
317
  hadDebugPause?: boolean;
318
+ projectName?: string;
312
319
  }
313
320
 
314
321
  async function runTest(
@@ -323,13 +330,29 @@ async function runTest(
323
330
 
324
331
  display.reset(start, expectedDuration);
325
332
  let hadDebugPause = false;
333
+ let capturedProjectName: string | undefined;
326
334
 
327
335
  return new Promise<TestResult>((resolveTest) => {
336
+ // Env vars let the test detect --keep / --reuse modes
337
+ const testEnv: NodeJS.ProcessEnv = { ...process.env };
338
+ if (flagKeep) testEnv.CELILO_E2E_KEEP = '1';
339
+ if (flagReuse) {
340
+ // Read the persistent project and pass it to the test
341
+ try {
342
+ const data = JSON.parse(readFileSync(persistentFile, 'utf-8'));
343
+ testEnv.CELILO_E2E_REUSE = '1';
344
+ testEnv.CELILO_E2E_PROJECT = data.projectName;
345
+ } catch {
346
+ console.error(`${red}✗ --reuse: no persistent network found. Run with --keep first.${reset}`);
347
+ process.exit(1);
348
+ }
349
+ }
350
+
328
351
  // 1 hour timeout per test (debug sessions can be long)
329
352
  const proc = spawn('bun', ['test', '--timeout', '3600000', file], {
330
353
  cwd: PKG_DIR,
331
354
  stdio: ['pipe', 'pipe', 'pipe'],
332
- env: { ...process.env },
355
+ env: testEnv,
333
356
  });
334
357
 
335
358
  let stdout = '';
@@ -393,6 +416,13 @@ async function runTest(
393
416
  continue;
394
417
  }
395
418
 
419
+ // Capture project name for persistent network tracking
420
+ const projectMatch = trimmed.match(/\[e2e:project\] (\S+)/);
421
+ if (projectMatch) {
422
+ capturedProjectName = projectMatch[1];
423
+ continue;
424
+ }
425
+
396
426
  parseLine(trimmed, display);
397
427
  }
398
428
  }
@@ -423,10 +453,11 @@ async function runTest(
423
453
  status: 'suspicious',
424
454
  duration,
425
455
  hadDebugPause,
456
+ projectName: capturedProjectName,
426
457
  error: '0 assertions — test likely exited before reaching assertions',
427
458
  });
428
459
  } else {
429
- resolveTest({ name, status: 'pass', duration, hadDebugPause });
460
+ resolveTest({ name, status: 'pass', duration, hadDebugPause, projectName: capturedProjectName });
430
461
  }
431
462
  } else {
432
463
  resolveTest({
@@ -434,6 +465,7 @@ async function runTest(
434
465
  status: 'fail',
435
466
  duration,
436
467
  hadDebugPause,
468
+ projectName: capturedProjectName,
437
469
  error: extractFailureMessage(lines, code ?? 1),
438
470
  });
439
471
  }
@@ -556,6 +588,21 @@ async function main() {
556
588
  history[name] = result.duration;
557
589
  }
558
590
 
591
+ // Write persistent network info if --keep was set and we captured a project
592
+ if (flagKeep && result.projectName) {
593
+ writeFileSync(
594
+ persistentFile,
595
+ JSON.stringify({
596
+ projectName: result.projectName,
597
+ testFile: file,
598
+ createdAt: new Date().toISOString(),
599
+ }, null, 2) + '\n',
600
+ );
601
+ console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
602
+ console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
603
+ console.log(`${dim} Tear down: e2e-down${reset}`);
604
+ }
605
+
559
606
  if (result.status === 'pass') {
560
607
  console.log(` ${green}✔ passed (${result.duration}s)${reset}`);
561
608
  passed++;