@celilo/e2e 0.7.15 → 0.7.17

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.
@@ -13,9 +13,14 @@
13
13
  * @celilo/capabilities → (none)
14
14
  * @celilo/cli-display → (none)
15
15
  *
16
- * So 5 workspace packages need to land in the simulator. We rely on
17
- * `bun pm pack` to rewrite `workspace:^` deps to concrete versions, so
18
- * the published tarballs resolve cleanly without real npm.
16
+ * So 5 workspace packages need to land in the simulator. We rewrite each
17
+ * package's `workspace:^` deps to concrete `^<workspace-version>` ranges
18
+ * OURSELVES before `bun pm pack` — we do NOT rely on bun's lock-based
19
+ * expansion, which goes stale after a cross-caret version bump and pins
20
+ * dependents to an unresolvable old range (ISS-0103: a `0.3→0.4`
21
+ * capabilities bump left the lock at 0.3, so packed cli pinned
22
+ * `^0.3.0-alpha.0`, excluding the 0.4.x the sim served). package.json is
23
+ * restored after packing so `workspace:^` stays in source.
19
24
  *
20
25
  * Output directory is typically `packages/e2e/.npm-registry-cache/` —
21
26
  * staged into the npm-registry-sim Docker image at build time.
@@ -25,11 +30,73 @@
25
30
  */
26
31
 
27
32
  import { execSync } from 'node:child_process';
28
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
33
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
29
34
  import { join } from 'node:path';
30
35
 
31
36
  const REPO_ROOT = join(import.meta.dir, '..', '..', '..');
32
37
 
