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.

718 lines (658 loc) 24.1 kB
/** * Test Frameworks Configuration * * Defines supported test frameworks, their configurations, and utilities. */ import { log } from '../../../utils/logging.js'; import path from 'path'; import fs from 'fs'; import { execSync } from 'child_process'; /** * Supported test frameworks */ export const TEST_FRAMEWORKS = { // Unit and Component Testing JEST: { name: 'jest', type: 'unit', fileExtension: '.test.js', tsFileExtension: '.test.ts', reactFileExtension: '.test.jsx', reactTsFileExtension: '.test.tsx', defaultDir: 'tests', configFile: 'jest.config.js', installCmd: 'npm install --save-dev jest @testing-library/react @testing-library/jest-dom', runCmd: 'npx jest', coverageCmd: 'npx jest --coverage', watchCmd: 'npx jest --watch', initCmd: 'npx jest --init', supports: ['unit', 'integration', 'snapshot'], capabilities: ['react', 'vue', 'angular', 'node', 'typescript'] }, VITEST: { name: 'vitest', type: 'unit', fileExtension: '.test.js', tsFileExtension: '.test.ts', reactFileExtension: '.test.jsx', reactTsFileExtension: '.test.tsx', defaultDir: 'tests', configFile: 'vitest.config.js', installCmd: 'npm install --save-dev vitest @testing-library/react jsdom @testing-library/jest-dom', runCmd: 'npx vitest run', coverageCmd: 'npx vitest run --coverage', watchCmd: 'npx vitest', initCmd: 'npm install --save-dev vitest', supports: ['unit', 'integration', 'snapshot'], capabilities: ['react', 'vue', 'svelte', 'node', 'typescript', 'vite'] }, MOCHA: { name: 'mocha', type: 'unit', fileExtension: '.spec.js', tsFileExtension: '.spec.ts', defaultDir: 'test', configFile: '.mocharc.js', installCmd: 'npm install --save-dev mocha chai sinon', runCmd: 'npx mocha', coverageCmd: 'npx nyc mocha', watchCmd: 'npx mocha --watch', initCmd: 'npx mocha --init', supports: ['unit', 'integration'], capabilities: ['node', 'browser', 'typescript'] }, JASMINE: { name: 'jasmine', type: 'unit', fileExtension: '.spec.js', tsFileExtension: '.spec.ts', defaultDir: 'spec', configFile: 'jasmine.json', installCmd: 'npm install --save-dev jasmine', runCmd: 'npx jasmine', coverageCmd: 'npx nyc jasmine', watchCmd: 'npx jasmine-node --autotest .', initCmd: 'npx jasmine init', supports: ['unit', 'integration'], capabilities: ['node', 'browser', 'angular'] }, AVA: { name: 'ava', type: 'unit', fileExtension: '.test.js', tsFileExtension: '.test.ts', defaultDir: 'test', configFile: 'ava.config.js', installCmd: 'npm install --save-dev ava', runCmd: 'npx ava', coverageCmd: 'npx c8 ava', watchCmd: 'npx ava --watch', initCmd: 'npx ava --init', supports: ['unit', 'integration'], capabilities: ['node', 'typescript', 'esm'] }, RTL: { name: 'rtl', type: 'component', fileExtension: '.test.js', tsFileExtension: '.test.ts', reactFileExtension: '.test.jsx', reactTsFileExtension: '.test.tsx', defaultDir: 'tests', configFile: 'jest.config.js', // Usually used with Jest installCmd: 'npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event', runCmd: 'npx jest', coverageCmd: 'npx jest --coverage', watchCmd: 'npx jest --watch', initCmd: null, // Uses Jest init supports: ['component', 'unit', 'integration'], capabilities: ['react', 'hooks', 'dom', 'events'] }, // End-to-End Testing CYPRESS: { name: 'cypress', type: 'e2e', fileExtension: '.cy.js', tsFileExtension: '.cy.ts', reactFileExtension: '.cy.jsx', reactTsFileExtension: '.cy.tsx', defaultDir: 'cypress/e2e', configFile: 'cypress.config.js', installCmd: 'npm install --save-dev cypress', runCmd: 'npx cypress run', coverageCmd: 'npx cypress run --env coverage=true', watchCmd: 'npx cypress open', initCmd: 'npx cypress open', supports: ['e2e', 'component', 'visual', 'api'], capabilities: ['react', 'vue', 'angular', 'web'] }, PLAYWRIGHT: { name: 'playwright', type: 'e2e', fileExtension: '.spec.js', tsFileExtension: '.spec.ts', defaultDir: 'tests', configFile: 'playwright.config.js', installCmd: 'npm install --save-dev @playwright/test', runCmd: 'npx playwright test', coverageCmd: 'npx playwright test --reporter=html', watchCmd: 'npx playwright test --ui', initCmd: 'npx playwright install', supports: ['e2e', 'visual', 'api', 'accessibility'], capabilities: ['react', 'vue', 'angular', 'web', 'mobile-web', 'cross-browser'] }, PUPPETEER: { name: 'puppeteer', type: 'e2e', fileExtension: '.test.js', tsFileExtension: '.test.ts', defaultDir: 'tests/e2e', configFile: null, // Puppeteer doesn't have a standard config file installCmd: 'npm install --save-dev puppeteer jest', runCmd: 'npx jest', coverageCmd: 'npx jest --coverage', watchCmd: 'npx jest --watch', initCmd: 'npm install --save-dev puppeteer', supports: ['e2e', 'visual', 'performance'], capabilities: ['web', 'headless', 'screenshots', 'pdf'] }, WEBDRIVERIO: { name: 'webdriverio', type: 'e2e', fileExtension: '.spec.js', tsFileExtension: '.spec.ts', defaultDir: 'test/specs', configFile: 'wdio.conf.js', installCmd: 'npm install --save-dev @wdio/cli', runCmd: 'npx wdio run wdio.conf.js', coverageCmd: 'npx wdio run wdio.conf.js --coverage', watchCmd: 'npx wdio run wdio.conf.js --watch', initCmd: 'npx wdio config', supports: ['e2e', 'mobile', 'api', 'visual'], capabilities: ['web', 'mobile', 'cross-browser', 'parallel'] }, // API Testing SUPERTEST: { name: 'supertest', type: 'api', fileExtension: '.api.test.js', tsFileExtension: '.api.test.ts', defaultDir: 'tests/api', configFile: null, // Usually used with Jest or Mocha installCmd: 'npm install --save-dev supertest', runCmd: 'npx jest', coverageCmd: 'npx jest --coverage', watchCmd: 'npx jest --watch', initCmd: 'npm install --save-dev supertest', supports: ['api', 'integration'], capabilities: ['rest', 'http', 'express', 'node'] }, POSTMAN: { name: 'newman', type: 'api', fileExtension: '.postman_collection.json', defaultDir: 'tests/api', configFile: null, installCmd: 'npm install --save-dev newman', runCmd: 'npx newman run', coverageCmd: 'npx newman run --reporters cli,html', watchCmd: null, initCmd: null, // Collections are created in Postman UI supports: ['api', 'integration'], capabilities: ['rest', 'graphql', 'soap', 'websocket'] }, // Visual Regression Testing STORYBOOK: { name: 'storybook', type: 'visual', fileExtension: '.stories.js', tsFileExtension: '.stories.ts', reactFileExtension: '.stories.jsx', reactTsFileExtension: '.stories.tsx', defaultDir: 'src/stories', configFile: '.storybook/main.js', installCmd: 'npx storybook init', runCmd: 'npm run storybook', coverageCmd: null, watchCmd: 'npm run storybook', initCmd: 'npx storybook init', supports: ['visual', 'component', 'accessibility'], capabilities: ['react', 'vue', 'angular', 'web', 'design-system'] } }; /** * Test types with descriptions */ export const TEST_TYPES = { UNIT: { name: 'unit', description: 'Tests individual functions, methods, or classes in isolation', frameworks: ['jest', 'vitest', 'mocha', 'jasmine', 'ava', 'rtl'], priority: 1 }, INTEGRATION: { name: 'integration', description: 'Tests interactions between different parts of the application', frameworks: ['jest', 'vitest', 'mocha', 'jasmine', 'supertest', 'ava', 'rtl'], priority: 2 }, E2E: { name: 'e2e', description: 'Tests the application from end to end, simulating user behavior', frameworks: ['cypress', 'playwright', 'puppeteer', 'webdriverio'], priority: 3 }, API: { name: 'api', description: 'Tests API endpoints and responses', frameworks: ['supertest', 'newman', 'cypress', 'playwright'], priority: 2 }, VISUAL: { name: 'visual', description: 'Tests the visual appearance of components', frameworks: ['storybook', 'cypress', 'playwright', 'puppeteer'], priority: 4 }, PERFORMANCE: { name: 'performance', description: 'Tests the performance of the application', frameworks: ['lighthouse', 'puppeteer'], priority: 5 }, ACCESSIBILITY: { name: 'accessibility', description: 'Tests the accessibility of the application', frameworks: ['storybook', 'cypress', 'playwright'], priority: 4 }, SNAPSHOT: { name: 'snapshot', description: 'Tests that components render consistently', frameworks: ['jest', 'vitest'], priority: 3 } }; /** * Detect test frameworks in a project * @param {string} projectRoot - Project root directory * @returns {Array<string>} Detected test frameworks */ export function detectTestFrameworks(projectRoot) { try { const packageJsonPath = path.join(projectRoot, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return ['jest']; // Default to Jest if no package.json } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }; const detectedFrameworks = []; // Check for each framework if (dependencies.jest) detectedFrameworks.push('jest'); if (dependencies.vitest) detectedFrameworks.push('vitest'); if (dependencies.mocha) detectedFrameworks.push('mocha'); if (dependencies.jasmine) detectedFrameworks.push('jasmine'); if (dependencies.ava) detectedFrameworks.push('ava'); if (dependencies['@testing-library/react']) detectedFrameworks.push('rtl'); if (dependencies.cypress) detectedFrameworks.push('cypress'); if (dependencies['@playwright/test']) detectedFrameworks.push('playwright'); if (dependencies.puppeteer) detectedFrameworks.push('puppeteer'); if (dependencies['@wdio/cli']) detectedFrameworks.push('webdriverio'); if (dependencies.supertest) detectedFrameworks.push('supertest'); if (dependencies.newman) detectedFrameworks.push('newman'); if (dependencies['@storybook/react'] || dependencies['@storybook/vue']) detectedFrameworks.push('storybook'); // Check for config files if no frameworks detected in dependencies if (detectedFrameworks.length === 0) { if (fs.existsSync(path.join(projectRoot, 'jest.config.js'))) detectedFrameworks.push('jest'); if (fs.existsSync(path.join(projectRoot, 'vitest.config.js'))) detectedFrameworks.push('vitest'); if (fs.existsSync(path.join(projectRoot, '.mocharc.js'))) detectedFrameworks.push('mocha'); if (fs.existsSync(path.join(projectRoot, 'jasmine.json'))) detectedFrameworks.push('jasmine'); if (fs.existsSync(path.join(projectRoot, 'ava.config.js'))) detectedFrameworks.push('ava'); if (fs.existsSync(path.join(projectRoot, 'cypress.config.js'))) detectedFrameworks.push('cypress'); if (fs.existsSync(path.join(projectRoot, 'playwright.config.js'))) detectedFrameworks.push('playwright'); if (fs.existsSync(path.join(projectRoot, 'wdio.conf.js'))) detectedFrameworks.push('webdriverio'); if (fs.existsSync(path.join(projectRoot, '.storybook'))) detectedFrameworks.push('storybook'); // Check for RTL by looking for testing-library imports in test files const testFiles = findTestFiles(projectRoot); if (testFiles.some(file => { try { const content = fs.readFileSync(file, 'utf8'); return content.includes('@testing-library/react') || content.includes('@testing-library/vue') || content.includes('render(') || content.includes('screen.'); } catch (e) { return false; } })) { detectedFrameworks.push('rtl'); } } return detectedFrameworks.length > 0 ? detectedFrameworks : ['jest']; // Default to Jest if nothing detected } catch (error) { log.warn(`Error detecting test frameworks: ${error.message}`); return ['jest']; // Default to Jest on error } } /** * Install a test framework * @param {string} framework - Framework name * @param {string} projectRoot - Project root directory * @returns {Promise<boolean>} Whether installation was successful */ export async function installTestFramework(framework, projectRoot) { try { const frameworkConfig = Object.values(TEST_FRAMEWORKS).find(f => f.name === framework); if (!frameworkConfig) { throw new Error(`Unknown test framework: ${framework}`); } log.info(`Installing ${framework} in ${projectRoot}...`); // Execute the install command execSync(frameworkConfig.installCmd, { cwd: projectRoot, stdio: 'inherit' }); // Run init command if it exists if (frameworkConfig.initCmd) { execSync(frameworkConfig.initCmd, { cwd: projectRoot, stdio: 'inherit' }); } log.info(`Successfully installed ${framework}`); return true; } catch (error) { log.error(`Error installing ${framework}: ${error.message}`); return false; } } /** * Run tests with a specific framework * @param {string} framework - Framework name * @param {string} testPath - Path to test file or directory * @param {Object} options - Options for running tests * @param {boolean} options.coverage - Whether to collect coverage * @param {boolean} options.watch - Whether to run in watch mode * @param {string} options.projectRoot - Project root directory * @returns {Promise<Object>} Test results */ export async function runTests(framework, testPath, options = {}) { try { const { coverage = false, watch = false, projectRoot = process.cwd() } = options; const frameworkConfig = Object.values(TEST_FRAMEWORKS).find(f => f.name === framework); if (!frameworkConfig) { throw new Error(`Unknown test framework: ${framework}`); } let command; if (coverage && frameworkConfig.coverageCmd) { command = `${frameworkConfig.coverageCmd} ${testPath}`; } else if (watch && frameworkConfig.watchCmd) { command = `${frameworkConfig.watchCmd} ${testPath}`; } else { command = `${frameworkConfig.runCmd} ${testPath}`; } log.info(`Running tests with ${framework}: ${command}`); // Execute the command const output = execSync(command, { cwd: projectRoot, encoding: 'utf8' }); // Parse the output to extract test results // This is a simplified version; in reality, you'd need to parse the output based on the framework const passed = !output.includes('FAIL') && !output.includes('ERROR'); const failures = (output.match(/FAIL/g) || []).length; const totalTests = (output.match(/PASS|FAIL/g) || []).length; // Extract coverage if available let coverageResult = null; if (coverage) { const coverageMatch = output.match(/All files[^\n]*?(\d+(?:\.\d+)?)%/); if (coverageMatch) { coverageResult = parseFloat(coverageMatch[1]); } } return { passed, failures, totalTests, coverage: coverageResult, output, framework, testPath }; } catch (error) { log.error(`Error running tests with ${framework}: ${error.message}`); return { passed: false, failures: 1, totalTests: 1, coverage: null, output: error.message, framework, testPath, error: error.message }; } } /** * Recommend test frameworks based on project type * @param {Object} projectInfo - Project information * @param {string} projectInfo.type - Project type (react, vue, node, etc.) * @param {Array<string>} projectInfo.features - Project features * @param {string} projectInfo.projectRoot - Project root directory * @returns {Object} Recommended test frameworks */ export function recommendTestFrameworks(projectInfo) { const { type, features = [], projectRoot } = projectInfo; // Default recommendations const recommendations = { unit: 'jest', integration: 'jest', e2e: 'cypress', api: 'supertest', visual: 'storybook' }; // Check existing frameworks const existingFrameworks = detectTestFrameworks(projectRoot); // Adjust recommendations based on project type switch (type) { case 'react': recommendations.unit = existingFrameworks.includes('vitest') ? 'vitest' : 'jest'; recommendations.e2e = existingFrameworks.includes('playwright') ? 'playwright' : 'cypress'; break; case 'vue': recommendations.unit = existingFrameworks.includes('vitest') ? 'vitest' : 'jest'; break; case 'angular': recommendations.unit = 'jasmine'; // Angular uses Jasmine by default break; case 'node': recommendations.unit = existingFrameworks.includes('mocha') ? 'mocha' : 'jest'; recommendations.api = 'supertest'; break; default: // Use detected frameworks if available if (existingFrameworks.length > 0) { const unitFrameworks = existingFrameworks.filter(f => ['jest', 'vitest', 'mocha', 'jasmine'].includes(f) ); if (unitFrameworks.length > 0) { recommendations.unit = unitFrameworks[0]; recommendations.integration = unitFrameworks[0]; } const e2eFrameworks = existingFrameworks.filter(f => ['cypress', 'playwright', 'puppeteer'].includes(f) ); if (e2eFrameworks.length > 0) { recommendations.e2e = e2eFrameworks[0]; } } } // Adjust based on features if (features.includes('api')) { recommendations.api = 'supertest'; } if (features.includes('graphql')) { recommendations.api = 'apollo-client-testing'; } if (features.includes('design-system')) { recommendations.visual = 'storybook'; } if (features.includes('accessibility')) { recommendations.e2e = 'playwright'; // Playwright has better accessibility testing } return recommendations; } /** * Get framework configuration by name * @param {string} frameworkName - Framework name * @returns {Object} Framework configuration */ export function getFrameworkConfig(frameworkName) { return Object.values(TEST_FRAMEWORKS).find(f => f.name === frameworkName) || TEST_FRAMEWORKS.JEST; } /** * Get test type configuration by name * @param {string} typeName - Test type name * @returns {Object} Test type configuration */ export function getTestTypeConfig(typeName) { return Object.values(TEST_TYPES).find(t => t.name === typeName) || TEST_TYPES.UNIT; } /** * Find test files in a project * @param {string} projectRoot - Project root directory * @returns {Array<string>} Test file paths */ function findTestFiles(projectRoot) { try { const testFiles = []; const testPatterns = [ '**/*.test.js', '**/*.test.jsx', '**/*.test.ts', '**/*.test.tsx', '**/*.spec.js', '**/*.spec.jsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**/*.js', '**/test/**/*.jsx', '**/test/**/*.ts', '**/test/**/*.tsx', '**/tests/**/*.js', '**/tests/**/*.jsx', '**/tests/**/*.ts', '**/tests/**/*.tsx', '**/spec/**/*.js', '**/spec/**/*.jsx', '**/spec/**/*.ts', '**/spec/**/*.tsx', '**/cypress/e2e/**/*.js', '**/cypress/e2e/**/*.ts' ]; // This is a simplified implementation // In a real implementation, you would use a glob library to find files // For now, we'll just check a few common directories const commonDirs = ['test', 'tests', 'spec', 'specs', 'src', 'cypress/e2e']; for (const dir of commonDirs) { const dirPath = path.join(projectRoot, dir); if (fs.existsSync(dirPath)) { const files = findFilesInDir(dirPath, (file) => { return file.endsWith('.test.js') || file.endsWith('.test.jsx') || file.endsWith('.test.ts') || file.endsWith('.test.tsx') || file.endsWith('.spec.js') || file.endsWith('.spec.jsx') || file.endsWith('.spec.ts') || file.endsWith('.spec.tsx'); }); testFiles.push(...files); } } return testFiles; } catch (error) { log.warn(`Error finding test files: ${error.message}`); return []; } } /** * Find files in a directory recursively * @param {string} dir - Directory to search * @param {Function} filter - Filter function * @returns {Array<string>} File paths */ function findFilesInDir(dir, filter) { if (!fs.existsSync(dir)) { return []; } const files = []; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...findFilesInDir(fullPath, filter)); } else if (filter(fullPath)) { files.push(fullPath); } } return files; } /** * Handle ESM/CJS interoperability issues * @param {string} projectRoot - Project root directory * @param {string} framework - Test framework * @returns {Promise<Object>} Configuration result */ export async function handleModuleInterop(projectRoot, framework) { try { const packageJsonPath = path.join(projectRoot, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return { success: false, message: 'package.json not found' }; } const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const isESM = packageJson.type === 'module'; // Handle Jest with ESM if (framework === 'jest' && isESM) { const jestConfigPath = path.join(projectRoot, 'jest.config.js'); // Create Jest config for ESM if it doesn't exist if (!fs.existsSync(jestConfigPath)) { const jestConfig = `export default { transform: {}, extensionsToTreatAsEsm: ['.js', '.jsx', '.ts', '.tsx'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, testEnvironment: 'node', }; `; fs.writeFileSync(jestConfigPath, jestConfig, 'utf8'); log.info('Created Jest config for ESM compatibility'); } else { // Update existing Jest config let jestConfig = fs.readFileSync(jestConfigPath, 'utf8'); if (!jestConfig.includes('extensionsToTreatAsEsm')) { // This is a simplified approach; in a real implementation, you would parse the config if (jestConfig.includes('module.exports')) { // Convert to ESM jestConfig = jestConfig.replace('module.exports', 'export default'); } // Add ESM configuration jestConfig = jestConfig.replace('export default {', `export default { transform: {}, extensionsToTreatAsEsm: ['.js', '.jsx', '.ts', '.tsx'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', },`); fs.writeFileSync(jestConfigPath, jestConfig, 'utf8'); log.info('Updated Jest config for ESM compatibility'); } } return { success: true, message: 'Configured Jest for ESM compatibility' }; } // Handle Vitest with CJS if (framework === 'vitest' && !isESM) { const vitestConfigPath = path.join(projectRoot, 'vitest.config.js'); // Create Vitest config for CJS if it doesn't exist if (!fs.existsSync(vitestConfigPath)) { const vitestConfig = `import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', }, }); `; fs.writeFileSync(vitestConfigPath, vitestConfig, 'utf8'); log.info('Created Vitest config for CJS compatibility'); } return { success: true, message: 'Configured Vitest for CJS compatibility' }; } return { success: true, message: 'No module interop issues detected' }; } catch (error) { log.error(`Error handling module interop: ${error.message}`); return { success: false, message: error.message }; } }