/** * Interactive Config Tests * Tests for interactive configuration collection */ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type DbClient, createDbClient } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules } from '../db/schema'; import type { VariableDeclare } from '../manifest/schema'; // Stable mock references — referenced by name in tests so we can call // .mockResolvedValue() etc. on them. Created BEFORE mock.module so the // factory closure captures the same instances we use in tests. const promptTextMock = mock(() => Promise.resolve('default')); const promptPasswordMock = mock(() => Promise.resolve('default')); const promptConfirmMock = mock(() => Promise.resolve(true)); const celiloIntroMock = mock(() => undefined); // Install module mock BEFORE dynamically importing the SUT. // bun:test's mock.module is a runtime call (no hoisting), so the order // matters: anything that imports './prompts' AFTER this call gets the mock. // // IMPORTANT: mock.module replaces the module PROCESS-GLOBALLY for the // remainder of the test run. Every export from the real module needs a // stub here, or test files loaded after this one will fail with // `SyntaxError: Export named 'X' not found in module 'prompts.ts'` when // they try to import a missing one. The mocks below replace what this // test actively asserts on; the no-op stubs satisfy other modules that // import from prompts.ts just to use them as side-effects. mock.module('./prompts', () => ({ celiloIntro: celiloIntroMock, celiloOutro: async () => undefined, promptText: promptTextMock, promptPassword: promptPasswordMock, promptConfirm: promptConfirmMock, showNote: () => undefined, getActiveDisplay: () => undefined, setActiveDisplay: () => undefined, log: { success: () => undefined, error: () => undefined, warn: () => undefined, info: () => undefined, message: () => undefined, }, })); // Dynamic import after the mock is installed. const { promptForMissingConfig } = await import('./interactive-config'); describe('promptForMissingConfig', () => { let db: DbClient; let testDbPath: string; let testDir: string; beforeEach(async () => { // Reset all mocks promptTextMock.mockClear(); promptPasswordMock.mockClear(); promptConfirmMock.mockClear(); celiloIntroMock.mockClear(); // Create temp directory for test database testDir = mkdtempSync(join(tmpdir(), 'celilo-test-')); testDbPath = join(testDir, 'test.db'); // Set environment variable for database path process.env.CELILO_DB_PATH = testDbPath; // Run migrations (creates the database) await runMigrations(testDbPath); // Get database client db = createDbClient({ path: testDbPath }); // Create test module db.insert(modules) .values({ id: 'test-module', name: 'Test Module', version: '1.0.0', sourcePath: '/tmp/test-module', importedAt: new Date(), manifestData: { apiVersion: '1.0.0', kind: 'module', metadata: { name: 'test-module', version: '1.0.0' }, }, }) .run(); }); afterEach(() => { db.$client.close(); try { rmSync(testDir, { recursive: true, force: true }); } catch { // Ignore cleanup errors } }); test('prompts for text variables', async () => { const missingVars: VariableDeclare[] = [ { name: 'hostname', type: 'string', source: 'user', description: 'Container hostname', required: true, }, ]; promptTextMock.mockResolvedValue('test-host'); const result = await promptForMissingConfig('test-module', missingVars, db); expect(result).toBe(true); expect(celiloIntroMock).toHaveBeenCalledWith('🔧 Configuration needed for test-module'); expect(promptTextMock).toHaveBeenCalledWith({ message: 'hostname', placeholder: 'Container hostname', validate: expect.any(Function), }); }); test('prompts for secret variables with text prompt', async () => { const missingVars: VariableDeclare[] = [ { name: 'api_token', type: 'string', source: 'user', description: 'API access token', required: true, }, ]; promptTextMock.mockResolvedValue('secret-token-123'); const result = await promptForMissingConfig('test-module', missingVars, db); expect(result).toBe(true); expect(promptTextMock).toHaveBeenCalledWith({ message: 'api_token', placeholder: 'API access token', validate: expect.any(Function), }); }); test('handles multiple variables', async () => { const missingVars: VariableDeclare[] = [ { name: 'hostname', type: 'string', source: 'user', description: 'Container hostname', required: true, }, { name: 'port', type: 'number', source: 'user', description: 'Service port', required: true, }, ]; promptTextMock.mockResolvedValueOnce('test-host').mockResolvedValueOnce('8080'); const result = await promptForMissingConfig('test-module', missingVars, db); expect(result).toBe(true); expect(promptTextMock).toHaveBeenCalledTimes(2); }); test('returns false if prompt fails', async () => { const missingVars: VariableDeclare[] = [ { name: 'hostname', type: 'string', source: 'user', description: 'Container hostname', required: true, }, ]; promptTextMock.mockRejectedValue(new Error('User cancelled')); const result = await promptForMissingConfig('test-module', missingVars, db); expect(result).toBe(false); }); test('uses default value as placeholder', async () => { const missingVars: VariableDeclare[] = [ { name: 'port', type: 'number', source: 'user', description: 'Service port', required: false, default: 8080, }, ]; promptTextMock.mockResolvedValue('9090'); const result = await promptForMissingConfig('test-module', missingVars, db); expect(result).toBe(true); expect(promptTextMock).toHaveBeenCalledWith({ message: 'port', placeholder: '8080', validate: undefined, // Not required }); }); });