38
+ interface PackageJson {
39
+ name?: string;
40
+ version?: string;
41
+ dependencies?: Record<string, string>;
42
+ devDependencies?: Record<string, string>;
43
+ peerDependencies?: Record<string, string>;
44
+ optionalDependencies?: Record<string, string>;
45
+ }
46
+
47
+ export interface WorkspaceRewrite {
48
+ depName: string;
49
+ oldSpec: string;
50
+ newSpec: string;
51
+ }
52
+
53
+ const DEP_BUCKETS = [
54
+ 'dependencies',
55
+ 'devDependencies',
56
+ 'peerDependencies',
57
+ 'optionalDependencies',
58
+ ] as const;
59
+
60
+ /**
61
+ * Rewrite `workspace:` deps in `pkg` to concrete ranges using the
62
+ * name→version map, mutating `pkg`. Mirrors the publish flow's
63
+ * `rewriteWorkspaceDeps`:
64
+ * workspace:^ → ^<version>
65
+ * workspace:~ → ~<version>
66
+ * workspace:* / workspace: → * (any version)
67
+ * workspace:X.Y.Z → X.Y.Z (explicit, passes through)
68
+ *
69
+ * We do this ourselves rather than trust `bun pm pack`'s lock-based
70
+ * expansion, which goes stale after a cross-caret version bump (ISS-0103).
71
+ * Only deps present in `versions` (our own @celilo/* set) are touched.
72
+ * Returns the rewrites applied.
73
+ */
74
+ export function rewriteWorkspacePins(
75
+ pkg: PackageJson,
76
+ versions: Map<string, string>,
77
+ ): WorkspaceRewrite[] {
78
+ const rewrites: WorkspaceRewrite[] = [];
79
+ for (const bucket of DEP_BUCKETS) {
80
+ const deps = pkg[bucket];
81
+ if (!deps) continue;
82
+ for (const [name, spec] of Object.entries(deps)) {
83
+ if (!spec.startsWith('workspace:')) continue;
84
+ const version = versions.get(name);
85
+ if (!version) continue;
86
+ const range = spec.slice('workspace:'.length);
87
+ let newSpec: string;
88
+ if (range === '*' || range === '') newSpec = '*';
89
+ else if (range === '^') newSpec = `^${version}`;
90
+ else if (range === '~') newSpec = `~${version}`;
91
+ else newSpec = range;
92
+ if (newSpec === spec) continue;
93
+ deps[name] = newSpec;
94
+ rewrites.push({ depName: name, oldSpec: spec, newSpec });
95
+ }
96
+ }
97
+ return rewrites;
98
+ }
99
+
33
100
  /**
34
101
  * Workspace packages that install.sh's transitive @celilo/* closure depends
35
102
  * on. Hardcoded by name → relative path so a typo in package.json or a future
@@ -63,27 +130,46 @@ function parseArgs(): CliArgs {
63
130
  return { destination };
64
131
  }
65
132
 
66
- function pack(pkgPath: string, destination: string): string {
133
+ function pack(pkgPath: string, destination: string, versions: Map<string, string>): string {
67
134
  const fullPath = join(REPO_ROOT, pkgPath);
68
- if (!existsSync(join(fullPath, 'package.json'))) {
135
+ const pkgJsonPath = join(fullPath, 'package.json');
136
+ if (!existsSync(pkgJsonPath)) {
69
137
  throw new Error(`No package.json in ${fullPath}`);
70
138
  }
71
- // Capture filenames before/after so we can identify the tarball this
72
- // pack call produced even when the destination already contains tarballs
73
- // from earlier packages in the loop.
74
- const before = new Set(readdirSync(destination).filter((f) => f.endsWith('.tgz')));
75
- execSync(`bun pm pack --destination=${destination}`, {
76
- cwd: fullPath,
77
- stdio: 'inherit',
78
- });
79
- const after = readdirSync(destination).filter((f) => f.endsWith('.tgz'));
80
- const produced = after.filter((f) => !before.has(f));
81
- if (produced.length !== 1) {
82
- throw new Error(
83
- `Expected exactly one new tarball from packing ${pkgPath}, got ${produced.length}: ${produced.join(', ')}`,
84
- );
139
+
140
+ // Rewrite workspace:^ deps to concrete ranges ourselves (see header /
141
+ // ISS-0103), then restore the original so workspace:^ stays in source.
142
+ const originalPkgJson = readFileSync(pkgJsonPath, 'utf-8');
143
+ const parsed = JSON.parse(originalPkgJson) as PackageJson;
144
+ const rewrites = rewriteWorkspacePins(parsed, versions);
145
+ if (rewrites.length > 0) {
146
+ const trailingNl = originalPkgJson.endsWith('\n') ? '\n' : '';
147
+ writeFileSync(pkgJsonPath, JSON.stringify(parsed, null, 2) + trailingNl);
148
+ for (const r of rewrites) {
149
+ console.log(` rewrote ${r.depName}: ${r.oldSpec} → ${r.newSpec}`);
150
+ }
151
+ }
152
+
153
+ try {
154
+ // Capture filenames before/after so we can identify the tarball this
155
+ // pack call produced even when the destination already contains tarballs
156
+ // from earlier packages in the loop.
157
+ const before = new Set(readdirSync(destination).filter((f) => f.endsWith('.tgz')));
158
+ execSync(`bun pm pack --destination=${destination}`, {
159
+ cwd: fullPath,
160
+ stdio: 'inherit',
161
+ });
162
+ const after = readdirSync(destination).filter((f) => f.endsWith('.tgz'));
163
+ const produced = after.filter((f) => !before.has(f));
164
+ if (produced.length !== 1) {
165
+ throw new Error(
166
+ `Expected exactly one new tarball from packing ${pkgPath}, got ${produced.length}: ${produced.join(', ')}`,
167
+ );
168
+ }
169
+ return produced[0];
170
+ } finally {
171
+ writeFileSync(pkgJsonPath, originalPkgJson);
85
172
  }
86
- return produced[0];
87
173
  }
88
174
 
89
175
  function workspaceVersion(pkgPath: string): string {
@@ -104,11 +190,17 @@ function main(): void {
104
190
  console.log(`Packing @celilo/* packages → ${destination}`);
105
191
  console.log('');
106
192
 
193
+ // The name→version map used to rewrite each package's workspace:^ deps to
194
+ // concrete ranges that resolve against the co-packed versions (ISS-0103).
195
+ const versions = new Map<string, string>(
196
+ PACKAGES_TO_PACK.map(({ name, path }) => [name, workspaceVersion(path)]),
197
+ );
198
+
107
199
  const results: Array<{ name: string; version: string; tarball: string }> = [];
108
200
  for (const { name, path } of PACKAGES_TO_PACK) {
109
201
  const version = workspaceVersion(path);
110
202
  console.log(`▸ ${name}@${version} (${path})`);
111
- const tarball = pack(path, destination);
203
+ const tarball = pack(path, destination, versions);
112
204
  results.push({ name, version, tarball });
113
205
  console.log(` → ${tarball}`);
114
206
  console.log('');
@@ -65,11 +65,24 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
65
65
  process.stdout.write(` ${'apt-repo (build debs)'.padEnd(28)} `);
66
66
  const t0 = Date.now();
67
67
 
68
+ // The deb must install the EXACT version the npm-registry-sim serves locally
69
+ // (= apps/celilo/package.json). Pin CELILO_VERSION so the postinst runs
70
+ // `bun add @celilo/cli@<version>` rather than `@celilo/cli@alpha` (build-deb.sh's
71
+ // default): the sim has no local `alpha` dist-tag, so an `@alpha` install leaks
72
+ // to the real npm uplink and pulls a stale published alpha (ISS-0153). Pinning
73
+ // makes the deb version, the installed npm version, and the test expectation a
74
+ // single source of truth.
75
+ const version = currentCeliloVersion(repoRoot);
76
+
68
77
  // Reuse the canonical build-deb scripts so versioning/contents match a
69
78
  // real release exactly. build:deb builds both arches; build:deb:bootstrap
70
79
  // builds the arch-all meta-package.
71
80
  for (const script of ['build:deb', 'build:deb:bootstrap']) {
72
- const result = spawnSync('bun', ['run', script], { cwd: repoRoot, stdio: 'pipe' });
81
+ const result = spawnSync('bun', ['run', script], {
82
+ cwd: repoRoot,
83
+ stdio: 'pipe',
84
+ env: { ...process.env, CELILO_VERSION: version },
85
+ });
73
86
  if (result.status !== 0) {
74
87
  console.log(`${red}✗${reset}`);
75
88
  console.error(result.stderr?.toString());
@@ -81,7 +94,6 @@ export function stageAptRepo(repoRoot: string, pkgDir: string): boolean {
81
94
  // Stage every freshly-built .deb into the pool. dist/ may also hold older
82
95
  // versions; copy only the current ones so the repo doesn't advertise
83
96
  // stale celilo versions the npm-registry-sim no longer serves.
84
- const version = currentCeliloVersion(repoRoot);
85
97
  const distDir = join(repoRoot, 'dist');
86
98
 
87
99
  const debs = readdirSync(distDir).filter((f) => f.endsWith('.deb') && f.includes(version));
package/src/cli/build.ts CHANGED
@@ -48,6 +48,7 @@ const bold = '\x1b[1m';
48
48
  const dim = '\x1b[2m';
49
49
  const green = '\x1b[32m';
50
50
  const red = '\x1b[31m';
51
+ const yellow = '\x1b[33m';
51
52
  const reset = '\x1b[0m';
52
53
 
53
54
  interface BuildOptions {
@@ -320,6 +321,30 @@ function bakeManagement(pkgDir: string): void {
320
321
  console.log(`\n${green}Baked in ${elapsed}s${reset}\n`);
321
322
  }
322
323
 
324
+ /**
325
+ * Pre-seed heavy application images (authentik server, postgres, redis) into
326
+ * the app-zone preload cache. Without this the `docker-image-cache` is empty on
327
+ * a fresh checkout / the builder, so every app-module deploy pulls GB through
328
+ * the simulated internet — slow enough to time the authentik deploy out
329
+ * (exit 124) and take down forgejo-deploy + full-stack-pipeline (ISS-0154).
330
+ * Best-effort: `e2e-cache-images` is per-image resilient and the preload
331
+ * service skips a missing tarball, so a transient registry hiccup degrades to a
332
+ * slow pull rather than failing build-infra.
333
+ */
334
+ function cacheAppImages(pkgDir: string): void {
335
+ const cacheScript = join(pkgDir, 'bin', 'e2e-cache-images');
336
+ if (!existsSync(cacheScript)) return;
337
+
338
+ console.log(`${bold}Pre-seeding app-zone image cache...${reset}\n`);
339
+ const result = spawnSync('bash', [cacheScript], { stdio: 'inherit' });
340
+ if (result.status !== 0) {
341
+ console.error(
342
+ `${yellow}⚠ image cache step failed — app-zone deploys may pull over the sim${reset}`,
343
+ );
344
+ }
345
+ console.log('');
346
+ }
347
+
323
348
  function saveImages(pkgDir: string): void {
324
349
  const cacheDir = join(pkgDir, '.docker-cache');
325
350
  mkdirSync(cacheDir, { recursive: true });
@@ -406,6 +431,10 @@ export function runBuild(options: BuildOptions): void {
406
431
  // install.sh, rather than a parallel `bun add -g` pinning.
407
432
  bakeManagement(pkgDir);
408
433
 
434
+ // Pre-seed heavy app images so app-zone deploys load them from the cache
435
+ // instead of pulling GB through the sim (ISS-0154).
436
+ cacheAppImages(pkgDir);
437
+
409
438
  if (save) {
410
439
  saveImages(pkgDir);
411
440
  }
@@ -26,6 +26,8 @@ export const COMMANDS: CommandDef[] = [
26
26
  description: 'Run e2e tests — pass a module path or pattern, or --all / --all-modules',
27
27
  flags: [
28
28
  { name: '--all', description: 'Run the full regression: every module suite + top-level e2e/tests/' },
29
+ { name: '--complete', description: 'Alias of --all: run every test, including ci-unsafe (quarantined) ones' },
30
+ { name: '--ci-safe', description: 'Full regression EXCEPT tests marked cele2e-ci-unsafe (used by the nightly)' },
29
31
  { name: '--all-modules', description: 'Run e2e tests for all modules with e2e/ directories' },
30
32
  { name: '--keep', description: 'Keep network running after tests' },
31
33
  { name: '--reuse', description: 'Reuse existing network if running' },
@@ -99,7 +101,7 @@ export const COMMANDS: CommandDef[] = [
99
101
 
100
102
  export const UP_PRESETS = ['--caddy', '--full-stack', '--infrastructure'];
101
103
 
102
- export const RUN_FLAGS = ['--all', '--all-modules', '--keep', '--reuse', '--live', '--published'];
104
+ export const RUN_FLAGS = ['--all', '--complete', '--ci-safe', '--all-modules', '--keep', '--reuse', '--live', '--published'];
103
105
 
104
106
  export const DOWN_FLAGS = ['--keep', '--all'];
105
107
 
@@ -119,6 +119,8 @@ _cele2e() {
119
119
  '--reuse[Reuse existing network if running]' \\
120
120
  '--live[Use live (non-simulated) internet]' \\
121
121
  '--published[Use published .netapp packages]' \\
122
+ '--ci[Plain log output: one line per step, no spinner]' \\
123
+ '--no-ci[Force spinner even when a CI env var is set]' \\
122
124
  '*::test name or module path:_cele2e_run_args'
123
125
  ;;
124
126
  list)
package/src/cli/index.ts CHANGED
@@ -139,6 +139,8 @@ Options for \`run\`:
139
139
  --reuse Reuse existing network if running
140
140
  --live Use live (non-simulated) internet
141
141
  --published Use published .netapp packages
142
+ --ci Plain log output: one ✔/✗ line per step, no spinner
143
+ (auto-on when a CI env var is set; --no-ci forces off)
142
144
 
143
145
  Options for \`up\`:
144
146
  --caddy Start with caddy machine in DMZ
@@ -187,8 +189,17 @@ switch (command) {
187
189
  // repo's top-level e2e/tests/ in one invocation; `--all-modules` runs just
188
190
  // the module suites. Both resolve the repo root via resolveSuiteRoot() so
189
191
  // they work through the `./cele2e` wrapper's cd into packages/e2e.
190
- if (args.includes('--all') || args.includes('--all-modules')) {
191
- const wantTopLevel = args.includes('--all');
192
+ if (
193
+ args.includes('--all') ||
194
+ args.includes('--all-modules') ||
195
+ args.includes('--complete') ||
196
+ args.includes('--ci-safe')
197
+ ) {
198
+ // --complete and --ci-safe both run the full discovery (every module +
199
+ // the repo's top-level e2e/tests/). --ci-safe additionally has the runner
200
+ // skip tests marked `cele2e-ci-unsafe`. --all-modules stays modules-only.
201
+ const wantTopLevel =
202
+ args.includes('--all') || args.includes('--complete') || args.includes('--ci-safe');
192
203
  const suiteRoot = resolveSuiteRoot();
193
204
  const allModules = findAllModules(suiteRoot);
194
205
  const env: Record<string, string> = {
@@ -206,7 +217,9 @@ switch (command) {
206
217
  }
207
218
  runScript(
208
219
  'e2e-run',
209
- args.filter((a) => a !== '--all' && a !== '--all-modules'),
220
+ // Strip the discovery aliases; KEEP --ci-safe so the runner applies the
221
+ // `cele2e-ci-unsafe` quarantine exclusion.
222
+ args.filter((a) => a !== '--all' && a !== '--all-modules' && a !== '--complete'),
210
223
  env,
211
224
  );
212
225
  }
@@ -396,6 +396,17 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
396
396
  variant === 'vanilla' ? 'celilo-e2e/management:vanilla' : 'celilo-e2e/management:latest',
397
397
  networks: { internal: { ipv4_address: ip } },
398
398
  cap_add: ['NET_ADMIN'],
399
+ // ISS-0157: `module import`'s `bun install` hard-links packages from the
400
+ // bun cache into node_modules, which on overlayfs forces a copy-up + fsync
401
+ // per file. On a slow/contended builder disk that storm blows past the
402
+ // 120s import timeout (intermittently). Put the celilo data dir (the
403
+ // node_modules install target) on tmpfs (RAM): bun then copies cache→tmpfs
404
+ // (cross-fs, no overlay copy-up) at RAM speed, immune to host disk. NOT
405
+ // /root/.bun itself — that holds the baked `celilo` CLI (`bun add -g`).
406
+ // Tmpfs the bun *cache* subdir too: the copy-up + fsync storm is the
407
+ // overlay upper layer under /root/.bun/install/cache. RAM-backing it (cold
408
+ // → re-fetch over the fast sim/net) keeps the CLI intact. Ephemeral e2e.
409
+ tmpfs: ['/root/.local/share/celilo', '/root/.bun/install/cache'],
399
410
  volumes: [
400
411
  ...(celiloRoot ? [`${celiloRoot}:/celilo`] : []),
401
412
  'ssh-keys:/ssh-keys',
package/src/runner.ts CHANGED
@@ -19,9 +19,9 @@
19
19
 
20
20
  import { spawn, spawnSync, execSync } from 'node:child_process';
21
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
22
- import { basename, join, resolve } from 'node:path';
22
+ import { basename, dirname, join, resolve } from 'node:path';
23
23
  import { parse as parseYaml } from 'yaml';
24
- import { ProgressDisplay } from '@celilo/cli-display';
24
+ import { type DisplayMode, ProgressDisplay } from '@celilo/cli-display';
25
25
  import {
26
26
  emitRunCompleted,
27
27
  emitRunFailed,
@@ -70,7 +70,23 @@ const flagReuse = rawArgs.includes('--reuse');
70
70
  const flagLive = rawArgs.includes('--live');
71
71
  const flagPublished = rawArgs.includes('--published');
72
72
  const flagVerbose = rawArgs.includes('--verbose');
73
+ // CI mode: no animated spinner, no [progress:*] markers — just one clean
74
+ // ✔/✗ line per step (plus sub-events) written straight to the log. Forgejo's
75
+ // runner allocates a PTY, so process.stdout.isTTY is truthy there and the
76
+ // interactive footer (redrawn via `\r` on every spinner tick) gets linearized
77
+ // into one log line per tick — dozens of identical braille lines per step.
78
+ // `--ci` (or any standard `CI` env var, e.g. Forgejo/GitHub Actions) forces
79
+ // the non-interactive render path that sidesteps that entirely. `--no-ci`
80
+ // opts back out when a CI env var is set but an operator wants the spinner.
81
+ const flagCi = rawArgs.includes('--ci');
82
+ const flagNoCi = rawArgs.includes('--no-ci');
83
+ const ci = flagCi || (!flagNoCi && !!process.env.CI);
73
84
  const patterns = rawArgs.filter((a) => !a.startsWith('--'));
85
+ // --ci-safe: skip tests marked `cele2e-ci-unsafe` in their file (quarantined —
86
+ // known-failing or builder-flaky, each tracked by an ISS). `--complete`/`--all`
87
+ // run everything. Passed through from the CLI (not stripped) so the runner can
88
+ // apply the exclusion.
89
+ const flagCiSafe = rawArgs.includes('--ci-safe');
74
90
 
75
91
  // ─── Colors ──────────────────────────────────────────────────────────
76
92
 
@@ -81,7 +97,8 @@ const red = '\x1b[31m';
81
97
  const cyan = '\x1b[36m';
82
98
  const reset = '\x1b[0m';
83
99
 
84
- const interactive = process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose;
100
+ const interactive =
101
+ process.stdout.isTTY && !process.argv.includes('--no-interactive') && !flagVerbose && !ci;
85
102
 
86
103
  // ─── Timing ──────────────────────────────────────────────────────────
87
104
 
@@ -454,12 +471,45 @@ async function main() {
454
471
  .filter((f) => f.endsWith('.test.ts'))
455
472
  .sort()
456
473
  .map((f) => resolve(testsPath, f));
474
+ // Docker-backed integration tests live in a sibling `tests-integration/`
475
+ // so they stay OUT of the hermetic `bun test tests/` gate. cele2e is the
476
+ // heavy runner (Docker + build-infra present), so it covers BOTH tiers —
477
+ // this is what keeps the documented `cele2e run ansible-output` working
478
+ // after the test moved out of tests/.
479
+ const integrationPath = join(dirname(testsPath), 'tests-integration');
480
+ if (existsSync(integrationPath)) {
481
+ testFiles.push(
482
+ ...readdirSync(integrationPath)
483
+ .filter((f) => f.endsWith('.test.ts'))
484
+ .sort()
485
+ .map((f) => resolve(integrationPath, f)),
486
+ );
487
+ }
457
488
  }
458
489
 
459
490
  if (patterns.length > 0) {
460
491
  testFiles = testFiles.filter((f) => patterns.some((p) => basename(f, '.test.ts').includes(p)));
461
492
  }
462
493
 
494
+ // --ci-safe: drop quarantined tests — those whose file contains the marker
495
+ // `cele2e-ci-unsafe` (a comment naming the reason + ISS). Keeps the nightly
496
+ // green on the CI-safe subset while the quarantined tests are tracked + fixed
497
+ // separately. `--complete`/`--all` run everything (no exclusion).
498
+ if (flagCiSafe) {
499
+ const before = testFiles.length;
500
+ testFiles = testFiles.filter((f) => {
501
+ try {
502
+ return !readFileSync(f, 'utf-8').includes('cele2e-ci-unsafe');
503
+ } catch {
504
+ return true;
505
+ }
506
+ });
507
+ const skipped = before - testFiles.length;
508
+ if (skipped > 0) {
509
+ console.log(`${dim}--ci-safe: skipped ${skipped} quarantined (cele2e-ci-unsafe) test(s)${reset}`);
510
+ }
511
+ }
512
+
463
513
  if (testFiles.length === 0) {
464
514
  console.error(`${red}No test files matched${reset}`);
465
515
  process.exit(1);
@@ -524,14 +574,21 @@ async function main() {
524
574
 
525
575
  // 'auto' resolves to 'render' when isTTY is true (operator at a
526
576
  // terminal sees animated spinners) and 'protocol' when isTTY is
527
- // false (CI / cele2e-run.sh wrapper / any redirected stdout gets
528
- // ASCII `[progress:*]` markers — grep-friendly, no braille noise).
577
+ // false (cele2e-run.sh wrapper / any redirected stdout gets ASCII
578
+ // `[progress:*]` markers — grep-friendly, no braille noise).
529
579
  // Prior to this change the mode was hardcoded 'render', so non-TTY
530
580
  // callers got `⣾ phase started` / `✔ phase done` lines per phase
531
581
  // (e.g. ~29 braille codepoints in a typical test log) — cosmetic
532
582
  // but actively unhelpful for log scanning and tooling.
583
+ //
584
+ // CI mode forces 'render' with isTTY:false (non-interactive render):
585
+ // one ✔/✗ line per step, sub-events emitted inline, NO spinner and
586
+ // NO `[progress:*]` markers. We can't rely on 'auto' here because
587
+ // Forgejo's PTY makes isTTY truthy — which would pick the animated
588
+ // footer and spam the log with one braille line per spinner tick.
589
+ const displayMode: DisplayMode = ci ? 'render' : 'auto';
533
590
  const display = new ProgressDisplay({
534
- mode: 'auto',
591
+ mode: displayMode,
535
592
  out: { write: process.stdout.write.bind(process.stdout), isTTY: interactive },
536
593
  });
537
594
  let passed = 0;
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { execSync } from 'node:child_process';
14
14
  import { join } from 'node:path';
15
- import { writeFileSync } from 'node:fs';
15
+ import { copyFileSync, existsSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
16
  import { generateSharedInfraYaml, SHARED_PROJECT_NAME, SHARED_NETWORKS } from './docker-compose-generator';
17
17
  import { ensureRegistryServerBundle } from './registry-bundle';
18
18
 
@@ -146,6 +146,23 @@ export async function ensureSharedInfra(): Promise<void> {
146
146
  const yaml = generateSharedInfraYaml();
147
147
  writeFileSync(join(e2eDir, SHARED_COMPOSE_FILE), yaml);
148
148
 
149
+ // Seed the live DNS zone files from their templates BEFORE the compose
150
+ // mounts them. config/dns/{iamtheinternet.org,example.net}.zone are
151
+ // gitignored runtime state (scrubDnsZones rewrites them per-test); on a
152
+ // fresh checkout they don't exist yet, and a Docker bind-mount of a missing
153
+ // host path silently creates an empty DIRECTORY — so Knot serves no zone and
154
+ // DNS convergence times out. Seeding here makes the bind-mount targets real
155
+ // files, so shared infra bootstraps from a clean checkout (e.g. CI builder).
156
+ for (const zone of ['iamtheinternet.org', 'example.net']) {
157
+ const tpl = join(e2eDir, 'config', 'dns', 'templates', `${zone}.zone`);
158
+ const live = join(e2eDir, 'config', 'dns', `${zone}.zone`);
159
+ // Remove an empty dir left by a prior bind-mount on a missing path.
160
+ if (existsSync(live) && statSync(live).isDirectory()) {
161
+ rmSync(live, { recursive: true, force: true });
162
+ }
163
+ copyFileSync(tpl, live);
164
+ }
165
+
149
166
  // Build and start
150
167
  console.log('[progress:start] building shared infrastructure images | shared images built');
151
168
  run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} build`,
@@ -155,11 +172,16 @@ export async function ensureSharedInfra(): Promise<void> {
155
172
  run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} up -d`,
156
173
  { cwd: e2eDir, timeout: 120_000 });
157
174
 
158
- // Wait for DNS convergence
175
+ // Wait for DNS convergence — robustly. The old single 60s window with no
176
+ // recovery was an intermittent build-infra failure on the builder (#319:
177
+ // namecheap-dns hadn't served its zone within 60s, and the bare throw gave
178
+ // no clue why). Now: a longer window, RESTART the DNS container if it's
179
+ // stuck (re-triggers the zone load), and DUMP diagnostics before giving up.
159
180
  console.log('[progress:start] waiting for DNS convergence | DNS converged');
181
+ const dnsTimeout = 180_000;
160
182
  const start = Date.now();
161
- const timeout = 60_000;
162
- while (Date.now() - start < timeout) {
183
+ let lastRestart = start;
184
+ while (Date.now() - start < dnsTimeout) {
163
185
  try {
164
186
  const result = run(
165
187
  `docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} exec -T namecheap-dns dig @127.0.0.1 iamtheinternet.org SOA +short +timeout=2`,
@@ -173,9 +195,34 @@ export async function ensureSharedInfra(): Promise<void> {
173
195
  } catch {
174
196
  // retry
175
197
  }
198
+ // Recovery: if ~45s have passed since the last (re)start without
199
+ // converging, the DNS container likely didn't load its zone — restart
200
+ // it to re-trigger the load instead of waiting out the whole window.
201
+ if (Date.now() - lastRestart > 45_000) {
202
+ console.log('[progress:sub] DNS not converged yet — restarting namecheap-dns');
203
+ try {
204
+ run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} restart namecheap-dns`,
205
+ { cwd: e2eDir, timeout: 30_000 });
206
+ } catch {}
207
+ lastRestart = Date.now();
208
+ }
176
209
  await new Promise(r => setTimeout(r, 2000));
177
210
  }
178
- throw new Error('Timeout waiting for shared infrastructure DNS convergence');
211
+ // Diagnostics before failing, so the log shows WHY it didn't converge.
212
+ let psOut = '';
213
+ try {
214
+ psOut = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} ps`,
215
+ { cwd: e2eDir, timeout: 10_000 });
216
+ } catch {}
217
+ let dnsLog = '';
218
+ try {
219
+ dnsLog = run(`docker compose -f ${SHARED_COMPOSE_FILE} -p ${SHARED_PROJECT_NAME} logs --tail 40 namecheap-dns`,
220
+ { cwd: e2eDir, timeout: 10_000 });
221
+ } catch {}
222
+ throw new Error(
223
+ `Timeout waiting for shared infrastructure DNS convergence (${dnsTimeout / 1000}s)\n` +
224
+ `--- compose ps ---\n${psOut}\n--- namecheap-dns logs (tail 40) ---\n${dnsLog}`,
225
+ );
179
226
  }
180
227
 
181
228
  /**