/** * Walks the cwd tree to find Celilo modules and their e2e tests. * * Used so that `cele2e run ` works from `infra/modules/` (or the * repo root) without needing to cd into the module first, and so tab * completion can suggest test names discovered across all modules. * * Scans direct subdirs and one level deeper — covers `modules/` (where each * subdir is a module) and `infra/` (where `modules//` is one level * deeper). */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; import { parse as parseYaml } from 'yaml'; const MAX_DEPTH = 2; function readManifestTestsDir(manifestPath: string): string | undefined { try { const parsed = parseYaml(readFileSync(manifestPath, 'utf-8')) as { e2e?: { tests_dir?: string }; }; return parsed?.e2e?.tests_dir; } catch { return undefined; } } function listTestNames(testsPath: string): string[] { if (!existsSync(testsPath)) return []; try { return readdirSync(testsPath) .filter((f) => f.endsWith('.test.ts')) .map((f) => f.replace(/\.test\.ts$/, '')); } catch { return []; } } function isModule(dir: string): { manifestPath: string; testsPath: string } | null { const manifestPath = join(dir, 'manifest.yml'); if (!existsSync(manifestPath)) return null; // Match the runner's resolution: prefer e2e.tests_dir from the manifest, // fall back to a conventional e2e/ directory at the module root. const testsDir = readManifestTestsDir(manifestPath) ?? 'e2e'; return { manifestPath, testsPath: resolve(dir, testsDir) }; } function walkModules(cwd: string, visit: (moduleDir: string, testsPath: string) => void): void { function scan(dir: string, depth: number): void { if (depth > MAX_DEPTH) return; let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { if (entry.startsWith('.') || entry === 'node_modules') continue; const subdir = join(dir, entry); try { if (!statSync(subdir).isDirectory()) continue; } catch { continue; } const mod = isModule(subdir); if (mod) { visit(subdir, mod.testsPath); } else if (depth < MAX_DEPTH) { scan(subdir, depth + 1); } } } scan(cwd, 1); } export const TOP_LEVEL_GROUP = '(top-level)'; export interface SuiteTest { name: string; file: string; group: string; /** Owning directory: the module dir, or the suite's `e2e/` dir for top-level entries. */ dir: string; } /** * Enumerate every discoverable Docker e2e test (all module suites + the repo's * top-level e2e/tests/) sorted, with module tests first. */ export function discoverSuiteTests(suiteRoot: string): SuiteTest[] { const out: SuiteTest[] = []; for (const moduleDir of findAllModules(suiteRoot)) { const testsDir = existsSync(join(moduleDir, 'manifest.yml')) ? resolve(moduleDir, readManifestTestsDir(join(moduleDir, 'manifest.yml')) ?? 'e2e') : join(moduleDir, 'e2e'); if (!existsSync(testsDir)) continue; for (const f of readdirSync(testsDir) .filter((f) => f.endsWith('.test.ts')) .sort()) { out.push({ name: f.replace(/\.test\.ts$/, ''), file: join(testsDir, f), group: basename(moduleDir), dir: moduleDir, }); } } const topLevel = join(suiteRoot, 'e2e', 'tests'); if (existsSync(topLevel)) { for (const f of readdirSync(topLevel) .filter((f) => f.endsWith('.test.ts')) .sort()) { out.push({ name: f.replace(/\.test\.ts$/, ''), file: join(topLevel, f), group: TOP_LEVEL_GROUP, dir: dirname(topLevel), }); } } return out; } export type PatternTarget = | { kind: 'module'; moduleDir: string } | { kind: 'top-level'; dir: string; testsPath: string } | { kind: 'ambiguous'; matches: SuiteTest[] }; /** * Resolve a `cele2e run ` positional against the full discovered * suite. Pattern semantics match the runner's filter: substring against the * test name, with one exception — a pattern that exactly equals a test name * wins outright, so a suite whose name prefixes another (`aspect-fanout` vs * `aspect-fanout-new-systems`) stays reachable from the repo root instead of * reading as permanently ambiguous. Returns null when nothing matches, so the * caller can fall through to its default tests dir. */ export function resolvePatternTarget(suiteRoot: string, pattern: string): PatternTarget | null { const tests = discoverSuiteTests(suiteRoot); const exact = tests.filter((t) => t.name === pattern); if (exact.length === 1) { return toPatternTarget(exact[0]); } if (exact.length > 1) { return { kind: 'ambiguous', matches: exact }; } const matches = tests.filter((t) => t.name.includes(pattern)); if (matches.length === 0) return null; if (matches.length > 1) return { kind: 'ambiguous', matches }; return toPatternTarget(matches[0]); } function toPatternTarget(match: SuiteTest): PatternTarget { if (match.group === TOP_LEVEL_GROUP) { return { kind: 'top-level', dir: match.dir, testsPath: join(match.dir, 'tests') }; } return { kind: 'module', moduleDir: match.dir }; } export type RunTargets = | { kind: 'ok'; modules: string[]; topLevel: boolean } | { kind: 'no-match'; pattern: string } | { kind: 'ambiguous'; pattern: string; matches: SuiteTest[] }; /** * Resolve every `cele2e run ...` positional independently and aggregate * the targets into the env-shaped set the runner consumes: module dirs (for * E2E_MODULE_DIRS) plus a top-level flag (for E2E_TOP_LEVEL_TESTS). Reports * the FIRST unresolvable name rather than silently dropping it, so a typo or * an un-followable ambiguity errors instead of falling through to a * top-level-only run that prints "No test files matched". */ export function resolveRunTargets(suiteRoot: string, patterns: readonly string[]): RunTargets { const modules = new Set(); let topLevel = false; for (const pattern of patterns) { const target = resolvePatternTarget(suiteRoot, pattern); if (!target) return { kind: 'no-match', pattern }; if (target.kind === 'ambiguous') return { kind: 'ambiguous', pattern, matches: target.matches }; if (target.kind === 'module') { modules.add(target.moduleDir); } else { topLevel = true; } } return { kind: 'ok', modules: Array.from(modules).sort(), topLevel }; } /** * Collect all test names reachable from `cwd`. Includes tests in cwd itself * (when cwd is a module) and tests in any module discovered under cwd. */ export function findAllTestNames(cwd: string): string[] { const names = new Set(); const cwdMod = isModule(cwd); if (cwdMod) { for (const n of listTestNames(cwdMod.testsPath)) names.add(n); } walkModules(cwd, (_moduleDir, testsPath) => { for (const n of listTestNames(testsPath)) names.add(n); }); return Array.from(names).sort(); } /** * Return the absolute paths of all modules discoverable under `cwd` that have * an e2e tests directory containing at least one `*.test.ts` file. */ export function findAllModules(cwd: string): string[] { const modules: string[] = []; walkModules(cwd, (moduleDir, testsPath) => { if (listTestNames(testsPath).length > 0) modules.push(moduleDir); }); return modules.sort(); }