@celilo/e2e 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +30 -13
  2. package/bin/e2e-bake-management +196 -12
  3. package/bin/e2e-infra +0 -1
  4. package/bin/e2e-up +14 -3
  5. package/docker/Dockerfile.observer +12 -1
  6. package/docker/Dockerfile.target-machine +22 -1
  7. package/npm-registry-server/package.json +1 -1
  8. package/package.json +3 -3
  9. package/registry-server/package.json +1 -1
  10. package/scripts/pack-celilo-packages.ts +15 -0
  11. package/src/block-timing.test.ts +559 -0
  12. package/src/block-timing.ts +366 -0
  13. package/src/cli/build.test.ts +135 -4
  14. package/src/cli/build.ts +237 -94
  15. package/src/cli/command-registry.ts +21 -0
  16. package/src/cli/command-tree-parser.ts +11 -2
  17. package/src/cli/completion.ts +9 -0
  18. package/src/cli/host.ts +252 -0
  19. package/src/cli/index.ts +78 -51
  20. package/src/cli/module-discovery.ts +108 -13
  21. package/src/cli/scaffold.ts +18 -26
  22. package/src/container-manager.cleanup.test.ts +284 -0
  23. package/src/container-manager.runner.test.ts +351 -0
  24. package/src/container-manager.test.ts +84 -0
  25. package/src/container-manager.ts +721 -185
  26. package/src/docker-compose-generator.ts +135 -61
  27. package/src/doctor.test.ts +259 -4
  28. package/src/doctor.ts +276 -3
  29. package/src/exit-cleanup.test.ts +83 -1
  30. package/src/fleet-nameserver-gate.test.ts +45 -0
  31. package/src/host-vm.test.ts +156 -0
  32. package/src/host-vm.ts +230 -0
  33. package/src/index.ts +11 -0
  34. package/src/live-stack.test.ts +300 -0
  35. package/src/live-stack.ts +355 -0
  36. package/src/no-unjustified-sleep.test.ts +90 -0
  37. package/src/proxmox-provisioner.test.ts +18 -2
  38. package/src/proxmox-provisioner.ts +22 -0
  39. package/src/public-sim-routes.test.ts +9 -2
  40. package/src/repo-root.ts +33 -0
  41. package/src/run-args.test.ts +76 -0
  42. package/src/run-args.ts +89 -0
  43. package/src/runner.ts +213 -8
  44. package/src/shared-infra.ts +88 -32
  45. package/src/socks-proxy.ts +2 -0
  46. package/src/source-fingerprint.test.ts +213 -0
  47. package/src/source-fingerprint.ts +212 -0
  48. package/src/stage-simulator-inputs.ts +93 -0
  49. package/src/stages.ts +133 -0
  50. package/src/wait-for-run.ts +1 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `cele2e host` — bring the Docker host up and down in the shape the rig needs.
