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

687 lines
24 KiB
JavaScript

/**
* CLI Argument Handling Test Suite
*
* Tests for proper handling of CLI arguments, especially:
* - Arguments containing spaces
* - Arguments with special characters
* - The npx wrapper's argument preservation
* - Various quoting scenarios
*
* This test suite was created to prevent regression of the bug where
* the npx wrapper used args.join(' ') which broke arguments containing spaces.
*
* Usage: node test/test-cli-arguments.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 { 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, 500)}..."`);
}
}
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}`);
}
}
// Create temporary test directory with BMAD structure
function createTestProject() {
const tmpDir = path.join(os.tmpdir(), `bmad-cli-args-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
}
}
// Paths to CLI entry points
const CLI_PATH = path.join(__dirname, '..', 'tools', 'cli', 'bmad-cli.js');
const NPX_WRAPPER_PATH = path.join(__dirname, '..', 'tools', 'bmad-npx-wrapper.js');
/**
* Execute CLI command using spawnSync with an array of arguments.
* This properly preserves argument boundaries, just like the shell does.
*
* @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,
};
}
/**
* Execute CLI command via the npx wrapper using spawnSync.
* This tests the actual npx execution path.
*
* @param {string[]} args - Array of arguments
* @param {string} cwd - Working directory
* @param {Object} options - Additional options
* @returns {Object} Result with success, output, stderr, exitCode
*/
function runNpxWrapper(args, cwd, options = {}) {
const result = spawnSync('node', [NPX_WRAPPER_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,
};
}
// ============================================================================
// Arguments with Spaces Tests
// ============================================================================
function testArgumentsWithSpaces() {
console.log(`\n${colors.blue}${colors.bold}Arguments with Spaces Tests${colors.reset}`);
test('scope create with description containing spaces (direct CLI)', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'test-scope', '--name', 'Test Scope', '--description', 'This is a description with multiple words'],
tmpDir,
);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
assertContains(result.output, "Scope 'test-scope' created successfully");
// Verify the description was saved correctly
const infoResult = runCliArray(['scope', 'info', 'test-scope'], tmpDir);
assertContains(infoResult.output, 'This is a description with multiple words');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing spaces (via npx wrapper)', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(
['scope', 'create', 'test-scope', '--name', 'Test Scope', '--description', 'This is a description with multiple words'],
tmpDir,
);
assertTrue(result.success, `Create should succeed via wrapper: ${result.stderr || result.error}`);
assertContains(result.output, "Scope 'test-scope' created successfully");
// Verify the description was saved correctly
const infoResult = runNpxWrapper(['scope', 'info', 'test-scope'], tmpDir);
assertContains(infoResult.output, 'This is a description with multiple words');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with long description (many spaces)', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const longDesc = 'PRD Auto queue for not inbound yet products with special handling for edge cases';
const result = runCliArray(['scope', 'create', 'auto-queue', '--name', 'AutoQueue', '--description', longDesc], tmpDir);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
const infoResult = runCliArray(['scope', 'info', 'auto-queue'], tmpDir);
assertContains(infoResult.output, 'PRD Auto queue for not inbound yet products');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with name containing spaces', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'User Authentication Service', '--description', 'Handles user auth'],
tmpDir,
);
assertTrue(result.success, `Create should succeed: ${result.stderr || result.error}`);
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'User Authentication Service');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Special Characters Tests
// ============================================================================
function testSpecialCharacters() {
console.log(`\n${colors.blue}${colors.bold}Special Characters Tests${colors.reset}`);
test('scope create with name containing ampersand', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth & Users', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle ampersand');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Auth & Users');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with name containing parentheses', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth Service (v2)', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle parentheses');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Auth Service (v2)');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing quotes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Handle "special" cases'], tmpDir);
assertTrue(result.success, 'Should handle quotes in description');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Handle "special" cases');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing single quotes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', "Handle user's authentication"], tmpDir);
assertTrue(result.success, 'Should handle single quotes');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, "user's");
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing colons', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Features: login, logout, sessions'],
tmpDir,
);
assertTrue(result.success, 'Should handle colons');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Features: login, logout, sessions');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing hyphens and dashes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(
['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Multi-factor auth - two-step verification'],
tmpDir,
);
assertTrue(result.success, 'Should handle hyphens and dashes');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'Multi-factor auth - two-step verification');
} finally {
cleanupTestProject(tmpDir);
}
});
test('scope create with description containing slashes', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Handles /api/auth/* endpoints'], tmpDir);
assertTrue(result.success, 'Should handle slashes');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, '/api/auth/*');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// NPX Wrapper Specific Tests
// ============================================================================
function testNpxWrapperBehavior() {
console.log(`\n${colors.blue}${colors.bold}NPX Wrapper Behavior Tests${colors.reset}`);
test('npx wrapper preserves argument boundaries', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
// This was the exact failing case: description with multiple words
const result = runNpxWrapper(
['scope', 'create', 'auto-queue', '--name', 'AutoQueue', '--description', 'PRD Auto queue for not inbound yet products'],
tmpDir,
);
assertTrue(result.success, `NPX wrapper should preserve spaces: ${result.stderr || result.output}`);
// Verify full description was saved
const infoResult = runNpxWrapper(['scope', 'info', 'auto-queue'], tmpDir);
assertContains(infoResult.output, 'PRD Auto queue for not inbound yet products');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper handles multiple space-containing arguments', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(
['scope', 'create', 'test-scope', '--name', 'My Test Scope Name', '--description', 'A long description with many words and spaces'],
tmpDir,
);
assertTrue(result.success, 'Should handle multiple space-containing args');
const infoResult = runNpxWrapper(['scope', 'info', 'test-scope'], tmpDir);
assertContains(infoResult.output, 'My Test Scope Name');
assertContains(infoResult.output, 'A long description with many words and spaces');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper handles help commands', () => {
const tmpDir = createTestProject();
try {
const result = runNpxWrapper(['scope', 'help'], tmpDir);
assertTrue(result.success, 'Help should work via wrapper');
assertContains(result.output, 'BMAD Scope Management');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper handles subcommand help', () => {
const tmpDir = createTestProject();
try {
const result = runNpxWrapper(['scope', 'help', 'create'], tmpDir);
assertTrue(result.success, 'Subcommand help should work via wrapper');
assertContains(result.output, 'bmad scope create');
} finally {
cleanupTestProject(tmpDir);
}
});
test('npx wrapper preserves exit codes on failure', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(['scope', 'info', 'nonexistent'], tmpDir);
assertFalse(result.success, 'Should fail for non-existent scope');
assertTrue(result.exitCode !== 0, 'Exit code should be non-zero');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Edge Cases Tests
// ============================================================================
function testEdgeCases() {
console.log(`\n${colors.blue}${colors.bold}Edge Cases Tests${colors.reset}`);
test('empty description argument', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle empty description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('description with only spaces', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', ' '], tmpDir);
assertTrue(result.success, 'Should handle whitespace-only description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('name with leading and trailing spaces', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', ' Spaced Name ', '--description', ''], tmpDir);
assertTrue(result.success, 'Should handle leading/trailing spaces in name');
} finally {
cleanupTestProject(tmpDir);
}
});
test('mixed flags and positional arguments', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// Some CLI parsers are sensitive to flag ordering
const result = runCliArray(['scope', 'create', '--name', 'Auth Service', 'auth', '--description', 'User authentication'], tmpDir);
// Depending on Commander.js behavior, this might fail or succeed
// The important thing is it doesn't crash unexpectedly
// Note: Commander.js is strict about positional arg ordering, so this may fail
} finally {
cleanupTestProject(tmpDir);
}
});
test('very long description', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const longDesc = 'A '.repeat(100) + 'very long description';
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', longDesc], tmpDir);
assertTrue(result.success, 'Should handle very long description');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, 'very long description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('description with newline-like content', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// Note: actual newlines would be handled by the shell, this tests the literal string
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', String.raw`Line1\nLine2`], tmpDir);
assertTrue(result.success, 'Should handle backslash-n in description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('description with unicode characters', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const result = runCliArray(['scope', 'create', 'auth', '--name', 'Auth', '--description', 'Handles authentication 认证 🔐'], tmpDir);
assertTrue(result.success, 'Should handle unicode in description');
const infoResult = runCliArray(['scope', 'info', 'auth'], tmpDir);
assertContains(infoResult.output, '认证');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Argument Count Tests (Regression tests for "too many arguments" error)
// ============================================================================
function testArgumentCounts() {
console.log(`\n${colors.blue}${colors.bold}Argument Count Tests (Regression)${colors.reset}`);
test('9-word description does not cause "too many arguments" error', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
// This was the exact case that failed: 9 words became 9 separate arguments
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');
} finally {
cleanupTestProject(tmpDir);
}
});
test('20-word description works correctly', () => {
const tmpDir = createTestProject();
try {
runCliArray(['scope', 'init'], tmpDir);
const desc =
'This is a very long description with exactly twenty words to test that argument parsing works correctly for descriptions';
const result = runCliArray(['scope', 'create', 'test', '--name', 'Test', '--description', desc], tmpDir);
assertTrue(result.success, 'Should handle 20-word description');
} finally {
cleanupTestProject(tmpDir);
}
});
test('multiple flag values with spaces all preserved', () => {
const tmpDir = createTestProject();
try {
runNpxWrapper(['scope', 'init'], tmpDir);
const result = runNpxWrapper(
['scope', 'create', 'my-scope', '--name', 'My Scope Name Here', '--description', 'This is a description with many spaces'],
tmpDir,
);
assertTrue(result.success, 'All spaced arguments should be preserved');
const infoResult = runNpxWrapper(['scope', 'info', 'my-scope'], tmpDir);
assertContains(infoResult.output, 'My Scope Name Here');
assertContains(infoResult.output, 'This is a description with many spaces');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Install Command Tests (for completeness)
// ============================================================================
function testInstallCommand() {
console.log(`\n${colors.blue}${colors.bold}Install Command Tests${colors.reset}`);
test('install --help works via npx wrapper', () => {
const tmpDir = createTestProject();
try {
const result = runNpxWrapper(['install', '--help'], tmpDir);
assertTrue(result.success || result.output.includes('Install'), 'Install help should work');
} finally {
cleanupTestProject(tmpDir);
}
});
test('install --debug flag works', () => {
const tmpDir = createTestProject();
try {
// Just verify the flag is recognized, don't actually run full install
const result = runNpxWrapper(['install', '--help'], tmpDir);
// If we got here without crashing, the CLI is working
assertTrue(true, 'Install command accepts flags');
} finally {
cleanupTestProject(tmpDir);
}
});
}
// ============================================================================
// Main Test Runner
// ============================================================================
function main() {
console.log(`\n${colors.bold}BMAD CLI Argument Handling Test Suite${colors.reset}`);
console.log(colors.dim + '═'.repeat(70) + colors.reset);
console.log(colors.cyan + 'Testing proper preservation of argument boundaries,' + colors.reset);
console.log(colors.cyan + 'especially for arguments containing spaces.' + colors.reset);
const startTime = Date.now();
// Run all test suites
testArgumentsWithSpaces();
testSpecialCharacters();
testNpxWrapperBehavior();
testEdgeCases();
testArgumentCounts();
testInstallCommand();
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();