task-master-neo-sdlc
Version:
Enhanced task management system with Neo SDLC agents and MCP tools for comprehensive, AI-driven software development lifecycle management.
795 lines (688 loc) • 26.9 kB
JavaScript
/**
* QA Agent for Neo System
*
* Specializes in test creation, validation, and quality assurance.
* Implements the "checker" role in the maker-checker pattern.
*/
import { BaseAgent } from './base-agent.js';
import { log } from '../../../utils/logging.js';
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { getAvailableAIModel } from '../../../../scripts/modules/ai-services.js';
import { loadModule, moduleEnvironment } from '../../../utils/module-loader.js';
import readline from 'readline';
// Import QA Agent helper methods
import {
determineProjectType,
ensureTestFrameworkInstalled,
setupCICDIntegration,
collectAndAnalyzeMetrics,
runTestsWithFramework,
generateRefactoringSuggestions
} from './qa-agent-helpers.js';
// Import test framework utilities
import {
TEST_FRAMEWORKS,
TEST_TYPES,
detectTestFrameworks,
installTestFramework,
runTests,
recommendTestFrameworks,
getFrameworkConfig
} from '../testing/test-frameworks.js';
// Import AI test generation utilities
import {
generateTestCasesWithAI,
generateTestCodeWithAI
} from '../testing/ai-test-generator.js';
// Import CI/CD integration utilities
import {
detectCICDSystem,
createCICDConfig
} from '../testing/ci-cd-integration.js';
// Import test metrics utilities
import {
collectTestMetrics,
analyzeTestMetrics,
generateTestReport,
trackTestMetricsOverTime
} from '../testing/test-metrics.js';
export class QAAgent extends BaseAgent {
constructor(id, options = {}) {
// Define QA-specific capabilities
const qaCapabilities = [
'testing',
'quality-assurance',
'test-automation',
'tdd',
'bdd',
'unit-testing',
'integration-testing',
'e2e-testing'
];
// Combine with any passed capabilities
const capabilities = [...new Set([
...(options.capabilities || []),
...qaCapabilities
])];
// Define QA-specific knowledge domains
const qaKnowledgeDomains = [
'jest',
'mocha',
'chai',
'cypress',
'selenium',
'testing-library',
'test-driven-development',
'behavior-driven-development'
];
// Combine with any passed knowledge domains
const knowledgeDomains = [...new Set([
...(options.knowledgeDomains || []),
...qaKnowledgeDomains
])];
// Call parent constructor with enhanced capabilities and knowledge domains
super(id, {
...options,
capabilities,
knowledgeDomains,
role: 'qa'
});
// QA-specific properties
this.testFrameworks = options.testFrameworks || Object.values(TEST_FRAMEWORKS).map(f => f.name);
this.preferredFramework = options.preferredFramework || 'jest';
this.coverageThreshold = options.coverageThreshold || 80;
this.useAI = options.useAI !== undefined ? options.useAI : true;
this.cicdIntegration = options.cicdIntegration !== undefined ? options.cicdIntegration : true;
this.collectMetrics = options.collectMetrics !== undefined ? options.collectMetrics : true;
// Initialize test framework capabilities
this.frameworkCapabilities = {};
Object.values(TEST_FRAMEWORKS).forEach(framework => {
this.frameworkCapabilities[framework.name] = {
supports: framework.supports || [],
capabilities: framework.capabilities || [],
commands: {
install: framework.installCmd,
run: framework.runCmd,
coverage: framework.coverageCmd,
watch: framework.watchCmd
}
};
});
// Initialize test type capabilities
this.testTypeCapabilities = {};
Object.values(TEST_TYPES).forEach(type => {
this.testTypeCapabilities[type.name] = {
description: type.description,
frameworks: type.frameworks || [],
priority: type.priority || 99
};
});
log.info(`QA Agent ${this.id} initialized with ${this.capabilities.length} capabilities`);
}
/**
* Create tests for a component or module
* @param {Object} params - Test creation parameters
* @param {string} params.testTarget - Target component or module to test
* @param {Array<string>} params.testTypes - Types of tests to create (unit, integration, e2e)
* @param {number} params.coverage - Target code coverage percentage
* @param {Array<Object>} params.acceptanceCriteria - Acceptance criteria to test against
* @param {Object} context - Execution context
* @returns {Promise<Object>} Test creation result
*/
async createTest(params, context) {
const { testTarget, testTypes, coverage, acceptanceCriteria, implementationPath } = params;
const targetCoverage = coverage || this.coverageThreshold;
const projectRoot = context.projectRoot || process.cwd();
log.info(`QA Agent ${this.id} creating ${testTypes.join(', ')} tests for ${testTarget}`);
try {
// Determine project type
const projectType = this.determineProjectType(projectRoot);
// Recommend test frameworks based on project type
const recommendedFrameworks = recommendTestFrameworks({
type: projectType,
features: context.features || [],
projectRoot
});
// Determine test framework to use
const testFramework = this.determineTestFramework(testTarget, context, recommendedFrameworks);
log.info(`Selected test framework: ${testFramework}`);
// Install test framework if needed
const frameworkInstalled = await this.ensureTestFrameworkInstalled(testFramework, projectRoot);
if (!frameworkInstalled) {
log.warn(`Could not install ${testFramework}, but will continue with test generation`);
}
// Generate test cases
let testCases;
if (this.useAI) {
// Use AI to generate test cases
log.info(`Generating test cases with AI for ${testTarget}`);
testCases = await generateTestCasesWithAI({
testTarget,
testTypes,
acceptanceCriteria,
implementationPath,
framework: testFramework
}, context);
} else {
// Use template-based test case generation
testCases = this.generateTestCases(testTarget, acceptanceCriteria, testTypes);
}
log.info(`Generated ${testCases.length} test cases for ${testTarget}`);
// Generate test code
let testCode;
if (this.useAI) {
// Use AI to generate test code
log.info(`Generating test code with AI for ${testTarget}`);
testCode = await generateTestCodeWithAI({
testTarget,
testCases,
framework: testFramework,
testTypes,
implementationPath
}, context);
} else {
// Use template-based test code generation
testCode = await this.generateTestCode(testTarget, testCases, testFramework, testTypes);
}
// Determine test file path
const testPath = this.determineTestPath(testTarget, testFramework, projectRoot);
// Write test file
await this.writeTestFile(testPath, testCode);
// Run tests if requested
let testResults = null;
if (context.runTests) {
testResults = await this.runTests(testPath, testFramework, projectRoot);
}
// Set up CI/CD integration if requested
let cicdConfig = null;
if (this.cicdIntegration && context.setupCICD) {
cicdConfig = await this.setupCICDIntegration(testFramework, projectRoot, context);
}
// Collect metrics if requested
let metrics = null;
if (this.collectMetrics && context.collectMetrics) {
metrics = await this.collectAndAnalyzeMetrics(testFramework, projectRoot, testResults);
}
return {
testPath,
testCode,
testCases: testCases.map(tc => tc.description),
testFramework,
testResults,
coverage: testResults?.coverage || targetCoverage,
cicdConfig,
metrics
};
} catch (error) {
log.error(`Error creating tests for ${testTarget}: ${error.message}`);
throw error;
}
}
/**
* Validate implementation against tests
* @param {Object} params - Validation parameters
* @param {string} params.implementationPath - Path to implementation
* @param {string} params.testPath - Path to tests
* @param {string} params.testFramework - Test framework to use
* @param {Array<string>} params.testTypes - Types of tests to run
* @param {Object} context - Execution context
* @returns {Promise<Object>} Validation result
*/
async validateImplementation(params, context) {
const { implementationPath, testPath, testFramework: specifiedFramework, testTypes } = params;
const projectRoot = context.projectRoot || process.cwd();
log.info(`QA Agent ${this.id} validating implementation at ${implementationPath} against tests at ${testPath}`);
try {
// Determine test framework
const testFramework = specifiedFramework || this.determineTestFrameworkFromPath(testPath);
// Run tests
const testResults = await this.runTests(testPath, testFramework, projectRoot, {
coverage: true
});
// Analyze test results
const validationResult = this.analyzeTestResults(testResults, params);
// Generate feedback
const feedback = this.generateFeedback(validationResult, params);
// Collect metrics if requested
let metrics = null;
if (this.collectMetrics && context.collectMetrics) {
metrics = await collectAndAnalyzeMetrics(testFramework, projectRoot, testResults);
}
return {
passed: validationResult.passed,
coverage: validationResult.coverage,
feedback,
testResults,
implementationPath,
testPath,
metrics
};
} catch (error) {
log.error(`Error validating implementation: ${error.message}`);
// Return a failed validation result instead of throwing
return {
passed: false,
coverage: 0,
feedback: `Error validating implementation: ${error.message}`,
testResults: null,
implementationPath,
testPath,
error: error.message
};
}
}
/**
* Generate refactoring suggestions based on test results
* @param {Object} params - Refactoring parameters
* @param {string} params.implementationPath - Path to implementation
* @param {string} params.testPath - Path to tests
* @param {Object} params.testResults - Test results
* @param {string} params.testCode - Test code
* @param {Object} context - Execution context
* @returns {Promise<Object>} Refactoring suggestions
*/
async generateRefactoringSuggestions(params, context) {
const { implementationPath, testPath, testResults, testCode } = params;
log.info(`QA Agent ${this.id} generating refactoring suggestions for ${implementationPath}`);
try {
// Use the helper function to generate refactoring suggestions
return await generateRefactoringSuggestions(params, context);
} catch (error) {
log.error(`Error generating refactoring suggestions: ${error.message}`);
return {
refactoredCode: null,
explanation: `Error generating refactoring suggestions: ${error.message}`,
implementationPath
};
}
}
/**
* Determine the appropriate test framework to use
* @param {string} testTarget - Target component or module to test
* @param {Object} context - Execution context
* @param {Object} recommendedFrameworks - Recommended frameworks by test type
* @returns {string} Test framework to use
*/
determineTestFramework(testTarget, context, recommendedFrameworks = {}) {
// Check if context specifies a framework
if (context.testFramework && this.testFrameworks.includes(context.testFramework)) {
return context.testFramework;
}
// Get test types from context or default to unit
const testTypes = context.testTypes || ['unit'];
// Check if we have recommended frameworks for the test types
if (recommendedFrameworks && Object.keys(recommendedFrameworks).length > 0) {
// Find the highest priority test type
const highestPriorityType = testTypes.sort((a, b) => {
const priorityA = this.testTypeCapabilities[a]?.priority || 99;
const priorityB = this.testTypeCapabilities[b]?.priority || 99;
return priorityA - priorityB;
})[0];
// Use the recommended framework for this test type
if (recommendedFrameworks[highestPriorityType]) {
return recommendedFrameworks[highestPriorityType];
}
}
// Check if there's a package.json with test framework info
try {
const projectRoot = context.projectRoot || process.cwd();
const packageJsonPath = path.join(projectRoot, 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Check dependencies for test frameworks
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies
};
// Check for each framework in order of preference for the test types
for (const testType of testTypes) {
const frameworks = this.testTypeCapabilities[testType]?.frameworks || [];
for (const framework of frameworks) {
// Check for framework-specific packages
switch (framework) {
case 'jest':
if (deps.jest) return 'jest';
break;
case 'vitest':
if (deps.vitest) return 'vitest';
break;
case 'mocha':
if (deps.mocha) return 'mocha';
break;
case 'cypress':
if (deps.cypress) return 'cypress';
break;
case 'playwright':
if (deps['@playwright/test']) return 'playwright';
break;
default:
if (deps[framework]) return framework;
}
}
}
}
} catch (error) {
log.warn(`Error reading package.json: ${error.message}`);
}
// Check if the test target is a React component
const isReactComponent = testTarget.charAt(0).toUpperCase() === testTarget.charAt(0);
// For React components, prefer Jest or Vitest
if (isReactComponent) {
if (this.testFrameworks.includes('jest')) return 'jest';
if (this.testFrameworks.includes('vitest')) return 'vitest';
}
// For E2E tests, prefer Cypress or Playwright
if (testTypes.includes('e2e')) {
if (this.testFrameworks.includes('cypress')) return 'cypress';
if (this.testFrameworks.includes('playwright')) return 'playwright';
}
// For API tests, prefer Supertest
if (testTypes.includes('api')) {
if (this.testFrameworks.includes('supertest')) return 'supertest';
}
// Default to preferred framework
return this.preferredFramework;
}
/**
* Determine test framework from test file path
* @param {string} testPath - Path to test file
* @returns {string} Test framework
*/
determineTestFrameworkFromPath(testPath) {
if (testPath.includes('.spec.')) return 'jest';
if (testPath.includes('.test.')) return 'jest';
if (testPath.includes('.cy.')) return 'cypress';
if (testPath.endsWith('.spec.js') || testPath.endsWith('.spec.ts')) return 'mocha';
// Default to preferred framework
return this.preferredFramework;
}
/**
* Generate test cases based on acceptance criteria
* @param {string} testTarget - Target component or module to test
* @param {Array<Object>} acceptanceCriteria - Acceptance criteria to test against
* @param {Array<string>} testTypes - Types of tests to create
* @returns {Array<Object>} Generated test cases
*/
generateTestCases(testTarget, acceptanceCriteria, testTypes) {
const testCases = [];
// If no acceptance criteria provided, generate basic test cases
if (!acceptanceCriteria || acceptanceCriteria.length === 0) {
if (testTypes.includes('unit')) {
testCases.push(
{ type: 'unit', description: `Should render ${testTarget} without crashing` },
{ type: 'unit', description: `Should match snapshot` }
);
}
if (testTypes.includes('integration')) {
testCases.push(
{ type: 'integration', description: `Should interact correctly with dependencies` }
);
}
if (testTypes.includes('e2e')) {
testCases.push(
{ type: 'e2e', description: `Should work in an end-to-end flow` }
);
}
return testCases;
}
// Generate test cases from acceptance criteria
acceptanceCriteria.forEach(criterion => {
if (typeof criterion === 'string') {
// Simple string criterion
testCases.push({
type: 'unit',
description: `Should satisfy: ${criterion}`,
criterion
});
} else if (criterion.criterion && criterion.testCases) {
// Structured criterion with specific test cases
criterion.testCases.forEach(tc => {
testCases.push({
type: testTypes.includes('unit') ? 'unit' : 'integration',
description: tc,
criterion: criterion.criterion
});
});
}
});
return testCases;
}
/**
* Generate test code based on test cases
* @param {string} testTarget - Target component or module to test
* @param {Array<Object>} testCases - Test cases to implement
* @param {string} testFramework - Test framework to use
* @param {Array<string>} testTypes - Types of tests to create
* @returns {Promise<string>} Generated test code
*/
async generateTestCode(testTarget, testCases, testFramework, testTypes) {
// This would use AI to generate the actual test code
// For now, we'll use a template-based approach
let testCode = '';
switch (testFramework) {
case 'jest':
testCode = this.generateJestTestCode(testTarget, testCases, testTypes);
break;
case 'mocha':
testCode = this.generateMochaTestCode(testTarget, testCases, testTypes);
break;
case 'cypress':
testCode = this.generateCypressTestCode(testTarget, testCases, testTypes);
break;
default:
testCode = this.generateJestTestCode(testTarget, testCases, testTypes);
}
return testCode;
}
/**
* Generate Jest test code
* @param {string} testTarget - Target component or module to test
* @param {Array<Object>} testCases - Test cases to implement
* @param {Array<string>} testTypes - Types of tests to create
* @returns {string} Generated Jest test code
*/
generateJestTestCode(testTarget, testCases, testTypes) {
// Determine if this is likely a React component
const isReactComponent = testTarget.charAt(0).toUpperCase() === testTarget.charAt(0);
let imports = '';
let testSetup = '';
let testBody = '';
if (isReactComponent && testTypes.includes('unit')) {
imports = `import React from 'react';\nimport { render, screen, fireEvent } from '@testing-library/react';\nimport ${testTarget} from '../src/components/${testTarget}';\n\n`;
testSetup = `describe('${testTarget} Component', () => {\n`;
// Group test cases by type
const unitTests = testCases.filter(tc => tc.type === 'unit');
unitTests.forEach(tc => {
testBody += ` test('${tc.description}', () => {\n`;
testBody += ` // TODO: Implement test for: ${tc.criterion || tc.description}\n`;
testBody += ` render(<${testTarget} />);\n`;
if (tc.description.toLowerCase().includes('render')) {
testBody += ` expect(screen.getByTestId('${testTarget.toLowerCase()}')).toBeInTheDocument();\n`;
} else if (tc.description.toLowerCase().includes('snapshot')) {
testBody += ` const { container } = render(<${testTarget} />);\n`;
testBody += ` expect(container).toMatchSnapshot();\n`;
} else {
testBody += ` // Add assertions based on acceptance criteria\n`;
}
testBody += ` });\n\n`;
});
testBody += `});\n`;
} else {
// Non-React module
imports = `import ${testTarget} from '../src/${testTarget}';\n\n`;
testSetup = `describe('${testTarget}', () => {\n`;
testCases.forEach(tc => {
testBody += ` test('${tc.description}', () => {\n`;
testBody += ` // TODO: Implement test for: ${tc.criterion || tc.description}\n`;
testBody += ` // Add assertions based on acceptance criteria\n`;
testBody += ` expect(true).toBe(true); // Placeholder assertion\n`;
testBody += ` });\n\n`;
});
testBody += `});\n`;
}
return imports + testSetup + testBody;
}
/**
* Generate Mocha test code
* @param {string} testTarget - Target component or module to test
* @param {Array<Object>} testCases - Test cases to implement
* @param {Array<string>} testTypes - Types of tests to create
* @returns {string} Generated Mocha test code
*/
generateMochaTestCode(testTarget, testCases, testTypes) {
let imports = `const { expect } = require('chai');\nconst ${testTarget} = require('../src/${testTarget}');\n\n`;
let testSetup = `describe('${testTarget}', () => {\n`;
let testBody = '';
testCases.forEach(tc => {
testBody += ` it('${tc.description}', () => {\n`;
testBody += ` // TODO: Implement test for: ${tc.criterion || tc.description}\n`;
testBody += ` // Add assertions based on acceptance criteria\n`;
testBody += ` expect(true).to.equal(true); // Placeholder assertion\n`;
testBody += ` });\n\n`;
});
testBody += `});\n`;
return imports + testSetup + testBody;
}
/**
* Generate Cypress test code
* @param {string} testTarget - Target component or module to test
* @param {Array<Object>} testCases - Test cases to implement
* @param {Array<string>} testTypes - Types of tests to create
* @returns {string} Generated Cypress test code
*/
generateCypressTestCode(testTarget, testCases, testTypes) {
let testSetup = `describe('${testTarget}', () => {\n`;
let testBody = '';
testCases.forEach(tc => {
testBody += ` it('${tc.description}', () => {\n`;
testBody += ` // TODO: Implement test for: ${tc.criterion || tc.description}\n`;
testBody += ` cy.visit('/');\n`;
testBody += ` // Add Cypress commands based on acceptance criteria\n`;
testBody += ` });\n\n`;
});
testBody += `});\n`;
return testSetup + testBody;
}
/**
* Determine the appropriate test file path
* @param {string} testTarget - Target component or module to test
* @param {string} testFramework - Test framework to use
* @returns {string} Test file path
*/
determineTestPath(testTarget, testFramework) {
// Determine if this is likely a React component
const isReactComponent = testTarget.charAt(0).toUpperCase() === testTarget.charAt(0);
let testDir = '';
let testExt = '';
switch (testFramework) {
case 'jest':
testDir = isReactComponent ? 'tests/components' : 'tests/units';
testExt = '.test.js';
break;
case 'mocha':
testDir = 'test';
testExt = '.spec.js';
break;
case 'cypress':
testDir = 'cypress/integration';
testExt = '.spec.js';
break;
default:
testDir = 'tests';
testExt = '.test.js';
}
return `${testDir}/${testTarget}${testExt}`;
}
/**
* Write test file to disk
* @param {string} testPath - Path to write test file
* @param {string} testCode - Test code to write
* @returns {Promise<void>}
*/
async writeTestFile(testPath, testCode) {
try {
// Create directory if it doesn't exist
const testDir = path.dirname(testPath);
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true });
}
// Write test file
fs.writeFileSync(testPath, testCode, 'utf8');
log.info(`Test file written to ${testPath}`);
} catch (error) {
log.error(`Error writing test file: ${error.message}`);
throw error;
}
}
/**
* Run tests
* @param {string} testPath - Path to test file
* @param {string} testFramework - Test framework to use
* @param {string} projectRoot - Project root directory
* @param {Object} options - Additional options
* @returns {Promise<Object>} Test results
*/
async runTests(testPath, testFramework, projectRoot = process.cwd(), options = {}) {
log.info(`Running tests at ${testPath} with ${testFramework}`);
try {
// Use the runTestsWithFramework helper
return await runTestsWithFramework(testPath, testFramework, projectRoot, options);
} catch (error) {
log.error(`Error running tests: ${error.message}`);
// Return a failed test result instead of throwing
return {
passed: false,
failures: 1,
totalTests: 1,
coverage: 0,
duration: 0,
testFile: testPath,
error: error.message
};
}
}
/**
* Analyze test results
* @param {Object} testResults - Test results
* @param {Object} params - Validation parameters
* @returns {Object} Analysis result
*/
analyzeTestResults(testResults, params) {
const { coverage = 80 } = params;
const passed = testResults.passed && testResults.coverage >= coverage;
return {
passed,
coverage: testResults.coverage,
meetsCoverageThreshold: testResults.coverage >= coverage,
failures: testResults.failures,
totalTests: testResults.totalTests
};
}
/**
* Generate feedback based on validation result
* @param {Object} validationResult - Validation result
* @param {Object} params - Validation parameters
* @returns {string} Feedback
*/
generateFeedback(validationResult, params) {
let feedback = '';
if (validationResult.passed) {
feedback += `✅ Implementation passed all tests with ${validationResult.coverage}% coverage.\n`;
} else {
feedback += `❌ Implementation failed validation.\n`;
if (validationResult.failures > 0) {
feedback += `- ${validationResult.failures} test failures detected.\n`;
}
if (!validationResult.meetsCoverageThreshold) {
feedback += `- Coverage of ${validationResult.coverage}% does not meet the threshold of ${params.coverage || 80}%.\n`;
}
}
return feedback;
}
}
// Export a factory function to create QA agents
export function createQAAgent(id, options = {}) {
return new QAAgent(id, options);
}