@celilo/e2e 0.8.0 → 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 {
package/src/runner.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from './bus-events';
32
32
  import { parseLine } from './parse-line';
33
33
  import { acquireRunLock, E2eBusyError, markKept } from './run-lock';
34
+ import { SIMULATOR_IPS } from './simulator-ips';
34
35
 
35
36
  // ─── Config ──────────────────────────────────────────────────────────
36
37
 
@@ -91,6 +92,32 @@ const flagCiSafe = rawArgs.includes('--ci-safe');
91
92
  // --notify: fire a desktop notification when the run finishes (handy for long
92
93
  // detached/background runs). Best-effort — never affects the exit code.
93
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
+ }
94
121
 
95
122
  function notify(title: string, message: string): void {
96
123
  if (!flagNotify) return;
@@ -411,6 +438,31 @@ function stopSharedInfra(): void {
411
438
  try { execSync('docker network prune -f', { stdio: 'pipe', timeout: 10_000 }); } catch {}
412
439
  }
413
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
+
414
466
  // ─── Docker Check ────────────────────────────────────────────────────
415
467
 
416
468
  function assertDockerAvailable(): void {
@@ -545,6 +597,17 @@ async function main() {
545
597
  process.exit(1);
546
598
  }
547
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
+
548
611
  // --keep keeps a single test network alive after the run; only one network
549
612
  // can live in `.e2e-persistent.json` at a time, so multiple tests would
550
613
  // either fight for that slot or run with no functional --keep at all. Bail
@@ -664,6 +727,17 @@ async function main() {
664
727
 
665
728
  emitTestStarted({ runId, name, expectedDurationS: expected });
666
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
+
667
741
  emitTestCompleted({
668
742
  runId,
669
743
  name,
@@ -28,8 +28,6 @@ function run(cmd: string, opts?: { cwd?: string; timeout?: number }): string {
28
28
  }).trim();
29
29
  }
30
30
 
31
- let sharedInfraRunning = false;
32
-
33
31
  /**
34
32
  * Remove ALL celilo-e2e-* Docker resources by name prefix (#212). Used at
35
33
  * start-of-run, where the run-lock guarantees no other session's stack is live.
@@ -74,10 +72,8 @@ function nukeE2eResources(e2eDir: string): void {
74
72
  * per-test stack failed with "network … declared as external,
75
73
  * but could not be found" when starting per-test services).
76
74
  *
77
- * Source of truth is Docker, never the module-local
78
- * `sharedInfraRunning` flag in isolation — each test file runs in a
79
- * fresh bun process so that flag is always `false` at startup. The
80
- * flag exists only as a within-process cache for redundant calls.
75
+ * Source of truth is always Docker — each test file runs in a fresh
76
+ * bun process, so there is no reliable in-process cache to lean on.
81
77
  */
82
78
  export function isSharedInfraRunning(): boolean {
83
79
  try {
@@ -86,20 +82,16 @@ export function isSharedInfraRunning(): boolean {
86
82
  { cwd: getE2eDir() },
87
83
  );
88
84
  if (containers.trim().length === 0) {
89
- sharedInfraRunning = false;
90
85
  return false;
91
86
  }
92
87
  const networks = run('docker network ls --format "{{.Name}}"');
93
88
  const networkLines = networks.split('\n');
94
89
  const haveAll = Object.values(SHARED_NETWORKS).every((n) => networkLines.includes(n));
95
90
  if (!haveAll) {
96
- sharedInfraRunning = false;
97
91
  return false;
98
92
  }
99
- sharedInfraRunning = true;
100
93
  return true;
101
94
  } catch {
102
- sharedInfraRunning = false;
103
95
  return false;
104
96
  }
105
97
  }
@@ -192,7 +184,6 @@ export async function ensureSharedInfra(): Promise<void> {
192
184
  { cwd: e2eDir, timeout: 10_000 },
193
185
  );
194
186
  if (result.trim().length > 0) {
195
- sharedInfraRunning = true;
196
187
  console.log('[progress:done] shared infrastructure ready');
197
188
  return;
198
189
  }
@@ -241,21 +232,4 @@ export async function stopSharedInfra(): Promise<void> {
241
232
  } catch {
242
233
  // Best effort
243
234
  }
244
- sharedInfraRunning = false;
245
- }
246
-
247
- /**
248
- * Execute a command in a shared infrastructure container.
249
- */
250
- export function sharedExec(container: string, cmd: string, timeoutMs = 60_000): { stdout: string; exitCode: number } {
251
- try {
252
- const stdout = run(
253
- `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T ${container} bash -c ${JSON.stringify(cmd)}`,
254
- { cwd: getE2eDir(), timeout: timeoutMs },
255
- );
256
- return { stdout, exitCode: 0 };
257
- } catch (err: unknown) {
258
- const e = err as { stdout?: string; status?: number };
259
- return { stdout: e.stdout?.toString() ?? '', exitCode: e.status ?? 1 };
260
- }
261
235
  }
package/src/types.ts CHANGED
@@ -193,11 +193,17 @@ export interface NetworkHandle {
193
193
  /** Resolve a DNS name from the management machine */
194
194
  dig(name: string): Promise<string>;
195
195
 
196
- /** Wait for a condition with timeout */
196
+ /**
197
+ * Wait for a condition with timeout. On timeout, `onTimeout` (if given) is
198
+ * invoked to collect live-state diagnostics that are appended to the error —
199
+ * so a readiness failure self-diagnoses instead of speculating
200
+ * (e2e-confidence #255).
201
+ */
197
202
  waitFor(
198
203
  check: () => Promise<boolean>,
199
204
  timeoutMs: number,
200
205
  label: string,
206
+ onTimeout?: () => string | Promise<string>,
201
207
  ): Promise<void>;
202
208
 
203
209
  /**
@@ -342,6 +348,15 @@ export interface ResponderValues {
342
348
  secretValues?: Record<string, string>;
343
349
  }
344
350
  >;
351
+ /**
352
+ * Aspect-consent decisions (ISS-0027 / #262). When a headless deploy emits
353
+ * `aspect.required.<module>.<role>`, the responder replies `{ consented }`
354
+ * so a module's base_module_aspect fan-out is approved without a TTY and
355
+ * without `--accept-aspects` at import. Lookup precedence: `<module>.<role>`,
356
+ * then `<module>`, then the `'*'` wildcard. Absent → skipped (never
357
+ * silently approved). Passed straight through to `celilo events respond`.
358
+ */
359
+ aspects?: Record<string, boolean>;
345
360
  }
346
361
 
347
362
  export const ZONE_GATEWAYS: Record<Zone, string> = {
@@ -1,16 +0,0 @@
1
- $ORIGIN example.net.
2
- $TTL 300
3
-
4
- @ IN SOA ns1.example.net. admin.example.net. (
5
- 2024020108 300 60 604800 300
6
- )
7
-
8
- ; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
9
- ; the e2e simulation is to model real internet connectivity, where
10
- ; private IPs are unreachable from outside. All public-facing names
11
- ; resolve to the firewall's external IP (100.100.0.100), which DNATs
12
- ; inbound to the right internal host.
13
- @ IN NS ns1.example.net.
14
- ns1 IN A 100.64.0.55
15
- @ IN A 100.100.0.100
16
- www IN A 100.100.0.100
@@ -1,16 +0,0 @@
1
- $ORIGIN iamtheinternet.org.
2
- $TTL 300
3
-
4
- @ IN SOA ns1.iamtheinternet.org. admin.iamtheinternet.org. (
5
- 2024020247 300 60 604800 300
6
- )
7
-
8
- ; Public DNS — RFC 1918 addresses NEVER appear here. The whole point of
9
- ; the e2e simulation is to model real internet connectivity, where
10
- ; private IPs are unreachable from outside. All public-facing names
11
- ; resolve to the firewall's external IP (100.100.0.100), which DNATs
12
- ; inbound to the right internal host.
13
- @ IN NS ns1.iamtheinternet.org.
14
- ns1 IN A 100.64.0.55
15
- @ IN A 0.0.0.0
16
- www IN A 100.100.0.100