UNPKG

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
/** * 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); }