3
+ *
4
+ * On Linux this is a no-op: docker runs on the host kernel. On macOS docker
5
+ * runs inside a colima VM whose memory and mount transport dominate how long a
6
+ * suite takes, and both are set at `colima start` — so "start colima" is not a
7
+ * neutral act, and everyone who typed it by hand got whatever colima's defaults
8
+ * or the last person's flags left behind. That is what this command replaces:
9
+ * one invocation, one policy, derived from the host it is running on.
10
+ *
11
+ * Three verbs, and the split between them is deliberate.
12
+ *
13
+ * `status` reads and reports. No side effects.
14
+ * `up` starts the VM, or reports precisely why a running one cannot be
15
+ * brought into policy. It NEVER destroys anything.
16
+ * `reset` destroys the VM and recreates it. The only way to change the
17
+ * mount type, and it costs the whole image cache, so it demands
18
+ * `--yes` and refuses while the run-lock is held.
19
+ *
20
+ * `up` cannot fix a wrong mount type, and pretending otherwise would be worse
21
+ * than useless: colima accepts `--mount-type` on an existing VM, prints
22
+ * `'volume mount type' cannot be updated after initial setup, discarded`, and
23
+ * carries on with the old one. A command that appeared to fix it and did not
24
+ * would be exactly the "check that cannot reach its subject" failure this rig
25
+ * has a whole section about.
26
+ */
27
+
28
+ import { spawnSync } from 'node:child_process';
29
+ import {
30
+ type HostVmBudget,
31
+ activeProfile,
32
+ evaluateHostVm,
33
+ readHostFacts,
34
+ readHostVmFacts,
35
+ recommendedBudget,
36
+ } from '../host-vm';
37
+ import { lockStatus } from '../run-lock';
38
+
39
+ const bold = '\x1b[1m';
40
+ const dim = '\x1b[2m';
41
+ const green = '\x1b[32m';
42
+ const red = '\x1b[31m';
43
+ const yellow = '\x1b[33m';
44
+ const reset = '\x1b[0m';
45
+
46
+ /** Is there a colima binary to drive? */
47
+ function haveColima(): boolean {
48
+ return spawnSync('colima', ['version'], { stdio: 'ignore' }).status === 0;
49
+ }
50
+
51
+ function colimaRunning(profile: string): boolean {
52
+ return spawnSync('colima', ['status', '-p', profile], { stdio: 'ignore' }).status === 0;
53
+ }
54
+
55
+ /**
56
+ * The `colima start` a fresh VM should get.
57
+ *
58
+ * `vz` (Apple Virtualization) rather than qemu, because virtiofs needs it.
59
+ * Exported so the docs and the tests quote the same string the command runs
60
+ * rather than a transcription of it.
61
+ */
62
+ export function colimaStartArgs(budget: HostVmBudget, profile: string): string[] {
63
+ return [
64
+ 'start',
65
+ '-p',
66
+ profile,
67
+ '--vm-type',
68
+ 'vz',
69
+ '--cpu',
70
+ String(budget.cpus),
71
+ '--memory',
72
+ String(budget.memoryGiB),
73
+ '--mount-type',
74
+ budget.mountType,
75
+ ];
76
+ }
77
+
78
+ function reportStatus(): number {
79
+ const profile = activeProfile();
80
+ const host = readHostFacts();
81
+ const facts = readHostVmFacts(profile);
82
+
83
+ console.log(`${bold}=== Docker host ===${reset}`);
84
+ console.log(` host ${host.cpus} CPU, ${host.memoryGiB} GiB`);
85
+
86
+ if (!facts) {
87
+ console.log(' VM none — docker runs natively, no host policy applies');
88
+ return 0;
89
+ }
90
+
91
+ const budget = recommendedBudget(host, facts.vmType);
92
+ console.log(
93
+ ` VM colima/${facts.profile} (${facts.vmType}): ${facts.cpus} CPU, ${facts.memoryGiB} GiB, ${facts.mountType} mounts`,
94
+ );
95
+ console.log(
96
+ ` policy ${budget.cpus} CPU, ${budget.memoryGiB} GiB, ${budget.mountType} mounts`,
97
+ );
98
+ console.log(` running ${colimaRunning(profile) ? 'yes' : 'no'}`);
99
+ console.log('');
100
+
101
+ const { problems, needsRecreate } = evaluateHostVm(facts, host, budget);
102
+ if (problems.length === 0) {
103
+ console.log(`${green}The VM matches the policy.${reset}`);
104
+ return 0;
105
+ }
106
+ for (const problem of problems) console.log(` ${yellow}!${reset} ${problem}`);
107
+ console.log('');
108
+ console.log(
109
+ needsRecreate
110
+ ? ` fix: ${bold}cele2e host reset --yes${reset} ${dim}(destroys the VM and its image cache; budget one \`cele2e build-infra\`)${reset}`
111
+ : ` fix: ${bold}cele2e host up${reset} ${dim}(restarts the VM; images survive)${reset}`,
112
+ );
113
+ return 1;
114
+ }
115
+
116
+ function bringUp(): number {
117
+ const profile = activeProfile();
118
+ const host = readHostFacts();
119
+ const existing = readHostVmFacts(profile);
120
+ const budget = recommendedBudget(host, existing?.vmType ?? 'vz');
121
+
122
+ if (!existing) {
123
+ console.log(
124
+ `${bold}Creating the colima VM${reset} ${dim}(${budget.cpus} CPU, ${budget.memoryGiB} GiB, ${budget.mountType} mounts)${reset}`,
125
+ );
126
+ const result = spawnSync('colima', colimaStartArgs(budget, profile), { stdio: 'inherit' });
127
+ return result.status ?? 1;
128
+ }
129
+
130
+ const { problems, needsRecreate } = evaluateHostVm(existing, host, budget);
131
+ if (needsRecreate) {
132
+ console.error(`${red}The running VM cannot be brought into policy by restarting it.${reset}`);
133
+ for (const problem of problems) console.error(` ! ${problem}`);
134
+ console.error('');
135
+ console.error('colima discards a mount-type change on an existing VM, so the only fix is to');
136
+ console.error('recreate it. That deletes the local image cache and costs one full');
137
+ console.error(`${bold}cele2e build-infra${reset}. When you are ready:`);
138
+ console.error('');
139
+ console.error(` ${bold}cele2e host reset --yes${reset}`);
140
+ return 1;
141
+ }
142
+
143
+ if (colimaRunning(profile) && problems.length === 0) {
144
+ console.log(`${green}Docker host already up and in policy.${reset}`);
145
+ return 0;
146
+ }
147
+
148
+ // Memory and CPU DO take on a restart, so a plain `colima start` with the
149
+ // budget is the fix for those.
150
+ console.log(
151
+ `${bold}Starting the colima VM${reset} ${dim}(${budget.cpus} CPU, ${budget.memoryGiB} GiB)${reset}`,
152
+ );
153
+ const result = spawnSync(
154
+ 'colima',
155
+ ['start', '-p', profile, '--cpu', String(budget.cpus), '--memory', String(budget.memoryGiB)],
156
+ { stdio: 'inherit' },
157
+ );
158
+ return result.status ?? 1;
159
+ }
160
+
161
+ /**
162
+ * Refuse to touch the VM while a run holds the lock.
163
+ *
164
+ * Not hypothetical: stopping the VM under a live run killed a colleague's
165
+ * suite mid-flight on 2026-09-05, and because every per-test container carries
166
+ * `restart: unless-stopped`, the next VM boot resurrected that dead run's
167
+ * containers as orphans.
168
+ */
169
+ function lockBlocks(action: string): boolean {
170
+ const lock = lockStatus();
171
+ if (lock.free || !lock.holder) return false;
172
+ console.error(`${red}Refusing to ${action}: the e2e run-lock is held.${reset}`);
173
+ console.error(
174
+ ` ${lock.holder.session} — ${lock.holder.state} ${lock.holder.test} (pid ${lock.holder.pid})`,
175
+ );
176
+ console.error('');
177
+ console.error('Poll with `cele2e status --json` (exit 0 = free, 3 = busy), or release a stack');
178
+ console.error('you know is abandoned with `cele2e down`.');
179
+ return true;
180
+ }
181
+
182
+ function bringDown(): number {
183
+ if (lockBlocks('stop the Docker host')) return 3;
184
+ const profile = activeProfile();
185
+ if (!readHostVmFacts(profile)) {
186
+ console.log('No colima VM to stop.');
187
+ return 0;
188
+ }
189
+ console.log(`${bold}Stopping the colima VM${reset}`);
190
+ return spawnSync('colima', ['stop', '-p', profile], { stdio: 'inherit' }).status ?? 1;
191
+ }
192
+
193
+ function resetVm(args: string[]): number {
194
+ if (!args.includes('--yes')) {
195
+ console.error(`${red}cele2e host reset DESTROYS the VM, including every built image.${reset}`);
196
+ console.error('');
197
+ console.error('It is the only way to change the mount transport, which colima fixes at');
198
+ console.error('creation. After it you must run `cele2e build-infra` before any test.');
199
+ console.error('');
200
+ console.error(`Re-run as ${bold}cele2e host reset --yes${reset} when that is what you want.`);
201
+ return 1;
202
+ }
203
+ if (lockBlocks('reset the Docker host')) return 3;
204
+
205
+ const profile = activeProfile();
206
+ const host = readHostFacts();
207
+ const budget = recommendedBudget(host, 'vz');
208
+
209
+ console.log(
210
+ `${bold}Deleting the colima VM${reset} ${dim}(every local image goes with it)${reset}`,
211
+ );
212
+ const deleted = spawnSync('colima', ['delete', '-p', profile, '--force'], { stdio: 'inherit' });
213
+ if ((deleted.status ?? 1) !== 0) return deleted.status ?? 1;
214
+
215
+ console.log(
216
+ `${bold}Creating it again${reset} ${dim}(${budget.cpus} CPU, ${budget.memoryGiB} GiB, ${budget.mountType} mounts)${reset}`,
217
+ );
218
+ const created = spawnSync('colima', colimaStartArgs(budget, profile), { stdio: 'inherit' });
219
+ if ((created.status ?? 1) !== 0) return created.status ?? 1;
220
+
221
+ console.log('');
222
+ console.log(
223
+ `${yellow}The image cache is empty. Run \`cele2e build-infra\` before any test.${reset}`,
224
+ );
225
+ return 0;
226
+ }
227
+
228
+ /** Route a `cele2e host <verb>`; returns the process exit code. */
229
+ export function runHost(args: string[]): number {
230
+ const verb = args[0] ?? 'status';
231
+
232
+ if (!haveColima() && verb !== 'status') {
233
+ console.error('No `colima` binary found — nothing for `cele2e host` to drive.');
234
+ console.error('On Linux docker runs natively and there is no host VM to shape.');
235
+ return 1;
236
+ }
237
+
238
+ switch (verb) {
239
+ case 'status':
240
+ return reportStatus();
241
+ case 'up':
242
+ return bringUp();
243
+ case 'down':
244
+ return bringDown();
245
+ case 'reset':
246
+ return resetVm(args.slice(1));
247
+ default:
248
+ console.error(`Unknown host verb: ${verb}`);
249
+ console.error('Usage: cele2e host <status|up|down|reset>');
250
+ return 1;
251
+ }
252
+ }
package/src/cli/index.ts CHANGED
@@ -12,14 +12,15 @@
12
12
  * cele2e shell [container]
