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

1041 lines
40 KiB
JavaScript

/**
* End-to-End Test: Multi-Scope Parallel Workflows
*
* Tests the complete flow of running parallel workflows in different scopes,
* including artifact isolation, sync operations, and event notifications.
*
* Usage: node test/test-scope-e2e.js
* Exit codes: 0 = all tests pass, 1 = test failures
*/
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
// 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',
};
// Test utilities
let testCount = 0;
let passCount = 0;
let failCount = 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 asyncTest(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 assertEqual(actual, expected, message = '') {
if (actual !== expected) {
throw new Error(`${message}\n Expected: ${expected}\n Actual: ${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 assertFileExists(filePath, message = '') {
if (!fs.existsSync(filePath)) {
throw new Error(`${message || 'File should exist'}: ${filePath}`);
}
}
function assertFileNotExists(filePath, message = '') {
if (fs.existsSync(filePath)) {
throw new Error(`${message || 'File should not exist'}: ${filePath}`);
}
}
function assertFileContains(filePath, content, message = '') {
const fileContent = fs.readFileSync(filePath, 'utf8');
if (!fileContent.includes(content)) {
throw new Error(`${message || 'File should contain'}: "${content}" in ${filePath}`);
}
}
// Create temporary test directory with BMAD structure
function createTestProject() {
const tmpDir = path.join(os.tmpdir(), `bmad-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`);
// Create BMAD directory structure
fs.mkdirSync(path.join(tmpDir, '_bmad', '_config'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '_bmad', '_events'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '_bmad-output'), { recursive: true });
return tmpDir;
}
function cleanupTestProject(tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
// ============================================================================
// E2E Test: Complete Parallel Workflow Simulation
// ============================================================================
async function testParallelScopeWorkflow() {
console.log(`\n${colors.blue}E2E: Parallel Scope Workflow Simulation${colors.reset}`);
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
const { ScopeContext } = require('../src/core/lib/scope/scope-context');
const { ArtifactResolver } = require('../src/core/lib/scope/artifact-resolver');
const { ScopeSync } = require('../src/core/lib/scope/scope-sync');
const { EventLogger } = require('../src/core/lib/scope/event-logger');
let tmpDir;
try {
tmpDir = createTestProject();
// Initialize components
const manager = new ScopeManager({ projectRoot: tmpDir });
const context = new ScopeContext({ projectRoot: tmpDir });
// ========================================
// Step 1: Initialize scope system
// ========================================
await asyncTest('Initialize scope system', async () => {
await manager.initialize();
assertFileExists(path.join(tmpDir, '_bmad', '_config', 'scopes.yaml'));
assertFileExists(path.join(tmpDir, '_bmad-output', '_shared'));
assertFileExists(path.join(tmpDir, '_bmad', '_events'));
});
// ========================================
// Step 2: Create two scopes (auth and payments)
// ========================================
await asyncTest('Create auth scope', async () => {
const scope = await manager.createScope('auth', {
name: 'Authentication Service',
description: 'User auth, SSO, authorization',
});
assertEqual(scope.id, 'auth');
assertEqual(scope.status, 'active');
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts'));
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'implementation-artifacts'));
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'tests'));
});
await asyncTest('Create payments scope with dependency on auth', async () => {
const scope = await manager.createScope('payments', {
name: 'Payment Processing',
description: 'Payment gateway integration',
dependencies: ['auth'],
});
assertEqual(scope.id, 'payments');
assertTrue(scope.dependencies.includes('auth'));
});
// ========================================
// Step 3: Simulate parallel artifact creation
// ========================================
await asyncTest('Simulate parallel PRD creation in auth scope', async () => {
// Create PRD in auth scope
const prdPath = path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md');
fs.writeFileSync(prdPath, '# Auth PRD\n\nAuthentication requirements...');
assertFileExists(prdPath);
assertFileContains(prdPath, 'Auth PRD');
});
await asyncTest('Simulate parallel PRD creation in payments scope', async () => {
// Create PRD in payments scope
const prdPath = path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md');
fs.writeFileSync(prdPath, '# Payments PRD\n\nPayment processing requirements...');
assertFileExists(prdPath);
assertFileContains(prdPath, 'Payments PRD');
});
// ========================================
// Step 4: Verify artifact isolation
// ========================================
await asyncTest('Verify artifacts are isolated', async () => {
const authPrd = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md'), 'utf8');
const paymentsPrd = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md'), 'utf8');
assertTrue(authPrd.includes('Auth PRD'), 'Auth PRD should contain auth content');
assertTrue(paymentsPrd.includes('Payments PRD'), 'Payments PRD should contain payments content');
assertFalse(authPrd.includes('Payments'), 'Auth PRD should not contain payments content');
assertFalse(paymentsPrd.includes('Auth'), 'Payments PRD should not contain auth content');
});
// ========================================
// Step 5: Test ArtifactResolver access control
// ========================================
await asyncTest('ArtifactResolver allows cross-scope read', async () => {
const resolver = new ArtifactResolver({
currentScope: 'payments',
basePath: '_bmad-output',
projectRoot: tmpDir,
});
// Payments scope can read auth scope - canRead returns {allowed, reason}
assertTrue(
resolver.canRead(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md')).allowed,
'Should allow cross-scope read',
);
});
await asyncTest('ArtifactResolver blocks cross-scope write', async () => {
const resolver = new ArtifactResolver({
currentScope: 'payments',
basePath: '_bmad-output',
projectRoot: tmpDir,
isolationMode: 'strict',
});
// Payments scope cannot write to auth scope - canWrite returns {allowed, reason, warning}
assertFalse(
resolver.canWrite(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'new.md')).allowed,
'Should block cross-scope write',
);
});
// ========================================
// Step 6: Test scope context session
// ========================================
await asyncTest('Session-sticky scope works', async () => {
await context.setScope('auth');
const currentScope = await context.getCurrentScope();
assertEqual(currentScope, 'auth', 'Session scope should be auth');
// Check .bmad-scope file was created
assertFileExists(path.join(tmpDir, '.bmad-scope'));
});
await asyncTest('Session scope can be switched', async () => {
await context.setScope('payments');
const currentScope = await context.getCurrentScope();
assertEqual(currentScope, 'payments', 'Session scope should be payments');
});
// ========================================
// Step 7: Test sync-up (promote to shared)
// ========================================
await asyncTest('Sync-up promotes artifacts to shared layer', async () => {
const sync = new ScopeSync({ projectRoot: tmpDir });
// Create a promotable artifact matching pattern 'architecture/*.md'
const archPath = path.join(tmpDir, '_bmad-output', 'auth', 'architecture', 'overview.md');
fs.mkdirSync(path.dirname(archPath), { recursive: true });
fs.writeFileSync(archPath, '# Auth Architecture\n\nShared auth patterns...');
await sync.syncUp('auth');
// Check artifact was promoted to _shared/auth/architecture/overview.md
assertFileExists(
path.join(tmpDir, '_bmad-output', '_shared', 'auth', 'architecture', 'overview.md'),
'Architecture should be promoted to shared',
);
});
// ========================================
// Step 8: Test event logging
// ========================================
await asyncTest('Events are logged', async () => {
const eventLogger = new EventLogger({ projectRoot: tmpDir });
await eventLogger.initialize();
// EventLogger uses logEvent(type, scopeId, data) not log({...})
await eventLogger.logEvent('artifact_created', 'auth', { artifact: 'prd.md' });
// getEvents takes (scopeId, options) not ({scope})
const events = await eventLogger.getEvents('auth');
assertTrue(events.length > 0, 'Should have logged events');
assertEqual(events[0].type, 'artifact_created');
});
// ========================================
// Step 9: Test dependency tracking
// ========================================
await asyncTest('Dependent scopes can be found', async () => {
const dependents = await manager.findDependentScopes('auth');
assertTrue(dependents.includes('payments'), 'payments should depend on auth');
});
// ========================================
// Step 10: Test scope archival
// ========================================
await asyncTest('Scope can be archived', async () => {
await manager.archiveScope('auth');
const scope = await manager.getScope('auth');
assertEqual(scope.status, 'archived', 'Scope should be archived');
// Re-activate for cleanup
await manager.activateScope('auth');
});
// ========================================
// Step 11: Verify final state
// ========================================
await asyncTest('Final state verification', async () => {
const scopes = await manager.listScopes();
assertEqual(scopes.length, 2, 'Should have 2 scopes');
// Both scopes should have their artifacts
assertFileExists(path.join(tmpDir, '_bmad-output', 'auth', 'planning-artifacts', 'prd.md'));
assertFileExists(path.join(tmpDir, '_bmad-output', 'payments', 'planning-artifacts', 'prd.md'));
// Shared layer should have promoted artifacts
assertFileExists(path.join(tmpDir, '_bmad-output', '_shared'));
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// E2E Test: Concurrent Lock Simulation
// ============================================================================
async function testConcurrentLockSimulation() {
console.log(`\n${colors.blue}E2E: Concurrent Lock Simulation${colors.reset}`);
const { StateLock } = require('../src/core/lib/scope/state-lock');
let tmpDir;
try {
tmpDir = createTestProject();
const lock = new StateLock();
const lockPath = path.join(tmpDir, 'state.lock');
// ========================================
// Simulate concurrent access from two "terminals"
// ========================================
await asyncTest('Concurrent operations are serialized', async () => {
const results = [];
const startTime = Date.now();
// Simulate Terminal 1 (auth scope)
const terminal1 = lock.withLock(lockPath, async () => {
results.push({ terminal: 1, action: 'start', time: Date.now() - startTime });
await new Promise((r) => setTimeout(r, 50)); // Simulate work
results.push({ terminal: 1, action: 'end', time: Date.now() - startTime });
return 'terminal1';
});
// Simulate Terminal 2 (payments scope) - starts slightly after
await new Promise((r) => setTimeout(r, 10));
const terminal2 = lock.withLock(lockPath, async () => {
results.push({ terminal: 2, action: 'start', time: Date.now() - startTime });
await new Promise((r) => setTimeout(r, 50)); // Simulate work
results.push({ terminal: 2, action: 'end', time: Date.now() - startTime });
return 'terminal2';
});
await Promise.all([terminal1, terminal2]);
// Terminal 2 should start after Terminal 1 ends
const t1End = results.find((r) => r.terminal === 1 && r.action === 'end');
const t2Start = results.find((r) => r.terminal === 2 && r.action === 'start');
assertTrue(t2Start.time >= t1End.time, `Terminal 2 should start (${t2Start.time}ms) after Terminal 1 ends (${t1End.time}ms)`);
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// E2E Test: Help Commands
// ============================================================================
async function testHelpCommandsE2E() {
console.log(`\n${colors.blue}E2E: Help Commands${colors.reset}`);
const { execSync } = require('node:child_process');
const cliPath = path.join(__dirname, '..', 'tools', 'cli', 'bmad-cli.js');
await asyncTest('scope --help shows subcommands', () => {
const output = execSync(`node ${cliPath} scope --help`, { encoding: 'utf8' });
assertTrue(output.includes('SUBCOMMANDS'), 'Should show SUBCOMMANDS section');
assertTrue(output.includes('init'), 'Should mention init');
assertTrue(output.includes('create'), 'Should mention create');
assertTrue(output.includes('list'), 'Should mention list');
});
await asyncTest('scope -h shows same as --help', () => {
const output = execSync(`node ${cliPath} scope -h`, { encoding: 'utf8' });
assertTrue(output.includes('SUBCOMMANDS'), 'Should show SUBCOMMANDS section');
});
await asyncTest('scope help shows comprehensive documentation', () => {
const output = execSync(`node ${cliPath} scope help`, { encoding: 'utf8' });
assertTrue(output.includes('OVERVIEW'), 'Should show OVERVIEW section');
assertTrue(output.includes('COMMANDS'), 'Should show COMMANDS section');
assertTrue(output.includes('QUICK START'), 'Should show QUICK START section');
});
await asyncTest('scope help create shows detailed create help', () => {
const output = execSync(`node ${cliPath} scope help create`, { encoding: 'utf8' });
assertTrue(output.includes('bmad scope create'), 'Should show create command title');
assertTrue(output.includes('ARGUMENTS'), 'Should show ARGUMENTS section');
assertTrue(output.includes('OPTIONS'), 'Should show OPTIONS section');
});
await asyncTest('scope help init shows detailed init help', () => {
const output = execSync(`node ${cliPath} scope help init`, { encoding: 'utf8' });
assertTrue(output.includes('bmad scope init'), 'Should show init command title');
assertTrue(output.includes('DESCRIPTION'), 'Should show DESCRIPTION section');
});
await asyncTest('scope help with invalid subcommand shows error', () => {
const output = execSync(`node ${cliPath} scope help invalidcommand`, { encoding: 'utf8' });
assertTrue(output.includes('Unknown command'), 'Should show unknown command error');
});
await asyncTest('scope help works with aliases', () => {
const output = execSync(`node ${cliPath} scope help ls`, { encoding: 'utf8' });
assertTrue(output.includes('bmad scope list'), 'Should show list help for ls alias');
});
}
// ============================================================================
// E2E Test: Error Handling and Edge Cases
// ============================================================================
async function testErrorHandlingE2E() {
console.log(`\n${colors.blue}E2E: Error Handling and Edge Cases${colors.reset}`);
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
const { ScopeContext } = require('../src/core/lib/scope/scope-context');
const { ScopeSync } = require('../src/core/lib/scope/scope-sync');
let tmpDir;
try {
tmpDir = createTestProject();
const manager = new ScopeManager({ projectRoot: tmpDir });
// ========================================
// Error: Operations on uninitialized system
// ========================================
await asyncTest('List scopes on uninitialized system returns empty', async () => {
// Don't initialize, just try to list
let result = [];
try {
result = await manager.listScopes();
} catch {
// Expected - system not initialized
result = [];
}
assertEqual(result.length, 0, 'Should return empty or throw');
});
// Initialize for remaining tests
await manager.initialize();
// ========================================
// Error: Duplicate scope creation
// ========================================
await asyncTest('Creating duplicate scope throws meaningful error', async () => {
await manager.createScope('duptest', { name: 'Dup Test' });
let errorMsg = '';
try {
await manager.createScope('duptest', { name: 'Dup Test 2' });
} catch (error) {
errorMsg = error.message;
}
assertTrue(errorMsg.includes('already exists') || errorMsg.includes('duplicate'), `Error should mention duplicate: ${errorMsg}`);
});
// ========================================
// Error: Invalid operations on archived scope
// ========================================
await asyncTest('Operations on archived scope work correctly', async () => {
await manager.createScope('archtest', { name: 'Archive Test' });
await manager.archiveScope('archtest');
// Should still be able to get info
const scope = await manager.getScope('archtest');
assertEqual(scope.status, 'archived', 'Should get archived scope');
// Activate should work
await manager.activateScope('archtest');
const reactivated = await manager.getScope('archtest');
assertEqual(reactivated.status, 'active', 'Should be reactivated');
});
// ========================================
// Edge: Scope with maximum valid name length
// ========================================
await asyncTest('Scope with maximum length name', async () => {
const longName = 'A'.repeat(200); // Very long name
const scope = await manager.createScope('longname', {
name: longName,
description: 'B'.repeat(500),
});
assertEqual(scope.id, 'longname', 'Should create scope with long name');
});
// ========================================
// Edge: Scope with special characters in name/description
// ========================================
await asyncTest('Scope with special characters in metadata', async () => {
const scope = await manager.createScope('specialchars', {
name: 'Test <script>alert("xss")</script>',
description: 'Description with "quotes" and \'apostrophes\' and `backticks`',
});
assertEqual(scope.id, 'specialchars', 'Should create scope with special chars in metadata');
});
// ========================================
// Edge: Empty dependencies array
// ========================================
await asyncTest('Scope with empty dependencies array', async () => {
const scope = await manager.createScope('nodeps', {
name: 'No Deps',
dependencies: [],
});
assertEqual(scope.dependencies.length, 0, 'Should have no dependencies');
});
// ========================================
// Sync operations on non-existent scope - documents current behavior
// ========================================
await asyncTest('Sync-up on non-existent scope handles gracefully', async () => {
const sync = new ScopeSync({ projectRoot: tmpDir });
// Current implementation may return empty result or throw
// This documents actual behavior
let result = null;
try {
result = await sync.syncUp('nonexistent');
// If it doesn't throw, result should indicate no files synced
assertTrue(result.promoted.length === 0 || result.success !== false, 'Should handle gracefully with no files to sync');
} catch {
// Throwing is also acceptable behavior
assertTrue(true, 'Throws for non-existent scope');
}
});
// ========================================
// Edge: Rapid scope status changes
// ========================================
await asyncTest('Rapid archive/activate cycles', async () => {
await manager.createScope('rapidcycle', { name: 'Rapid Cycle' });
for (let i = 0; i < 5; i++) {
await manager.archiveScope('rapidcycle');
await manager.activateScope('rapidcycle');
}
const scope = await manager.getScope('rapidcycle');
assertEqual(scope.status, 'active', 'Should end up active after cycles');
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// E2E Test: Complex Dependency Scenarios
// ============================================================================
async function testComplexDependencyE2E() {
console.log(`\n${colors.blue}E2E: Complex Dependency Scenarios${colors.reset}`);
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
let tmpDir;
try {
tmpDir = createTestProject();
const manager = new ScopeManager({ projectRoot: tmpDir });
await manager.initialize();
// ========================================
// Diamond dependency pattern
// ========================================
await asyncTest('Diamond dependency pattern works', async () => {
// core
// / \
// auth user
// \ /
// payments
await manager.createScope('core', { name: 'Core' });
await manager.createScope('auth', { name: 'Auth', dependencies: ['core'] });
await manager.createScope('user', { name: 'User', dependencies: ['core'] });
await manager.createScope('payments', { name: 'Payments', dependencies: ['auth', 'user'] });
const payments = await manager.getScope('payments');
assertTrue(payments.dependencies.includes('auth'), 'Should depend on auth');
assertTrue(payments.dependencies.includes('user'), 'Should depend on user');
});
// ========================================
// Finding all dependents in complex graph
// ========================================
await asyncTest('Finds all dependents in complex graph', async () => {
const coreDependents = await manager.findDependentScopes('core');
assertTrue(coreDependents.includes('auth'), 'auth should depend on core');
assertTrue(coreDependents.includes('user'), 'user should depend on core');
// Transitive dependents may or may not be included depending on implementation
});
// ========================================
// Removing scope in middle of dependency chain
// ========================================
await asyncTest('Cannot remove scope with dependents without force', async () => {
let threw = false;
try {
await manager.removeScope('auth'); // payments depends on auth
} catch {
threw = true;
}
assertTrue(threw, 'Should throw when removing scope with dependents');
});
// ========================================
// Adding dependency to existing scope
// ========================================
await asyncTest('Adding new dependency to existing scope', async () => {
await manager.createScope('notifications', { name: 'Notifications' });
await manager.updateScope('payments', {
dependencies: ['auth', 'user', 'notifications'],
});
const payments = await manager.getScope('payments');
assertTrue(payments.dependencies.includes('notifications'), 'Should have new dependency');
});
// ========================================
// Archiving scope in dependency chain
// ========================================
await asyncTest('Archiving scope in dependency chain', async () => {
await manager.archiveScope('auth');
// Payments should still exist and have auth as dependency
const payments = await manager.getScope('payments');
assertTrue(payments.dependencies.includes('auth'), 'Dependency should remain');
// Reactivate for cleanup
await manager.activateScope('auth');
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// E2E Test: Sync Operations Edge Cases
// ============================================================================
async function testSyncOperationsE2E() {
console.log(`\n${colors.blue}E2E: Sync Operations Edge Cases${colors.reset}`);
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
const { ScopeSync } = require('../src/core/lib/scope/scope-sync');
let tmpDir;
try {
tmpDir = createTestProject();
const manager = new ScopeManager({ projectRoot: tmpDir });
await manager.initialize();
const sync = new ScopeSync({ projectRoot: tmpDir });
// Create test scope
await manager.createScope('synctest', { name: 'Sync Test' });
// ========================================
// Sync-up with no promotable files
// ========================================
await asyncTest('Sync-up with no promotable files', async () => {
// Create non-promotable file
const filePath = path.join(tmpDir, '_bmad-output', 'synctest', 'planning-artifacts', 'notes.md');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, '# Random Notes');
const result = await sync.syncUp('synctest');
// Should succeed but with no files promoted
assertTrue(result.success || result.promoted.length === 0, 'Should handle no promotable files');
});
// ========================================
// Sync-up with empty architecture directory
// ========================================
await asyncTest('Sync-up with empty promotable directory', async () => {
// Create empty architecture directory
fs.mkdirSync(path.join(tmpDir, '_bmad-output', 'synctest', 'architecture'), { recursive: true });
const result = await sync.syncUp('synctest');
assertTrue(result.success !== false, 'Should handle empty directory');
});
// ========================================
// Sync-up with binary files (should skip)
// ========================================
await asyncTest('Sync-up skips binary files', async () => {
// Create a file that might be considered binary
const archPath = path.join(tmpDir, '_bmad-output', 'synctest', 'architecture', 'diagram.png');
fs.mkdirSync(path.dirname(archPath), { recursive: true });
fs.writeFileSync(archPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // PNG header
const result = await sync.syncUp('synctest');
// Should succeed, binary might be skipped or included depending on implementation
assertTrue(result.success !== false, 'Should handle binary files gracefully');
});
// ========================================
// Create scope when directory already exists - safe by default
// ========================================
await asyncTest('Creating scope when directory exists throws by default', async () => {
// Pre-create the directory
fs.mkdirSync(path.join(tmpDir, '_bmad-output', 'preexist', 'planning-artifacts'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '_bmad-output', 'preexist', 'planning-artifacts', 'existing.md'), '# Existing File');
// Create scope - should throw because directory exists (safe default)
let threw = false;
let errorMsg = '';
try {
await manager.createScope('preexist', { name: 'Pre-existing' });
} catch (error) {
threw = true;
errorMsg = error.message;
}
assertTrue(threw, 'Should throw when directory exists');
assertTrue(errorMsg.includes('already exists'), 'Error should mention directory exists');
// Existing file should still be preserved
const existingContent = fs.readFileSync(path.join(tmpDir, '_bmad-output', 'preexist', 'planning-artifacts', 'existing.md'), 'utf8');
assertTrue(existingContent.includes('Existing File'), 'Should preserve existing files');
});
// ========================================
// Sync with very long file paths
// ========================================
await asyncTest('Sync handles deeply nested paths', async () => {
const deepPath = path.join(tmpDir, '_bmad-output', 'synctest', 'architecture', 'deep', 'nested', 'structure', 'document.md');
fs.mkdirSync(path.dirname(deepPath), { recursive: true });
fs.writeFileSync(deepPath, '# Deeply Nested');
const result = await sync.syncUp('synctest');
assertTrue(result.success !== false, 'Should handle deep paths');
});
// ========================================
// Sync with special characters in filename
// ========================================
await asyncTest('Sync handles special characters in filenames', async () => {
const specialPath = path.join(tmpDir, '_bmad-output', 'synctest', 'architecture', 'design (v2) [draft].md');
fs.mkdirSync(path.dirname(specialPath), { recursive: true });
fs.writeFileSync(specialPath, '# Design v2 Draft');
const result = await sync.syncUp('synctest');
assertTrue(result.success !== false, 'Should handle special chars in filenames');
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// E2E Test: File System Edge Cases
// ============================================================================
async function testFileSystemEdgeCasesE2E() {
console.log(`\n${colors.blue}E2E: File System Edge Cases${colors.reset}`);
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
const { ScopeInitializer } = require('../src/core/lib/scope/scope-initializer');
let tmpDir;
try {
tmpDir = createTestProject();
const manager = new ScopeManager({ projectRoot: tmpDir });
const initializer = new ScopeInitializer({ projectRoot: tmpDir });
await manager.initialize();
// ========================================
// Remove scope with readonly files
// ========================================
await asyncTest('Remove scope handles readonly files', async () => {
await manager.createScope('readonly', { name: 'Readonly Test' });
// Make a file readonly
const filePath = path.join(tmpDir, '_bmad-output', 'readonly', 'planning-artifacts', 'locked.md');
fs.writeFileSync(filePath, '# Locked');
try {
fs.chmodSync(filePath, 0o444); // Read-only
} catch {
// Windows might not support chmod
}
// Remove should handle this gracefully
let removed = false;
try {
await initializer.removeScope('readonly', { backup: false });
await manager.removeScope('readonly', { force: true });
removed = true;
} catch {
// May fail on some systems, that's ok
// Clean up by making it writable again
try {
fs.chmodSync(filePath, 0o644);
await initializer.removeScope('readonly', { backup: false });
await manager.removeScope('readonly', { force: true });
removed = true;
} catch {
// Ignore cleanup errors
}
}
// Just verify it attempted the operation
assertTrue(true, 'Attempted removal of readonly files');
});
// ========================================
// Scope with symlinks (if supported)
// ========================================
await asyncTest('Scope handles symlinks gracefully', async () => {
await manager.createScope('symtest', { name: 'Symlink Test' });
const targetPath = path.join(tmpDir, '_bmad-output', 'symtest', 'planning-artifacts', 'target.md');
const linkPath = path.join(tmpDir, '_bmad-output', 'symtest', 'planning-artifacts', 'link.md');
fs.writeFileSync(targetPath, '# Target');
try {
fs.symlinkSync(targetPath, linkPath);
// Should be able to read through symlink
const content = fs.readFileSync(linkPath, 'utf8');
assertTrue(content.includes('Target'), 'Should read through symlink');
} catch {
// Symlinks may not be supported on all systems
assertTrue(true, 'Symlinks not supported on this system');
}
});
// ========================================
// Large number of files in scope
// ========================================
await asyncTest('Scope with many files', async () => {
await manager.createScope('manyfiles', { name: 'Many Files' });
const planningDir = path.join(tmpDir, '_bmad-output', 'manyfiles', 'planning-artifacts');
// Create 100 files
for (let i = 0; i < 100; i++) {
fs.writeFileSync(path.join(planningDir, `file-${i}.md`), `# File ${i}`);
}
// Should still be able to manage scope
const scope = await manager.getScope('manyfiles');
assertEqual(scope.id, 'manyfiles', 'Should manage scope with many files');
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// E2E Test: Concurrent Operations Stress Test
// ============================================================================
async function testConcurrentOperationsE2E() {
console.log(`\n${colors.blue}E2E: Concurrent Operations Stress Test${colors.reset}`);
const { ScopeManager } = require('../src/core/lib/scope/scope-manager');
const { ScopeContext } = require('../src/core/lib/scope/scope-context');
let tmpDir;
try {
tmpDir = createTestProject();
const manager = new ScopeManager({ projectRoot: tmpDir });
await manager.initialize();
// ========================================
// Concurrent scope creation stress test
// ========================================
await asyncTest('Concurrent scope creations (stress test)', async () => {
const createPromises = [];
for (let i = 0; i < 20; i++) {
createPromises.push(
manager
.createScope(`concurrent-${i}`, { name: `Concurrent ${i}` })
.catch((error) => ({ error: error.message, id: `concurrent-${i}` })),
);
}
const results = await Promise.all(createPromises);
// Count successes
const successes = results.filter((r) => !r.error);
assertTrue(successes.length > 0, 'At least some concurrent creates should succeed');
// Verify all created scopes exist
const scopes = await manager.listScopes();
assertTrue(scopes.length >= successes.length, 'All successful creates should persist');
});
// ========================================
// Concurrent read/write operations
// ========================================
await asyncTest('Concurrent reads during writes', async () => {
await manager.createScope('rwtest', { name: 'Read/Write Test' });
const operations = [];
// Mix of reads and writes
for (let i = 0; i < 10; i++) {
if (i % 2 === 0) {
// Read
operations.push(manager.getScope('rwtest'));
} else {
// Write (update)
operations.push(manager.updateScope('rwtest', { description: `Update ${i}` }));
}
}
await Promise.all(operations);
// Verify scope is still valid
const scope = await manager.getScope('rwtest');
assertEqual(scope.id, 'rwtest', 'Scope should still be valid');
});
// ========================================
// Concurrent context switches
// ========================================
await asyncTest('Concurrent context switches', async () => {
const context1 = new ScopeContext({ projectRoot: tmpDir });
const context2 = new ScopeContext({ projectRoot: tmpDir });
// Both try to set different scopes
const [, scope1, scope2] = await Promise.all([
manager.createScope('ctx1', { name: 'Context 1' }),
context1.setScope('rwtest').then(() => context1.getCurrentScope()),
context2.setScope('rwtest').then(() => context2.getCurrentScope()),
]);
// One should win (last write wins)
const finalScope = await context1.getCurrentScope();
assertTrue(finalScope === 'rwtest', 'Should have a valid scope set');
});
} finally {
if (tmpDir) {
cleanupTestProject(tmpDir);
}
}
}
// ============================================================================
// Main Runner
// ============================================================================
async function main() {
console.log(`${colors.cyan}╔═══════════════════════════════════════════════════════════╗${colors.reset}`);
console.log(`${colors.cyan}║ Multi-Scope End-to-End Test Suite ║${colors.reset}`);
console.log(`${colors.cyan}╚═══════════════════════════════════════════════════════════╝${colors.reset}`);
try {
await testParallelScopeWorkflow();
await testConcurrentLockSimulation();
// New comprehensive E2E tests
await testHelpCommandsE2E();
await testErrorHandlingE2E();
await testComplexDependencyE2E();
await testSyncOperationsE2E();
await testFileSystemEdgeCasesE2E();
await testConcurrentOperationsE2E();
} catch (error) {
console.log(`\n${colors.red}Fatal error: ${error.message}${colors.reset}`);
console.log(error.stack);
process.exit(1);
}
// Summary
console.log(`\n${colors.cyan}═══════════════════════════════════════════════════════════${colors.reset}`);
console.log(`${colors.cyan}E2E Test Results:${colors.reset}`);
console.log(` Total: ${testCount}`);
console.log(` Passed: ${colors.green}${passCount}${colors.reset}`);
console.log(` Failed: ${failCount === 0 ? colors.green : colors.red}${failCount}${colors.reset}`);
console.log(`${colors.cyan}═══════════════════════════════════════════════════════════${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}${colors.reset} ${failure.name}`);
console.log(` ${failure.error}\n`);
}
process.exit(1);
}
console.log(`${colors.green}All E2E tests passed!${colors.reset}\n`);
process.exit(0);
}
main().catch((error) => {
console.error(`${colors.red}Fatal error:${colors.reset}`, error);
process.exit(1);
});