diff --git a/package.json b/package.json index f3207f5fa..f47591b0b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", "rebundle": "node tools/cli/bundlers/bundle-web.js rebundle", - "test": "npm run test:schemas && npm run test:refs && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run test:schemas && npm run test:refs && npm run test:install && npm run test:copilot && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", + "test:copilot": "node test/test-github-copilot-installer.js", "test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", diff --git a/test/test-github-copilot-installer.js b/test/test-github-copilot-installer.js new file mode 100644 index 000000000..7db0af8fe --- /dev/null +++ b/test/test-github-copilot-installer.js @@ -0,0 +1,238 @@ +/** + * GitHub Copilot Installer Tests + * + * Tests for the GitHubCopilotSetup class methods: + * - loadModuleConfig: module-aware config loading + * - createTechWriterPromptContent: BMM-only tech-writer handling + * - generateCopilotInstructions: selectedModules deduplication + * + * Usage: node test/test-github-copilot-installer.js + */ + +const path = require('node:path'); +const fs = require('fs-extra'); +const { GitHubCopilotSetup } = require('../tools/cli/installers/lib/ide/github-copilot'); + +// ANSI colors +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + yellow: '\u001B[33m', + cyan: '\u001B[36m', + dim: '\u001B[2m', +}; + +let passed = 0; +let failed = 0; + +/** + * Test helper: Assert condition + */ +function assert(condition, testName, errorMessage = '') { + if (condition) { + console.log(`${colors.green}✓${colors.reset} ${testName}`); + passed++; + } else { + console.log(`${colors.red}✗${colors.reset} ${testName}`); + if (errorMessage) { + console.log(` ${colors.dim}${errorMessage}${colors.reset}`); + } + failed++; + } +} + +/** + * Test Suite + */ +async function runTests() { + console.log(`${colors.cyan}========================================`); + console.log('GitHub Copilot Installer Tests'); + console.log(`========================================${colors.reset}\n`); + + const tempDir = path.join(__dirname, 'temp-copilot-test'); + + try { + // Clean up any leftover temp directory + await fs.remove(tempDir); + await fs.ensureDir(tempDir); + + const installer = new GitHubCopilotSetup(); + + // ============================================================ + // Test Suite 1: loadModuleConfig + // ============================================================ + console.log(`${colors.yellow}Test Suite 1: loadModuleConfig${colors.reset}\n`); + + // Create mock bmad directory structure with multiple modules + const bmadDir = path.join(tempDir, '_bmad'); + await fs.ensureDir(path.join(bmadDir, 'core')); + await fs.ensureDir(path.join(bmadDir, 'bmm')); + await fs.ensureDir(path.join(bmadDir, 'custom-module')); + + // Create config files for each module + await fs.writeFile(path.join(bmadDir, 'core', 'config.yaml'), 'project_name: Core Project\nuser_name: CoreUser\n'); + await fs.writeFile(path.join(bmadDir, 'bmm', 'config.yaml'), 'project_name: BMM Project\nuser_name: BmmUser\n'); + await fs.writeFile(path.join(bmadDir, 'custom-module', 'config.yaml'), 'project_name: Custom Project\nuser_name: CustomUser\n'); + + // Test 1a: Load config with only core module (default) + const coreConfig = await installer.loadModuleConfig(bmadDir, ['core']); + assert( + coreConfig.project_name === 'Core Project', + 'loadModuleConfig loads core config when only core installed', + `Got: ${coreConfig.project_name}`, + ); + + // Test 1b: Load config with bmm module (should prefer bmm over core) + const bmmConfig = await installer.loadModuleConfig(bmadDir, ['bmm', 'core']); + assert(bmmConfig.project_name === 'BMM Project', 'loadModuleConfig prefers bmm config over core', `Got: ${bmmConfig.project_name}`); + + // Test 1c: Load config with custom module (should prefer custom over core) + const customConfig = await installer.loadModuleConfig(bmadDir, ['custom-module', 'core']); + assert( + customConfig.project_name === 'Custom Project', + 'loadModuleConfig prefers custom module config over core', + `Got: ${customConfig.project_name}`, + ); + + // Test 1d: Load config with multiple non-core modules (first wins) + const multiConfig = await installer.loadModuleConfig(bmadDir, ['bmm', 'custom-module', 'core']); + assert( + multiConfig.project_name === 'BMM Project', + 'loadModuleConfig uses first non-core module config', + `Got: ${multiConfig.project_name}`, + ); + + // Test 1e: Empty modules list uses default (core) + const defaultConfig = await installer.loadModuleConfig(bmadDir); + assert( + defaultConfig.project_name === 'Core Project', + 'loadModuleConfig defaults to core when no modules specified', + `Got: ${defaultConfig.project_name}`, + ); + + // Test 1f: Non-existent module falls back to core + const fallbackConfig = await installer.loadModuleConfig(bmadDir, ['nonexistent', 'core']); + assert( + fallbackConfig.project_name === 'Core Project', + 'loadModuleConfig falls back to core for non-existent modules', + `Got: ${fallbackConfig.project_name}`, + ); + + console.log(''); + + // ============================================================ + // Test Suite 2: createTechWriterPromptContent (BMM-only) + // ============================================================ + console.log(`${colors.yellow}Test Suite 2: createTechWriterPromptContent (BMM-only)${colors.reset}\n`); + + // Test 2a: BMM tech-writer entry should generate content + const bmmTechWriterEntry = { + 'agent-name': 'tech-writer', + module: 'bmm', + name: 'Write Document', + }; + const bmmResult = installer.createTechWriterPromptContent(bmmTechWriterEntry); + assert( + bmmResult !== null && bmmResult.fileName === 'bmad-bmm-write-document', + 'createTechWriterPromptContent generates content for BMM tech-writer', + `Got: ${bmmResult ? bmmResult.fileName : 'null'}`, + ); + + // Test 2b: Non-BMM tech-writer entry should return null + const customTechWriterEntry = { + 'agent-name': 'tech-writer', + module: 'custom-module', + name: 'Write Document', + }; + const customResult = installer.createTechWriterPromptContent(customTechWriterEntry); + assert(customResult === null, 'createTechWriterPromptContent returns null for non-BMM tech-writer', `Got: ${customResult}`); + + // Test 2c: Core tech-writer entry should return null + const coreTechWriterEntry = { + 'agent-name': 'tech-writer', + module: 'core', + name: 'Write Document', + }; + const coreResult = installer.createTechWriterPromptContent(coreTechWriterEntry); + assert(coreResult === null, 'createTechWriterPromptContent returns null for core tech-writer', `Got: ${coreResult}`); + + // Test 2d: Non-tech-writer BMM entry should return null + const nonTechWriterEntry = { + 'agent-name': 'pm', + module: 'bmm', + name: 'Write Document', + }; + const nonTechResult = installer.createTechWriterPromptContent(nonTechWriterEntry); + assert(nonTechResult === null, 'createTechWriterPromptContent returns null for non-tech-writer agents', `Got: ${nonTechResult}`); + + // Test 2e: Unknown tech-writer command should return null + const unknownCmdEntry = { + 'agent-name': 'tech-writer', + module: 'bmm', + name: 'Unknown Command', + }; + const unknownResult = installer.createTechWriterPromptContent(unknownCmdEntry); + assert(unknownResult === null, 'createTechWriterPromptContent returns null for unknown commands', `Got: ${unknownResult}`); + + console.log(''); + + // ============================================================ + // Test Suite 3: selectedModules deduplication + // ============================================================ + console.log(`${colors.yellow}Test Suite 3: selectedModules deduplication${colors.reset}\n`); + + // We can't easily test generateCopilotInstructions directly without mocking, + // but we can verify the deduplication logic pattern + const testDedupe = (modules) => { + const installedModules = modules.length > 0 ? [...new Set(modules)] : ['core']; + return installedModules; + }; + + // Test 3a: Duplicate modules should be deduplicated + const dupeResult = testDedupe(['bmm', 'core', 'bmm', 'custom', 'core', 'custom']); + assert( + dupeResult.length === 3 && dupeResult.includes('bmm') && dupeResult.includes('core') && dupeResult.includes('custom'), + 'Deduplication removes duplicate modules', + `Got: ${JSON.stringify(dupeResult)}`, + ); + + // Test 3b: Empty array defaults to core + const emptyResult = testDedupe([]); + assert( + emptyResult.length === 1 && emptyResult[0] === 'core', + 'Empty modules array defaults to core', + `Got: ${JSON.stringify(emptyResult)}`, + ); + + // Test 3c: Order is preserved after deduplication (first occurrence wins) + const orderResult = testDedupe(['custom', 'bmm', 'custom', 'bmm']); + assert( + orderResult[0] === 'custom' && orderResult[1] === 'bmm', + 'Deduplication preserves order (first occurrence)', + `Got: ${JSON.stringify(orderResult)}`, + ); + } finally { + // Cleanup + await fs.remove(tempDir); + } + + // Print summary + console.log(`${colors.cyan}========================================`); + console.log('Test Results:'); + console.log(` Passed: ${passed}`); + console.log(` Failed: ${failed}`); + console.log(`========================================${colors.reset}\n`); + + if (failed > 0) { + console.log(`${colors.red}Some tests failed!${colors.reset}`); + process.exit(1); + } else { + console.log(`${colors.green}✨ All GitHub Copilot installer tests passed!${colors.reset}`); + } +} + +runTests().catch((error) => { + console.error(`${colors.red}Test runner error:${colors.reset}`, error); + process.exit(1); +}); diff --git a/tools/cli/installers/lib/ide/github-copilot.js b/tools/cli/installers/lib/ide/github-copilot.js index 059127f81..e44816258 100644 --- a/tools/cli/installers/lib/ide/github-copilot.js +++ b/tools/cli/installers/lib/ide/github-copilot.js @@ -247,9 +247,9 @@ You must fully embody this agent's persona and follow all activation instruction */ createWorkflowPromptContent(entry, workflowFile, toolsStr) { const description = this.escapeYamlSingleQuote(this.createPromptDescription(entry.name)); - // bmm/config.yaml is safe to hardcode here: these prompts are only generated when - // bmad-help.csv exists (bmm module data), so bmm is guaranteed to be installed. - const configLine = `1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables`; + // Use the module from the bmad-help.csv entry to reference the correct config.yaml + const configModule = entry.module || 'core'; + const configLine = `1. Load {project-root}/${this.bmadFolderName}/${configModule}/config.yaml and store ALL fields as session variables`; let body; if (workflowFile.endsWith('.yaml')) { @@ -324,11 +324,13 @@ ${body} /** * Create prompt content for tech-writer agent-only commands (Pattern C) + * Tech-writer is BMM-specific - these commands only work with the BMM module. * @param {Object} entry - bmad-help.csv row * @returns {Object|null} { fileName, content } or null if not a tech-writer command */ createTechWriterPromptContent(entry) { - if (entry['agent-name'] !== 'tech-writer') return null; + // Tech-writer is BMM-specific - only process entries from the bmm module + if (entry['agent-name'] !== 'tech-writer' || entry.module !== 'bmm') return null; const techWriterCommands = { 'Write Document': { code: 'WD', file: 'bmad-bmm-write-document', description: 'Write document' }, @@ -344,14 +346,16 @@ ${body} const safeDescription = this.escapeYamlSingleQuote(cmd.description); const toolsStr = this.getToolsForFile(`${cmd.file}.prompt.md`); + // Use the module from the bmad-help.csv entry to reference the correct paths + const configModule = entry.module || 'core'; const content = `--- description: '${safeDescription}' agent: 'agent' tools: ${toolsStr} --- -1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables -2. Load the full agent file from {project-root}/${this.bmadFolderName}/bmm/agents/tech-writer/tech-writer.md and activate the Paige (Technical Writer) persona +1. Load {project-root}/${this.bmadFolderName}/${configModule}/config.yaml and store ALL fields as session variables +2. Load the full agent file from {project-root}/${this.bmadFolderName}/${configModule}/agents/tech-writer/tech-writer.md and activate the Paige (Technical Writer) persona 3. Execute the ${entry.name} menu command (${cmd.code}) `; @@ -376,15 +380,15 @@ tools: ${toolsStr} const agentPath = artifact.agentPath || artifact.relativePath; const agentFilePath = `{project-root}/${this.bmadFolderName}/${agentPath}`; - // bmm/config.yaml is safe to hardcode: agent activators are only generated from - // bmm agent artifacts, so bmm is guaranteed to be installed. + // Use the agent's module to reference the correct config.yaml + const configModule = artifact.module || 'core'; return `--- description: '${safeDescription}' agent: 'agent' tools: ${toolsStr} --- -1. Load {project-root}/${this.bmadFolderName}/bmm/config.yaml and store ALL fields as session variables +1. Load {project-root}/${this.bmadFolderName}/${configModule}/config.yaml and store ALL fields as session variables 2. Load the full agent file from ${agentFilePath} 3. Follow ALL activation instructions in the agent file 4. Display the welcome/greeting as instructed @@ -400,7 +404,13 @@ tools: ${toolsStr} * @param {Map} agentManifest - Agent manifest data */ async generateCopilotInstructions(projectDir, bmadDir, agentManifest, options = {}) { - const configVars = await this.loadModuleConfig(bmadDir); + // Determine installed modules (excluding internal directories) + const selectedModules = options.selectedModules || []; + // Deduplicate selectedModules to prevent duplicate paths in generated markdown + const installedModules = selectedModules.length > 0 ? [...new Set(selectedModules)] : ['core']; + const configVars = await this.loadModuleConfig(bmadDir, installedModules); + // Filter to only non-core modules for display (core is always listed separately) + const nonCoreModules = installedModules.filter((m) => m !== 'core'); // Build the agents table from the manifest let agentsTable = '| Agent | Persona | Title | Capabilities |\n|---|---|---|---|\n'; @@ -427,6 +437,36 @@ tools: ${toolsStr} } const bmad = this.bmadFolderName; + + // Build dynamic module paths based on installed modules + const moduleAgentPaths = nonCoreModules.map((m) => `\`${bmad}/${m}/agents/\``).join(', '); + const moduleWorkflowPaths = nonCoreModules.map((m) => `\`${bmad}/${m}/workflows/\``).join(', '); + const moduleConfigPaths = nonCoreModules.map((m) => `\`${bmad}/${m}/config.yaml\``).join(', '); + + // Build agent definitions line + let agentDefsLine; + if (nonCoreModules.length > 0) { + agentDefsLine = `- **Agent definitions**: ${moduleAgentPaths} and \`${bmad}/core/agents/\` (core)`; + } else { + agentDefsLine = `- **Agent definitions**: \`${bmad}/core/agents/\``; + } + + // Build workflow definitions line + let workflowDefsLine; + if (nonCoreModules.length > 0) { + workflowDefsLine = `- **Workflow definitions**: ${moduleWorkflowPaths} (organized by phase)`; + } else { + workflowDefsLine = `- **Workflow definitions**: \`${bmad}/core/workflows/\``; + } + + // Build module configuration line + let moduleConfigLine; + if (nonCoreModules.length > 0) { + moduleConfigLine = `- **Module configuration**: ${moduleConfigPaths}`; + } else { + moduleConfigLine = `- **Module configuration**: (no non-core modules installed)`; + } + const bmadSection = `# BMAD Method — Project Instructions ## Project Configuration @@ -443,12 +483,12 @@ tools: ${toolsStr} ## BMAD Runtime Structure -- **Agent definitions**: \`${bmad}/bmm/agents/\` (BMM module) and \`${bmad}/core/agents/\` (core) -- **Workflow definitions**: \`${bmad}/bmm/workflows/\` (organized by phase) +${agentDefsLine} +${workflowDefsLine} - **Core tasks**: \`${bmad}/core/tasks/\` (help, editorial review, indexing, sharding, adversarial review) - **Core workflows**: \`${bmad}/core/workflows/\` (brainstorming, party-mode, advanced-elicitation) - **Workflow engine**: \`${bmad}/core/tasks/workflow.xml\` (executes YAML-based workflows) -- **Module configuration**: \`${bmad}/bmm/config.yaml\` +${moduleConfigLine} - **Core configuration**: \`${bmad}/core/config.yaml\` - **Agent manifest**: \`${bmad}/_config/agent-manifest.csv\` - **Workflow manifest**: \`${bmad}/_config/workflow-manifest.csv\` @@ -457,7 +497,7 @@ tools: ${toolsStr} ## Key Conventions -- Always load \`${bmad}/bmm/config.yaml\` before any agent activation or workflow execution +- Always load the agent/workflow's module \`config.yaml\` before activation or execution (each prompt file specifies which config to load) - Store all config fields as session variables: \`{user_name}\`, \`{communication_language}\`, \`{output_folder}\`, \`{planning_artifacts}\`, \`{implementation_artifacts}\`, \`{project_knowledge}\` - MD-based workflows execute directly — load and follow the \`.md\` file - YAML-based workflows require the workflow engine — load \`workflow.xml\` first, then pass the \`.yaml\` config @@ -504,13 +544,15 @@ Type \`/bmad-\` in Copilot Chat to see all available BMAD workflows and agent ac /** * Load module config.yaml for template variables * @param {string} bmadDir - BMAD installation directory + * @param {string[]} installedModules - List of installed modules to check for config * @returns {Object} Config variables */ - async loadModuleConfig(bmadDir) { - const bmmConfigPath = path.join(bmadDir, 'bmm', 'config.yaml'); - const coreConfigPath = path.join(bmadDir, 'core', 'config.yaml'); + async loadModuleConfig(bmadDir, installedModules = ['core']) { + // Build config paths from installed modules (non-core first, then core as fallback) + const nonCoreModules = installedModules.filter((m) => m !== 'core'); + const configPaths = [...nonCoreModules.map((m) => path.join(bmadDir, m, 'config.yaml')), path.join(bmadDir, 'core', 'config.yaml')]; - for (const configPath of [bmmConfigPath, coreConfigPath]) { + for (const configPath of configPaths) { if (await fs.pathExists(configPath)) { try { const content = await fs.readFile(configPath, 'utf8');