13
13
  * cele2e status
14
14
  * cele2e doctor
15
+ * cele2e host <status|up|down|reset>
15
16
  * cele2e last [--json]
16
17
  * cele2e load
17
18
  * cele2e scaffold <test-name>
18
19
  */
19
20
 
20
21
  import { spawnSync } from 'node:child_process';
21
- import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
22
- import { basename, dirname, join, resolve } from 'node:path';
22
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
23
+ import { dirname, join, resolve } from 'node:path';
23
24
  import { parse as parseYaml } from 'yaml';
24
25
  import { diagnose, formatHolderLine, formatReport } from '../doctor';
25
26
  import { readLastRun } from '../last-run';
@@ -33,7 +34,13 @@ import {
33
34
  } from '../run-lock';
34
35
  import { runBuild } from './build';
35
36
  import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
36
- import { findAllModules, findModulesWithMatchingTest } from './module-discovery';
37
+ import { runHost } from './host';
38
+ import {
39
+ discoverSuiteTests,
40
+ findAllModules,
41
+ resolvePatternTarget,
42
+ resolveRunTargets,
43
+ } from './module-discovery';
37
44
  import { runScaffold } from './scaffold';
38
45
 
39
46
  const PKG_DIR = resolve(import.meta.dir, '../..');
@@ -96,43 +103,6 @@ function resolveSuiteRoot(): string {
96
103
  return start;
97
104
  }
