@celilo/e2e 0.7.17 → 0.9.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.
@@ -18,9 +18,11 @@ import { ZONE_GATEWAYS } from './types';
18
18
  * - **npm-installed consumer** (lunacycle and friends) — `../../modules`
19
19
  * would resolve to `<consumer>/node_modules/modules`, which doesn't
20
20
  * exist. Docker fails the mount with "permission denied" trying to
21
- * create that path. Instead we bind the pre-shipped `./netapps/` dir
22
- * (always present in the npm tarball) to `/uploads`, and the registry's
23
- * scanUploadsDir picks up the .netapp files.
21
+ * create that path. Instead we bind the local `./netapps/` dir to
22
+ * `/uploads`, and the registry's scanUploadsDir picks up the .netapp
23
+ * files. That dir is populated at `cele2e build-infra` time by fetching
24
+ * the standard-module netapps from the public registry (see
25
+ * stageNetappsFromRegistry in cli/build.ts) — consumers vendor nothing.
24
26
  *
25
27
  * Detection: check whether the would-be monorepo modules dir is real.
26
28
  * `import.meta.dir` is `<pkgDir>/src/`, so `../../../modules` from there
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { startNetwork } from './container-manager';
5
- import type { NetworkConfig, NetworkHandle, TopologyPreset, Vantage } from './types';
5
+ import type { MachineSpec, NetworkConfig, NetworkHandle, TopologyPreset, Vantage, Zone } from './types';
6
6
 
7
7
  /**
8
8
  * If a mounted path looks like a celilo module (has scripts/package.json),
@@ -51,53 +51,45 @@ export class NetworkBuilder {
51
51
  return this;
52
52
  }
53
53
 
54
- dmz(machines: Record<string, string>): this {
54
+ /** Append `{name, ip, zone, ...extra}` for each entry to a zone's machine list. */
55
+ private addMachines(
56
+ target: MachineSpec[],
57
+ zone: Zone,
58
+ machines: Record<string, string>,
59
+ extra?: Pick<MachineSpec, 'docker' | 'dockerfile'>,
60
+ ): this {
55
61
  for (const [name, ip] of Object.entries(machines)) {
56
- this.config.dmzMachines.push({ name, ip, zone: 'dmz' });
62
+ target.push({ name, ip, zone, ...extra });
57
63
  }
58
64
  return this;
59
65
  }
60
66
 
67
+ dmz(machines: Record<string, string>): this {
68
+ return this.addMachines(this.config.dmzMachines, 'dmz', machines);
69
+ }
70
+
61
71
  app(machines: Record<string, string>): this {
62
- for (const [name, ip] of Object.entries(machines)) {
63
- this.config.appMachines.push({ name, ip, zone: 'app' });
64
- }
65
- return this;
72
+ return this.addMachines(this.config.appMachines, 'app', machines);
66
73
  }
67
74
 
68
75
  secure(machines: Record<string, string>): this {
69
- for (const [name, ip] of Object.entries(machines)) {
70
- this.config.secureMachines.push({ name, ip, zone: 'secure' });
71
- }
72
- return this;
76
+ return this.addMachines(this.config.secureMachines, 'secure', machines);
73
77
  }
74
78
 
75
79
  internal(machines: Record<string, string>): this {
76
- for (const [name, ip] of Object.entries(machines)) {
77
- this.config.internalMachines.push({ name, ip, zone: 'internal' });
78
- }
79
- return this;
80
+ return this.addMachines(this.config.internalMachines, 'internal', machines);
80
81
  }
81
82
 
82
83
  /** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
83
84
  internalDocker(machines: Record<string, string>): this {
84
- for (const [name, ip] of Object.entries(machines)) {
85
- this.config.internalMachines.push({ name, ip, zone: 'internal', docker: true });
86
- }
87
- return this;
85
+ return this.addMachines(this.config.internalMachines, 'internal', machines, { docker: true });
88
86
  }
89
87
 
90
88
  /** Add a Technitium DNS machine using the dedicated pre-installed image */
