/** * Smoke test for bmad-quick-dev render.py * * Sets up a temp project with base + override config layers and a * _bmad/custom/bmad-quick-dev.user.toml [workflow] override, runs render.py, * and asserts: * 1. The central-config override wins (workflow.md contains "Japanese"). * 2. sprint_status is an absolute path rooted at the temp project dir. * 3. [workflow] customization is self-resolved and inlined: prepend bullet, * persistent_facts append (base kept), empty list -> _None._, on_complete * scalar baked into step-05/step-oneshot. * 4. No {workflow.*} placeholder or resolve_customization.py call survives * in any rendered file. * * Usage: node test/test-quick-dev-renderer.js * Exit codes: 0 = all tests pass, 1 = test failures */ 'use strict'; const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); // ANSI color codes (same as other test files) const colors = { reset: '\u001B[0m', green: '\u001B[32m', red: '\u001B[31m', cyan: '\u001B[36m', }; let totalTests = 0; let passedTests = 0; const failures = []; function test(name, fn) { totalTests++; try { fn(); passedTests++; console.log(` ${colors.green}\u2713${colors.reset} ${name}`); } catch (error) { console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`); failures.push({ name, message: error.message }); } } function assert(condition, message) { if (!condition) throw new Error(message); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const SKILL_SRC = path.join(__dirname, '..', 'src', 'bmm-skills', '4-implementation', 'bmad-quick-dev'); /** * Recursively copy a directory (stdlib only, no fs.cp to stay >=20 compat). */ function copyDirSync(src, dst) { fs.mkdirSync(dst, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const dstPath = path.join(dst, entry.name); if (entry.isDirectory()) { copyDirSync(srcPath, dstPath); } else { fs.copyFileSync(srcPath, dstPath); } } } // --------------------------------------------------------------------------- // Test fixture setup // --------------------------------------------------------------------------- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-test-')); try { // _bmad/config.toml — base layer fs.mkdirSync(path.join(tmpDir, '_bmad'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '_bmad', 'config.toml'), [ '[core]', 'communication_language = "French"', '', '[modules.bmm]', 'planning_artifacts = "{project-root}/plan"', 'implementation_artifacts = "{project-root}/impl"', ].join('\n'), 'utf-8', ); // _bmad/custom/config.user.toml — override layer (should win) fs.mkdirSync(path.join(tmpDir, '_bmad', 'custom'), { recursive: true }); fs.writeFileSync( path.join(tmpDir, '_bmad', 'custom', 'config.user.toml'), ['[core]', 'communication_language = "Japanese"'].join('\n'), 'utf-8', ); // _bmad/custom/bmad-quick-dev.user.toml — [workflow] customization override. // Exercises render.py's self-resolution: array append (persistent_facts), // list inlining (activation_steps_prepend), and scalar override (on_complete), // all baked into the rendered output with no runtime resolve_customization.py. fs.writeFileSync( path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'), [ '[workflow]', 'activation_steps_prepend = ["TEST_PREPEND_STEP"]', 'persistent_facts = ["TEST_EXTRA_FACT"]', 'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"', ].join('\n'), 'utf-8', ); // Copy skill dir into /bmad-quick-dev/ so find_project_root() walks // up and finds /_bmad/, and os.path.basename(script_dir) resolves // to the real skill name so the render output lands at // _bmad/render/bmad-quick-dev/workflow.md. const skillDst = path.join(tmpDir, 'bmad-quick-dev'); copyDirSync(SKILL_SRC, skillDst); // --------------------------------------------------------------------------- // Run render.py // --------------------------------------------------------------------------- console.log(`\n${colors.cyan}Quick-dev renderer smoke tests${colors.reset}\n`); const result = spawnSync('python3', [path.join(skillDst, 'render.py')], { cwd: skillDst, encoding: 'utf-8', }); const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev'); const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8'); const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md')); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- test('render.py exits with code 0', () => { assert(result.status === 0, `exit code ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); }); test('workflow.md exists in render output', () => { const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md'); assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`); }); test('custom override wins — workflow.md contains "Japanese"', () => { const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md'); const content = fs.readFileSync(rendered, 'utf-8'); assert(content.includes('Japanese'), `"Japanese" not found in workflow.md (communication_language override did not win)`); }); test('sprint_status is an absolute path rooted at temp project dir', () => { const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md'); const content = fs.readFileSync(rendered, 'utf-8'); // Normalize to forward slashes for cross-platform matching const normalizedTmp = tmpDir.replaceAll('\\', '/'); // sprint_status should appear as /impl/sprint-status.yaml const expected = `${normalizedTmp}/impl/sprint-status.yaml`; assert( content.includes(expected), `sprint_status path not found.\nExpected substring: ${expected}\n` + `workflow.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`, ); }); test('workflow override — prepend step inlined as a bullet', () => { const content = readRendered('workflow.md'); assert(content.includes('- TEST_PREPEND_STEP'), 'activation_steps_prepend not inlined as a bullet'); }); test('workflow override — persistent_facts append (base kept, override added)', () => { const content = readRendered('workflow.md'); assert(content.includes('- TEST_EXTRA_FACT'), 'override persistent_fact not inlined'); assert(content.includes('project-context.md'), 'base persistent_fact dropped — append semantics broken'); }); test('empty activation_steps_append renders the _None._ sentinel', () => { const content = readRendered('workflow.md'); assert(content.includes('_None._'), '_None._ sentinel missing for empty list'); }); test('on_complete scalar inlined into step-05 and step-oneshot', () => { for (const file of ['step-05-present.md', 'step-oneshot.md']) { assert(readRendered(file).includes('TEST_ON_COMPLETE_INSTRUCTION'), `on_complete not inlined into ${file}`); } }); test('no {workflow.*} placeholder survives in any rendered file', () => { const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('{workflow.')); assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`); }); test('no resolve_customization.py reference survives in any rendered file', () => { const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('resolve_customization.py')); assert(leaks.length === 0, `resolve_customization.py still referenced in: ${leaks.join(', ')}`); }); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- console.log(`\n${colors.cyan}${'═'.repeat(55)}${colors.reset}`); console.log(`${colors.cyan}Test Results:${colors.reset}`); console.log(` Total: ${totalTests}`); console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`); console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`); console.log(`${colors.cyan}${'═'.repeat(55)}${colors.reset}\n`); if (failures.length > 0) { console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`); for (const failure of failures) { console.log(`${colors.red}\u2717${colors.reset} ${failure.name}`); console.log(` ${failure.message}\n`); } process.exit(1); } console.log(`${colors.green}All tests passed!${colors.reset}\n`); process.exit(0);