98
105
 
99
- /**
100
- * Enumerate every discoverable Docker e2e test (all module suites + the repo's
101
- * top-level e2e/tests/) as `{ name, file }`, sorted, with module tests first.
102
- */
103
- function discoverSuiteTests(suiteRoot: string): { name: string; file: string; group: string }[] {
104
- const out: { name: string; file: string; group: string }[] = [];
105
- for (const moduleDir of findAllModules(suiteRoot)) {
106
- const tp = join(moduleDir, 'e2e');
107
- const testsDir = existsSync(join(moduleDir, 'manifest.yml'))
108
- ? resolve(moduleDir, readManifestTestsDir(join(moduleDir, 'manifest.yml')) ?? 'e2e')
109
- : tp;
110
- if (!existsSync(testsDir)) continue;
111
- for (const f of readdirSync(testsDir)
112
- .filter((f) => f.endsWith('.test.ts'))
113
- .sort()) {
114
- out.push({
115
- name: f.replace(/\.test\.ts$/, ''),
116
- file: join(testsDir, f),
117
- group: basename(moduleDir),
118
- });
119
- }
120
- }
121
- const topLevel = join(suiteRoot, 'e2e', 'tests');
122
- if (existsSync(topLevel)) {
123
- for (const f of readdirSync(topLevel)
124
- .filter((f) => f.endsWith('.test.ts'))
125
- .sort()) {
126
- out.push({
127
- name: f.replace(/\.test\.ts$/, ''),
128
- file: join(topLevel, f),
129
- group: '(top-level)',
130
- });
131
- }
132
- }
133
- return out;
134
- }
135
-
136
106
  /**
137
107
  * Say out loud when we cleared this session's own kept stack. Silence would be
138
108
  * worse than the old refusal: the operator asked for a kept stack, and it is
@@ -173,6 +143,7 @@ Commands:
173
143
  shell [container] Shell into a running container (default: management)
174
144
  status Show run-lock holder + network status (exit 3 if busy)
175
145
  doctor Check the environment a run needs (run's implicit preflight)
146
+ host <verb> Bring the Docker host VM up/down in the shape the rig needs
176
147
  last Show the most recent run's results dir + counts
177
148
  release Free a run-lock left by --keep/up (does not tear down)
178
149
  load Load cached Docker images from tarball
@@ -187,6 +158,9 @@ Options for \`run\`:
187
158
  --live Use live (non-simulated) internet
188
159
  --published Use published .netapp packages
189
160
  --notify Desktop notification when the run finishes (best-effort)
161
+ --source-cli Run the celilo CLI from the mounted workspace instead of the
162
+ one baked into the management image. Roughly doubles each
163
+ celilo command's start-up; iterate without a \`build-infra\`
190
164
  --ci Plain log output: one ✔/✗ line per step, no spinner
191
165
  (auto-on when a CI env var is set; --no-ci forces off)
192
166
 
@@ -195,6 +169,13 @@ Options for \`up\`:
195
169
  --full-stack Start with caddy, IDP, and DB machines
196
170
  --custom <json> Custom machine spec JSON
197
171
 
172
+ Verbs for \`host\` (macOS/colima; a no-op where docker runs natively):
173
+ status Report the VM against the policy (exit 1 if out of policy)
174
+ up Start it, or say exactly why a running one is out of policy
175
+ down Stop it (refuses while the e2e run-lock is held)
176
+ reset --yes Recreate it — the ONLY way to change the mount transport.
177
+ DESTROYS every built image; budget one \`cele2e build-infra\`
178
+
198
179
  Options for \`down\`:
199
180
  --keep Stop containers but preserve volumes
200
181
  --all Also stop competing module containers
@@ -219,6 +200,8 @@ Examples:
219
200
  cele2e shell caddy # debug running caddy container
220
201
  cele2e build-infra # rebuild Docker images + repackage all modules
221
202
  cele2e build-infra --skip-modules # rebuild Docker images only (faster)
203
+ cele2e host status # is the Docker host VM shaped for the rig?
204
+ cele2e run caddy-uninstall --source-cli # run the WORKSPACE cli, not the baked one
222
205
  cele2e scaffold lunacycle-auth # create tests/lunacycle-auth.test.ts
223
206
  cele2e completion zsh # print zsh completion script
224
207
  `);
@@ -302,21 +285,29 @@ switch (command) {
302
285
 
303
286
  if (!cwdHasManifest) {
304
287
  if (positionals.length === 1) {
305
- const matches = findModulesWithMatchingTest(process.cwd(), positionals[0]);
306
- if (matches.length === 1) {
288
+ // Resolve the pattern against the FULL discovered suite (every module +
289
+ // the repo's top-level e2e/tests/), rooted at resolveSuiteRoot() like
290
+ // `list` and `--all`. The wrapper cd's into packages/e2e before
291
+ // exec'ing, so the old walk down from process.cwd() started below the
292
+ // repo root: a test name `cele2e list` printed came back "No test
293
+ // files matched" from `cele2e run <name>`.
294
+ const target = resolvePatternTarget(resolveSuiteRoot(), positionals[0]);
295
+ if (target?.kind === 'top-level') {
296
+ runScript('e2e-run', args, {
297
+ E2E_TEST_DIR: target.dir,
298
+ E2E_TESTS_PATH: target.testsPath,
299
+ });
300
+ } else if (target?.kind === 'module') {
307
301
  runScript('e2e-run', args, {
308
302
  E2E_TEST_DIR: stateDir,
309
- E2E_MODULE_DIR: matches[0].moduleDir,
303
+ E2E_MODULE_DIR: target.moduleDir,
310
304
  });
311
- }
312
- if (matches.length > 1) {
313
- console.error(`Multiple modules contain tests matching "${positionals[0]}":`);
314
- for (const m of matches) {
315
- console.error(` ${basename(m.moduleDir)}: ${m.matchedTests.join(', ')}`);
305
+ } else if (target?.kind === 'ambiguous') {
306
+ console.error(`Multiple tests match "${positionals[0]}":`);
307
+ for (const m of target.matches) {
308
+ console.error(` ${m.group}: ${m.name}`);
316
309
  }
317
- console.error(
318
- '\nDisambiguate by running from inside the module directory or passing its path.',
319
- );
310
+ console.error('\nDisambiguate with a longer pattern (an exact name always wins).');
320
311
  process.exit(1);
321
312
  }
322
313
  } else if (positionals.length === 0) {
@@ -327,6 +318,33 @@ switch (command) {
327
318
  E2E_MODULE_DIRS: allModules.join('\n'),
328
319
  });
329
320
  }
321
+ } else {
322
+ // Several names: resolve each independently against the full suite
323
+ // and feed the runner's multi-module env (E2E_MODULE_DIRS /
324
+ // E2E_TOP_LEVEL_TESTS). The old fall-through ran top-level discovery
325
+ // only, so module suites reported "No test files matched" — which
326
+ // reads as a typo, not a limitation.
327
+ const resolved = resolveRunTargets(resolveSuiteRoot(), positionals);
328
+ if (resolved.kind === 'no-match') {
329
+ console.error(`No suite matches "${resolved.pattern}". List them: cele2e list`);
330
+ process.exit(1);
331
+ }
332
+ if (resolved.kind === 'ambiguous') {
333
+ console.error(`Multiple tests match "${resolved.pattern}":`);
334
+ for (const m of resolved.matches) {
335
+ console.error(` ${m.group}: ${m.name}`);
336
+ }
337
+ console.error('\nDisambiguate with a longer pattern (an exact name always wins).');
338
+ process.exit(1);
339
+ }
340
+ const env: Record<string, string> = { E2E_TEST_DIR: stateDir };
341
+ if (resolved.modules.length > 0) {
342
+ env.E2E_MODULE_DIRS = resolved.modules.join('\n');
343
+ }
344
+ if (resolved.topLevel) {
345
+ env.E2E_TOP_LEVEL_TESTS = join(resolveSuiteRoot(), 'e2e', 'tests');
346
+ }
347
+ runScript('e2e-run', args, env);
330
348
  }
331
349
  }
332
350
 
@@ -467,6 +485,15 @@ switch (command) {
467
485
  break;
468
486
  }
469
487
 
488
+ case 'host': {
489
+ // Deliberately outside the run-lock: `status` is read-only, and `up`/`down`/
490
+ // `reset` take the lock's VERDICT (see host.ts lockBlocks) rather than the
491
+ // lock itself — taking it would make `cele2e host down` unable to report
492
+ // that somebody else's run is what stopped it.
493
+ process.exit(runHost(args));
494
+ break;
495
+ }
496
+
470
497
  case 'last': {
471
498
  // Which results directory was MINE? Inferring it from `ls -t results` returns
472
499
  // the newest run, which after a refused start is somebody else's — a suite
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
14
- import { join, resolve } from 'node:path';
14
+ import { basename, dirname, join, resolve } from 'node:path';
15
15
  import { parse as parseYaml } from 'yaml';
16
16
 
17
17
  const MAX_DEPTH = 2;
@@ -75,22 +75,117 @@ function walkModules(cwd: string, visit: (moduleDir: string, testsPath: string)
75
75
  scan(cwd, 1);
76
76
  }
77
77
 
78
- export interface ModuleTestMatch {
79
- moduleDir: string;
80
- matchedTests: string[];
78
+ export const TOP_LEVEL_GROUP = '(top-level)';
79
+
80
+ export interface SuiteTest {
81
+ name: string;
82
+ file: string;
83
+ group: string;
84
+ /** Owning directory: the module dir, or the suite's `e2e/` dir for top-level entries. */
85
+ dir: string;
81
86
  }
