BMAD-METHOD/test/test-scope-cli.js

1476 lines
49 KiB
JavaScript

/**
* Scope CLI Test Suite
*
* Comprehensive tests for the scope CLI command including:
* - All subcommands (init, create, list, info, set, unset, remove, archive, activate, sync-up, sync-down)
* - Help system (main help and subcommand-specific help)
* - Error handling and edge cases
* - Integration with ScopeManager, ScopeSync, and other components
*
* Usage: node test/test-scope-cli.js
* Exit codes: 0 = all tests pass, 1 = test failures
*/
const fs = require('fs-extra');
const path = require('node:path');
const os = require('node:os');
const { execSync, spawnSync } = require('node:child_process');
// ANSI color codes
const colors = {
reset: '\u001B[0m',
green: '\u001B[32m',
red: '\u001B[31m',
yellow: '\u001B[33m',
blue: '\u001B[34m',
cyan: '\u001B[36m',
dim: '\u001B[2m',
bold: '\u001B[1m',
};
// Test utilities
let testCount = 0;
let passCount = 0;
let failCount = 0;
let skipCount = 0;
const failures = [];
function test(name, fn) {
testCount++;
try {
fn();
passCount++;
console.log(` ${colors.green}${colors.reset} ${name}`);
} catch (error) {
failCount++;
console.log(` ${colors.red}${colors.reset} ${name}`);
console.log(` ${colors.red}${error.message}${colors.reset}`);
failures.push({ name, error: error.message });
}
}
async function testAsync(name, fn) {
testCount++;
try {
await fn();
passCount++;
console.log(` ${colors.green}${colors.reset} ${name}`);
} catch (error) {
failCount++;
console.log(` ${colors.red}${colors.reset} ${name}`);
console.log(` ${colors.red}${error.message}${colors.reset}`);
failures.push({ name, error: error.message });
}
}
function skip(name, reason = '') {
skipCount++;
console.log(` ${colors.yellow}${colors.reset} ${name} ${colors.dim}(skipped${reason ? ': ' + reason : ''})${colors.reset}`);
}
function assertEqual(actual, expected, message = '') {
if (actual !== expected) {
throw new Error(`${message}\n Expected: ${JSON.stringify(expected)}\n Actual: ${JSON.stringify(actual)}`);
}
}
function assertTrue(value, message = 'Expected true') {
if (!value) {
throw new Error(message);
}
}
function assertFalse(value, message = 'Expected false') {
if (value) {
throw new Error(message);
}
}
function assertContains(str, substring, message = '') {
if (!str.includes(substring)) {
throw new Error(`${message}\n Expected to contain: "${substring}"\n Actual: "${str.slice(0, 200)}..."`);
}
}
function assertNotContains(str, substring, message = '') {
if (str.includes(substring)) {
throw new Error(`${message}\n Expected NOT to contain: "${substring}"`);
}
}
function assertExists(filePath, message = '') {
if (!fs.existsSync(filePath)) {
throw new Error(`${message || 'File does not exist'}: ${filePath}`);
}
}
function assertNotExists(filePath, message = '') {
if (fs.existsSync(filePath)) {
throw new Error(`${message || 'File should not exist'}: ${filePath}`);
}
}
// Create temporary test directory with BMAD structure
function createTestProject() {
const tmpDir = path.join(os.tmpdir(), `bmad-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tmpDir, { recursive: true });
// Create minimal BMAD structure
fs.mkdirSync(path.join(tmpDir, '_bmad', '_config'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '_bmad-output'), { recursive: true });
return tmpDir;
}
function cleanupTestProject(tmpDir) {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
// Get path to CLI
const CLI_PATH = path.join(__dirname, '..', 'tools', 'cli', 'bmad-cli.js');
// Execute CLI command and capture output (string-based, for simple cases)
function runCli(args, cwd, options = {}) {
const cmd = `node "${CLI_PATH}" ${args}`;
try {
const output = execSync(cmd, {
cwd,
encoding: 'utf8',
timeout: options.timeout || 30_000,
env: { ...process.env, ...options.env, FORCE_COLOR: '0' },
});
return { success: true, output, exitCode: 0 };
} catch (error) {
return {
success: false,
output: error.stdout || '',
stderr: error.stderr || '',
exitCode: error.status || 1,
error: error.message,
};
}
}
/**
* Execute CLI command using spawnSync with an array of arguments.
* This properly preserves argument boundaries, essential for arguments with spaces.
*
* @param {string[]} args - Array of arguments (NOT a joined string)
* @param {string} cwd - Working directory
* @param {Object} options - Additional options
* @returns {Object} Result with success, output, stderr, exitCode
*/
function runCliArray(args, cwd, options = {}) {
const result = spawnSync('node', [CLI_PATH, ...args], {
cwd,
encoding: 'utf8',
timeout: options.timeout || 30_000,
env: { ...process.env, ...options.env, FORCE_COLOR: '0' },
});
return {
success: result.status === 0,
output: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.status || 0,
error: result.error ? result.error.message : null,
};
}
// ============================================================================
// Help System Tests
// ============================================================================
function testHelpSystem() {
console.log(`\n${colors.blue}${colors.bold}Help System Tests${colors.reset}`);
const tmpDir = createTestProject();
try {
// Main help
test('scope help shows overview', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'BMAD Scope Management');
assertContains(result.output, 'OVERVIEW');
assertContains(result.output, 'COMMANDS');
});
test('scope help shows all commands', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'init');
assertContains(result.output, 'list');
assertContains(result.output, 'create');
assertContains(result.output, 'info');
assertContains(result.output, 'set');
assertContains(result.output, 'unset');
assertContains(result.output, 'remove');
assertContains(result.output, 'archive');
assertContains(result.output, 'activate');
assertContains(result.output, 'sync-up');
assertContains(result.output, 'sync-down');
});
test('scope help shows options', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'OPTIONS');
assertContains(result.output, '--name');
assertContains(result.output, '--description');
assertContains(result.output, '--force');
assertContains(result.output, '--dry-run');
assertContains(result.output, '--resolution');
});
test('scope help shows quick start', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'QUICK START');
assertContains(result.output, 'scope init');
assertContains(result.output, 'scope create');
assertContains(result.output, 'scope set');
});
test('scope help shows directory structure', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'DIRECTORY STRUCTURE');
assertContains(result.output, '_bmad-output');
assertContains(result.output, '_shared');
assertContains(result.output, 'scopes.yaml');
});
test('scope help shows access model', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'ACCESS MODEL');
assertContains(result.output, 'read-any');
assertContains(result.output, 'write-own');
});
test('scope help shows troubleshooting', () => {
const result = runCli('scope help', tmpDir);
assertContains(result.output, 'TROUBLESHOOTING');
});
// Subcommand-specific help
test('scope help init shows detailed help', () => {
const result = runCli('scope help init', tmpDir);
assertContains(result.output, 'bmad scope init');
assertContains(result.output, 'DESCRIPTION');
assertContains(result.output, 'USAGE');
assertContains(result.output, 'WHAT IT CREATES');
});
test('scope help create shows detailed help', () => {
const result = runCli('scope help create', tmpDir);
assertContains(result.output, 'bmad scope create');
assertContains(result.output, 'ARGUMENTS');
assertContains(result.output, 'OPTIONS');
assertContains(result.output, '--name');
assertContains(result.output, '--deps');
assertContains(result.output, 'SCOPE ID RULES');
});
test('scope help list shows detailed help', () => {
const result = runCli('scope help list', tmpDir);
assertContains(result.output, 'bmad scope list');
assertContains(result.output, '--status');
assertContains(result.output, 'OUTPUT COLUMNS');
});
test('scope help info shows detailed help', () => {
const result = runCli('scope help info', tmpDir);
assertContains(result.output, 'bmad scope info');
assertContains(result.output, 'DISPLAYED INFORMATION');
});
test('scope help set shows detailed help', () => {
const result = runCli('scope help set', tmpDir);
assertContains(result.output, 'bmad scope set');
assertContains(result.output, '.bmad-scope');
assertContains(result.output, 'BMAD_SCOPE');
assertContains(result.output, 'FILE FORMAT');
});
test('scope help unset shows detailed help', () => {
const result = runCli('scope help unset', tmpDir);
assertContains(result.output, 'bmad scope unset');
assertContains(result.output, 'Clear');
});
test('scope help remove shows detailed help', () => {
const result = runCli('scope help remove', tmpDir);
assertContains(result.output, 'bmad scope remove');
assertContains(result.output, '--force');
assertContains(result.output, '--no-backup');
assertContains(result.output, 'BACKUP LOCATION');
});
test('scope help archive shows detailed help', () => {
const result = runCli('scope help archive', tmpDir);
assertContains(result.output, 'bmad scope archive');
assertContains(result.output, 'BEHAVIOR');
});
test('scope help activate shows detailed help', () => {
const result = runCli('scope help activate', tmpDir);
assertContains(result.output, 'bmad scope activate');
assertContains(result.output, 'Reactivate');
});
test('scope help sync-up shows detailed help', () => {
const result = runCli('scope help sync-up', tmpDir);
assertContains(result.output, 'bmad scope sync-up');
assertContains(result.output, 'WHAT GETS PROMOTED');
assertContains(result.output, '--dry-run');
assertContains(result.output, '--resolution');
});
test('scope help sync-down shows detailed help', () => {
const result = runCli('scope help sync-down', tmpDir);
assertContains(result.output, 'bmad scope sync-down');
assertContains(result.output, '--dry-run');
assertContains(result.output, 'keep-local');
assertContains(result.output, 'keep-shared');
});
// Alias help
test('scope help ls shows list help', () => {
const result = runCli('scope help ls', tmpDir);
assertContains(result.output, 'bmad scope list');
});
test('scope help use shows set help', () => {
const result = runCli('scope help use', tmpDir);
assertContains(result.output, 'bmad scope set');
});
test('scope help clear shows unset help', () => {
const result = runCli('scope help clear', tmpDir);
assertContains(result.output, 'bmad scope unset');
});
test('scope help rm shows remove help', () => {
const result = runCli('scope help rm', tmpDir);
assertContains(result.output, 'bmad scope remove');
});
test('scope help syncup shows sync-up help', () => {
const result = runCli('scope help syncup', tmpDir);
assertContains(result.output, 'bmad scope sync-up');
});
// Unknown command help
test('scope help unknown-cmd shows error', () => {
const result = runCli('scope help foobar', tmpDir);
assertContains(result.output, 'Unknown command');
assertContains(result.output, 'foobar');
});
// No args shows help
test('scope with no args shows help', () => {
const result = runCli('scope', tmpDir);
assertContains(result.output, 'BMAD Scope Management');
});
} finally {
cleanupTestProject(tmpDir);
}
}
// ============================================================================
// Init Command Tests
// ============================================================================
function testInitCommand() {
console.log(`\n${colors.blue}${colors.bold}Init Command Tests${colors.reset}`);
test('scope init creates configuration', () => {
const tmpDir = createTestProject();
try {
const result = runCli('scope init', tmpDir);
assertTrue(result.success, `Init should succeed: ${result.stderr || result.error}`);
assertContains(result.output, 'initialized successfully');
// Check files created
assertExists(path.join(tmpDir, '_bmad', '_config', 'scopes.yaml'));
assertExists(path.join(tmpDir, '_bmad-output', '_shared'));
assertExists(path.join(tmpDir, '_bmad', '_events'));
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope init is idempotent', () => {
const tmpDir = createTestProject();
try {
// Run init twice
runCli('scope init', tmpDir);
const result = runCli('scope init', tmpDir);
assertTrue(result.success, 'Second init should succeed');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Create Command Tests
// ============================================================================
function testCreateCommand() {
console.log(`\n${colors.blue}${colors.bold}Create Command Tests${colors.reset}`);
test('scope create with all options', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create auth --name "Authentication" --description "User auth"', tmpDir);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
assertContains(result.output, "Scope 'auth' created successfully");
// Check directories created
assertExists(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts'));
assertExists(path.join(tmpDir, '_bmad-output', 'auth', 'implementation-artifacts'));
assertExists(path.join(tmpDir, '_bmad-output', 'auth', 'tests'));
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with dependencies', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create users --name "Users" --description ""', tmpDir);
const result = runCli('scope create auth --name "Auth" --description "" --deps users', tmpDir);
assertTrue(result.success, 'Create with deps should succeed');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with --context flag', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create auth --name "Auth" --description "" --context', tmpDir);
assertTrue(result.success, 'Create with context should succeed');
// Note: project-context.md creation depends on ScopeInitializer implementation
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create auto-initializes if needed', () => {
const tmpDir = createTestProject();
try {
// Don't run init, but create should auto-init
const result = runCli('scope create auth --name "Auth" --description ""', tmpDir);
assertTrue(result.success, 'Create should auto-init');
assertExists(path.join(tmpDir, '_bmad', '_config', 'scopes.yaml'));
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create rejects invalid ID (uppercase)', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create Auth --name "Auth"', tmpDir);
assertFalse(result.success, 'Should reject uppercase');
assertContains(result.output + result.stderr, 'Error');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create rejects invalid ID (underscore)', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create user_auth --name "Auth" --description ""', tmpDir);
assertFalse(result.success, 'Should reject underscore');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create rejects reserved name _shared', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create _shared --name "Shared" --description ""', tmpDir);
assertFalse(result.success, 'Should reject _shared');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope new is alias for create', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope new auth --name "Auth"', tmpDir);
assertTrue(result.success, 'new alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// List Command Tests
// ============================================================================
function testListCommand() {
console.log(`\n${colors.blue}${colors.bold}List Command Tests${colors.reset}`);
test('scope list shows no scopes initially', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope list', tmpDir);
assertContains(result.output, 'No scopes found');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope list shows created scopes', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Authentication" --description ""', tmpDir);
runCli('scope create payments --name "Payments" --description ""', tmpDir);
const result = runCli('scope list', tmpDir);
assertContains(result.output, 'auth');
assertContains(result.output, 'payments');
assertContains(result.output, 'Authentication');
assertContains(result.output, 'Payments');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope list --status active filters', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
runCli('scope create old --name "Old" --description ""', tmpDir);
runCli('scope archive old', tmpDir);
const result = runCli('scope list --status active', tmpDir);
assertContains(result.output, 'auth');
assertNotContains(result.output, 'old');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope list --status archived filters', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
runCli('scope create old --name "Old" --description ""', tmpDir);
runCli('scope archive old', tmpDir);
const result = runCli('scope list --status archived', tmpDir);
assertContains(result.output, 'old');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope ls is alias for list', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope ls', tmpDir);
assertTrue(result.success, 'ls alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope list without init shows helpful message', () => {
const tmpDir = createTestProject();
try {
// Remove the _config directory to simulate uninitialized
fs.rmSync(path.join(tmpDir, '_bmad', '_config'), { recursive: true, force: true });
const result = runCli('scope list', tmpDir);
assertContains(result.output, 'not initialized');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Info Command Tests
// ============================================================================
function testInfoCommand() {
console.log(`\n${colors.blue}${colors.bold}Info Command Tests${colors.reset}`);
test('scope info shows scope details', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Authentication" --description "User auth system"', tmpDir);
const result = runCli('scope info auth', tmpDir);
assertTrue(result.success, 'Info should succeed');
assertContains(result.output, 'auth');
assertContains(result.output, 'Authentication');
assertContains(result.output, 'active');
assertContains(result.output, 'planning-artifacts');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope info shows dependencies', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create users --name "Users" --description ""', tmpDir);
runCli('scope create auth --name "Auth" --description "" --deps users', tmpDir);
const result = runCli('scope info auth', tmpDir);
assertContains(result.output, 'Dependencies');
assertContains(result.output, 'users');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope info on non-existent scope fails', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope info nonexistent', tmpDir);
assertFalse(result.success, 'Should fail for non-existent scope');
assertContains(result.output + result.stderr, 'not found');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope info requires ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope info', tmpDir);
assertFalse(result.success, 'Should require ID');
assertContains(result.output + result.stderr, 'required');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope show is alias for info', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope show auth', tmpDir);
assertTrue(result.success, 'show alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope <id> shorthand shows info', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope auth', tmpDir);
assertTrue(result.success, 'shorthand should work');
assertContains(result.output, 'auth');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Set/Unset Command Tests
// ============================================================================
function testSetUnsetCommands() {
console.log(`\n${colors.blue}${colors.bold}Set/Unset Command Tests${colors.reset}`);
test('scope set creates .bmad-scope file', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope set auth', tmpDir);
assertTrue(result.success, `Set should succeed: ${result.stderr || result.error}`);
assertContains(result.output, "Active scope set to 'auth'");
// Check file created
const scopeFile = path.join(tmpDir, '.bmad-scope');
assertExists(scopeFile);
const content = fs.readFileSync(scopeFile, 'utf8');
assertContains(content, 'active_scope: auth');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope set validates scope exists', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope set nonexistent', tmpDir);
assertFalse(result.success, 'Should fail for non-existent scope');
assertContains(result.output + result.stderr, 'not found');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope set warns for archived scope', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create old --name "Old" --description ""', tmpDir);
runCli('scope archive old', tmpDir);
// This will prompt for confirmation - we can't easily test interactive mode
// Just verify it doesn't crash with the scope being archived
const result = runCli('scope info old', tmpDir);
assertContains(result.output, 'archived');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope unset removes .bmad-scope file', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
runCli('scope set auth', tmpDir);
const scopeFile = path.join(tmpDir, '.bmad-scope');
assertExists(scopeFile);
const result = runCli('scope unset', tmpDir);
assertTrue(result.success, 'Unset should succeed');
assertContains(result.output, 'Active scope cleared');
assertNotExists(scopeFile);
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope unset when no scope is set', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope unset', tmpDir);
assertTrue(result.success, 'Unset should succeed even if no scope');
assertContains(result.output, 'No active scope');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope use is alias for set', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope use auth', tmpDir);
assertTrue(result.success, 'use alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope clear is alias for unset', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
runCli('scope set auth', tmpDir);
const result = runCli('scope clear', tmpDir);
assertTrue(result.success, 'clear alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Archive/Activate Command Tests
// ============================================================================
function testArchiveActivateCommands() {
console.log(`\n${colors.blue}${colors.bold}Archive/Activate Command Tests${colors.reset}`);
test('scope archive changes status', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope archive auth', tmpDir);
assertTrue(result.success, 'Archive should succeed');
assertContains(result.output, 'archived');
// Verify status changed
const infoResult = runCli('scope info auth', tmpDir);
assertContains(infoResult.output, 'archived');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope archive requires ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope archive', tmpDir);
assertFalse(result.success, 'Should require ID');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope activate reactivates archived scope', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
runCli('scope archive auth', tmpDir);
const result = runCli('scope activate auth', tmpDir);
assertTrue(result.success, 'Activate should succeed');
assertContains(result.output, 'activated');
// Verify status changed back
const infoResult = runCli('scope info auth', tmpDir);
assertContains(infoResult.output, 'active');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope activate requires ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope activate', tmpDir);
assertFalse(result.success, 'Should require ID');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Remove Command Tests
// ============================================================================
function testRemoveCommand() {
console.log(`\n${colors.blue}${colors.bold}Remove Command Tests${colors.reset}`);
test('scope remove --force removes scope', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope remove auth --force', tmpDir);
assertTrue(result.success, 'Remove should succeed');
assertContains(result.output, 'removed successfully');
// Verify scope is gone
const listResult = runCli('scope list', tmpDir);
assertNotContains(listResult.output, 'auth');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope remove creates backup by default', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope remove auth --force', tmpDir);
assertContains(result.output, 'backup');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope remove --force --no-backup skips backup', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope remove auth --force --no-backup', tmpDir);
assertTrue(result.success, 'Remove should succeed');
assertNotContains(result.output, 'backup was created');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope remove requires ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope remove --force', tmpDir);
assertFalse(result.success, 'Should require ID');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope remove on non-existent scope fails', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope remove nonexistent --force', tmpDir);
assertFalse(result.success, 'Should fail');
assertContains(result.output + result.stderr, 'not found');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope rm is alias for remove', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope rm auth --force', tmpDir);
assertTrue(result.success, 'rm alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope delete is alias for remove', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope delete auth --force', tmpDir);
assertTrue(result.success, 'delete alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Sync Command Tests
// ============================================================================
function testSyncCommands() {
console.log(`\n${colors.blue}${colors.bold}Sync Command Tests${colors.reset}`);
test('scope sync-up requires scope ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope sync-up', tmpDir);
assertFalse(result.success, 'Should require ID');
assertContains(result.output + result.stderr, 'required');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-up validates scope exists', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope sync-up nonexistent', tmpDir);
assertFalse(result.success, 'Should fail for non-existent scope');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-up --dry-run shows analysis', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope sync-up auth --dry-run', tmpDir);
assertTrue(result.success, 'Dry run should succeed');
assertContains(result.output, 'Dry Run');
assertContains(result.output, 'patterns');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-up runs without errors', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope sync-up auth', tmpDir);
assertTrue(result.success, `Sync-up should succeed: ${result.stderr || result.error}`);
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-down requires scope ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope sync-down', tmpDir);
assertFalse(result.success, 'Should require ID');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-down validates scope exists', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope sync-down nonexistent', tmpDir);
assertFalse(result.success, 'Should fail for non-existent scope');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-down --dry-run shows analysis', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope sync-down auth --dry-run', tmpDir);
assertTrue(result.success, 'Dry run should succeed');
assertContains(result.output, 'Dry Run');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope sync-down runs without errors', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope sync-down auth', tmpDir);
assertTrue(result.success, `Sync-down should succeed: ${result.stderr || result.error}`);
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope syncup is alias for sync-up', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope syncup auth --dry-run', tmpDir);
assertTrue(result.success, 'syncup alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope syncdown is alias for sync-down', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
const result = runCli('scope syncdown auth --dry-run', tmpDir);
assertTrue(result.success, 'syncdown alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Edge Cases and Error Handling Tests
// ============================================================================
function testEdgeCases() {
console.log(`\n${colors.blue}${colors.bold}Edge Cases and Error Handling Tests${colors.reset}`);
test('handles special characters in scope name', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create auth --name "Auth & Users (v2)" --description ""', tmpDir);
assertTrue(result.success, 'Should handle special chars in name');
} finally {
cleanupTestProject(tmpDir);
}
});
test('handles empty description', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const result = runCli('scope create auth --name "Auth" --description "" --description ""', tmpDir);
assertTrue(result.success, 'Should handle empty description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('handles multiple dependencies', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create users --name "Users" --description ""', tmpDir);
runCli('scope create notifications --name "Notifications" --description ""', tmpDir);
runCli('scope create logging --name "Logging" --description ""', tmpDir);
const result = runCli('scope create auth --name "Auth" --description "" --deps users,notifications,logging', tmpDir);
assertTrue(result.success, 'Should handle multiple deps');
} finally {
cleanupTestProject(tmpDir);
}
});
test('handles long scope ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const longId = 'a'.repeat(50);
const result = runCli(`scope create ${longId} --name "Long ID"`, tmpDir);
assertTrue(result.success, 'Should handle long ID');
} finally {
cleanupTestProject(tmpDir);
}
});
test('rejects too long scope ID', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
const tooLongId = 'a'.repeat(51);
const result = runCli(`scope create ${tooLongId} --name "Too Long"`, tmpDir);
assertFalse(result.success, 'Should reject too long ID');
} finally {
cleanupTestProject(tmpDir);
}
});
test('DEBUG env var enables verbose output', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
runCli('scope create auth --name "Auth" --description ""', tmpDir);
// Trigger an error with DEBUG enabled
const result = runCli('scope info nonexistent', tmpDir, { env: { DEBUG: 'true' } });
// Just verify it doesn't crash with DEBUG enabled
assertFalse(result.success);
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Integration Tests
// ============================================================================
function testIntegration() {
console.log(`\n${colors.blue}${colors.bold}Integration Tests${colors.reset}`);
test('full workflow: init -> create -> set -> list -> archive -> activate -> remove', () => {
const tmpDir = createTestProject();
try {
// Init
let result = runCli('scope init', tmpDir);
assertTrue(result.success, 'Init failed');
// Create scopes
result = runCli('scope create auth --name "Authentication" --description ""', tmpDir);
assertTrue(result.success, 'Create auth failed');
result = runCli('scope create payments --name "Payments" --description "" --deps auth', tmpDir);
assertTrue(result.success, 'Create payments failed');
// Set active scope
result = runCli('scope set auth', tmpDir);
assertTrue(result.success, 'Set failed');
// List scopes
result = runCli('scope list', tmpDir);
assertTrue(result.success, 'List failed');
assertContains(result.output, 'auth');
assertContains(result.output, 'payments');
// Archive
result = runCli('scope archive auth', tmpDir);
assertTrue(result.success, 'Archive failed');
// Activate
result = runCli('scope activate auth', tmpDir);
assertTrue(result.success, 'Activate failed');
// Unset
result = runCli('scope unset', tmpDir);
assertTrue(result.success, 'Unset failed');
// Remove
result = runCli('scope remove payments --force', tmpDir);
assertTrue(result.success, 'Remove payments failed');
result = runCli('scope remove auth --force', tmpDir);
assertTrue(result.success, 'Remove auth failed');
// Verify all gone
result = runCli('scope list', tmpDir);
assertContains(result.output, 'No scopes found');
} finally {
cleanupTestProject(tmpDir);
}
});
test('parallel scopes simulation', () => {
const tmpDir = createTestProject();
try {
runCli('scope init', tmpDir);
// Create multiple scopes (simulating parallel development)
runCli('scope create frontend --name "Frontend" --description ""', tmpDir);
runCli('scope create backend --name "Backend" --description ""', tmpDir);
runCli('scope create mobile --name "Mobile" --description "" --deps backend', tmpDir);
// Verify all exist
const result = runCli('scope list', tmpDir);
assertContains(result.output, 'frontend');
assertContains(result.output, 'backend');
assertContains(result.output, 'mobile');
// Check dependencies
const infoResult = runCli('scope info mobile', tmpDir);
assertContains(infoResult.output, 'backend');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Argument Handling Tests (using runCliArray for proper boundary preservation)
// ============================================================================
function testArgumentHandling() {
console.log(`\n${colors.blue}${colors.bold}Argument Handling Tests${colors.reset}`);
test('scope create with multi-word description (array args)', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'Auth Service', '--description', 'Handles user authentication and sessions'],
tmpDir,
);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Auth Service');
assertContains(infoResult.output, 'Handles user authentication and sessions');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with 9-word description (regression test)', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// This exact case caused "too many arguments" error before the fix
const result = runCliArray(
['scope', 'create', 'auto-queue', '--name', 'AutoQueue', '--description', 'PRD Auto queue for not inbound yet products'],
tmpDir,
);
assertTrue(result.success, `Should not fail with "too many arguments": ${result.stderr}`);
assertNotContains(result.stderr || '', 'too many arguments');
const infoResult = runCliArray(['scope', 'info', 'auto-queue'], tmpDir);
assertContains(infoResult.output, 'PRD Auto queue for not inbound yet products');
} finally {
cleanupTestProject(tmpDir);
}
});
test('all subcommands work with array args', () => {
const tmpDir = createTestProject();
try {
// init
let result = runCliArray(['scope', 'init'], tmpDir);
assertTrue(result.success, 'init should work');
// create
result = runCliArray(['scope', 'create', 'test', '--name', 'Test Scope', '--description', 'A test scope'], tmpDir);
assertTrue(result.success, 'create should work');
// list
result = runCliArray(['scope', 'list'], tmpDir);
assertTrue(result.success, 'list should work');
assertContains(result.output, 'test');
// info
result = runCliArray(['scope', 'info', 'test'], tmpDir);
assertTrue(result.success, 'info should work');
// set
result = runCliArray(['scope', 'set', 'test'], tmpDir);
assertTrue(result.success, 'set should work');
// archive
result = runCliArray(['scope', 'archive', 'test'], tmpDir);
assertTrue(result.success, 'archive should work');
// activate
result = runCliArray(['scope', 'activate', 'test'], tmpDir);
assertTrue(result.success, 'activate should work');
// sync-up
result = runCliArray(['scope', 'sync-up', 'test', '--dry-run'], tmpDir);
assertTrue(result.success, 'sync-up should work');
// sync-down
result = runCliArray(['scope', 'sync-down', 'test', '--dry-run'], tmpDir);
assertTrue(result.success, 'sync-down should work');
// unset
result = runCliArray(['scope', 'unset'], tmpDir);
assertTrue(result.success, 'unset should work');
// remove
result = runCliArray(['scope', 'remove', 'test', '--force'], tmpDir);
assertTrue(result.success, 'remove should work');
// help
result = runCliArray(['scope', 'help'], tmpDir);
assertTrue(result.success, 'help should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('subcommand aliases work with array args', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// new (alias for create) - include --description to avoid interactive prompt
let result = runCliArray(['scope', 'new', 'test', '--name', 'Test', '--description', ''], tmpDir);
assertTrue(result.success, 'new alias should work');
// ls (alias for list)
result = runCliArray(['scope', 'ls'], tmpDir);
assertTrue(result.success, 'ls alias should work');
// show (alias for info)
result = runCliArray(['scope', 'show', 'test'], tmpDir);
assertTrue(result.success, 'show alias should work');
// use (alias for set)
result = runCliArray(['scope', 'use', 'test'], tmpDir);
assertTrue(result.success, 'use alias should work');
// clear (alias for unset)
result = runCliArray(['scope', 'clear'], tmpDir);
assertTrue(result.success, 'clear alias should work');
// syncup (alias for sync-up)
result = runCliArray(['scope', 'syncup', 'test', '--dry-run'], tmpDir);
assertTrue(result.success, 'syncup alias should work');
// syncdown (alias for sync-down)
result = runCliArray(['scope', 'syncdown', 'test', '--dry-run'], tmpDir);
assertTrue(result.success, 'syncdown alias should work');
// rm (alias for remove)
result = runCliArray(['scope', 'rm', 'test', '--force'], tmpDir);
assertTrue(result.success, 'rm alias should work');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Main Test Runner
// ============================================================================
function main() {
console.log(`\n${colors.bold}BMAD Scope CLI Test Suite${colors.reset}`);
console.log(colors.dim + '═'.repeat(70) + colors.reset);
const startTime = Date.now();
// Run all test suites
testHelpSystem();
testInitCommand();
testCreateCommand();
testListCommand();
testInfoCommand();
testSetUnsetCommands();
testArchiveActivateCommands();
testRemoveCommand();
testSyncCommands();
testEdgeCases();
testIntegration();
testArgumentHandling();
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
// Summary
console.log(`\n${colors.dim}${'─'.repeat(70)}${colors.reset}`);
console.log(`\n${colors.bold}Test Results${colors.reset}`);
console.log(` Total: ${testCount}`);
console.log(` ${colors.green}Passed: ${passCount}${colors.reset}`);
if (failCount > 0) {
console.log(` ${colors.red}Failed: ${failCount}${colors.reset}`);
}
if (skipCount > 0) {
console.log(` ${colors.yellow}Skipped: ${skipCount}${colors.reset}`);
}
console.log(` Time: ${duration}s`);
if (failures.length > 0) {
console.log(`\n${colors.red}${colors.bold}Failures:${colors.reset}`);
for (const { name, error } of failures) {
console.log(`\n ${colors.red}${colors.reset} ${name}`);
console.log(` ${colors.dim}${error}${colors.reset}`);
}
process.exit(1);
}
console.log(`\n${colors.green}${colors.bold}All tests passed!${colors.reset}\n`);
process.exit(0);
}
main();