Compare commits
5 Commits
53a75cbf4a
...
cefdaddfbe
| Author | SHA1 | Date |
|---|---|---|
|
|
cefdaddfbe | |
|
|
1ee10ddcab | |
|
|
147144a1ec | |
|
|
7a016d5efa | |
|
|
c017a5fdba |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue