@celilo/e2e 0.5.1 → 0.6.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 (59) hide show
  1. package/README.md +2 -1
  2. package/bin/cele2e +14 -0
  3. package/bin/e2e-cache-images +57 -0
  4. package/bin/e2e-shell +36 -13
  5. package/bin/e2e-status +8 -2
  6. package/bin/e2e-up +5 -5
  7. package/completions/install-zsh-completion.sh +78 -0
  8. package/config/dns/celilo.computer.zone +16 -0
  9. package/config/dns/iamtheinternet.org.zone +7 -6
  10. package/config/dns/knot-namecheap.conf +8 -0
  11. package/config/dns/knot-tld.conf +4 -0
  12. package/config/dns/lunacycle.net.zone +16 -0
  13. package/config/dns/net.zone +11 -0
  14. package/config/dns/org.zone +5 -0
  15. package/config/dns/root.zone +2 -0
  16. package/config/dns/templates/iamtheinternet.org.zone +16 -0
  17. package/config/dns/templates/lunacycle.net.zone +16 -0
  18. package/config/resolver/unbound.conf +10 -0
  19. package/config/routing/docker-image-preload.service +14 -0
  20. package/config/routing/docker-image-preload.sh +24 -0
  21. package/config/routing/management-routes.sh +24 -2
  22. package/config/routing/resolver-internal-routes.sh +59 -0
  23. package/docker/Dockerfile.internal-resolver +16 -0
  24. package/docker/Dockerfile.isitup +37 -0
  25. package/docker/Dockerfile.management +2 -0
  26. package/docker/Dockerfile.registry +34 -0
  27. package/docker/Dockerfile.target-machine +20 -0
  28. package/docker/Dockerfile.target-machine-docker +11 -19
  29. package/docker/Dockerfile.target-technitium +97 -0
  30. package/netapps/authentik.netapp +0 -0
  31. package/netapps/caddy.netapp +0 -0
  32. package/netapps/greenwave.netapp +0 -0
  33. package/netapps/homebridge.netapp +0 -0
  34. package/netapps/iptables.netapp +0 -0
  35. package/netapps/knot-unbound-internal.netapp +0 -0
  36. package/netapps/namecheap.netapp +0 -0
  37. package/netapps/technitium.netapp +0 -0
  38. package/netapps/test-ansible-output.netapp +0 -0
  39. package/package.json +8 -1
  40. package/simulators/greenwave/state.ts +7 -12
  41. package/simulators/isitup/Caddyfile +11 -0
  42. package/simulators/isitup/entrypoint.sh +47 -0
  43. package/simulators/isitup/server.ts +112 -0
  44. package/simulators/namecheap-ddns/server.ts +31 -2
  45. package/src/cli/build.ts +214 -0
  46. package/src/cli/command-registry.ts +100 -0
  47. package/src/cli/command-tree-parser.ts +52 -0
  48. package/src/cli/completion.ts +195 -0
  49. package/src/cli/index.ts +273 -0
  50. package/src/cli/module-discovery.ts +120 -0
  51. package/src/cli/scaffold.ts +108 -0
  52. package/src/container-manager.ts +373 -76
  53. package/src/docker-compose-generator.ts +127 -23
  54. package/src/network-builder.ts +21 -0
  55. package/src/parse-line.ts +147 -0
  56. package/src/runner.ts +197 -247
  57. package/src/simulator-ips.ts +42 -0
  58. package/src/types.ts +28 -0
  59. package/bin/cache-docker-images +0 -46
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * cele2e — E2E test CLI for Celilo-deployed applications.
4
+ *
5
+ * Usage:
6
+ * cele2e run [pattern] [--keep] [--reuse] [--live] [--published]
7
+ * cele2e up [--caddy|--full-stack|--infrastructure|--custom '<json>']
8
+ * cele2e down [--keep] [--all]
9
+ * cele2e build-infra [module-dir...] [--save]
10
+ * cele2e clear-timing
11
+ * cele2e version
12
+ * cele2e shell [container]
13
+ * cele2e status
14
+ * cele2e load
15
+ * cele2e scaffold <test-name>
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
20
+ import { basename, dirname, join, resolve } from 'node:path';
21
+ import { parse as parseYaml } from 'yaml';
22
+ import { runBuild } from './build';
23
+ import { runScaffold } from './scaffold';
24
+ import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
25
+ import { findAllModules, findModulesWithMatchingTest } from './module-discovery';
26
+
27
+ const PKG_DIR = resolve(import.meta.dir, '../..');
28
+ const BIN_DIR = join(PKG_DIR, 'bin');
29
+
30
+
31
+ function readManifestTestsDir(manifestPath: string): string | undefined {
32
+ try {
33
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { e2e?: { tests_dir?: string } };
34
+ return parsed?.e2e?.tests_dir;
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ function detectTestDir(): { stateDir: string; testsPath: string } {
41
+ if (process.env.E2E_TEST_DIR) {
42
+ const stateDir = process.env.E2E_TEST_DIR;
43
+ return { stateDir, testsPath: join(stateDir, 'tests') };
44
+ }
45
+ const cwd = process.cwd();
46
+ const manifestPath = join(cwd, 'manifest.yml');
47
+ if (existsSync(manifestPath)) {
48
+ const testsDir = readManifestTestsDir(manifestPath);
49
+ if (testsDir) {
50
+ const testsPath = resolve(cwd, testsDir);
51
+ return { stateDir: dirname(testsPath), testsPath };
52
+ }
53
+ }
54
+ if (existsSync(join(cwd, 'tests'))) return { stateDir: cwd, testsPath: join(cwd, 'tests') };
55
+ return { stateDir: cwd, testsPath: join(cwd, 'tests') };
56
+ }
57
+
58
+ function runScript(script: string, args: string[], env?: Record<string, string>): never {
59
+ const result = spawnSync(join(BIN_DIR, script), args, {
60
+ stdio: 'inherit',
61
+ env: { ...process.env, ...env },
62
+ });
63
+ process.exit(result.status ?? 1);
64
+ }
65
+
66
+ function printHelp(): void {
67
+ console.log(`
68
+ cele2e — E2E test CLI for Celilo-deployed applications
69
+
70
+ Usage:
71
+ cele2e <command> [options]
72
+
73
+ Commands:
74
+ run [pattern] Run e2e tests (pattern filters by test file name)
75
+ up [preset] Start interactive test network
76
+ down Tear down running network
77
+ build-infra [dirs...] Rebuild Docker images and package all modules as .netapps
78
+ clear-timing Clear saved timing history used for test ETAs
79
+ version Show cele2e version
80
+ shell [container] Shell into a running container (default: management)
81
+ status Show network status and DNS health
82
+ load Load cached Docker images from tarball
83
+ scaffold <name> Generate a new test file from template
84
+ completion <shell> Generate shell completion script (zsh|bash)
85
+
86
+ Options for \`run\`:
87
+ --keep Keep network running after tests
88
+ --reuse Reuse existing network if running
89
+ --live Use live (non-simulated) internet
90
+ --published Use published .netapp packages
91
+
92
+ Options for \`up\`:
93
+ --caddy Start with caddy machine in DMZ
94
+ --full-stack Start with caddy, IDP, and DB machines
95
+ --custom <json> Custom machine spec JSON
96
+
97
+ Options for \`down\`:
98
+ --keep Stop containers but preserve volumes
99
+ --all Also stop competing module containers
100
+
101
+ Options for \`build-infra\`:
102
+ --save Save Docker images to tarball after build
103
+ --skip-modules Skip .netapp packaging (only rebuild Docker images)
104
+
105
+ Examples:
106
+ cele2e run # in a module: run its tests; outside: run all modules' tests
107
+ cele2e run deploy # run tests matching "deploy"
108
+ cele2e run caddy-direct-internet # from modules/, finds the matching module automatically
109
+ cele2e run --reuse deploy # reuse existing network
110
+ cele2e up --full-stack # interactive full-stack environment
111
+ cele2e shell caddy # debug running caddy container
112
+ cele2e build-infra # rebuild Docker images + repackage all modules
113
+ cele2e build-infra --skip-modules # rebuild Docker images only (faster)
114
+ cele2e scaffold lunacycle-auth # create tests/lunacycle-auth.test.ts
115
+ cele2e completion zsh # print zsh completion script
116
+ `);
117
+ }
118
+
119
+ const [, , command, ...args] = process.argv;
120
+ const { stateDir, testsPath: detectedTestsPath } = detectTestDir();
121
+
122
+ // Handle --get-completions before routing (used by shell completion scripts)
123
+ if (command === '--get-completions') {
124
+ const words = args.slice(0, -1);
125
+ const current = Number.parseInt(args[args.length - 1] || '0', 10);
126
+ const suggestions = getCompletions(words, current);
127
+ process.stdout.write(`${suggestions.join('\n')}\n`);
128
+ process.exit(0);
129
+ }
130
+
131
+ switch (command) {
132
+ case 'run': {
133
+ // Check if any non-flag arg is a module path (has manifest.yml)
134
+ const modulePath = args
135
+ .filter((a) => !a.startsWith('--'))
136
+ .find((a) => existsSync(join(resolve(a), 'manifest.yml')));
137
+
138
+ if (modulePath) {
139
+ const absModulePath = resolve(modulePath);
140
+ runScript('e2e-run', args.filter((a) => a !== modulePath), {
141
+ E2E_TEST_DIR: stateDir,
142
+ E2E_MODULE_DIR: absModulePath,
143
+ });
144
+ }
145
+
146
+ // If cwd is not a module, walk sibling modules so the user can invoke
147
+ // `cele2e run` from infra/modules/ (or the repo root) without cd-ing in.
148
+ const cwdHasManifest = existsSync(join(process.cwd(), 'manifest.yml'));
149
+ const positionals = args.filter((a) => !a.startsWith('--'));
150
+
151
+ if (!cwdHasManifest) {
152
+ if (positionals.length === 1) {
153
+ const matches = findModulesWithMatchingTest(process.cwd(), positionals[0]);
154
+ if (matches.length === 1) {
155
+ runScript('e2e-run', args, {
156
+ E2E_TEST_DIR: stateDir,
157
+ E2E_MODULE_DIR: matches[0].moduleDir,
158
+ });
159
+ }
160
+ if (matches.length > 1) {
161
+ console.error(`Multiple modules contain tests matching "${positionals[0]}":`);
162
+ for (const m of matches) {
163
+ console.error(` ${basename(m.moduleDir)}: ${m.matchedTests.join(', ')}`);
164
+ }
165
+ console.error('\nDisambiguate by running from inside the module directory or passing its path.');
166
+ process.exit(1);
167
+ }
168
+ } else if (positionals.length === 0) {
169
+ const allModules = findAllModules(process.cwd());
170
+ if (allModules.length > 0) {
171
+ runScript('e2e-run', args, {
172
+ E2E_TEST_DIR: stateDir,
173
+ E2E_MODULE_DIRS: allModules.join('\n'),
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ runScript('e2e-run', args, { E2E_TEST_DIR: stateDir, E2E_TESTS_PATH: detectedTestsPath });
180
+ break;
181
+ }
182
+
183
+ case 'up':
184
+ runScript('e2e-up', args);
185
+
186
+ case 'down':
187
+ runScript('e2e-down', args, { E2E_TEST_DIR: stateDir });
188
+
189
+ case 'shell':
190
+ runScript('e2e-shell', args);
191
+
192
+ case 'status': {
193
+ const persistentFile = join(stateDir, '.e2e-persistent.json');
194
+ let projectName: string | undefined;
195
+ if (existsSync(persistentFile)) {
196
+ try {
197
+ const data = JSON.parse(readFileSync(persistentFile, 'utf-8'));
198
+ projectName = data.projectName;
199
+ console.log('=== Persistent Test Network ===');
200
+ console.log(` Project: ${data.projectName}`);
201
+ console.log(` Test: ${data.testFile}`);
202
+ console.log(` Created: ${data.createdAt}`);
203
+ console.log(` Reuse: cele2e run --reuse`);
204
+ console.log(` Tear down: cele2e down`);
205
+ console.log('');
206
+ } catch { /* ignore parse errors */ }
207
+ }
208
+ runScript('e2e-status', args, projectName ? { CELILO_E2E_PROJECT: projectName } : undefined);
209
+ }
210
+
211
+ case 'load':
212
+ runScript('e2e-load', args);
213
+
214
+ case 'build-infra': {
215
+ const save = args.includes('--save');
216
+ const skipModules = args.includes('--skip-modules');
217
+ const moduleDirs = args.filter((a) => !a.startsWith('--'));
218
+ runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
219
+ break;
220
+ }
221
+
222
+ case 'clear-timing': {
223
+ const timingFile = join(stateDir, '.e2e-timing.json');
224
+ if (existsSync(timingFile)) {
225
+ rmSync(timingFile);
226
+ console.log(`Cleared timing data: ${timingFile}`);
227
+ } else {
228
+ console.log(`No timing data found at ${timingFile}`);
229
+ }
230
+ break;
231
+ }
232
+
233
+ case 'scaffold': {
234
+ const name = args[0];
235
+ if (!name) {
236
+ console.error('Error: scaffold requires a test name\n cele2e scaffold <name>');
237
+ process.exit(1);
238
+ }
239
+ runScaffold({ testsPath: detectedTestsPath, name });
240
+ break;
241
+ }
242
+
243
+ case 'version': {
244
+ const pkg = await import('../../package.json', { with: { type: 'json' } });
245
+ console.log(`cele2e v${pkg.default.version}`);
246
+ break;
247
+ }
248
+
249
+ case 'completion': {
250
+ const shell = args[0];
251
+ if (shell === 'zsh') {
252
+ process.stdout.write(generateZshCompletion());
253
+ } else if (shell === 'bash') {
254
+ process.stdout.write(generateBashCompletion());
255
+ } else {
256
+ console.error('Usage: cele2e completion <zsh|bash>');
257
+ process.exit(1);
258
+ }
259
+ break;
260
+ }
261
+
262
+ case undefined:
263
+ case '--help':
264
+ case '-h':
265
+ case 'help':
266
+ printHelp();
267
+ break;
268
+
269
+ default:
270
+ console.error(`Unknown command: ${command}\n`);
271
+ printHelp();
272
+ process.exit(1);
273
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Walks the cwd tree to find Celilo modules and their e2e tests.
3
+ *
4
+ * Used so that `cele2e run <test-name>` works from `infra/modules/` (or the
5
+ * repo root) without needing to cd into the module first, and so tab
6
+ * completion can suggest test names discovered across all modules.
7
+ *
8
+ * Scans direct subdirs and one level deeper — covers `modules/` (where each
9
+ * subdir is a module) and `infra/` (where `modules/<name>/` is one level
10
+ * deeper).
11
+ */
12
+
13
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ import { parse as parseYaml } from 'yaml';
16
+
17
+ const MAX_DEPTH = 2;
18
+
19
+ function readManifestTestsDir(manifestPath: string): string | undefined {
20
+ try {
21
+ const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { e2e?: { tests_dir?: string } };
22
+ return parsed?.e2e?.tests_dir;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ function listTestNames(testsPath: string): string[] {
29
+ if (!existsSync(testsPath)) return [];
30
+ try {
31
+ return readdirSync(testsPath)
32
+ .filter((f) => f.endsWith('.test.ts'))
33
+ .map((f) => f.replace(/\.test\.ts$/, ''));
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
39
+ function isModule(dir: string): { manifestPath: string; testsPath: string } | null {
40
+ const manifestPath = join(dir, 'manifest.yml');
41
+ if (!existsSync(manifestPath)) return null;
42
+ // Match the runner's resolution: prefer e2e.tests_dir from the manifest,
43
+ // fall back to a conventional e2e/ directory at the module root.
44
+ const testsDir = readManifestTestsDir(manifestPath) ?? 'e2e';
45
+ return { manifestPath, testsPath: resolve(dir, testsDir) };
46
+ }
47
+
48
+ function walkModules(cwd: string, visit: (moduleDir: string, testsPath: string) => void): void {
49
+ function scan(dir: string, depth: number): void {
50
+ if (depth > MAX_DEPTH) return;
51
+ let entries: string[];
52
+ try {
53
+ entries = readdirSync(dir);
54
+ } catch {
55
+ return;
56
+ }
57
+ for (const entry of entries) {
58
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
59
+ const subdir = join(dir, entry);
60
+ try {
61
+ if (!statSync(subdir).isDirectory()) continue;
62
+ } catch {
63
+ continue;
64
+ }
65
+ const mod = isModule(subdir);
66
+ if (mod) {
67
+ visit(subdir, mod.testsPath);
68
+ } else if (depth < MAX_DEPTH) {
69
+ scan(subdir, depth + 1);
70
+ }
71
+ }
72
+ }
73
+ scan(cwd, 1);
74
+ }
75
+
76
+ export interface ModuleTestMatch {
77
+ moduleDir: string;
78
+ matchedTests: string[];
79
+ }
80
+
81
+ /**
82
+ * Find modules under `cwd` whose e2e dir contains a test name including `pattern`.
83
+ * Used by `cele2e run <pattern>` when invoked outside any module dir.
84
+ */
85
+ export function findModulesWithMatchingTest(cwd: string, pattern: string): ModuleTestMatch[] {
86
+ const results: ModuleTestMatch[] = [];
87
+ walkModules(cwd, (moduleDir, testsPath) => {
88
+ const matched = listTestNames(testsPath).filter((t) => t.includes(pattern));
89
+ if (matched.length > 0) results.push({ moduleDir, matchedTests: matched });
90
+ });
91
+ return results;
92
+ }
93
+
94
+ /**
95
+ * Collect all test names reachable from `cwd`. Includes tests in cwd itself
96
+ * (when cwd is a module) and tests in any module discovered under cwd.
97
+ */
98
+ export function findAllTestNames(cwd: string): string[] {
99
+ const names = new Set<string>();
100
+ const cwdMod = isModule(cwd);
101
+ if (cwdMod) {
102
+ for (const n of listTestNames(cwdMod.testsPath)) names.add(n);
103
+ }
104
+ walkModules(cwd, (_moduleDir, testsPath) => {
105
+ for (const n of listTestNames(testsPath)) names.add(n);
106
+ });
107
+ return Array.from(names).sort();
108
+ }
109
+
110
+ /**
111
+ * Return the absolute paths of all modules discoverable under `cwd` that have
112
+ * an e2e tests directory containing at least one `*.test.ts` file.
113
+ */
114
+ export function findAllModules(cwd: string): string[] {
115
+ const modules: string[] = [];
116
+ walkModules(cwd, (moduleDir, testsPath) => {
117
+ if (listTestNames(testsPath).length > 0) modules.push(moduleDir);
118
+ });
119
+ return modules.sort();
120
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * cele2e scaffold — generate a new e2e test file from template.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const green = '\x1b[32m';
9
+ const dim = '\x1b[2m';
10
+ const reset = '\x1b[0m';
11
+
12
+ interface ScaffoldOptions {
13
+ testsPath: string;
14
+ name: string;
15
+ }
16
+
17
+ function toTitleCase(kebab: string): string {
18
+ return kebab
19
+ .split('-')
20
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
21
+ .join(' ');
22
+ }
23
+
24
+ function generateTestTemplate(name: string): string {
25
+ const title = toTitleCase(name);
26
+ return `/**
27
+ * ${title} E2E Test
28
+ *
29
+ * Tests that ${title} deploys and operates correctly through Celilo's infrastructure.
30
+ *
31
+ * Run: cele2e run ${name}
32
+ * Run (reuse): cele2e run --reuse ${name}
33
+ */
34
+
35
+ import { afterAll, describe, expect, test } from 'bun:test';
36
+ import { network, progress, reconnectNetwork } from '@celilo/e2e';
37
+ import type { NetworkHandle } from '@celilo/e2e/types';
38
+
39
+ const REUSE = process.argv.includes('--reuse');
40
+ const PROJECT_NAME = 'celilo-e2e-${name}';
41
+
42
+ describe('${name}', () => {
43
+ let net: NetworkHandle;
44
+ let stageError: string | null = null;
45
+
46
+ function requireStage(stage: string) {
47
+ if (stageError) {
48
+ throw new Error(\`Skipped: \${stage} — \${stageError}\`);
49
+ }
50
+ }
51
+
52
+ afterAll(async () => {
53
+ await net?.stop();
54
+ });
55
+
56
+ test('start network', async () => {
57
+ if (REUSE) {
58
+ net = await reconnectNetwork(PROJECT_NAME);
59
+ return;
60
+ }
61
+
62
+ net = await network({ projectName: PROJECT_NAME })
63
+ .dmz({ 'machine-1': '10.0.10.10' })
64
+ // .app({ 'app-1': '10.0.20.10' })
65
+ .start();
66
+ }, 120_000);
67
+
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
+ }
82
+ }, 300_000);
83
+
84
+ test('verify', async () => {
85
+ requireStage('verify');
86
+ // TODO: add verification steps
87
+ // const result = await net.exec('machine-1', 'curl -s http://localhost');
88
+ // expect(result.exitCode).toBe(0);
89
+ }, 60_000);
90
+ });
91
+ `;
92
+ }
93
+
94
+ export function runScaffold(options: ScaffoldOptions): void {
95
+ const { testsPath, name } = options;
96
+
97
+ mkdirSync(testsPath, { recursive: true });
98
+
99
+ const outPath = join(testsPath, `${name}.test.ts`);
100
+ if (existsSync(outPath)) {
101
+ console.error(`Error: ${outPath} already exists`);
102
+ process.exit(1);
103
+ }
104
+
105
+ writeFileSync(outPath, generateTestTemplate(name));
106
+ console.log(`${green}✔${reset} Created ${outPath}`);
107
+ console.log(`\n${dim}Run it with:\n cele2e run ${name}${reset}`);
108
+ }