82
87
 
83
88
  /**
84
- * Find modules under `cwd` whose e2e dir contains a test name including `pattern`.
85
- * Used by `cele2e run <pattern>` when invoked outside any module dir.
89
+ * Enumerate every discoverable Docker e2e test (all module suites + the repo's
90
+ * top-level e2e/tests/) sorted, with module tests first.
86
91
  */
87
- export function findModulesWithMatchingTest(cwd: string, pattern: string): ModuleTestMatch[] {
88
- const results: ModuleTestMatch[] = [];
89
- walkModules(cwd, (moduleDir, testsPath) => {
90
- const matched = listTestNames(testsPath).filter((t) => t.includes(pattern));
91
- if (matched.length > 0) results.push({ moduleDir, matchedTests: matched });
92
- });
93
- return results;
92
+ export function discoverSuiteTests(suiteRoot: string): SuiteTest[] {
93
+ const out: SuiteTest[] = [];
94
+ for (const moduleDir of findAllModules(suiteRoot)) {
95
+ const testsDir = existsSync(join(moduleDir, 'manifest.yml'))
96
+ ? resolve(moduleDir, readManifestTestsDir(join(moduleDir, 'manifest.yml')) ?? 'e2e')
97
+ : join(moduleDir, 'e2e');
98
+ if (!existsSync(testsDir)) continue;
99
+ for (const f of readdirSync(testsDir)
100
+ .filter((f) => f.endsWith('.test.ts'))
101
+ .sort()) {
102
+ out.push({
103
+ name: f.replace(/\.test\.ts$/, ''),
104
+ file: join(testsDir, f),
105
+ group: basename(moduleDir),
106
+ dir: moduleDir,
107
+ });
108
+ }
109
+ }
110
+ const topLevel = join(suiteRoot, 'e2e', 'tests');
111
+ if (existsSync(topLevel)) {
112
+ for (const f of readdirSync(topLevel)
113
+ .filter((f) => f.endsWith('.test.ts'))
114
+ .sort()) {
115
+ out.push({
116
+ name: f.replace(/\.test\.ts$/, ''),
117
+ file: join(topLevel, f),
118
+ group: TOP_LEVEL_GROUP,
119
+ dir: dirname(topLevel),
120
+ });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ export type PatternTarget =
127
+ | { kind: 'module'; moduleDir: string }
128
+ | { kind: 'top-level'; dir: string; testsPath: string }
129
+ | { kind: 'ambiguous'; matches: SuiteTest[] };
130
+
131
+ /**
132
+ * Resolve a `cele2e run <pattern>` positional against the full discovered
133
+ * suite. Pattern semantics match the runner's filter: substring against the
134
+ * test name, with one exception — a pattern that exactly equals a test name
135
+ * wins outright, so a suite whose name prefixes another (`aspect-fanout` vs
136
+ * `aspect-fanout-new-systems`) stays reachable from the repo root instead of
137
+ * reading as permanently ambiguous. Returns null when nothing matches, so the
138
+ * caller can fall through to its default tests dir.
139
+ */
140
+ export function resolvePatternTarget(suiteRoot: string, pattern: string): PatternTarget | null {
141
+ const tests = discoverSuiteTests(suiteRoot);
142
+ const exact = tests.filter((t) => t.name === pattern);
143
+ if (exact.length === 1) {
144
+ return toPatternTarget(exact[0]);
145
+ }
146
+ if (exact.length > 1) {
147
+ return { kind: 'ambiguous', matches: exact };
148
+ }
149
+ const matches = tests.filter((t) => t.name.includes(pattern));
150
+ if (matches.length === 0) return null;
151
+ if (matches.length > 1) return { kind: 'ambiguous', matches };
152
+ return toPatternTarget(matches[0]);
153
+ }
154
+
155
+ function toPatternTarget(match: SuiteTest): PatternTarget {
156
+ if (match.group === TOP_LEVEL_GROUP) {
157
+ return { kind: 'top-level', dir: match.dir, testsPath: join(match.dir, 'tests') };
158
+ }
159
+ return { kind: 'module', moduleDir: match.dir };
160
+ }
161
+
162
+ export type RunTargets =
163
+ | { kind: 'ok'; modules: string[]; topLevel: boolean }
164
+ | { kind: 'no-match'; pattern: string }
165
+ | { kind: 'ambiguous'; pattern: string; matches: SuiteTest[] };
166
+
167
+ /**
168
+ * Resolve every `cele2e run <name>...` positional independently and aggregate
169
+ * the targets into the env-shaped set the runner consumes: module dirs (for
170
+ * E2E_MODULE_DIRS) plus a top-level flag (for E2E_TOP_LEVEL_TESTS). Reports
171
+ * the FIRST unresolvable name rather than silently dropping it, so a typo or
172
+ * an un-followable ambiguity errors instead of falling through to a
173
+ * top-level-only run that prints "No test files matched".
174
+ */
175
+ export function resolveRunTargets(suiteRoot: string, patterns: readonly string[]): RunTargets {
176
+ const modules = new Set<string>();
177
+ let topLevel = false;
178
+ for (const pattern of patterns) {
179
+ const target = resolvePatternTarget(suiteRoot, pattern);
180
+ if (!target) return { kind: 'no-match', pattern };
181
+ if (target.kind === 'ambiguous') return { kind: 'ambiguous', pattern, matches: target.matches };
182
+ if (target.kind === 'module') {
183
+ modules.add(target.moduleDir);
184
+ } else {
185
+ topLevel = true;
186
+ }
187
+ }
188
+ return { kind: 'ok', modules: Array.from(modules).sort(), topLevel };
94
189
  }
95
190
 
96
191
  /**
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
+
6
7
  import { join } from 'node:path';
7
8
 
8
9
  const green = '\x1b[32m';
@@ -32,28 +33,23 @@ function generateTestTemplate(name: string): string {
32
33
  * Run (reuse): cele2e run --reuse ${name}
33
34
  */
34
35
 
35
- import { afterAll, describe, expect, test } from 'bun:test';
36
- import { network, progress, reconnectNetwork } from '@celilo/e2e';
36
+ import { afterAll, describe, expect } from 'bun:test';
37
+ import { createStages, network, progress, reconnectNetwork } from '@celilo/e2e';
37
38
  import type { NetworkHandle } from '@celilo/e2e/types';
38
39
 
39
40
  const REUSE = process.argv.includes('--reuse');
40
41
  const PROJECT_NAME = 'celilo-e2e-${name}';
41
42
 
42
43
  describe('${name}', () => {
43
- let net: NetworkHandle;
44
- let stageError: string | null = null;
44
+ const { stage } = createStages();
45
45
 
46
- function requireStage(stage: string) {
47
- if (stageError) {
48
- throw new Error(\`Skipped: \${stage} — \${stageError}\`);
49
- }
50
- }
46
+ let net: NetworkHandle;
51
47
 
52
48
  afterAll(async () => {
53
49
  await net?.stop();
54
50
  });
55
51
 
56
- test('start network', async () => {
52
+ stage('start network', async () => {
57
53
  if (REUSE) {
58
54
  net = await reconnectNetwork(PROJECT_NAME);
59
55
  return;
@@ -65,24 +61,20 @@ describe('${name}', () => {
65
61
  .start();
66
62
  }, 120_000);
67
63
 
68
- test('deploy', async () => {
69
- requireStage('deploy');
70
- try {
71
- progress('importing module', 'module imported');
72
- await net.celilo('module import /path/to/module.netapp');
73
- progress.done();
74
-
75
- progress('deploying', 'deployed');
76
- await net.celilo('module deploy my-module');
77
- progress.done();
78
- } catch (error) {
79
- stageError = String(error);
80
- throw error;
81
- }
64
+ // Each stage runs as its own bun test with its own timeout. If a stage fails
65
+ // or times out, the later stages skip with a 'Skipped: ...' reason naming
66
+ // the cause, instead of running against a half-built fixture.
67
+ stage('deploy', async () => {
68
+ progress('importing module', 'module imported');
69
+ await net.celilo('module import /path/to/module.netapp');
70
+ progress.done();
71
+
72
+ progress('deploying', 'deployed');
73
+ await net.celilo('module deploy my-module');
74
+ progress.done();
82
75
  }, 300_000);
83
76
 
84
- test('verify', async () => {
85
- requireStage('verify');
77
+ stage('verify', async () => {
86
78
  // TODO: add verification steps
87
79
  // const result = await net.exec('machine-1', 'curl -s http://localhost');
88
80
  // expect(result.exitCode).toBe(0);