import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { eq } from 'drizzle-orm'; import { type DbClient, createDbClient } from '../db/client'; import { modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { copyModuleFiles, importModule, insertModuleToDb, moduleExists, readModuleManifest, validateModuleDirectory, } from './import'; const TEST_FIXTURES_DIR = './test-fixtures-temp'; // Temporary fixtures for this test (not the real golden files) const TEST_TARGET_DIR = './test-modules-target'; const TEST_DB_PATH = './test-import.db'; describe('Module Import', () => { let db: DbClient; beforeEach(async () => { // Create test database 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 ) `); // Clean up test directories if (existsSync(TEST_FIXTURES_DIR)) { await rm(TEST_FIXTURES_DIR, { recursive: true }); } if (existsSync(TEST_TARGET_DIR)) { await rm(TEST_TARGET_DIR, { recursive: true }); } await mkdir(TEST_FIXTURES_DIR, { recursive: true }); await mkdir(TEST_TARGET_DIR, { recursive: true }); }); afterEach(async () => { db.$client.close(); // Clean up 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); } if (existsSync(TEST_FIXTURES_DIR)) { await rm(TEST_FIXTURES_DIR, { recursive: true }); } if (existsSync(TEST_TARGET_DIR)) { await rm(TEST_TARGET_DIR, { recursive: true }); } }); describe('validateModuleDirectory', () => { test('should return null for valid module directory', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'valid-module'); await mkdir(moduleDir, { recursive: true }); await writeFile(join(moduleDir, 'manifest.yml'), 'id: test'); const result = validateModuleDirectory(moduleDir); expect(result).toBeNull(); }); test('should return error for non-existent directory', () => { const result = validateModuleDirectory('./does-not-exist'); expect(result).toContain('does not exist'); }); test('should return error for file instead of directory', async () => { const filePath = join(TEST_FIXTURES_DIR, 'not-a-dir'); await writeFile(filePath, 'content'); const result = validateModuleDirectory(filePath); expect(result).toContain('not a directory'); }); test('should return error if manifest.yml missing', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'no-manifest'); await mkdir(moduleDir, { recursive: true }); const result = validateModuleDirectory(moduleDir); expect(result).toContain('manifest.yml not found'); }); }); describe('readModuleManifest', () => { test('should read and validate valid manifest', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'homebridge'); await mkdir(moduleDir, { recursive: true }); await writeFile( join(moduleDir, 'manifest.yml'), ` celilo_contract: "1.0" id: homebridge name: Homebridge version: 1.0.0 description: HomeKit bridge `, ); const result = await readModuleManifest(moduleDir); expect(result.success).toBe(true); if (result.success) { expect(result.manifest.id).toBe('homebridge'); expect(result.manifest.name).toBe('Homebridge'); expect(result.manifest.version).toBe('1.0.0'); } }); test('should return error for invalid manifest', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'invalid'); await mkdir(moduleDir, { recursive: true }); await writeFile( join(moduleDir, 'manifest.yml'), ` id: Invalid_ID name: Test version: 1.0.0 `, ); const result = await readModuleManifest(moduleDir); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('validation failed'); } }); test('should return error for missing manifest file', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'no-file'); await mkdir(moduleDir, { recursive: true }); const result = await readModuleManifest(moduleDir); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('Failed to read'); } }); }); describe('submodules (openspec/changes/submodules D1)', () => { const PARENT = ` celilo_contract: "1.0" id: forgejo name: Forgejo version: 1.0.0 submodules: - runner `; const SUBMODULE = ` celilo_contract: "1.0" id: runner name: Forgejo Runner version: 1.0.0 `; async function writeParent(dirName: string, parent: string, submodule?: string) { const moduleDir = join(TEST_FIXTURES_DIR, dirName); await mkdir(moduleDir, { recursive: true }); await writeFile(join(moduleDir, 'manifest.yml'), parent); if (submodule !== undefined) { const subDir = join(moduleDir, 'submodules', 'runner'); await mkdir(subDir, { recursive: true }); await writeFile(join(subDir, 'manifest.yml'), submodule); } return moduleDir; } test('accepts a parent whose declared submodule is present and legal', async () => { const dir = await writeParent('forgejo-ok', PARENT, SUBMODULE); const result = await readModuleManifest(dir); expect(result.success).toBe(true); if (result.success) expect(result.manifest.submodules).toEqual(['runner']); }); // The point of validating at PARENT import: an instantiation happens with // no operator present, which is the worst moment to learn a manifest is // broken. test('rejects a parent declaring a submodule that does not exist', async () => { const dir = await writeParent('forgejo-missing', PARENT); const result = await readModuleManifest(dir); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('submodules/runner/manifest.yml does not exist'); } }); test('rejects a parent whose submodule provides a capability', async () => { const dir = await writeParent( 'forgejo-provides', PARENT, `${SUBMODULE} provides: capabilities: - name: source_forge version: 1.0.0 data: {} `, ); const result = await readModuleManifest(dir); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('may not provide a capability'); }); test('rejects a submodule whose id disagrees with its directory', async () => { const dir = await writeParent( 'forgejo-mismatch', PARENT, SUBMODULE.replace('id: runner', 'id: builder'), ); const result = await readModuleManifest(dir); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain('does not match its directory'); }); test('a module declaring no submodules is unaffected', async () => { const dir = await writeParent( 'plain-module', ` celilo_contract: "1.0" id: plain name: Plain version: 1.0.0 `, ); const result = await readModuleManifest(dir); expect(result.success).toBe(true); }); // A submodule reaches the fleet inside its parent's package. The registry // path is already closed (submodules are never published separately), so a // local path is the only open door and this is what shuts it. test('refuses to import a submodule directory on its own, naming the parent', async () => { await writeParent('forgejo-direct', PARENT, SUBMODULE); const submodulePath = join(TEST_FIXTURES_DIR, 'forgejo-direct', 'submodules', 'runner'); const error = validateModuleDirectory(submodulePath); expect(error).not.toBeNull(); expect(error).toContain("is a submodule of 'forgejo-direct'"); expect(error).toContain('Import'); }); test('a trailing slash does not defeat the submodule refusal', async () => { await writeParent('forgejo-slash', PARENT, SUBMODULE); const submodulePath = `${join(TEST_FIXTURES_DIR, 'forgejo-slash', 'submodules', 'runner')}/`; expect(validateModuleDirectory(submodulePath)).toContain('is a submodule of'); }); test('an ordinary module directory is still importable', async () => { const dir = await writeParent('ordinary', PARENT, SUBMODULE); expect(validateModuleDirectory(dir)).toBeNull(); }); }); describe('copyModuleFiles', () => { test('should copy all files from source to target', async () => { const sourceDir = join(TEST_FIXTURES_DIR, 'source'); const targetDir = join(TEST_TARGET_DIR, 'target'); await mkdir(sourceDir, { recursive: true }); await writeFile(join(sourceDir, 'manifest.yml'), 'content1'); await writeFile(join(sourceDir, 'README.md'), 'content2'); await mkdir(join(sourceDir, 'terraform'), { recursive: true }); await writeFile(join(sourceDir, 'terraform', 'main.tf'), 'terraform content'); await copyModuleFiles(sourceDir, targetDir); expect(existsSync(join(targetDir, 'manifest.yml'))).toBe(true); expect(existsSync(join(targetDir, 'README.md'))).toBe(true); expect(existsSync(join(targetDir, 'terraform', 'main.tf'))).toBe(true); }); test('should create target directory if it does not exist', async () => { const sourceDir = join(TEST_FIXTURES_DIR, 'source2'); const targetDir = join(TEST_TARGET_DIR, 'nested', 'deep', 'target'); await mkdir(sourceDir, { recursive: true }); await writeFile(join(sourceDir, 'file.txt'), 'content'); await copyModuleFiles(sourceDir, targetDir); expect(existsSync(targetDir)).toBe(true); expect(existsSync(join(targetDir, 'file.txt'))).toBe(true); }); }); describe('insertModuleToDb', () => { test('should insert module record into database', async () => { const manifest: ModuleManifest = { celilo_contract: '1.0', id: 'test-module', name: 'Test Module', version: '1.0.0', requires: { capabilities: [] }, provides: { capabilities: [] }, variables: { owns: [], imports: [] }, }; await insertModuleToDb(manifest, '/target/path', db); const result = db.select().from(modules).where(eq(modules.id, 'test-module')).get(); expect(result).toBeDefined(); expect(result?.name).toBe('Test Module'); expect(result?.version).toBe('1.0.0'); expect(result?.state).toBe('IMPORTED'); expect(result?.sourcePath).toBe('/target/path'); }); }); describe('moduleExists', () => { test('should return true for existing module', () => { db.insert(modules) .values({ id: 'existing', name: 'Existing', version: '1.0.0', sourcePath: '/path', manifestData: {}, }) .run(); const result = moduleExists('existing', db); expect(result).toBe(true); }); test('should return false for non-existent module', () => { const result = moduleExists('does-not-exist', db); expect(result).toBe(false); }); }); describe('importModule', () => { test('should successfully import valid module', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'homebridge'); await mkdir(moduleDir, { recursive: true }); await mkdir(join(moduleDir, 'terraform'), { recursive: true }); await writeFile( join(moduleDir, 'manifest.yml'), ` celilo_contract: "1.0" id: homebridge name: Homebridge version: 1.0.0 `, ); await writeFile(join(moduleDir, 'README.md'), '# Homebridge'); await writeFile(join(moduleDir, 'terraform', 'main.tf'), 'resource "test" {}'); const result = await importModule({ sourcePath: moduleDir, targetBasePath: TEST_TARGET_DIR, db, }); expect(result.success).toBe(true); if (result.success) { expect(result.moduleId).toBe('homebridge'); expect(existsSync(result.targetPath)).toBe(true); expect(existsSync(join(result.targetPath, 'manifest.yml'))).toBe(true); expect(existsSync(join(result.targetPath, 'terraform', 'main.tf'))).toBe(true); } // Verify database const dbRecord = db.select().from(modules).where(eq(modules.id, 'homebridge')).get(); expect(dbRecord).toBeDefined(); expect(dbRecord?.name).toBe('Homebridge'); }); test('should reject import if directory does not exist', async () => { const result = await importModule({ sourcePath: './does-not-exist', targetBasePath: TEST_TARGET_DIR, db, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('does not exist'); } }); test('should reject import if manifest is invalid', async () => { const moduleDir = join(TEST_FIXTURES_DIR, 'invalid'); await mkdir(moduleDir, { recursive: true }); await writeFile( join(moduleDir, 'manifest.yml'), 'id: Invalid_ID\nname: Test\nversion: 1.0.0', ); const result = await importModule({ sourcePath: moduleDir, targetBasePath: TEST_TARGET_DIR, db, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('validation failed'); } }); test('should reject import if module already exists', async () => { // Insert existing module db.insert(modules) .values({ id: 'existing', name: 'Existing', version: '1.0.0', sourcePath: '/path', manifestData: {}, }) .run(); const moduleDir = join(TEST_FIXTURES_DIR, 'existing'); await mkdir(moduleDir, { recursive: true }); await writeFile( join(moduleDir, 'manifest.yml'), 'celilo_contract: "1.0"\nid: existing\nname: Existing\nversion: 2.0.0', ); const result = await importModule({ sourcePath: moduleDir, targetBasePath: TEST_TARGET_DIR, db, }); expect(result.success).toBe(false); if (!result.success) { expect(result.error).toContain('already exists'); } }); }); });