/** * Config Resolution Tests * * Tests the Python config resolution scripts by invoking them as subprocesses * with temporary TOML fixtures. Validates: * - Global user layer is loaded and merged with correct priority * - Project layers override global layers * - Missing global dir doesn't break anything (backward compat) * - resolve_customization.py global skill layer * * Usage: node test/test-config-resolution.js */ const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs/promises'); const { execSync } = require('node:child_process'); const SCRIPTS_DIR = path.resolve(__dirname, '..', 'src', 'scripts'); const colors = { reset: '', green: '', red: '', dim: '', }; let passed = 0; let failed = 0; function assert(condition, testName, errorMessage = '') { if (condition) { console.log(`${colors.green}✓${colors.reset} ${testName}`); passed++; } else { console.log(`${colors.red}✗${colors.reset} ${testName}`); if (errorMessage) { console.log(` ${colors.dim}${errorMessage}${colors.reset}`); } failed++; } } function writeToml(filePath, data) { const lines = []; for (const [key, val] of Object.entries(data)) { if (typeof val === 'string') { lines.push(`${key} = "${val}"`); } else if (typeof val === 'number' || typeof val === 'boolean') { lines.push(`${key} = ${val}`); } } return fs.writeFile(filePath, lines.join('\n') + '\n'); } async function withTempDir(fn) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-config-test-')); try { return await fn(dir); } finally { await fs.rm(dir, { recursive: true, force: true }); } } async function testResolveConfig() { console.log('\n--- resolve_config.py ---\n'); // Test 1: global user layer is loaded await withTempDir(async (tmpDir) => { const globalDir = path.join(tmpDir, '.bmad', 'config'); await fs.mkdir(globalDir, { recursive: true }); await writeToml(path.join(globalDir, 'config.user.toml'), { user_name: 'GlobalAlice', communication_language: 'en', }); const projectDir = path.join(tmpDir, 'project'); const bmadDir = path.join(projectDir, '_bmad'); await fs.mkdir(path.join(bmadDir, 'custom'), { recursive: true }); await writeToml(path.join(bmadDir, 'config.toml'), { project_name: 'TestProject', user_name: 'ProjectAlice', }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_config.py --project-root ${projectDir}`, { encoding: 'utf-8' })); assert( result.user_name === 'ProjectAlice', 'project config.user overrides global user_name', `Expected "ProjectAlice", got "${result.user_name}"`, ); assert( result.communication_language === 'en', 'global communication_language preserved when project has no override', `Expected "en", got "${result.communication_language}"`, ); assert( result.project_name === 'TestProject', 'project config values preserved', `Expected "TestProject", got "${result.project_name}"`, ); } finally { process.env.HOME = origHome; } }); // Test 2: missing global dir — backward compat await withTempDir(async (tmpDir) => { const projectDir = path.join(tmpDir, 'project'); const bmadDir = path.join(projectDir, '_bmad'); await fs.mkdir(path.join(bmadDir, 'custom'), { recursive: true }); await writeToml(path.join(bmadDir, 'config.toml'), { project_name: 'NoGlobalProject', }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_config.py --project-root ${projectDir}`, { encoding: 'utf-8' })); assert( result.project_name === 'NoGlobalProject', 'works fine without global config dir', `Expected "NoGlobalProject", got "${result.project_name}"`, ); } finally { process.env.HOME = origHome; } }); // Test 3: full priority chain — global < base_team < base_user < custom_team < custom_user await withTempDir(async (tmpDir) => { const globalDir = path.join(tmpDir, '.bmad', 'config'); await fs.mkdir(globalDir, { recursive: true }); await writeToml(path.join(globalDir, 'config.user.toml'), { user_name: 'L0-Global', }); const projectDir = path.join(tmpDir, 'project'); const bmadDir = path.join(projectDir, '_bmad'); await fs.mkdir(path.join(bmadDir, 'custom'), { recursive: true }); await writeToml(path.join(bmadDir, 'config.toml'), { user_name: 'L1-BaseTeam' }); await writeToml(path.join(bmadDir, 'config.user.toml'), { user_name: 'L2-BaseUser' }); await writeToml(path.join(bmadDir, 'custom', 'config.toml'), { user_name: 'L3-CustomTeam' }); await writeToml(path.join(bmadDir, 'custom', 'config.user.toml'), { user_name: 'L4-CustomUser' }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_config.py --project-root ${projectDir}`, { encoding: 'utf-8' })); assert( result.user_name === 'L4-CustomUser', 'highest priority layer (custom user) wins', `Expected "L4-CustomUser", got "${result.user_name}"`, ); } finally { process.env.HOME = origHome; } }); // Test 4: --key flag works with global layer await withTempDir(async (tmpDir) => { const globalDir = path.join(tmpDir, '.bmad', 'config'); await fs.mkdir(globalDir, { recursive: true }); await writeToml(path.join(globalDir, 'config.user.toml'), { user_name: 'KeyTestGlobal', communication_language: 'fr', }); const projectDir = path.join(tmpDir, 'project'); const bmadDir = path.join(projectDir, '_bmad'); await fs.mkdir(path.join(bmadDir, 'custom'), { recursive: true }); await writeToml(path.join(bmadDir, 'config.toml'), { project_name: 'KeyTestProject', }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse( execSync(`python3 ${SCRIPTS_DIR}/resolve_config.py --project-root ${projectDir} --key user_name --key communication_language`, { encoding: 'utf-8', }), ); assert( Object.keys(result).length === 2, '--key returns only requested keys', `Expected 2 keys, got ${Object.keys(result).length}: ${JSON.stringify(Object.keys(result))}`, ); assert( result.user_name === 'KeyTestGlobal', '--key user_name returns global value', `Expected "KeyTestGlobal", got "${result.user_name}"`, ); assert( result.communication_language === 'fr', '--key communication_language returns global value', `Expected "fr", got "${result.communication_language}"`, ); } finally { process.env.HOME = origHome; } }); } async function testResolveCustomization() { console.log('\n--- resolve_customization.py ---\n'); // Test 1: global skill user layer is loaded await withTempDir(async (tmpDir) => { const globalDir = path.join(tmpDir, '.bmad', 'config'); await fs.mkdir(globalDir, { recursive: true }); await writeToml(path.join(globalDir, 'test-skill.user.toml'), { agent: 'global-agent-prompt', }); const skillDir = path.join(tmpDir, 'skill', 'test-skill'); await fs.mkdir(skillDir, { recursive: true }); await writeToml(path.join(skillDir, 'customize.toml'), { agent: 'default-agent-prompt', version: '1.0.0', }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_customization.py --skill ${skillDir}`, { encoding: 'utf-8' })); assert( result.agent === 'default-agent-prompt', 'skill defaults override global user layer', `Expected "default-agent-prompt", got "${result.agent}"`, ); assert(result.version === '1.0.0', 'skill default values preserved', `Expected "1.0.0", got "${result.version}"`); } finally { process.env.HOME = origHome; } }); // Test 2: global skill layer provides value not in defaults await withTempDir(async (tmpDir) => { const globalDir = path.join(tmpDir, '.bmad', 'config'); await fs.mkdir(globalDir, { recursive: true }); await writeToml(path.join(globalDir, 'test-skill.user.toml'), { extra_global_key: 'from-global', }); const skillDir = path.join(tmpDir, 'skill', 'test-skill'); await fs.mkdir(skillDir, { recursive: true }); await writeToml(path.join(skillDir, 'customize.toml'), { version: '2.0.0', }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_customization.py --skill ${skillDir}`, { encoding: 'utf-8' })); assert( result.extra_global_key === 'from-global', 'global key not in defaults is preserved', `Expected "from-global", got "${result.extra_global_key}"`, ); assert(result.version === '2.0.0', 'skill defaults still present', `Expected "2.0.0", got "${result.version}"`); } finally { process.env.HOME = origHome; } }); // Test 3: missing global dir — backward compat await withTempDir(async (tmpDir) => { const skillDir = path.join(tmpDir, 'skill', 'test-skill'); await fs.mkdir(skillDir, { recursive: true }); await writeToml(path.join(skillDir, 'customize.toml'), { version: '3.0.0', }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_customization.py --skill ${skillDir}`, { encoding: 'utf-8' })); assert(result.version === '3.0.0', 'works without global config dir', `Expected "3.0.0", got "${result.version}"`); } finally { process.env.HOME = origHome; } }); // Test 4: full priority chain — global < defaults < team < user await withTempDir(async (tmpDir) => { const globalDir = path.join(tmpDir, '.bmad', 'config'); await fs.mkdir(globalDir, { recursive: true }); await writeToml(path.join(globalDir, 'test-skill.user.toml'), { agent: 'L0-Global', }); const skillDir = path.join(tmpDir, 'project', '_bmad', 'skills', 'test-skill'); await fs.mkdir(skillDir, { recursive: true }); await writeToml(path.join(skillDir, 'customize.toml'), { agent: 'L1-Defaults' }); const customDir = path.join(tmpDir, 'project', '_bmad', 'custom'); await fs.mkdir(customDir, { recursive: true }); await writeToml(path.join(customDir, 'test-skill.toml'), { agent: 'L2-Team' }); await writeToml(path.join(customDir, 'test-skill.user.toml'), { agent: 'L3-User' }); const origHome = process.env.HOME; process.env.HOME = tmpDir; try { const result = JSON.parse(execSync(`python3 ${SCRIPTS_DIR}/resolve_customization.py --skill ${skillDir}`, { encoding: 'utf-8' })); assert(result.agent === 'L3-User', 'highest priority layer (project user) wins', `Expected "L3-User", got "${result.agent}"`); } finally { process.env.HOME = origHome; } }); } async function main() { console.log('Config Resolution Tests\n'); try { await testResolveConfig(); await testResolveCustomization(); } catch (error) { console.error(`${colors.red}Fatal error: ${error.message}${colors.reset}`); process.exit(1); } console.log(`\n${colors.green}${passed} passed${colors.reset}, ${colors.red}${failed} failed${colors.reset}`); process.exit(failed > 0 ? 1 : 0); } main();