339 lines
12 KiB
JavaScript
339 lines
12 KiB
JavaScript
/**
|
||
* 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: '[0m',
|
||
green: '[32m',
|
||
red: '[31m',
|
||
dim: '[2m',
|
||
};
|
||
|
||
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();
|