mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-09-01 15:09:07 +08:00
43b54b8067
* fix(review): dispatch reviewers by prompt file
* fix(review): load review content from parent message only
Align phase-four reviewer prompt files with file-dispatch: drop the
{review_content} fill-in slot and take the review target exclusively
from the calling prompt.
* fix(review): keep no-subagent fallback prompts self-contained
When subagents are unavailable, write full instruction body plus
REVIEW TARGET under implementation_artifacts, not a path-only pointer.
* fix(review): keep layer recipes plain multi-line prompts
Drop blockquote wrapping so diffs stay intact, leave parent policy in
the review step, and keep customize instructions as the layer recipe
(default subagent prompt or a custom bash/LLM override).
447 lines
20 KiB
JavaScript
447 lines
20 KiB
JavaScript
// Test only deterministic renderer behavior.
|
|
// Do not test model inference or assert prose copied verbatim from skill sources.
|
|
/**
|
|
* 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 (step files' language line 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. Review layers materialize as direct invocation blocks: default layers
|
|
* become #### sections in step-04, an override replacing a layer by id
|
|
* wins, an empty-instruction override drops its layer, a `when` renders
|
|
* as a run-time guard, runtime placeholders like {diff_output} survive,
|
|
* and disabling every layer renders the HALT instruction.
|
|
* 5. 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extra one-off temp projects created by makeProject(); cleaned up in finally.
|
|
const extraTmpDirs = [];
|
|
|
|
/**
|
|
* Spin up an isolated temp project with the given _bmad/config.toml body and a
|
|
* copy of the skill dir, so a single bad-config scenario can be rendered in
|
|
* isolation. Returns { dir, skillDst }; the caller runs render.py against it.
|
|
*/
|
|
function makeProject(configText) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-halt-'));
|
|
extraTmpDirs.push(dir);
|
|
fs.mkdirSync(path.join(dir, '_bmad'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '_bmad', 'config.toml'), configText, 'utf-8');
|
|
const skillDst = path.join(dir, 'bmad-quick-dev');
|
|
copyDirSync(SKILL_SRC, skillDst);
|
|
return { dir, skillDst };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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"',
|
|
'document_output_language = "Klingon"',
|
|
'',
|
|
'[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"',
|
|
'',
|
|
'[[workflow.review_layers]]',
|
|
'id = "edge-case-hunter"',
|
|
'name = "Replaced Layer"',
|
|
'when = "TEST_WHEN_CONDITION"',
|
|
'instruction = "TEST_REPLACED_LAYER_INSTRUCTION"',
|
|
'',
|
|
'[[workflow.review_layers]]',
|
|
'id = "verification-gap"',
|
|
'instruction = ""',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
|
|
// Copy skill dir into <tmpDir>/bmad-quick-dev/ so find_project_root() walks
|
|
// up and finds <tmpDir>/_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 — communication_language baked into step files', () => {
|
|
const content = readRendered('step-01-clarify-and-route.md');
|
|
assert(content.includes('Japanese'), 'communication_language override (Japanese) did not win in the step-01 language line');
|
|
});
|
|
|
|
test('document_output_language bakes into the per-step language line', () => {
|
|
const content = readRendered('step-01-clarify-and-route.md');
|
|
assert(content.includes('Klingon'), 'document_output_language not baked into the step-01 language line');
|
|
});
|
|
|
|
test('sprint_status is an absolute path rooted at temp project dir', () => {
|
|
const content = readRendered('sync-sprint-status.md');
|
|
// Normalize to forward slashes for cross-platform matching
|
|
const normalizedTmp = tmpDir.replaceAll('\\', '/');
|
|
// sprint_status should appear as <tmpDir>/impl/sprint-status.yaml
|
|
const expected = `${normalizedTmp}/impl/sprint-status.yaml`;
|
|
assert(
|
|
content.includes(expected),
|
|
`sprint_status path not found.\nExpected substring: ${expected}\n` +
|
|
`sync-sprint-status.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('review layers materialize as invocation blocks in step-04', () => {
|
|
const content = readRendered('step-04-review.md');
|
|
const expectedPromptPath = `${skillDst.replaceAll('\\', '/')}/review-prompts/adversarial.md`;
|
|
assert(content.includes('#### Blind Hunter'), 'default review layer not rendered as a #### invocation block');
|
|
assert(!content.includes('- id:'), 'layer table data leaked into the rendered output');
|
|
assert(content.includes(expectedPromptPath), 'reviewer prompt path was not expanded to the absolute skill path');
|
|
assert(!content.includes('{skill-root}'), '{skill-root} survived in rendered review instructions');
|
|
assert(content.includes('{diff_output}'), 'runtime {diff_output} placeholder did not survive rendering');
|
|
// Diff is plain text after "Review content:", not trapped under a blockquote.
|
|
assert(content.includes('Review content:\n\n{diff_output}'), 'review content + diff not laid out as plain multi-line text');
|
|
assert(!content.includes('> {diff_output}'), '{diff_output} still blockquote-prefixed');
|
|
assert(!content.includes('{review_content}'), 'rendered review layers still reference a {review_content} file slot');
|
|
});
|
|
|
|
test('one-shot review layers use direct child file loading after rendering', () => {
|
|
const content = readRendered('step-oneshot.md');
|
|
const expectedPromptPath = `${skillDst.replaceAll('\\', '/')}/review-prompts/adversarial.md`;
|
|
assert(content.includes(expectedPromptPath), 'one-shot reviewer prompt path was not expanded to the absolute skill path');
|
|
assert(!content.includes('{skill-root}'), '{skill-root} survived in rendered one-shot review instructions');
|
|
assert(
|
|
content.includes('Review content: the changed files in the current worktree.'),
|
|
'one-shot review target is no longer the changed files in the current worktree',
|
|
);
|
|
assert(!content.includes('{review_content}'), 'one-shot layers still reference a {review_content} file slot');
|
|
assert(content.includes('Do not invoke any skill.'), 'one-shot reviewer may invoke another skill');
|
|
});
|
|
|
|
test('review layer override replaces the matching default by id', () => {
|
|
const content = readRendered('step-04-review.md');
|
|
assert(content.includes('#### Replaced Layer'), 'override layer name not used as block title');
|
|
assert(content.includes('TEST_REPLACED_LAYER_INSTRUCTION'), 'override layer instruction not inlined');
|
|
assert(!content.includes('review-prompts/edge-case-hunter.md'), 'replaced default layer instruction still present');
|
|
assert(content.includes('review-prompts/adversarial.md'), 'untouched default layer dropped by keyed merge');
|
|
});
|
|
|
|
test('empty-instruction override drops its layer entirely', () => {
|
|
const content = readRendered('step-04-review.md');
|
|
assert(!content.includes('verification-gap'), 'disabled layer id still present in rendered output');
|
|
assert(!content.includes('Verification Gap Reviewer'), 'disabled layer name still present in rendered output');
|
|
});
|
|
|
|
test('when condition renders as a run-time guard line', () => {
|
|
const content = readRendered('step-04-review.md');
|
|
assert(
|
|
content.includes('Run this layer only if the following holds in the current context: `TEST_WHEN_CONDITION`'),
|
|
'when condition not rendered as a guard line',
|
|
);
|
|
});
|
|
|
|
test('disabling every layer renders the HALT instruction', () => {
|
|
// Second render pass: replace the override file so every default layer
|
|
// (and the oneshot route's only layer) is disabled, then re-render.
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'),
|
|
[
|
|
'[workflow]',
|
|
'',
|
|
'[[workflow.review_layers]]',
|
|
'id = "blind-hunter"',
|
|
'instruction = ""',
|
|
'',
|
|
'[[workflow.review_layers]]',
|
|
'id = "edge-case-hunter"',
|
|
'instruction = ""',
|
|
'',
|
|
'[[workflow.review_layers]]',
|
|
'id = "verification-gap"',
|
|
'instruction = ""',
|
|
'',
|
|
'[[workflow.oneshot_review_layers]]',
|
|
'id = "blind-hunter"',
|
|
'instruction = ""',
|
|
].join('\n'),
|
|
'utf-8',
|
|
);
|
|
const rerun = spawnSync('python3', [path.join(skillDst, 'render.py')], {
|
|
cwd: skillDst,
|
|
encoding: 'utf-8',
|
|
});
|
|
assert(rerun.status === 0, `re-render exit code ${rerun.status}\nstderr: ${rerun.stderr}`);
|
|
const halt = 'No review layers are active. HALT with status `blocked` and blocking condition `no active review layers`.';
|
|
for (const file of ['step-04-review.md', 'step-oneshot.md']) {
|
|
assert(readRendered(file).includes(halt), `HALT instruction missing from ${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(', ')}`);
|
|
});
|
|
|
|
test('no main_config reference survives in any rendered file', () => {
|
|
const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('main_config'));
|
|
assert(leaks.length === 0, `main_config still referenced in: ${leaks.join(', ')} (the runtime config re-read was removed)`);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bad-config HALTs cleanly (never a raw Python traceback)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('missing implementation_artifacts HALTs cleanly (no traceback)', () => {
|
|
const { skillDst: dst } = makeProject(['[core]', 'communication_language = "French"'].join('\n'));
|
|
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
|
|
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
|
|
assert(
|
|
res.stdout.includes('HALT and report to the user: config is missing `implementation_artifacts`'),
|
|
`stdout missing the implementation_artifacts HALT directive.\nstdout: ${res.stdout}`,
|
|
);
|
|
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
|
|
});
|
|
|
|
test('missing planning_artifacts HALTs cleanly (no traceback)', () => {
|
|
// implementation_artifacts is present, so this exercises the general
|
|
// missing-vars scan rather than the dedicated guard.
|
|
const { skillDst: dst } = makeProject(
|
|
[
|
|
'[core]',
|
|
'communication_language = "French"',
|
|
'document_output_language = "Klingon"',
|
|
'implementation_artifacts = "{project-root}/impl"',
|
|
].join('\n'),
|
|
);
|
|
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
|
|
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
|
|
assert(
|
|
res.stdout.includes('HALT and report to the user: config is missing') && res.stdout.includes('`planning_artifacts`'),
|
|
`stdout missing the planning_artifacts HALT directive.\nstdout: ${res.stdout}`,
|
|
);
|
|
assert(
|
|
res.stdout.includes('step-01-clarify-and-route.md'),
|
|
`HALT directive does not name the referencing file.\nstdout: ${res.stdout}`,
|
|
);
|
|
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
|
|
});
|
|
|
|
test('unparseable customization override HALTs cleanly (no traceback)', () => {
|
|
const { dir, skillDst: dst } = makeProject(
|
|
[
|
|
'[core]',
|
|
'communication_language = "French"',
|
|
'document_output_language = "Klingon"',
|
|
'planning_artifacts = "{project-root}/plan"',
|
|
'implementation_artifacts = "{project-root}/impl"',
|
|
].join('\n'),
|
|
);
|
|
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'), '[workflow\non_complete = broken', 'utf-8');
|
|
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
|
|
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
|
|
assert(
|
|
res.stdout.includes('HALT and report to the user: failed to parse') && res.stdout.includes('bmad-quick-dev.user.toml'),
|
|
`stdout missing the failed-to-parse HALT directive naming the override file.\nstdout: ${res.stdout}`,
|
|
);
|
|
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
|
|
});
|
|
|
|
test('non-table [modules] does not crash the renderer', () => {
|
|
const { dir, skillDst: dst } = makeProject(
|
|
[
|
|
'modules = "oops-not-a-table"',
|
|
'',
|
|
'[core]',
|
|
'communication_language = "French"',
|
|
'document_output_language = "Klingon"',
|
|
'planning_artifacts = "{project-root}/plan"',
|
|
'implementation_artifacts = "{project-root}/impl"',
|
|
].join('\n'),
|
|
);
|
|
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
|
|
assert(res.status === 0, `expected exit 0, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
|
|
assert(!res.stderr.includes('Traceback'), `renderer crashed on non-table modules:\n${res.stderr}`);
|
|
assert(
|
|
fs.existsSync(path.join(dir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md')),
|
|
'workflow.md not rendered when [modules] was a non-table scalar',
|
|
);
|
|
});
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
for (const dir of extraTmpDirs) {
|
|
fs.rmSync(dir, { 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);
|