import { describe, expect, test } from 'bun:test'; import { cpSync, existsSync, mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { resolveBuildCommandPaths } from './build-paths'; /** Repo root: this file sits at apps/celilo/src/module/packaging/. */ const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..', '..'); /** Every module in the repo that declares a `build:` block (design, Context). */ const BUILD_MODULES = [ 'celilo-registry', 'celilo-web-console', 'celilo-website', 'forgejo', 'npm-cache-node', 'wireguard-manager', ] as const; function buildCommand(moduleId: string): string { const manifestPath = join(REPO_ROOT, 'modules', moduleId, 'manifest.yml'); if (!existsSync(manifestPath)) throw new Error(`fixture missing: ${manifestPath}`); const manifest = parseYaml(readFileSync(manifestPath, 'utf8')) as { build?: { command?: string }; }; const command = manifest.build?.command; if (!command) throw new Error(`${moduleId} declares no build command`); return command; } describe('publish-time build path gate', () => { test('flags celilo-registry when packaged outside the monorepo (the control-plane shape)', () => { // The known-bad form, per celilo#1307: the script reaches a sibling of the // module source (`../../packages/registry-server`). Inside the monorepo // that resolves; from a tree where the module stands alone — a control // plane, or any packaging that is not the source checkout — it does not. // The spec scenario pins this: "packaged from a tree where that directory // does not exist". const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-')); const moduleSourceDir = join(scratch, 'celilo-registry'); cpSync(join(REPO_ROOT, 'modules', 'celilo-registry'), moduleSourceDir, { recursive: true, }); const violations = resolveBuildCommandPaths(buildCommand('celilo-registry'), { moduleSourceDir, buildDir: moduleSourceDir, }); expect(violations).toHaveLength(1); expect(violations[0].rawPath).toContain('../../packages/registry-server'); expect(violations[0].resolvedPath).not.toContain(REPO_ROOT); }); test('flags a plain relative escape that does not resolve (spec scenario)', () => { const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-')); const violations = resolveBuildCommandPaths( 'cd ../../packages/registry-server && bun install', { moduleSourceDir: scratch, buildDir: scratch }, ); expect(violations).toHaveLength(1); expect(violations[0].rawPath).toBe('../../packages/registry-server'); }); test('accepts $CELILO_MODULE_SOURCE_DIR/server when it exists (npm-cache-node shape)', () => { const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-')); const moduleSourceDir = join(scratch, 'npm-cache-node'); cpSync( join(REPO_ROOT, 'modules', 'npm-cache-node', 'server'), join(moduleSourceDir, 'server'), { recursive: true, }, ); const violations = resolveBuildCommandPaths(buildCommand('npm-cache-node'), { moduleSourceDir, buildDir: moduleSourceDir, }); expect(violations).toEqual([]); }); test('accepts every module build command at publish time in the monorepo', () => { // The regression surface for the five healthy modules. celilo-registry // passes here too, and that is correct: at publish, inside the monorepo, // its `cd` target exists (the binaries shipped on 2026-08-20 prove the // build ran). The gate refuses the script only where it genuinely cannot // resolve — the first test above. for (const moduleId of BUILD_MODULES) { const moduleSourceDir = join(REPO_ROOT, 'modules', moduleId); const violations = resolveBuildCommandPaths(buildCommand(moduleId), { moduleSourceDir, buildDir: moduleSourceDir, }); expect(violations, moduleId).toEqual([]); } }); test('skips a cd target it cannot resolve rather than guessing (forgejo shape)', () => { // `cd "$D"` where D was assigned earlier in the same command: the gate // does not interpret shell, so an unresolvable target is skipped, not // failed. A guard that rejects working modules is worse than none. const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-')); const violations = resolveBuildCommandPaths('D=files && mkdir -p "$D" && cd "$D" && ls', { moduleSourceDir: scratch, buildDir: scratch, }); expect(violations).toEqual([]); }); test('tracks the working directory across chained cds (celilo-web-console shape)', () => { // `cd $SOURCE/../../apps/console` then `cd ../console-server`: the second // target resolves against the FIRST cd's destination, not the build dir. const scratch = mkdtempSync(join(tmpdir(), 'celilo-build-paths-')); const moduleSourceDir = join(scratch, 'web'); const monorepo = join(scratch, 'mono'); cpSync(join(REPO_ROOT, 'modules', 'celilo-web-console'), moduleSourceDir, { recursive: true }); // A monorepo-shaped sibling tree, minimal but real. const { mkdirSync } = require('node:fs') as typeof import('node:fs'); mkdirSync(join(monorepo, 'apps', 'console'), { recursive: true }); mkdirSync(join(monorepo, 'apps', 'console-server'), { recursive: true }); cpSync(moduleSourceDir, join(monorepo, 'modules', 'celilo-web-console'), { recursive: true }); const violations = resolveBuildCommandPaths(buildCommand('celilo-web-console'), { moduleSourceDir: join(monorepo, 'modules', 'celilo-web-console'), buildDir: join(monorepo, 'modules', 'celilo-web-console'), }); expect(violations).toEqual([]); }); });