From 550807f8ec057effbe02d7afb82f80cedf79167d Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:58:52 -0700 Subject: [PATCH] test(quick-dev): add renderer smoke test with TOML override New test/test-quick-dev-renderer.js spins up a temp project with base _bmad/config.toml and a _bmad/custom/config.user.toml override, runs render.py, and asserts the override wins in rendered workflow.md and that sprint_status is rooted at an absolute path in the temp project. Registered as test:renderer in package.json and chained into the npm test script. Part of plan-quick-dev-python-config-hardening.md (F7). Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 5 +- test/test-quick-dev-renderer.js | 175 ++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 test/test-quick-dev-renderer.js diff --git a/package.json b/package.json index b52bf2970..36896e9b6 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,13 @@ "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", - "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills", + "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run test:renderer && npm run validate:refs && npm run validate:skills", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", - "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run test:renderer && npm run lint && npm run lint:md && npm run format:check", "test:channels": "node test/test-installer-channels.js", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", + "test:renderer": "node test/test-quick-dev-renderer.js", "test:urls": "node test/test-parse-source-urls.js", "validate:refs": "node tools/validate-file-refs.js --strict", "validate:skills": "node tools/validate-skills.js --strict" diff --git a/test/test-quick-dev-renderer.js b/test/test-quick-dev-renderer.js new file mode 100644 index 000000000..d65263f2a --- /dev/null +++ b/test/test-quick-dev-renderer.js @@ -0,0 +1,175 @@ +/** + * Smoke test for bmad-quick-dev render.py + * + * Sets up a temp project with a base _bmad/config.toml and an override + * _bmad/custom/config.user.toml, runs render.py, and asserts: + * 1. The override wins (workflow.md contains "Japanese"). + * 2. sprint_status is an absolute path rooted at the temp project dir. + * + * 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', + ); + + // 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', + }); + + // --------------------------------------------------------------------------- + // 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)}`, + ); + }); +} 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);