91
89
  technitium(machines: Record<string, string>): this {
92
- for (const [name, ip] of Object.entries(machines)) {
93
- this.config.internalMachines.push({
94
- name,
95
- ip,
96
- zone: 'internal',
97
- dockerfile: 'docker/Dockerfile.target-technitium',
98
- });
99
- }
100
- return this;
90
+ return this.addMachines(this.config.internalMachines, 'internal', machines, {
91
+ dockerfile: 'docker/Dockerfile.target-technitium',
92
+ });
101
93
  }
102
94
 
103
95
  withDhcpClient(): this {
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { hostname, tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ acquireRunLock,
7
+ clearLock,
8
+ E2eBusyError,
9
+ type LockHolder,
10
+ lockStatus,
11
+ markKept,
12
+ readHolder,
13
+ releaseRunLock,
14
+ } from './run-lock';
15
+
16
+ // Isolate every test on its own lock file (machine-global path is overridable).
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), 'e2e-lock-'));
21
+ process.env.CELILO_E2E_LOCK_PATH = join(dir, 'run.lock');
22
+ });
23
+
24
+ afterEach(() => {
25
+ clearLock();
26
+ rmSync(dir, { recursive: true, force: true });
27
+ delete process.env.CELILO_E2E_LOCK_PATH;
28
+ });
29
+
30
+ function writeRawHolder(h: Partial<LockHolder>): void {
31
+ const full: LockHolder = {
32
+ pid: 123,
33
+ hostname: hostname(),
34
+ session: 'other',
35
+ test: 'foo',
36
+ runId: 'r',
37
+ startedAt: new Date().toISOString(),
38
+ beatAt: Date.now(),
39
+ state: 'running',
40
+ ...h,
41
+ };
42
+ writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, JSON.stringify(full));
43
+ }
44
+
45
+ test('acquire writes a holder for this process; release removes it', () => {
46
+ acquireRunLock({ test: 'mytest', runId: 'run-1' });
47
+ const h = readHolder();
48
+ expect(h?.pid).toBe(process.pid);
49
+ expect(h?.test).toBe('mytest');
50
+ expect(lockStatus().free).toBe(false);
51
+
52
+ releaseRunLock();
53
+ expect(readHolder()).toBeNull();
54
+ expect(lockStatus().free).toBe(true);
55
+ });
56
+
57
+ test('a live holder makes a second acquire throw E2eBusyError', () => {
58
+ acquireRunLock({ test: 't1', runId: 'r1' });
59
+ // The on-disk holder is us (a live pid) → busy.
60
+ expect(() => acquireRunLock({ test: 't2', runId: 'r2' })).toThrow(E2eBusyError);
61
+ releaseRunLock();
62
+ });
63
+
64
+ test('a dead-pid holder on the same host is stale and reclaimed', () => {
65
+ // 2e9 is far above any real pid → process.kill(pid, 0) throws ESRCH → dead.
66
+ writeRawHolder({ pid: 2_000_000_000 });
67
+ expect(lockStatus().free).toBe(true); // stale → reported free
68
+
69
+ acquireRunLock({ test: 'reclaimer', runId: 'r' }); // reclaims the stale lock
70
+ expect(readHolder()?.pid).toBe(process.pid);
71
+ releaseRunLock();
72
+ });
73
+
74
+ test('--keep leaves a kept lock that survives release and blocks the next run', () => {
75
+ acquireRunLock({ test: 'kept-test', runId: 'r' });
76
+ markKept();
77
+ releaseRunLock();
78
+
79
+ const h = readHolder();
80
+ expect(h?.state).toBe('kept');
81
+ expect(lockStatus().free).toBe(false); // kept is never stale
82
+
83
+ // A plain run is refused...
84
+ expect(() => acquireRunLock({ test: 'next', runId: 'r2' })).toThrow(E2eBusyError);
85
+ // ...but a --reuse run (allowKept) takes it over.
86
+ acquireRunLock({ test: 'reuse', runId: 'r3', allowKept: true });
87
+ expect(readHolder()?.pid).toBe(process.pid);
88
+ releaseRunLock();
89
+ });
90
+
91
+ test('clearLock frees a kept lock (the `cele2e release` path)', () => {
92
+ writeRawHolder({ state: 'kept', pid: 2_000_000_000 });
93
+ expect(lockStatus().free).toBe(false);
94
+ expect(clearLock()).toBe(true);
95
+ expect(existsSync(process.env.CELILO_E2E_LOCK_PATH as string)).toBe(false);
96
+ expect(lockStatus().free).toBe(true);
97
+ });
98
+
99
+ test('a corrupt lock file is reclaimed, not fatal', () => {
100
+ writeFileSync(process.env.CELILO_E2E_LOCK_PATH as string, 'not json{');
101
+ expect(readHolder()).toBeNull();
102
+ acquireRunLock({ test: 'survivor', runId: 'r' });
103
+ expect(readHolder()?.pid).toBe(process.pid);
104
+ releaseRunLock();
105
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * cele2e run-lock — cross-platform mutual exclusion for the shared e2e infra.
3
+ *
4
+ * cele2e is a single shared resource: the shared infra is one global compose
5
+ * project (celilo-e2e-shared) and start-of-run cleanup tears down every
6
+ * celilo-e2e-* container regardless of who started it. So two actors at once
7
+ * (two Claude sessions, or a session + an operator at the terminal) clobber
8
+ * each other. This lock serializes runs across processes — no external tools,
9
+ * identical on macOS and Linux.
10
+ *
11
+ * Design (Forgejo #243):
12
+ * - Acquire via fs.openSync(path, 'wx') — POSIX-atomic exclusive create,
13
+ * same behavior on mac+linux. The lockfile lives at a MACHINE-GLOBAL path
14
+ * (~/.cache/celilo-e2e/run.lock), NOT under any repo/worktree, because the
15
+ * Docker infra is global regardless of which checkout started it.
16
+ * - Fail fast on contention with a message naming the holder. Sessions poll
17
+ * `cele2e status` and decide for themselves whether to wait (no --wait).
18
+ * - Staleness: on the SAME host, PID-liveness is authoritative (process.kill
19
+ * (pid, 0)); a dead holder PID → reclaim. Cross-host we can't check the PID,
20
+ * so fall back to a heartbeat TTL (beatAt older than STALE_TTL_MS).
21
+ * - A `kept` lock (left by `--keep` / `up`) is NEVER auto-reclaimed — it
22
+ * guards a stack that outlives the process and is cleared only by
23
+ * `cele2e release` / `cele2e down`. This enforces the honor-system rule.
24
+ */
25
+
26
+ import { execFileSync } from 'node:child_process';
27
+ import { mkdirSync, openSync, closeSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
28
+ import { homedir, hostname } from 'node:os';
29
+ import { basename, dirname, join } from 'node:path';
30
+
31
+ const STALE_TTL_MS = 90_000;
32
+ const HEARTBEAT_MS = 30_000;
33
+
34
+ /**
35
+ * Machine-global lock path — NOT under any repo/worktree, because the Docker
36
+ * infra is global regardless of which checkout started it. Overridable via
37
+ * CELILO_E2E_LOCK_PATH (used by tests; also a handy operator escape hatch).
38
+ */
39
+ export function lockPath(): string {
40
+ return process.env.CELILO_E2E_LOCK_PATH || join(homedir(), '.cache', 'celilo-e2e', 'run.lock');
41
+ }
42
+
43
+ export interface LockHolder {
44
+ pid: number;
45
+ hostname: string;
46
+ session: string;
47
+ test: string;
48
+ runId: string;
49
+ startedAt: string;
50
+ beatAt: number;
51
+ state: 'running' | 'kept';
52
+ }
53
+
54
+ interface Held {
55
+ keepOnRelease: boolean;
56
+ heartbeat: ReturnType<typeof setInterval> | null;
57
+ released: boolean;
58
+ }
59
+
60
+ /** Set once this process owns the lock; null otherwise. */
61
+ let held: Held | null = null;
62
+
63
+ export class E2eBusyError extends Error {
64
+ constructor(public holder: LockHolder) {
65
+ super(formatBusy(holder));
66
+ this.name = 'E2eBusyError';
67
+ }
68
+ }
69
+
70
+ /** Human label for who's running, e.g. "e2e busy: <session> running <test>, started 3m ago (pid 1234)". */
71
+ export function formatBusy(h: LockHolder): string {
72
+ const age = ageString(Date.parse(h.startedAt));
73
+ const verb = h.state === 'kept' ? 'holds a kept stack from' : 'running';
74
+ const what = h.state === 'kept' ? `(run \`cele2e release\` to free it)` : `${h.test}, started ${age} ago (pid ${h.pid})`;
75
+ return `e2e busy: ${h.session} ${verb} ${what}`;
76
+ }
77
+
78
+ function ageString(since: number): string {
79
+ const s = Math.max(0, Math.floor((Date.now() - since) / 1000));
80
+ if (s < 60) return `${s}s`;
81
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
82
+ return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
83
+ }
84
+
85
+ /** Branch + worktree path so a holder maps back to a specific session/thread. */
86
+ function deriveSession(): string {
87
+ if (process.env.CELILO_E2E_SESSION) return process.env.CELILO_E2E_SESSION;
88
+ const cwd = process.cwd();
89
+ const git = (args: string[]): string => {
90
+ try {
91
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
92
+ } catch {
93
+ return '';
94
+ }
95
+ };
96
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']);
97
+ const top = git(['rev-parse', '--show-toplevel']);
98
+ const base = top ? basename(top) : basename(cwd);
99
+ if (branch && top) return `${branch} (${top})`;
100
+ return branch || base;
101
+ }
102
+
103
+ function pidAlive(pid: number): boolean {
104
+ try {
105
+ process.kill(pid, 0);
106
+ return true;
107
+ } catch (err) {
108
+ // ESRCH = no such process (dead). EPERM = alive but not ours (still alive).
109
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
110
+ }
111
+ }
112
+
113
+ export function readHolder(): LockHolder | null {
114
+ try {
115
+ return JSON.parse(readFileSync(lockPath(), 'utf-8')) as LockHolder;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Is the on-disk holder stale (safe to reclaim)? A `kept` lock is never stale —
123
+ * it deliberately outlives its process. On the same host the PID is the source
124
+ * of truth; cross-host we can only use the heartbeat TTL.
125
+ */
126
+ function isStale(h: LockHolder): boolean {
127
+ if (h.state === 'kept') return false;
128
+ if (h.hostname === hostname()) return !pidAlive(h.pid);
129
+ return Date.now() - h.beatAt > STALE_TTL_MS;
130
+ }
131
+
132
+ function writeHolder(fd: number, h: LockHolder): void {
133
+ writeFileSync(fd, JSON.stringify(h, null, 2));
134
+ }
135
+
136
+ /**
137
+ * Acquire the run lock for this process. Throws E2eBusyError if another live
138
+ * (non-stale) holder owns it. Registers an exit handler so the lock is released
139
+ * on any process.exit() path (normal, exception, or a SIGINT handler that
140
+ * exits). A signal-killed process with no exit handler leaks the lock, but the
141
+ * next run reclaims it via PID-liveness — that's exactly what staleness is for.
142
+ */
143
+ export function acquireRunLock(opts: { test: string; runId: string; allowKept?: boolean }): void {
144
+ mkdirSync(dirname(lockPath()), { recursive: true });
145
+
146
+ const holder: LockHolder = {
147
+ pid: process.pid,
148
+ hostname: hostname(),
149
+ session: deriveSession(),
150
+ test: opts.test,
151
+ runId: opts.runId,
152
+ startedAt: new Date().toISOString(),
153
+ beatAt: Date.now(),
154
+ state: 'running',
155
+ };
156
+
157
+ for (let attempt = 0; attempt < 2; attempt++) {
158
+ let fd: number;
159
+ try {
160
+ fd = openSync(lockPath(), 'wx');
161
+ } catch (err) {
162
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
163
+ const existing = readHolder();
164
+ // Reclaim when: corrupt/unreadable, the holder is stale, OR this is a
165
+ // `--reuse` run taking over a `kept` stack it's deliberately reusing.
166
+ if (!existing || isStale(existing) || (opts.allowKept && existing.state === 'kept')) {
167
+ try { unlinkSync(lockPath()); } catch {}
168
+ continue;
169
+ }
170
+ throw new E2eBusyError(existing);
171
+ }
172
+ writeHolder(fd, holder);
173
+ closeSync(fd);
174
+
175
+ const heartbeat = setInterval(() => {
176
+ // Only refresh while running; a kept lock's beatAt is frozen by design.
177
+ if (!held || held.released || held.keepOnRelease) return;
178
+ try {
179
+ const cur = readHolder();
180
+ if (cur && cur.pid === process.pid) {
181
+ writeFileSync(lockPath(), JSON.stringify({ ...cur, beatAt: Date.now() }, null, 2));
182
+ }
183
+ } catch {}
184
+ }, HEARTBEAT_MS);
185
+ heartbeat.unref();
186
+
187
+ held = { keepOnRelease: false, heartbeat, released: false };
188
+ process.on('exit', releaseRunLock);
189
+ return;
190
+ }
191
+ // Two reclaim attempts both lost the race → someone else won fair and square.
192
+ const existing = readHolder();
193
+ throw new E2eBusyError(existing ?? holderUnknown());
194
+ }
195
+
196
+ function holderUnknown(): LockHolder {
197
+ return {
198
+ pid: 0,
199
+ hostname: hostname(),
200
+ session: 'unknown',
201
+ test: '?',
202
+ runId: '?',
203
+ startedAt: new Date().toISOString(),
204
+ beatAt: Date.now(),
205
+ state: 'running',
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Mark the lock to survive process exit in a `kept` state — call when a stack
211
+ * (network/containers) will outlive this process (`--keep`, `up`). The next run
212
+ * then refuses to clobber it until `cele2e release` / `cele2e down`.
213
+ */
214
+ export function markKept(): void {
215
+ if (held) held.keepOnRelease = true;
216
+ }
217
+
218
+ /** Release the lock. Idempotent. Honors markKept() (kept-state) vs delete. */
219
+ export function releaseRunLock(): void {
220
+ if (!held || held.released) return;
221
+ held.released = true;
222
+ if (held.heartbeat) clearInterval(held.heartbeat);
223
+
224
+ if (held.keepOnRelease) {
225
+ const cur = readHolder();
226
+ if (cur && cur.pid === process.pid) {
227
+ try { writeFileSync(lockPath(), JSON.stringify({ ...cur, state: 'kept', beatAt: Date.now() }, null, 2)); } catch {}
228
+ }
229
+ return;
230
+ }
231
+ try {
232
+ const cur = readHolder();
233
+ if (!cur || cur.pid === process.pid) unlinkSync(lockPath());
234
+ } catch {}
235
+ }
236
+
237
+ /** Forcibly clear the lock regardless of holder — used by `cele2e release`/`down`. */
238
+ export function clearLock(): boolean {
239
+ try {
240
+ unlinkSync(lockPath());
241
+ return true;
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+
247
+ /** Current lock state for `cele2e status` (and any poller). */
248
+ export function lockStatus(): { free: boolean; holder: LockHolder | null } {
249
+ const h = readHolder();
250
+ if (!h || isStale(h)) return { free: true, holder: null };
251
+ return { free: false, holder: h };
252
+ }
package/src/runner.ts CHANGED
@@ -30,6 +30,8 @@ import {
30
30
  emitTestStarted,
31
31
  } from './bus-events';
32
32
  import { parseLine } from './parse-line';
33
+ import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
34
+ import { SIMULATOR_IPS } from './simulator-ips';
33
35
 
34
36
  // ─── Config ──────────────────────────────────────────────────────────
35
37
 
@@ -87,6 +89,52 @@ const patterns = rawArgs.filter((a) => !a.startsWith('--'));
87
89
  // run everything. Passed through from the CLI (not stripped) so the runner can
88
90
  // apply the exclusion.
89
91
  const flagCiSafe = rawArgs.includes('--ci-safe');
92
+ // --notify: fire a desktop notification when the run finishes (handy for long
93
+ // detached/background runs). Best-effort — never affects the exit code.
94
+ const flagNotify = rawArgs.includes('--notify');
95
+ // --shuffle [--seed=N]: randomize test order to surface order-dependent bugs
96
+ // (e2e-confidence #258). The install-sh DNS-pollution bug only manifested in a
97
+ // specific order — a shuffle lane flushes that class out. Seeded + logged so a
98
+ // failing order is reproducible: replay with the printed --seed=N.
99
+ const flagShuffle = rawArgs.includes('--shuffle');
100
+ const seedFlag = rawArgs.find((a) => a.startsWith('--seed='));
101
+ const seedArg = seedFlag ? Number.parseInt(seedFlag.split('=')[1] ?? '', 10) : undefined;
102
+
103
+ /** Deterministic Fisher-Yates shuffle seeded by a 32-bit value (mulberry32), so
104
+ * a randomized test order is reproducible via --seed=<n>. */
105
+ function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
106
+ let s = seed >>> 0;
107
+ const rand = (): number => {
108
+ s = (s + 0x6d2b79f5) >>> 0;
109
+ let t = s;
110
+ t = Math.imul(t ^ (t >>> 15), t | 1);
111
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
112
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
113
+ };
114
+ const out = [...arr];
115
+ for (let i = out.length - 1; i > 0; i--) {
116
+ const j = Math.floor(rand() * (i + 1));
117
+ [out[i], out[j]] = [out[j], out[i]];
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function notify(title: string, message: string): void {
123
+ if (!flagNotify) return;
124
+ try {
125
+ if (process.platform === 'darwin') {
126
+ const r = spawnSync('terminal-notifier', ['-title', title, '-message', message, '-sound', 'Glass'], { stdio: 'ignore' });
127
+ if (r.error) {
128
+ spawnSync('osascript', ['-e', `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)} sound name "Glass"`], { stdio: 'ignore' });
129
+ }
130
+ spawnSync('say', [message], { stdio: 'ignore' });
131
+ } else if (process.platform === 'linux') {
132
+ spawnSync('notify-send', [title, message], { stdio: 'ignore' });
133
+ }
134
+ } catch {
135
+ // ponytail: notification is a nicety; its failure must never break a run.
136
+ }
137
+ }
90
138
 
91
139
  // ─── Colors ──────────────────────────────────────────────────────────
92
140
 
@@ -94,7 +142,6 @@ const bold = '\x1b[1m';
94
142
  const dim = '\x1b[2m';
95
143
  const green = '\x1b[32m';
96
144
  const red = '\x1b[31m';
97
- const cyan = '\x1b[36m';
98
145
  const reset = '\x1b[0m';
99
146
 
100
147
  const interactive =
@@ -386,19 +433,36 @@ function dockerComposeShared(cmd: string): string {
386
433
  }
387
434
  }
388
435
 
389
- function isSharedInfraHealthy(): boolean {
390
- const containers = dockerComposeShared('ps -q');
391
- if (!containers) return false;
392
- return dockerComposeShared(
393
- 'exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2',
394
- ).length > 0;
395
- }
396
-
397
436
  function stopSharedInfra(): void {
398
437
  dockerComposeShared('down --volumes --remove-orphans');
399
438
  try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
400
439
  }
401
440
 
441
+ /**
442
+ * Detect cross-test pollution of the SHARED authoritative DNS at the test
443
+ * boundary (e2e-confidence #254). A test that leaves a protected sim record
444
+ * clobbered — canonically celilo.computer's apex off the website-sim, the bug
445
+ * that silently broke install-sh ~15 tests downstream — has violated the shared
446
+ * contract. Checked AFTER each test so the polluter is named HERE, at the cause,
447
+ * instead of a later test failing mysteriously. On detection we re-scrub
448
+ * (cp /seed -> /config, mirroring scrubDnsZones) so the damage doesn't cascade.
449
+ *
450
+ * Returns a description of the violation (and heals) if polluted, else null.
451
+ * A query failure (infra down / tool missing) is not treated as pollution.
452
+ */
453
+ function detectSharedDnsPollution(): string | null {
454
+ const served = dockerComposeShared(
455
+ `exec -T namecheap-dns sh -c ${JSON.stringify('kdig @127.0.0.1 celilo.computer A +short')}`,
456
+ ).trim();
457
+ if (!served) return null;
458
+ if (served.split(/\s+/).includes(SIMULATOR_IPS.WEBSITE)) return null;
459
+ // Polluted — heal so the next test starts from a clean baseline.
460
+ dockerComposeShared(
461
+ `exec -T namecheap-dns sh -c ${JSON.stringify('cp -f /seed/*.zone /config/ && knotc zone-reload')}`,
462
+ );
463
+ return `celilo.computer apex resolves to "${served}" (expected the website-sim ${SIMULATOR_IPS.WEBSITE})`;
464
+ }
465
+
402
466
  // ─── Docker Check ────────────────────────────────────────────────────
403
467
 
404
468
  function assertDockerAvailable(): void {
@@ -417,6 +481,24 @@ function assertDockerAvailable(): void {
417
481
  async function main() {
418
482
  assertDockerAvailable();
419
483
 
484
+ // Serialize against other cele2e runs sharing the global Docker infra. Fail
485
+ // fast naming the holder; pollers use `cele2e status`. Acquire BEFORE any
486
+ // docker mutation so a competing run can't wipe our shared infra mid-setup.
487
+ const lockLabel = patterns.length ? patterns.join(',') : moduleDirs.length ? 'modules' : 'all';
488
+ try {
489
+ acquireRunLock({ test: lockLabel, runId: process.env.CELE2E_RUN_ID ?? ambientRunId, allowKept: flagReuse });
490
+ // A --reuse run keeps using a kept stack that stays up afterwards — hold the
491
+ // lock in kept state so it isn't freed out from under the reused network.
492
+ if (flagReuse) markKept();
493
+ } catch (err) {
494
+ if (err instanceof E2eBusyError) {
495
+ console.error(`\n${red}${err.message}${reset}`);
496
+ console.error(`${dim}Poll with: cele2e status (machine-readable: cele2e status --json)${reset}\n`);
497
+ process.exit(3);
498
+ }
499
+ throw err;
500
+ }
501
+
420
502
  let testFiles: string[] = [];
421
503
 
422
504
  if (moduleDirs.length > 0) {
@@ -515,6 +597,17 @@ async function main() {
515
597
  process.exit(1);
516
598
  }
517
599
 
600
+ // e2e-confidence #258: optionally randomize order to surface order-dependent
601
+ // bugs (the install-sh DNS bleed only manifested in a specific order). Seeded
602
+ // + logged so a failing order replays exactly via --seed=<n>.
603
+ if (flagShuffle) {
604
+ const seed = (seedArg !== undefined && Number.isFinite(seedArg) ? seedArg : Date.now()) >>> 0;
605
+ testFiles = seededShuffle(testFiles, seed);
606
+ console.log(
607
+ `${dim}--shuffle: randomized test order (seed=${seed}) — replay this exact order with --seed=${seed}${reset}`,
608
+ );
609
+ }
610
+
518
611
  // --keep keeps a single test network alive after the run; only one network
519
612
  // can live in `.e2e-persistent.json` at a time, so multiple tests would
520
613
  // either fight for that slot or run with no functional --keep at all. Bail
@@ -557,9 +650,8 @@ async function main() {
557
650
  mkdirSync(resultsDir, { recursive: true });
558
651
 
559
652
  // runId scopes every event in this suite invocation. Read from env to
560
- // let callers (CI, the Claude tool wrapper) inject a known id and
561
- // subscribe to it before launching cele2e; otherwise generate one and
562
- // print it so the operator can correlate.
653
+ // let callers (CI) inject a known id and subscribe to it before launching
654
+ // cele2e; otherwise generate one and print it so the operator can correlate.
563
655
  const runId = process.env.CELE2E_RUN_ID || crypto.randomUUID();
564
656
  if (!process.env.CELE2E_RUN_ID) {
565
657
  console.log(`${dim}runId: ${runId}${reset}`);
@@ -574,7 +666,7 @@ async function main() {
574
666
 
575
667
  // 'auto' resolves to 'render' when isTTY is true (operator at a
576
668
  // terminal sees animated spinners) and 'protocol' when isTTY is
577
- // false (cele2e-run.sh wrapper / any redirected stdout gets ASCII
669
+ // false (a backgrounded run / any redirected stdout gets ASCII
578
670
  // `[progress:*]` markers — grep-friendly, no braille noise).
579
671
  // Prior to this change the mode was hardcoded 'render', so non-TTY
580
672
  // callers got `⣾ phase started` / `✔ phase done` lines per phase
@@ -635,6 +727,17 @@ async function main() {
635
727
 
636
728
  emitTestStarted({ runId, name, expectedDurationS: expected });
637
729
  const result = await runTest(file, name, expected, logDir, display);
730
+
731
+ // e2e-confidence #254: name a test that leaves shared infra polluted, at the
732
+ // boundary — before a later test fails mysteriously downstream. detect…()
733
+ // also heals (re-scrubs), so a passing-but-polluting test is failed here AND
734
+ // the next test still starts clean.
735
+ const pollution = detectSharedDnsPollution();
736
+ if (pollution && result.status === 'pass') {
737
+ result.status = 'fail';
738
+ result.error = `cross-test pollution (#254): this test left shared DNS dirty — ${pollution}`;
739
+ }
740
+
638
741
  emitTestCompleted({
639
742
  runId,
640
743
  name,
@@ -663,6 +766,9 @@ async function main() {
663
766
  console.log(`${dim} Persistent network: ${result.projectName}${reset}`);
664
767
  console.log(`${dim} Reuse: ${basename(process.argv[1])} --reuse${reset}`);
665
768
  console.log(`${dim} Tear down: cele2e down${reset}`);
769
+ // The kept network outlives this process — hold the lock in a `kept`
770
+ // state so the next run refuses to clobber it (cleared by `cele2e down`).
771
+ markKept();
666
772
  }
667
773
 
668
774
  if (result.status === 'pass') {
@@ -745,6 +851,7 @@ async function main() {
745
851
  resultsDir,
746
852
  });
747
853
 
854
+ notify('cele2e', `${passed} passed, ${failed} failed — ${formatDuration(suiteDuration)}`);
748
855
  process.exit(failed > 0 ? 1 : 0);
749
856
  }
750
857
 
@@ -762,5 +869,6 @@ main().catch((err) => {
762
869
  error: err instanceof Error ? err.message : String(err),
763
870
  });
764
871
  console.error(err);
872
+ notify('cele2e', 'run failed');
765
873
  process.exit(1);
766
874
  });