BMAD-METHOD/test/test-ide-sync.js

124 lines
5.0 KiB
JavaScript

// test-ide-sync — behavioral drift guard for the IDE-distribution path.
//
// The bmad-module skill runs a self-contained esbuild bundle
// (src/core-skills/bmad-module/scripts/lib/vendor/ide-sync.mjs) built FROM the
// real engine (tools/installer/ide/* via core/ide-sync.js). vendor:check already
// byte-verifies the bundle matches its source. This test verifies the two
// delivery vehicles behave IDENTICALLY at runtime:
// 1. `bmad ide-sync` — the engine, run directly from the package
// 2. `vendor/ide-sync.mjs` — the shipped, dependency-free bundle
// Both must produce the same IDE skill trees for the same project, including
// `--prune`. If the engine changes without rebuilding the bundle, the outputs
// diverge and this fails (complementing the byte-level vendor:check).
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const repoRoot = path.resolve(__dirname, '..');
const CLI = path.join(repoRoot, 'tools', 'installer', 'bmad-cli.js');
const BUNDLE = path.join(repoRoot, 'src', 'core-skills', 'bmad-module', 'scripts', 'lib', 'vendor', 'ide-sync.mjs');
let passed = 0;
let failed = 0;
function check(label, fn) {
try {
fn();
passed++;
process.stdout.write(`${label}\n`);
} catch (error) {
failed++;
process.stdout.write(`${label}\n ${error.message}\n`);
}
}
// Build a fresh project with two skills recorded for two IDEs.
function makeProject(skillIds) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-ide-sync-'));
fs.mkdirSync(path.join(dir, '_bmad', '_config'), { recursive: true });
fs.writeFileSync(
path.join(dir, '_bmad', '_config', 'manifest.yaml'),
'installation:\n version: "0.0.0"\nmodules:\n - name: demo\n source: community\nides:\n - claude-code\n - cursor\n',
);
let csv = 'canonicalId,name,description,module,path\n';
for (const id of skillIds) {
const sd = path.join(dir, '_bmad', 'demo', 'skills', id);
fs.mkdirSync(sd, { recursive: true });
fs.writeFileSync(path.join(sd, 'SKILL.md'), `---\nname: ${id}\ndescription: ${id} demo\n---\nbody ${id}\n`);
csv += `"${id}","${id}","${id} demo","demo","_bmad/demo/skills/${id}/SKILL.md"\n`;
}
fs.writeFileSync(path.join(dir, '_bmad', '_config', 'skill-manifest.csv'), csv);
return dir;
}
// Snapshot the IDE skill trees (relative path -> file contents) for comparison.
function snapshotIdeDirs(projectDir) {
const snap = {};
for (const rel of ['.claude/skills', '.agents/skills']) {
const base = path.join(projectDir, rel);
if (!fs.existsSync(base)) continue;
const walk = (d) => {
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const full = path.join(d, entry.name);
if (entry.isDirectory()) walk(full);
else snap[path.relative(projectDir, full)] = fs.readFileSync(full, 'utf8');
}
};
walk(base);
}
return snap;
}
function runEngine(projectDir, prune) {
const args = [CLI, 'ide-sync', '-d', projectDir];
if (prune) args.push('--prune', prune);
const r = spawnSync(process.execPath, args, { encoding: 'utf8' });
assert.strictEqual(r.status, 0, `engine ide-sync exited ${r.status}: ${r.stderr}`);
}
function runBundle(projectDir, prune) {
const args = [BUNDLE, '-d', projectDir];
if (prune) args.push('--prune', prune);
const r = spawnSync(process.execPath, args, { encoding: 'utf8' });
assert.strictEqual(r.status, 0, `bundle ide-sync exited ${r.status}: ${r.stderr}`);
}
process.stdout.write('IDE-sync engine/bundle parity\n');
check('bundle exists (run `npm run vendor:build` if missing)', () => {
assert.ok(fs.existsSync(BUNDLE), `missing ${BUNDLE}`);
});
const cleanup = [];
try {
// Distribute: engine vs bundle must yield identical IDE trees.
check('distribute: engine == bundle', () => {
const a = makeProject(['sk-a', 'sk-b']);
const b = makeProject(['sk-a', 'sk-b']);
cleanup.push(a, b);
runEngine(a);
runBundle(b);
assert.deepStrictEqual(snapshotIdeDirs(a), snapshotIdeDirs(b));
assert.ok(fs.existsSync(path.join(a, '.claude', 'skills', 'sk-a', 'SKILL.md')), 'engine did not distribute');
});
// Prune one skill (the remove path): engine vs bundle must agree.
check('prune: engine == bundle and removes pruned skill', () => {
const a = makeProject(['sk-a']); // sk-b dropped from manifest
const b = makeProject(['sk-a']);
cleanup.push(a, b);
runEngine(a, 'sk-b');
runBundle(b, 'sk-b');
assert.deepStrictEqual(snapshotIdeDirs(a), snapshotIdeDirs(b));
assert.ok(!fs.existsSync(path.join(a, '.claude', 'skills', 'sk-b')), 'pruned skill should be gone');
assert.ok(fs.existsSync(path.join(a, '.claude', 'skills', 'sk-a')), 'kept skill should remain');
});
} finally {
for (const d of cleanup) fs.rmSync(d, { recursive: true, force: true });
}
process.stdout.write(`\n ${passed} pass · ${failed} fail\n`);
process.exit(failed > 0 ? 1 : 0);