import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { eq } from 'drizzle-orm'; import { type DbClient, createDbClient } from '../db/client'; import { moduleBuilds, modules } from '../db/schema'; import { skipIntegration } from '../test-utils/integration-guard'; import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build'; const TEST_DB_PATH = './test-module-build.db'; const TEST_MODULE_DIR = './test-module-build-dir'; describe('Module Build Service', () => { let db: DbClient; beforeEach(() => { db = createDbClient({ path: TEST_DB_PATH }); // Create tables db.$client.run(` CREATE TABLE IF NOT EXISTS modules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, version TEXT NOT NULL, description TEXT, state TEXT NOT NULL DEFAULT 'IMPORTED', manifest_data TEXT NOT NULL, source_path TEXT NOT NULL, imported_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()), error_message TEXT ) `); db.$client.run(` CREATE TABLE IF NOT EXISTS module_builds ( id INTEGER PRIMARY KEY AUTOINCREMENT, module_id TEXT NOT NULL, version TEXT NOT NULL, built_at INTEGER NOT NULL DEFAULT (unixepoch()), artifacts TEXT NOT NULL, status TEXT NOT NULL, build_log TEXT, FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE ) `); }); afterEach(async () => { db.$client.close(); if (existsSync(TEST_DB_PATH)) { await rm(TEST_DB_PATH); } const walPath = `${TEST_DB_PATH}-wal`; const shmPath = `${TEST_DB_PATH}-shm`; if (existsSync(walPath)) { await rm(walPath); } if (existsSync(shmPath)) { await rm(shmPath); } // Clean up test module directory if (existsSync(TEST_MODULE_DIR)) { await rm(TEST_MODULE_DIR, { recursive: true }); } }); describe('buildModuleFromSource', () => { test('should return error if module not found', async () => { const result = await buildModuleFromSource('nonexistent', db); expect(result.success).toBe(false); expect(result.error).toContain('Module not found'); }); test('should return error if module has no build section', async () => { db.insert(modules) .values({ id: 'no-build', name: 'No Build Module', version: '1.0.0', sourcePath: '/test/no-build', manifestData: { id: 'no-build', name: 'No Build Module', version: '1.0.0', }, }) .run(); const result = await buildModuleFromSource('no-build', db); expect(result.success).toBe(false); expect(result.error).toContain('does not have a build section'); }); test.skipIf(skipIntegration({ tools: ['ansible'] }))( 'should record build metadata in database', async () => { await mkdir(TEST_MODULE_DIR, { recursive: true }); await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true }); await writeFile( `${TEST_MODULE_DIR}/build/playbook.yml`, `--- - name: Quick test build hosts: localhost gather_facts: false tasks: - name: Echo message ansible.builtin.debug: msg: "Build test" `, ); db.insert(modules) .values({ id: 'record-test', name: 'Record Test', version: '1.0.0', sourcePath: TEST_MODULE_DIR, manifestData: { id: 'record-test', name: 'Record Test', version: '1.0.0', build: { script: 'build/playbook.yml', }, }, }) .run(); const result = await buildModuleFromSource('record-test', db); // Build should succeed (simple debug task) expect(result.success).toBe(true); // Verify build metadata was recorded const buildRecord = await db .select() .from(moduleBuilds) .where(eq(moduleBuilds.moduleId, 'record-test')) .get(); expect(buildRecord).toBeDefined(); expect(buildRecord?.moduleId).toBe('record-test'); expect(buildRecord?.version).toBe('1.0.0'); expect(buildRecord?.status).toBe('success'); }, 10000, ); // 10 second timeout for ansible execution // Regression: a build.command that references $CELILO_MODULE_SOURCE_DIR // (the variable celilo-registry's manifest uses to find sibling packages // in the monorepo) must work the same at deploy time as at packaging // time. Before the fix, executeBuildCommand didn't set the env var, so // any module that imported from a local directory and relied on it // would silently fail before producing artifacts. test('build.command receives CELILO_MODULE_SOURCE_DIR pointing at modulePath', async () => { await mkdir(TEST_MODULE_DIR, { recursive: true }); db.insert(modules) .values({ id: 'env-test', name: 'Env Test', version: '1.0.0', sourcePath: TEST_MODULE_DIR, manifestData: { id: 'env-test', name: 'Env Test', version: '1.0.0', build: { // Write the env var's value to a file. The build runs with // cwd=modulePath, so a bare relative `built.marker` always // lands there — but we want to prove the env var itself was // set, so we fail explicitly if it's empty. command: 'test -n "$CELILO_MODULE_SOURCE_DIR" && echo "$CELILO_MODULE_SOURCE_DIR" > built.marker', artifacts: ['built.marker'], }, }, }) .run(); const result = await buildModuleFromSource('env-test', db); expect(result.success).toBe(true); expect(existsSync(`${TEST_MODULE_DIR}/built.marker`)).toBe(true); }, 10000); }); describe('getModuleBuildStatus', () => { test('should return null if module never built', async () => { const status = await getModuleBuildStatus('never-built', db); expect(status).toBeNull(); }); test('should return latest build status', async () => { // Create module first (for foreign key) db.insert(modules) .values({ id: 'test-module', name: 'Test Module', version: '1.0.0', sourcePath: '/test/module', manifestData: { id: 'test-module', name: 'Test Module', version: '1.0.0', }, }) .run(); db.insert(moduleBuilds) .values({ moduleId: 'test-module', version: '1.0.0', artifacts: ['/path/to/artifact'], status: 'success', buildLog: 'Build completed', }) .run(); const status = await getModuleBuildStatus('test-module', db); expect(status).toBeDefined(); expect(status?.status).toBe('success'); expect(status?.artifacts).toEqual(['/path/to/artifact']); expect(status?.buildLog).toBe('Build completed'); }); }); describe('verifyArtifactsExist', () => { test('should return true if all artifacts exist', async () => { // Create test artifact await mkdir(TEST_MODULE_DIR, { recursive: true }); const artifactPath = `${TEST_MODULE_DIR}/artifact.txt`; await writeFile(artifactPath, 'test'); const result = verifyArtifactsExist([artifactPath]); expect(result).toBe(true); }); test('should return false if any artifact missing', async () => { const result = verifyArtifactsExist(['/nonexistent/artifact.txt']); expect(result).toBe(false); }); test('should handle multiple artifacts', async () => { await mkdir(TEST_MODULE_DIR, { recursive: true }); const artifact1 = `${TEST_MODULE_DIR}/artifact1.txt`; const artifact2 = `${TEST_MODULE_DIR}/artifact2.txt`; await writeFile(artifact1, 'test1'); await writeFile(artifact2, 'test2'); const result = verifyArtifactsExist([artifact1, artifact2]); expect(result).toBe(true); }); test('should return false if any artifact in list is missing', async () => { await mkdir(TEST_MODULE_DIR, { recursive: true }); const artifact1 = `${TEST_MODULE_DIR}/exists.txt`; await writeFile(artifact1, 'test'); const result = verifyArtifactsExist([artifact1, '/nonexistent/missing.txt']); expect(result).toBe(false); }); }); });