UNPKG

testify-universal-cli

Version:

Universal interactive CLI tool for scanning and executing tests across multiple programming languages

403 lines (350 loc) 10.8 kB
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]; }