268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
/**
|
|
* Custom Content Parent Directory Tests
|
|
*
|
|
* Tests that --custom-content works with parent directories containing
|
|
* multiple module subdirectories (1-level recursive scan).
|
|
*
|
|
* Related: https://github.com/bmad-code-org/BMAD-METHOD/issues/2040
|
|
*
|
|
* Usage: node test/test-custom-content-parent-dir.js
|
|
*/
|
|
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('fs-extra');
|
|
const { UI } = require('../tools/cli/lib/ui');
|
|
|
|
// ANSI colors
|
|
const colors = {
|
|
reset: '\u001B[0m',
|
|
green: '\u001B[32m',
|
|
red: '\u001B[31m',
|
|
yellow: '\u001B[33m',
|
|
cyan: '\u001B[36m',
|
|
dim: '\u001B[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++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a temp directory simulating a parent dir with multiple custom modules
|
|
*
|
|
* Structure:
|
|
* parentDir/
|
|
* module-a/
|
|
* module.yaml (code: "module-a")
|
|
* module-b/
|
|
* module.yaml (code: "module-b")
|
|
* not-a-module/
|
|
* readme.md
|
|
*/
|
|
async function createParentDirFixture() {
|
|
const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-parent-'));
|
|
|
|
// Module A
|
|
await fs.ensureDir(path.join(parentDir, 'module-a'));
|
|
await fs.writeFile(path.join(parentDir, 'module-a', 'module.yaml'), 'code: module-a\nname: Module A\n');
|
|
|
|
// Module B
|
|
await fs.ensureDir(path.join(parentDir, 'module-b'));
|
|
await fs.writeFile(path.join(parentDir, 'module-b', 'module.yaml'), 'code: module-b\nname: Module B\n');
|
|
|
|
// Not a module (no module.yaml)
|
|
await fs.ensureDir(path.join(parentDir, 'not-a-module'));
|
|
await fs.writeFile(path.join(parentDir, 'not-a-module', 'readme.md'), '# Not a module\n');
|
|
|
|
// Module with missing code field (invalid)
|
|
await fs.ensureDir(path.join(parentDir, 'bad-module'));
|
|
await fs.writeFile(path.join(parentDir, 'bad-module', 'module.yaml'), 'name: Bad Module\n');
|
|
|
|
return parentDir;
|
|
}
|
|
|
|
/**
|
|
* Create a temp directory simulating a direct module path
|
|
*/
|
|
async function createDirectModuleFixture() {
|
|
const moduleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-module-'));
|
|
await fs.writeFile(path.join(moduleDir, 'module.yaml'), 'code: direct-module\nname: Direct Module\n');
|
|
return moduleDir;
|
|
}
|
|
|
|
/**
|
|
* Create a temp directory with no modules at any level
|
|
*/
|
|
async function createEmptyDirFixture() {
|
|
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-empty-'));
|
|
await fs.writeFile(path.join(emptyDir, 'readme.md'), '# Empty\n');
|
|
return emptyDir;
|
|
}
|
|
|
|
async function runTests() {
|
|
const ui = new UI();
|
|
|
|
// ============================================================
|
|
// Test Suite 1: validateCustomContentPathSync with parent dirs
|
|
// ============================================================
|
|
console.log(`\n${colors.yellow}Test Suite 1: validateCustomContentPathSync${colors.reset}\n`);
|
|
|
|
let parentDir;
|
|
let directModuleDir;
|
|
let emptyDir;
|
|
|
|
try {
|
|
parentDir = await createParentDirFixture();
|
|
directModuleDir = await createDirectModuleFixture();
|
|
emptyDir = await createEmptyDirFixture();
|
|
|
|
// Direct module path should pass (existing behavior)
|
|
const directResult = ui.validateCustomContentPathSync(directModuleDir);
|
|
assert(directResult === undefined, 'Direct module path validates successfully');
|
|
|
|
// Parent dir with module subdirs should pass (new behavior)
|
|
const parentResult = ui.validateCustomContentPathSync(parentDir);
|
|
assert(parentResult === undefined, 'Parent directory with module subdirectories validates successfully', `Got error: ${parentResult}`);
|
|
|
|
// Empty dir (no modules at any level) should fail
|
|
const emptyResult = ui.validateCustomContentPathSync(emptyDir);
|
|
assert(
|
|
typeof emptyResult === 'string' && emptyResult.length > 0,
|
|
'Directory with no modules at any level fails validation',
|
|
`Expected error string, got: ${emptyResult}`,
|
|
);
|
|
|
|
// Non-existent path should fail
|
|
const nonExistResult = ui.validateCustomContentPathSync('/tmp/does-not-exist-bmad-xyz');
|
|
assert(typeof nonExistResult === 'string', 'Non-existent path fails validation');
|
|
|
|
// Empty input should pass (allows cancel)
|
|
const emptyInputResult = ui.validateCustomContentPathSync('');
|
|
assert(emptyInputResult === undefined, 'Empty input passes (allows cancel)');
|
|
} finally {
|
|
if (parentDir) await fs.remove(parentDir).catch(() => {});
|
|
if (directModuleDir) await fs.remove(directModuleDir).catch(() => {});
|
|
if (emptyDir) await fs.remove(emptyDir).catch(() => {});
|
|
}
|
|
|
|
// ============================================================
|
|
// Test Suite 2: resolveCustomContentPaths
|
|
// ============================================================
|
|
console.log(`\n${colors.yellow}Test Suite 2: resolveCustomContentPaths${colors.reset}\n`);
|
|
|
|
try {
|
|
parentDir = await createParentDirFixture();
|
|
directModuleDir = await createDirectModuleFixture();
|
|
emptyDir = await createEmptyDirFixture();
|
|
|
|
// Direct module path returns itself
|
|
const directPaths = ui.resolveCustomContentPaths(directModuleDir);
|
|
assert(Array.isArray(directPaths), 'resolveCustomContentPaths returns an array for direct module');
|
|
assert(directPaths.length === 1, 'Direct module path resolves to exactly 1 path');
|
|
assert(
|
|
directPaths[0] === directModuleDir,
|
|
'Direct module path resolves to itself',
|
|
`Expected ${directModuleDir}, got ${directPaths[0]}`,
|
|
);
|
|
|
|
// Parent dir expands to individual module subdirs (any dir with module.yaml)
|
|
const parentPaths = ui.resolveCustomContentPaths(parentDir);
|
|
assert(Array.isArray(parentPaths), 'resolveCustomContentPaths returns an array for parent dir');
|
|
assert(
|
|
parentPaths.length === 3,
|
|
'Parent dir resolves to 3 module paths (skips not-a-module which has no module.yaml)',
|
|
`Expected 3, got ${parentPaths.length}: ${JSON.stringify(parentPaths)}`,
|
|
);
|
|
|
|
const resolvedNames = parentPaths.map((p) => path.basename(p)).sort();
|
|
assert(
|
|
resolvedNames.includes('module-a') && resolvedNames.includes('module-b'),
|
|
'Parent dir includes module-a and module-b',
|
|
`Got: ${JSON.stringify(resolvedNames)}`,
|
|
);
|
|
|
|
// bad-module has module.yaml but no code field — resolveCustomContentPaths includes it
|
|
// (callers are responsible for filtering invalid modules)
|
|
assert(resolvedNames.includes('bad-module'), 'Parent dir includes bad-module (has module.yaml, callers filter by code)');
|
|
|
|
// Empty dir returns empty array
|
|
const emptyPaths = ui.resolveCustomContentPaths(emptyDir);
|
|
assert(Array.isArray(emptyPaths), 'resolveCustomContentPaths returns an array for empty dir');
|
|
assert(emptyPaths.length === 0, 'Empty dir resolves to 0 paths', `Expected 0, got ${emptyPaths.length}`);
|
|
|
|
// Non-existent path returns empty array
|
|
const nonExistPaths = ui.resolveCustomContentPaths('/tmp/does-not-exist-bmad-xyz');
|
|
assert(Array.isArray(nonExistPaths), 'resolveCustomContentPaths returns array for non-existent path');
|
|
assert(nonExistPaths.length === 0, 'Non-existent path resolves to 0 paths');
|
|
} finally {
|
|
if (parentDir) await fs.remove(parentDir).catch(() => {});
|
|
if (directModuleDir) await fs.remove(directModuleDir).catch(() => {});
|
|
if (emptyDir) await fs.remove(emptyDir).catch(() => {});
|
|
}
|
|
|
|
// ============================================================
|
|
// Test Suite 3: Edge cases
|
|
// ============================================================
|
|
console.log(`\n${colors.yellow}Test Suite 3: Edge cases${colors.reset}\n`);
|
|
|
|
let parentWithBadModule;
|
|
let directModule2;
|
|
|
|
try {
|
|
// Parent dir where only subdir has module.yaml without code field
|
|
parentWithBadModule = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-bad-'));
|
|
await fs.ensureDir(path.join(parentWithBadModule, 'no-code-module'));
|
|
await fs.writeFile(path.join(parentWithBadModule, 'no-code-module', 'module.yaml'), 'name: No Code Module\n');
|
|
|
|
// resolveCustomContentPaths includes it (callers filter)
|
|
const badPaths = ui.resolveCustomContentPaths(parentWithBadModule);
|
|
assert(badPaths.length === 1, 'Dir with only code-less module.yaml still resolves (callers filter)');
|
|
|
|
// validateCustomContentPathSync accepts it (has module.yaml in subdir)
|
|
const badValidation = ui.validateCustomContentPathSync(parentWithBadModule);
|
|
assert(
|
|
badValidation === undefined,
|
|
'Parent dir with code-less module.yaml passes validation (callers handle filtering)',
|
|
`Got error: ${badValidation}`,
|
|
);
|
|
|
|
// Subdir with malformed YAML
|
|
await fs.ensureDir(path.join(parentWithBadModule, 'malformed'));
|
|
await fs.writeFile(path.join(parentWithBadModule, 'malformed', 'module.yaml'), '{{invalid yaml');
|
|
const malformedPaths = ui.resolveCustomContentPaths(parentWithBadModule);
|
|
assert(malformedPaths.length === 2, 'Subdirs with malformed YAML are still resolved (callers handle parse errors)');
|
|
|
|
// Direct module alongside parent dir (simulates comma-separated usage)
|
|
directModule2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-direct2-'));
|
|
await fs.writeFile(path.join(directModule2, 'module.yaml'), 'code: direct-2\nname: Direct Two\n');
|
|
|
|
const directPaths2 = ui.resolveCustomContentPaths(directModule2);
|
|
const parentPaths2 = ui.resolveCustomContentPaths(parentWithBadModule);
|
|
const combined = [...directPaths2, ...parentPaths2];
|
|
assert(
|
|
combined.length === 3,
|
|
'Mixed direct + parent paths combine correctly (1 direct + 2 from parent)',
|
|
`Expected 3, got ${combined.length}`,
|
|
);
|
|
} finally {
|
|
if (parentWithBadModule) await fs.remove(parentWithBadModule).catch(() => {});
|
|
if (directModule2) await fs.remove(directModule2).catch(() => {});
|
|
}
|
|
|
|
// ============================================================
|
|
// Summary
|
|
// ============================================================
|
|
console.log(`\n${colors.cyan}========================================`);
|
|
console.log('Test Results:');
|
|
console.log(` Passed: ${colors.green}${passed}${colors.reset}`);
|
|
console.log(` Failed: ${colors.red}${failed}${colors.reset}`);
|
|
console.log(`========================================${colors.reset}\n`);
|
|
|
|
if (failed === 0) {
|
|
console.log(`${colors.green}✨ All custom content parent dir tests passed!${colors.reset}\n`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`${colors.red}❌ Some custom content parent dir tests failed${colors.reset}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
runTests().catch((error) => {
|
|
console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message);
|
|
console.error(error.stack);
|
|
process.exit(1);
|
|
});
|