testify-universal-cli
Version:
Universal interactive CLI tool for scanning and executing tests across multiple programming languages
403 lines (350 loc) • 10.8 kB
JavaScript
import { globby } from 'globby';
import fs from 'node:fs/promises';
import path from 'node:path';
import { logger } from './logger.js';
/**
* Scan a repository for test files based on language-specific patterns
*
* @param {string} cwd - Current working directory
* @param {string} language - Repository language ('python' or 'node')
* @returns {Promise<Array<{filePath: string, markers: string[]}>>} - Test files with their markers
*/
export async function scanRepository(cwd, language) {
logger.debug(`Scanning repository for ${language} test files in ${cwd}`);
const patterns = getPatterns(language);
logger.trace(`Using glob patterns for ${language}:`, patterns);
const startTime = process.hrtime();
const files = await globby(patterns, { cwd, absolute: true });
const [seconds, nanoseconds] = process.hrtime(startTime);
const duration = seconds * 1000 + nanoseconds / 1000000;
logger.debug(`Found ${files.length} test files in ${duration.toFixed(2)}ms`);
// Process each file to extract markers
logger.debug('Extracting test markers from files...');
const testFiles = [];
for (const filePath of files) {
const relativePath = path.relative(cwd, filePath);
logger.trace(`Processing file: ${relativePath}`);
try {
const markers = await extractMarkers(filePath, language);
testFiles.push({
filePath,
relativePath,
markers
});
if (markers.length > 0) {
logger.trace(`Found markers in ${relativePath}:`, markers);
}
} catch (error) {
logger.warn(`Error extracting markers from ${relativePath}: ${error.message}`);
}
}
logger.debug(`Processed ${testFiles.length} test files with markers`);
return testFiles;
}
/**
* Get glob patterns for test files based on language
*
* @param {string} language - Repository language
* @returns {string[]} - Glob patterns
*/
function getPatterns(language) {
const patterns = {
python: [
'test_*.py',
'tests/**/*.py',
'*/test_*.py',
'*/tests/**/*.py',
'**/test_*.py'
],
node: [
'**/*.test.js',
'**/*.test.ts',
'**/*.spec.js',
'**/*.spec.ts',
'**/test/**/*.js',
'**/test/**/*.ts',
'**/tests/**/*.js',
'**/tests/**/*.ts',
'**/__tests__/**/*.js',
'**/__tests__/**/*.ts',
],
ruby: [
'spec/**/*_spec.rb',
'test/**/*_test.rb',
'test/**/*.rb',
'spec/**/*.rb'
],
go: [
'**/*_test.go'
],
java: [
'**/src/test/**/*.java',
'**/test/**/*.java',
'**/*Test.java',
'**/*Tests.java'
],
rust: [
'tests/**/*.rs',
'src/**/tests/*.rs',
'src/**/*_test.rs',
'**/*_test.rs'
]
};
// Return patterns for the specified language or empty array if not found
return patterns[language] || [];
}
/**
* Extract test markers from a file
*
* @param {string} filePath - Path to the test file
* @param {string} language - Repository language
* @returns {Promise<string[]>} - Array of markers
*/
export async function extractMarkers(filePath, language) {
logger.trace(`Extracting markers from ${filePath} (${language})`);
try {
const content = await fs.readFile(filePath, 'utf8');
// Use language-specific marker extraction
let markers = [];
switch (language) {
case 'python':
markers = extractPythonMarkers(content);
break;
case 'node':
markers = extractNodeMarkers(content);
break;
case 'ruby':
markers = extractRubyMarkers(content);
break;
case 'go':
markers = extractGoMarkers(content);
break;
case 'java':
markers = extractJavaMarkers(content);
break;
case 'rust':
markers = extractRustMarkers(content);
break;
default:
// Try generic extraction for unknown languages
markers = extractGenericMarkers(content);
}
logger.trace(`Extracted ${markers.length} markers from ${path.basename(filePath)}`);
return markers;
} catch (error) {
logger.warn(`Failed to extract markers from ${path.basename(filePath)}: ${error.message}`);
return [];
}
}
/**
* Extract markers from Python test files
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractPythonMarkers(content) {
const markers = new Set();
// Regular pytest markers
const pytestMarkerRegex = /@pytest\.mark\.([a-zA-Z0-9_]+)/g;
let match;
while ((match = pytestMarkerRegex.exec(content)) !== null) {
markers.add(match[1]);
}
// Parametrized markers
const paramMarkerRegex = /pytest\.mark\.([a-zA-Z0-9_]+)/g;
while ((match = paramMarkerRegex.exec(content)) !== null) {
markers.add(match[1]);
}
return [...markers];
}
/**
* Extract markers from Node.js test files
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractNodeMarkers(content) {
const markers = new Set();
// Jest tags
const jestTagRegex = /@tag\(["']([a-zA-Z0-9_-]+)["']\)/g;
let match;
while ((match = jestTagRegex.exec(content)) !== null) {
markers.add(match[1]);
}
// Vitest tags
const vitestTagRegex = /it\.([a-zA-Z0-9_]+)\(/g;
while ((match = vitestTagRegex.exec(content)) !== null) {
if (match[1] !== 'skip' && match[1] !== 'only' && match[1] !== 'todo') {
markers.add(match[1]);
}
}
// Mocha-style tags
const mochaTags = ['slow', 'timeout', 'retries', 'bail'];
mochaTags.forEach(tag => {
if (content.includes(`.${tag}(`)) {
markers.add(tag);
}
});
// Check for common categories in describe blocks
const categories = ['unit', 'integration', 'e2e', 'api', 'ui', 'smoke', 'regression'];
categories.forEach(category => {
const regex = new RegExp(`describe\\(['"](.*${category}.*)['"]`, 'i');
if (regex.test(content)) {
markers.add(category);
}
});
return [...markers];
}
/**
* Extract markers from Ruby test files
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractRubyMarkers(content) {
const markers = new Set();
// RSpec tags
const rspecTagRegex = /(?:describe|context|it)\s*.*,\s*(?::([a-zA-Z0-9_]+)|['"]([a-zA-Z0-9_]+)['"])/g;
let match;
while ((match = rspecTagRegex.exec(content)) !== null) {
const tag = match[1] || match[2];
if (tag) {
markers.add(tag);
}
}
// Common RSpec metadata
const metadataTags = ['slow', 'fast', 'integration', 'api', 'feature', 'regression'];
metadataTags.forEach(tag => {
if (content.includes(`:${tag}`) || content.includes(`'${tag}'`) || content.includes(`"${tag}"`)) {
markers.add(tag);
}
});
return [...markers];
}
/**
* Extract markers from Go test files
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractGoMarkers(content) {
const markers = new Set();
// Extract test function names
const testFuncRegex = /func\s+(Test[A-Za-z0-9_]+)/g;
let match;
while ((match = testFuncRegex.exec(content)) !== null) {
const testName = match[1].replace(/^Test/, '');
// Split camel case into individual words
const parts = testName.replace(/([A-Z])/g, ' $1').trim().toLowerCase().split(' ');
// Add each meaningful part as a marker
parts.forEach(part => {
if (part && part.length > 2) {
markers.add(part);
}
});
}
// Check for benchmarks
if (content.includes('func Benchmark')) {
markers.add('benchmark');
}
// Check for parallel tests
if (content.includes('t.Parallel()')) {
markers.add('parallel');
}
return [...markers];
}
/**
* Extract markers from Java test files
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractJavaMarkers(content) {
const markers = new Set();
// JUnit 5 tags
const junitTagRegex = /@Tag\(["']([a-zA-Z0-9_-]+)["']\)/g;
let match;
while ((match = junitTagRegex.exec(content)) !== null) {
markers.add(match[1]);
}
// Method annotations
const annotationRegex = /@(?:Test|DisplayName|ParameterizedTest|RepeatedTest|Disabled|Timeout|Order)/g;
while ((match = annotationRegex.exec(content)) !== null) {
const annotation = match[0].replace('@', '').toLowerCase();
markers.add(annotation);
}
// Extract categories from method names
const methodRegex = /public\s+void\s+([a-zA-Z0-9_]+)\(\)/g;
while ((match = methodRegex.exec(content)) !== null) {
const methodName = match[1];
const categories = ['unit', 'integration', 'api', 'ui', 'smoke', 'e2e'];
categories.forEach(category => {
if (methodName.toLowerCase().includes(category)) {
markers.add(category);
}
});
}
return [...markers];
}
/**
* Extract markers from Rust test files
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractRustMarkers(content) {
const markers = new Set();
// Rust test attributes
const testAttributeRegex = /#\[(?:test|ignore|should_panic|bench)\]/g;
let match;
while ((match = testAttributeRegex.exec(content)) !== null) {
const attribute = match[0].replace('#[', '').replace(']', '');
markers.add(attribute);
}
// Extract test function names
const testFnRegex = /fn\s+([a-z0-9_]+)(?:\(\)|\s*\()/g;
while ((match = testFnRegex.exec(content)) !== null) {
const fnName = match[1];
// Split snake case into words
const words = fnName.split('_');
words.forEach(word => {
if (word.length > 2) {
markers.add(word);
}
});
// Check for common categories
const categories = ['unit', 'integration', 'api', 'e2e'];
categories.forEach(category => {
if (fnName.includes(category)) {
markers.add(category);
}
});
}
return [...markers];
}
/**
* Extract generic markers from any test file
* This is a fallback for unknown languages
*
* @param {string} content - File content
* @returns {string[]} - Extracted markers
*/
function extractGenericMarkers(content) {
const markers = new Set();
// Look for common test categories anywhere in the file
const commonCategories = [
'unit', 'integration', 'e2e', 'api', 'ui', 'smoke',
'regression', 'performance', 'slow', 'fast', 'mock',
'benchmark', 'stress', 'security'
];
// Check for words that look like categories
const lowerContent = content.toLowerCase();
commonCategories.forEach(category => {
// Look for category with word boundaries to avoid partial matches
const pattern = new RegExp(`\\b${category}\\b`, 'i');
if (pattern.test(lowerContent)) {
markers.add(category);
}
});
return [...markers];
}