aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
1,260 lines (1,107 loc) • 34.5 kB
JavaScript
/**
* Test Discovery
* Gap Analysis Implementation
*
* Auto-detects test frameworks, test files, and provides
* intelligent test execution capabilities.
*/
const fs = require('fs');
const path = require('path');
const { EventEmitter } = require('events');
const { execSync, spawn } = require('child_process');
/**
* Test framework configurations
*/
const FRAMEWORKS = {
jest: {
name: 'Jest',
configFiles: ['jest.config.js', 'jest.config.ts', 'jest.config.json', 'jest.config.mjs'],
packageKey: 'jest',
testPatterns: [
'**/*.test.js',
'**/*.test.ts',
'**/*.test.jsx',
'**/*.test.tsx',
'**/*.spec.js',
'**/*.spec.ts',
],
runCommand: 'npx jest',
coverageFlag: '--coverage',
watchFlag: '--watch',
selectiveFlag: '--findRelatedTests',
},
vitest: {
name: 'Vitest',
configFiles: ['vitest.config.js', 'vitest.config.ts', 'vite.config.js', 'vite.config.ts'],
packageKey: 'vitest',
testPatterns: ['**/*.test.js', '**/*.test.ts', '**/*.spec.js', '**/*.spec.ts'],
runCommand: 'npx vitest run',
coverageFlag: '--coverage',
watchFlag: '--watch',
selectiveFlag: '--related',
},
mocha: {
name: 'Mocha',
configFiles: ['.mocharc.js', '.mocharc.json', '.mocharc.yaml', 'mocha.opts'],
packageKey: 'mocha',
testPatterns: ['test/**/*.js', 'test/**/*.ts', '**/*.test.js', '**/*.spec.js'],
runCommand: 'npx mocha',
coverageFlag: '', // Uses nyc separately
watchFlag: '--watch',
selectiveFlag: '--grep',
},
pytest: {
name: 'Pytest',
configFiles: ['pytest.ini', 'pyproject.toml', 'setup.cfg', 'conftest.py'],
packageKey: 'pytest',
testPatterns: ['test_*.py', '*_test.py', 'tests/**/*.py'],
runCommand: 'pytest',
coverageFlag: '--cov',
watchFlag: '', // Uses pytest-watch
selectiveFlag: '-k',
},
rspec: {
name: 'RSpec',
configFiles: ['.rspec', 'spec/spec_helper.rb'],
packageKey: 'rspec',
testPatterns: ['spec/**/*_spec.rb'],
runCommand: 'bundle exec rspec',
coverageFlag: '', // Uses simplecov
watchFlag: '', // Uses guard
selectiveFlag: '--example',
},
go: {
name: 'Go Test',
configFiles: ['go.mod'],
packageKey: null, // Built-in
testPatterns: ['**/*_test.go'],
runCommand: 'go test',
coverageFlag: '-cover',
watchFlag: '', // Uses gowatch
selectiveFlag: '-run',
},
phpunit: {
name: 'PHPUnit',
configFiles: ['phpunit.xml', 'phpunit.xml.dist'],
packageKey: 'phpunit',
testPatterns: ['tests/**/*Test.php', '**/*Test.php'],
runCommand: 'vendor/bin/phpunit',
coverageFlag: '--coverage-text',
watchFlag: '', // Uses phpunit-watcher
selectiveFlag: '--filter',
},
junit: {
name: 'JUnit',
configFiles: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
packageKey: 'junit',
testPatterns: ['**/Test*.java', '**/*Test.java', '**/*Tests.java'],
runCommand: 'mvn test',
coverageFlag: '', // Uses jacoco
watchFlag: '',
selectiveFlag: '-Dtest=',
},
playwright: {
name: 'Playwright',
configFiles: ['playwright.config.js', 'playwright.config.ts'],
packageKey: '@playwright/test',
testPatterns: ['**/*.spec.js', '**/*.spec.ts', 'e2e/**/*.js', 'e2e/**/*.ts'],
runCommand: 'npx playwright test',
coverageFlag: '',
watchFlag: '',
selectiveFlag: '--grep',
type: 'e2e',
},
cypress: {
name: 'Cypress',
configFiles: ['cypress.config.js', 'cypress.config.ts', 'cypress.json'],
packageKey: 'cypress',
testPatterns: [
'cypress/e2e/**/*.cy.js',
'cypress/e2e/**/*.cy.ts',
'cypress/integration/**/*.js',
],
runCommand: 'npx cypress run',
coverageFlag: '',
watchFlag: '',
selectiveFlag: '--spec',
type: 'e2e',
},
};
/**
* FrameworkDetector - Detects test frameworks in use
*/
class FrameworkDetector {
constructor(rootPath) {
this.rootPath = rootPath;
}
/**
* Detect all test frameworks in the project
*/
async detect() {
const detected = [];
// Check package.json for Node.js projects
const packageJson = this.readPackageJson();
for (const [frameworkId, config] of Object.entries(FRAMEWORKS)) {
const detection = {
id: frameworkId,
name: config.name,
confidence: 0,
configFile: null,
inPackageJson: false,
type: config.type || 'unit',
};
// Check config files
for (const configFile of config.configFiles) {
const configPath = path.join(this.rootPath, configFile);
if (fs.existsSync(configPath)) {
detection.configFile = configFile;
detection.confidence += 50;
break;
}
}
// Check package.json
if (packageJson && config.packageKey) {
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (deps[config.packageKey]) {
detection.inPackageJson = true;
detection.confidence += 40;
}
// Check scripts
if (packageJson.scripts) {
const scriptsStr = JSON.stringify(packageJson.scripts);
if (scriptsStr.includes(config.packageKey) || scriptsStr.includes(frameworkId)) {
detection.confidence += 10;
}
}
}
// Special case for Go
if (frameworkId === 'go' && fs.existsSync(path.join(this.rootPath, 'go.mod'))) {
detection.confidence = 90;
detection.configFile = 'go.mod';
}
if (detection.confidence > 0) {
detected.push(detection);
}
}
// Sort by confidence
detected.sort((a, b) => b.confidence - a.confidence);
return detected;
}
/**
* Read package.json if it exists
*/
readPackageJson() {
const packagePath = path.join(this.rootPath, 'package.json');
if (!fs.existsSync(packagePath)) return null;
try {
return JSON.parse(fs.readFileSync(packagePath, 'utf8'));
} catch {
return null;
}
}
/**
* Get the primary test framework
*/
async getPrimary() {
const detected = await this.detect();
return detected.length > 0 ? detected[0] : null;
}
}
/**
* TestFileFinder - Finds test files in the project
*/
class TestFileFinder {
constructor(rootPath) {
this.rootPath = rootPath;
this.ignorePatterns = [
'node_modules',
'dist',
'build',
'.git',
'coverage',
'vendor',
'__pycache__',
];
}
/**
* Find all test files for a framework
*/
async findTests(frameworkId) {
const config = FRAMEWORKS[frameworkId];
if (!config) return [];
const testFiles = [];
for (const pattern of config.testPatterns) {
const files = await this.glob(pattern);
testFiles.push(...files);
}
// Remove duplicates
return [...new Set(testFiles)];
}
/**
* Find all test files regardless of framework
*/
async findAllTests() {
const allPatterns = new Set();
for (const config of Object.values(FRAMEWORKS)) {
for (const pattern of config.testPatterns) {
allPatterns.add(pattern);
}
}
const testFiles = [];
for (const pattern of allPatterns) {
const files = await this.glob(pattern);
testFiles.push(...files);
}
return [...new Set(testFiles)];
}
/**
* Simple glob implementation
*/
async glob(pattern) {
const files = [];
const parts = pattern.split('/');
const walk = (dir, patternParts, depth = 0) => {
if (depth > 10) return; // Prevent infinite recursion
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
// Skip ignored directories
if (entry.isDirectory() && this.ignorePatterns.includes(entry.name)) {
continue;
}
const fullPath = path.join(dir, entry.name);
const relativePath = path.relative(this.rootPath, fullPath);
if (entry.isDirectory()) {
// Handle ** pattern
if (patternParts[0] === '**') {
walk(fullPath, patternParts, depth + 1);
walk(fullPath, patternParts.slice(1), depth + 1);
} else if (this.matchPart(entry.name, patternParts[0])) {
walk(fullPath, patternParts.slice(1), depth + 1);
}
} else if (entry.isFile()) {
// Check if file matches remaining pattern
const remainingPattern = patternParts.join('/');
if (
this.matchFile(entry.name, remainingPattern) ||
this.matchFile(entry.name, patternParts[patternParts.length - 1])
) {
files.push(relativePath);
}
}
}
} catch {
// Ignore permission errors
}
};
// Handle patterns starting with **
if (parts[0] === '**') {
walk(this.rootPath, parts, 0);
} else {
walk(this.rootPath, parts, 0);
}
return files;
}
/**
* Match a single path part against a pattern part
*/
matchPart(name, pattern) {
if (pattern === '**' || pattern === '*') return true;
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
return regex.test(name);
}
return name === pattern;
}
/**
* Match a filename against a pattern
*/
matchFile(filename, pattern) {
// Handle simple wildcard patterns
if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
return regex.test(filename);
}
return filename === pattern;
}
/**
* Find tests related to changed files
*/
async findRelatedTests(changedFiles) {
const allTests = await this.findAllTests();
const relatedTests = new Set();
for (const changedFile of changedFiles) {
const baseName = path.basename(changedFile, path.extname(changedFile));
const dirName = path.dirname(changedFile);
for (const testFile of allTests) {
// Check if test file name contains the source file name
if (testFile.includes(baseName)) {
relatedTests.add(testFile);
continue;
}
// Check if test is in same directory or __tests__ subdirectory
const testDir = path.dirname(testFile);
if (testDir === dirName || testDir === path.join(dirName, '__tests__')) {
relatedTests.add(testFile);
}
}
}
return [...relatedTests];
}
}
/**
* TestAnalyzer - Analyzes test files and extracts metadata
*/
class TestAnalyzer {
/**
* Analyze a test file
*/
analyze(filePath, content) {
const ext = path.extname(filePath);
const analysis = {
file: filePath,
suites: [],
tests: [],
imports: [],
hooks: [],
skipped: 0,
focused: 0,
totalTests: 0,
};
switch (ext) {
case '.js':
case '.jsx':
case '.ts':
case '.tsx':
case '.mjs':
this.analyzeJavaScript(content, analysis);
break;
case '.py':
this.analyzePython(content, analysis);
break;
case '.rb':
this.analyzeRuby(content, analysis);
break;
case '.go':
this.analyzeGo(content, analysis);
break;
case '.php':
this.analyzePHP(content, analysis);
break;
case '.java':
this.analyzeJava(content, analysis);
break;
default:
// Try JavaScript patterns as fallback
this.analyzeJavaScript(content, analysis);
}
analysis.totalTests = analysis.tests.length;
return analysis;
}
/**
* Analyze JavaScript/TypeScript test file
*/
analyzeJavaScript(content, analysis) {
// Extract imports
const importMatches = content.matchAll(/import\s+.*?from\s+['"]([^'"]+)['"]/g);
for (const match of importMatches) {
analysis.imports.push(match[1]);
}
const requireMatches = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
for (const match of requireMatches) {
analysis.imports.push(match[1]);
}
// Extract describe blocks (suites)
const describeMatches = content.matchAll(/describe\s*\(\s*['"`]([^'"`]+)['"`]/g);
for (const match of describeMatches) {
analysis.suites.push(match[1]);
}
// Extract test/it blocks
const testMatches = content.matchAll(/(?:test|it)\s*\(\s*['"`]([^'"`]+)['"`]/g);
for (const match of testMatches) {
analysis.tests.push(match[1]);
}
// Check for skipped tests
const skipMatches = content.matchAll(/(?:test|it|describe)\.skip\s*\(/g);
analysis.skipped = [...skipMatches].length;
// Check for focused tests (only/fit)
const focusMatches = content.matchAll(/(?:test|it|describe)\.only\s*\(|fit\s*\(/g);
analysis.focused = [...focusMatches].length;
// Extract hooks
if (content.includes('beforeEach')) analysis.hooks.push('beforeEach');
if (content.includes('afterEach')) analysis.hooks.push('afterEach');
if (content.includes('beforeAll')) analysis.hooks.push('beforeAll');
if (content.includes('afterAll')) analysis.hooks.push('afterAll');
}
/**
* Analyze Python test file
*/
analyzePython(content, analysis) {
// Extract imports
const importMatches = content.matchAll(/(?:from|import)\s+([\w.]+)/g);
for (const match of importMatches) {
analysis.imports.push(match[1]);
}
// Extract test classes (suites)
const classMatches = content.matchAll(/class\s+(Test\w+)/g);
for (const match of classMatches) {
analysis.suites.push(match[1]);
}
// Extract test functions
const testMatches = content.matchAll(/def\s+(test_\w+)/g);
for (const match of testMatches) {
analysis.tests.push(match[1]);
}
// Check for skipped tests
const skipMatches = content.matchAll(/\.mark\.skip|\.skip/g);
analysis.skipped = [...skipMatches].length;
// Check for fixtures
if (content.includes('@pytest.fixture')) analysis.hooks.push('fixture');
if (content.includes('setUp')) analysis.hooks.push('setUp');
if (content.includes('tearDown')) analysis.hooks.push('tearDown');
}
/**
* Analyze Ruby test file
*/
analyzeRuby(content, analysis) {
// Extract describe blocks
const describeMatches = content.matchAll(/describe\s+['"]?([^'"]+)['"]?\s+do/g);
for (const match of describeMatches) {
analysis.suites.push(match[1].trim());
}
// Extract it blocks
const itMatches = content.matchAll(/it\s+['"]([^'"]+)['"]\s+do/g);
for (const match of itMatches) {
analysis.tests.push(match[1]);
}
// Check for pending/skipped
const skipMatches = content.matchAll(/(?:xit|pending)\s+/g);
analysis.skipped = [...skipMatches].length;
// Check for hooks
if (content.includes('before(:each)') || content.includes('before do')) {
analysis.hooks.push('before');
}
if (content.includes('after(:each)') || content.includes('after do')) {
analysis.hooks.push('after');
}
}
/**
* Analyze Go test file
*/
analyzeGo(content, analysis) {
// Extract test functions
const testMatches = content.matchAll(/func\s+(Test\w+)\s*\(/g);
for (const match of testMatches) {
analysis.tests.push(match[1]);
}
// Extract benchmark functions
const benchMatches = content.matchAll(/func\s+(Benchmark\w+)\s*\(/g);
for (const match of benchMatches) {
analysis.tests.push(match[1]);
}
// Check for t.Skip
const skipMatches = content.matchAll(/t\.Skip\(/g);
analysis.skipped = [...skipMatches].length;
}
/**
* Analyze PHP test file
*/
analyzePHP(content, analysis) {
// Extract test class
const classMatches = content.matchAll(/class\s+(\w+Test)\s+extends/g);
for (const match of classMatches) {
analysis.suites.push(match[1]);
}
// Extract test methods
const testMatches = content.matchAll(/function\s+(test\w+)\s*\(/g);
for (const match of testMatches) {
analysis.tests.push(match[1]);
}
// Also check for @test annotation
const annotationMatches = content.matchAll(/[\s\S]*?function\s+(\w+)\s*\(/g);
for (const match of annotationMatches) {
if (!analysis.tests.includes(match[1])) {
analysis.tests.push(match[1]);
}
}
// Check for skipped
const skipMatches = content.matchAll(/->markTestSkipped\(|/g);
analysis.skipped = [...skipMatches].length;
}
/**
* Analyze Java test file
*/
analyzeJava(content, analysis) {
// Extract test class
const classMatches = content.matchAll(/class\s+(\w+(?:Test|Tests))\s+/g);
for (const match of classMatches) {
analysis.suites.push(match[1]);
}
// Extract test methods (@Test annotation)
const testMatches = content.matchAll(/[\s\S]*?(?:public|void)\s+(?:void\s+)?(\w+)\s*\(/g);
for (const match of testMatches) {
analysis.tests.push(match[1]);
}
// Check for disabled tests
const skipMatches = content.matchAll(/|/g);
analysis.skipped = [...skipMatches].length;
// Check for lifecycle methods
if (content.includes('@BeforeEach') || content.includes('@Before')) {
analysis.hooks.push('beforeEach');
}
if (content.includes('@AfterEach') || content.includes('@After')) {
analysis.hooks.push('afterEach');
}
}
}
/**
* CoverageAnalyzer - Analyzes test coverage configuration
*/
class CoverageAnalyzer {
constructor(rootPath) {
this.rootPath = rootPath;
}
/**
* Analyze coverage configuration
*/
async analyze() {
const result = {
enabled: false,
tool: null,
threshold: null,
include: [],
exclude: [],
reportFormats: [],
};
// Check Jest coverage config
const jestConfig = this.readConfig(['jest.config.js', 'jest.config.ts', 'jest.config.json']);
if (jestConfig) {
if (jestConfig.collectCoverage || jestConfig.coverageThreshold) {
result.enabled = true;
result.tool = 'jest';
result.threshold =
jestConfig.coverageThreshold?.global?.branches ||
jestConfig.coverageThreshold?.global?.lines;
result.include = jestConfig.collectCoverageFrom || [];
result.exclude = jestConfig.coveragePathIgnorePatterns || [];
result.reportFormats = jestConfig.coverageReporters || ['text', 'lcov'];
}
}
// Check nyc config (for Mocha)
const nycConfig = this.readConfig(['.nycrc', '.nycrc.json', 'nyc.config.js']);
if (nycConfig) {
result.enabled = true;
result.tool = 'nyc';
result.threshold = nycConfig.branches || nycConfig.lines;
result.include = nycConfig.include || [];
result.exclude = nycConfig.exclude || [];
result.reportFormats = nycConfig.reporter || ['text', 'lcov'];
}
// Check pytest coverage
const pytestConfig = this.readConfig(['pytest.ini', 'setup.cfg', 'pyproject.toml']);
if (
pytestConfig &&
(pytestConfig.addopts?.includes('--cov') ||
pytestConfig.tool?.pytest?.ini_options?.addopts?.includes('--cov'))
) {
result.enabled = true;
result.tool = 'pytest-cov';
}
// Check package.json scripts
const packageJson = this.readConfig(['package.json']);
if (packageJson?.scripts) {
const scriptsStr = JSON.stringify(packageJson.scripts);
if (scriptsStr.includes('coverage') || scriptsStr.includes('--coverage')) {
result.enabled = true;
if (!result.tool) result.tool = 'unknown';
}
}
return result;
}
/**
* Read configuration file
*/
readConfig(files) {
for (const file of files) {
const fullPath = path.join(this.rootPath, file);
if (!fs.existsSync(fullPath)) continue;
try {
const content = fs.readFileSync(fullPath, 'utf8');
if (file.endsWith('.json') || file === 'package.json') {
return JSON.parse(content);
}
if (file.endsWith('.js') || file.endsWith('.ts')) {
// Extract object from module.exports
const match = content.match(/module\.exports\s*=\s*(\{[\s\S]*\})/);
if (match) {
try {
// eslint-disable-next-line no-eval
return eval('(' + match[1] + ')');
} catch {
return {};
}
}
}
return { raw: content };
} catch {
continue;
}
}
return null;
}
}
/**
* TestRunner - Executes tests
*/
class TestRunner extends EventEmitter {
constructor(rootPath, frameworkId) {
super();
this.rootPath = rootPath;
this.frameworkId = frameworkId;
this.framework = FRAMEWORKS[frameworkId];
this.process = null;
}
/**
* Run all tests
*/
async runAll(options = {}) {
const args = [];
if (options.coverage && this.framework.coverageFlag) {
args.push(this.framework.coverageFlag);
}
if (options.watch && this.framework.watchFlag) {
args.push(this.framework.watchFlag);
}
if (options.verbose) {
args.push('--verbose');
}
return this.execute(args, options);
}
/**
* Run specific test files
*/
async runFiles(files, options = {}) {
const args = [...files];
if (options.coverage && this.framework.coverageFlag) {
args.push(this.framework.coverageFlag);
}
return this.execute(args, options);
}
/**
* Run tests related to changed files
*/
async runRelated(changedFiles, options = {}) {
if (!this.framework.selectiveFlag) {
// Framework doesn't support selective testing, run all
return this.runAll(options);
}
const args = [this.framework.selectiveFlag, ...changedFiles];
if (options.coverage && this.framework.coverageFlag) {
args.push(this.framework.coverageFlag);
}
return this.execute(args, options);
}
/**
* Run tests matching a pattern
*/
async runPattern(pattern, options = {}) {
const args = [];
// Different frameworks handle patterns differently
switch (this.frameworkId) {
case 'jest':
case 'vitest':
args.push('-t', pattern);
break;
case 'mocha':
args.push('--grep', pattern);
break;
case 'pytest':
args.push('-k', pattern);
break;
case 'rspec':
args.push('--example', pattern);
break;
case 'go':
args.push('-run', pattern);
break;
default:
args.push(pattern);
}
if (options.coverage && this.framework.coverageFlag) {
args.push(this.framework.coverageFlag);
}
return this.execute(args, options);
}
/**
* Execute test command
*/
execute(args, options = {}) {
return new Promise((resolve, reject) => {
const command = this.framework.runCommand;
const [cmd, ...cmdArgs] = command.split(' ');
const fullArgs = [...cmdArgs, ...args];
this.emit('run:start', { command, args: fullArgs });
const startTime = Date.now();
let stdout = '';
let stderr = '';
this.process = spawn(cmd, fullArgs, {
cwd: this.rootPath,
shell: true,
env: {
...process.env,
FORCE_COLOR: '1',
CI: options.ci ? 'true' : undefined,
},
});
this.process.stdout.on('data', (data) => {
const text = data.toString();
stdout += text;
this.emit('output', { type: 'stdout', text });
});
this.process.stderr.on('data', (data) => {
const text = data.toString();
stderr += text;
this.emit('output', { type: 'stderr', text });
});
this.process.on('close', (code) => {
const duration = Date.now() - startTime;
const result = {
success: code === 0,
exitCode: code,
duration,
stdout,
stderr,
summary: this.parseOutput(stdout + stderr),
};
this.emit('run:complete', result);
this.process = null;
if (code === 0 || options.allowFailure) {
resolve(result);
} else {
reject(new Error(`Tests failed with exit code ${code}`));
}
});
this.process.on('error', (error) => {
this.emit('run:error', { error: error.message });
reject(error);
});
});
}
/**
* Parse test output for summary
*/
parseOutput(output) {
const summary = {
total: 0,
passed: 0,
failed: 0,
skipped: 0,
duration: null,
};
// Jest/Vitest format
const jestMatch = output.match(
/Tests:\s+(\d+)\s+passed(?:,\s+(\d+)\s+failed)?(?:,\s+(\d+)\s+skipped)?(?:,\s+(\d+)\s+total)?/
);
if (jestMatch) {
summary.passed = parseInt(jestMatch[1]) || 0;
summary.failed = parseInt(jestMatch[2]) || 0;
summary.skipped = parseInt(jestMatch[3]) || 0;
summary.total = parseInt(jestMatch[4]) || summary.passed + summary.failed + summary.skipped;
return summary;
}
// Mocha format
const mochaMatch = output.match(/(\d+)\s+passing.*?(\d+)\s+failing/s);
if (mochaMatch) {
summary.passed = parseInt(mochaMatch[1]) || 0;
summary.failed = parseInt(mochaMatch[2]) || 0;
summary.total = summary.passed + summary.failed;
return summary;
}
// Pytest format
const pytestMatch = output.match(
/(\d+)\s+passed(?:,\s+(\d+)\s+failed)?(?:,\s+(\d+)\s+skipped)?/
);
if (pytestMatch) {
summary.passed = parseInt(pytestMatch[1]) || 0;
summary.failed = parseInt(pytestMatch[2]) || 0;
summary.skipped = parseInt(pytestMatch[3]) || 0;
summary.total = summary.passed + summary.failed + summary.skipped;
return summary;
}
// Go format
const goMatch = output.match(/ok\s+.*?\s+([\d.]+)s|FAIL\s+.*?\s+([\d.]+)s/g);
if (goMatch) {
summary.total = goMatch.length;
summary.passed = (output.match(/ok\s+/g) || []).length;
summary.failed = (output.match(/FAIL\s+/g) || []).length;
return summary;
}
return summary;
}
/**
* Stop running tests
*/
stop() {
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
}
}
}
/**
* TestDiscovery - Main discovery engine
*/
class TestDiscovery extends EventEmitter {
constructor(config = {}) {
super();
this.rootPath = config.rootPath || process.cwd();
this.detector = new FrameworkDetector(this.rootPath);
this.finder = new TestFileFinder(this.rootPath);
this.analyzer = new TestAnalyzer();
this.coverageAnalyzer = new CoverageAnalyzer(this.rootPath);
}
/**
* Full scan of test infrastructure
*/
async scan(options = {}) {
this.emit('scan:start', { rootPath: this.rootPath });
const result = {
frameworks: [],
primaryFramework: null,
testFiles: [],
analysis: {},
coverage: {},
summary: {},
};
try {
// Detect frameworks
result.frameworks = await this.detector.detect();
result.primaryFramework = result.frameworks[0] || null;
this.emit('frameworks:detected', { count: result.frameworks.length });
// Find test files
if (result.primaryFramework) {
result.testFiles = await this.finder.findTests(result.primaryFramework.id);
} else {
result.testFiles = await this.finder.findAllTests();
}
this.emit('files:found', { count: result.testFiles.length });
// Analyze test files (optional, can be slow)
if (options.analyzeFiles && result.testFiles.length <= 100) {
for (const file of result.testFiles) {
try {
const content = fs.readFileSync(path.join(this.rootPath, file), 'utf8');
result.analysis[file] = this.analyzer.analyze(file, content);
} catch {
// Skip files that can't be read
}
}
}
// Analyze coverage
result.coverage = await this.coverageAnalyzer.analyze();
// Generate summary
result.summary = this.generateSummary(result);
this.emit('scan:complete', result);
return result;
} catch (error) {
this.emit('scan:error', { error: error.message });
throw error;
}
}
/**
* Generate summary from scan results
*/
generateSummary(result) {
const summary = {
hasTests: result.testFiles.length > 0,
framework: result.primaryFramework?.name || 'Unknown',
frameworkId: result.primaryFramework?.id || null,
testFileCount: result.testFiles.length,
hasCoverage: result.coverage.enabled,
coverageTool: result.coverage.tool,
coverageThreshold: result.coverage.threshold,
testTypes: {
unit: 0,
e2e: 0,
},
};
// Count test types
for (const framework of result.frameworks) {
if (framework.type === 'e2e') {
summary.testTypes.e2e++;
} else {
summary.testTypes.unit++;
}
}
// Count tests from analysis
if (Object.keys(result.analysis).length > 0) {
summary.totalTests = 0;
summary.totalSuites = 0;
summary.skippedTests = 0;
for (const analysis of Object.values(result.analysis)) {
summary.totalTests += analysis.totalTests;
summary.totalSuites += analysis.suites.length;
summary.skippedTests += analysis.skipped;
}
}
return summary;
}
/**
* Quick check for test presence
*/
async quickCheck() {
const frameworks = await this.detector.detect();
const primary = frameworks[0];
let fileCount = 0;
if (primary) {
const files = await this.finder.findTests(primary.id);
fileCount = files.length;
}
return {
hasTests: frameworks.length > 0 && fileCount > 0,
framework: primary?.name || null,
fileCount,
};
}
/**
* Find tests affected by changed files
*/
async findAffected(changedFiles) {
return this.finder.findRelatedTests(changedFiles);
}
/**
* Run tests
*/
async run(options = {}) {
const primary = await this.detector.getPrimary();
if (!primary) {
throw new Error('No test framework detected');
}
const runner = new TestRunner(this.rootPath, primary.id);
// Forward events
runner.on('run:start', (data) => this.emit('run:start', data));
runner.on('output', (data) => this.emit('output', data));
runner.on('run:complete', (data) => this.emit('run:complete', data));
runner.on('run:error', (data) => this.emit('run:error', data));
if (options.files) {
return runner.runFiles(options.files, options);
} else if (options.related) {
return runner.runRelated(options.related, options);
} else if (options.pattern) {
return runner.runPattern(options.pattern, options);
} else {
return runner.runAll(options);
}
}
/**
* Run tests affected by changed files
*/
async runAffected(changedFiles, options = {}) {
const affectedTests = await this.findAffected(changedFiles);
if (affectedTests.length === 0) {
return { success: true, message: 'No affected tests found', tests: [] };
}
return this.run({ ...options, files: affectedTests });
}
/**
* Get changed files from git
*/
async getChangedFiles(baseBranch = 'main') {
try {
const output = execSync(`git diff --name-only ${baseBranch}...HEAD`, {
cwd: this.rootPath,
encoding: 'utf8',
});
return output.split('\n').filter((f) => f.trim());
} catch {
// Fallback to unstaged changes
try {
const output = execSync('git diff --name-only', {
cwd: this.rootPath,
encoding: 'utf8',
});
return output.split('\n').filter((f) => f.trim());
} catch {
return [];
}
}
}
}
// CLI interface
if (require.main === module) {
const args = process.argv.slice(2);
const discovery = new TestDiscovery();
discovery.on('frameworks:detected', ({ count }) => {
console.log(`Found ${count} test framework(s)`);
});
discovery.on('files:found', ({ count }) => {
console.log(`Found ${count} test file(s)`);
});
discovery.on('output', ({ text }) => {
process.stdout.write(text);
});
if (args.includes('--quick')) {
discovery.quickCheck().then((result) => {
console.log(JSON.stringify(result, null, 2));
});
} else if (args.includes('--run')) {
discovery
.run({
coverage: args.includes('--coverage'),
verbose: args.includes('--verbose'),
})
.then((result) => {
console.log('\n--- Results ---');
console.log(JSON.stringify(result.summary, null, 2));
process.exit(result.success ? 0 : 1);
})
.catch((error) => {
console.error('Error:', error.message);
process.exit(1);
});
} else if (args.includes('--affected')) {
discovery
.getChangedFiles()
.then((files) => discovery.runAffected(files))
.then((result) => {
if (result.message) {
console.log(result.message);
} else {
console.log('\n--- Results ---');
console.log(JSON.stringify(result.summary, null, 2));
}
process.exit(result.success ? 0 : 1);
})
.catch((error) => {
console.error('Error:', error.message);
process.exit(1);
});
} else {
discovery
.scan({ analyzeFiles: args.includes('--analyze') })
.then((result) => {
console.log('\n--- Summary ---');
console.log(JSON.stringify(result.summary, null, 2));
if (args.includes('--files')) {
console.log('\n--- Test Files ---');
for (const file of result.testFiles) {
console.log(` ${file}`);
}
}
})
.catch((error) => {
console.error('Error:', error.message);
process.exit(1);
});
}
}
module.exports = TestDiscovery;
module.exports.TestDiscovery = TestDiscovery;
module.exports.FrameworkDetector = FrameworkDetector;
module.exports.TestFileFinder = TestFileFinder;
module.exports.TestAnalyzer = TestAnalyzer;
module.exports.CoverageAnalyzer = CoverageAnalyzer;
module.exports.TestRunner = TestRunner;
module.exports.FRAMEWORKS = FRAMEWORKS;