testify-universal-cli
Version:
Universal interactive CLI tool for scanning and executing tests across multiple programming languages
239 lines (205 loc) • 8.04 kB
JavaScript
import { findUp } from 'find-up';
import { globby } from 'globby';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { logger } from './logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LANGUAGE_SUPPORT_PATH = path.join(__dirname, '..', '..', '.language-support.json');
/**
* Detect the primary language of a repository
*
* @param {string} cwd - Current working directory
* @returns {Promise<string>} - Detected language ('python', 'node', 'ruby', 'go', 'java', 'rust', or 'unknown')
*/
export async function detectLanguage(cwd) {
logger.debug(`Detecting language in directory: ${cwd}`);
// Get available languages from support file
const availableLanguages = await getAvailableLanguages();
logger.trace('Available languages:', availableLanguages);
// Try to detect by project files first
logger.debug('Attempting to detect language by project files...');
const detectedByFiles = await detectByProjectFiles(cwd, availableLanguages);
if (detectedByFiles) {
logger.debug(`Language detected by project files: ${detectedByFiles}`);
return detectedByFiles;
}
// Fall back to detecting by file extensions
logger.debug('Falling back to detection by file extensions...');
const detectedByExtensions = await detectByFileExtensions(cwd, availableLanguages);
if (detectedByExtensions) {
logger.debug(`Language detected by file extensions: ${detectedByExtensions}`);
return detectedByExtensions;
}
logger.warn('Could not detect language, defaulting to "unknown"');
return 'unknown';
}
/**
* Detect language by project files
*
* @param {string} cwd - Current working directory
* @param {string[]} availableLanguages - Available languages
* @returns {Promise<string>} - Detected language
*/
async function detectByProjectFiles(cwd, availableLanguages) {
logger.debug(`Detecting language by project files in ${cwd}`);
logger.debug(`Available languages: ${availableLanguages.join(', ')}`);
// Define detection patterns for each language
const detectionPatterns = {
python: ['requirements.txt', 'setup.py', 'pyproject.toml', 'pytest.ini', 'conftest.py'],
node: ['package.json', 'package-lock.json', 'node_modules', 'jest.config.js', 'vitest.config.js', 'vitest.config.ts', 'local_examples/**/*.test.ts', 'local_examples/**/*.test.js'],
ruby: ['Gemfile', '.rspec', 'spec/spec_helper.rb', 'spec', 'Rakefile'],
go: ['go.mod', 'go.sum', '*_test.go'],
java: ['pom.xml', 'build.gradle', 'settings.gradle', 'src/test/java'],
};
// Store language detection results
const detectionResults = {};
const languageCounts = {};
// Check for each language if it's available
for (const language of Object.keys(detectionPatterns)) {
if (!availableLanguages.includes(language) &&
!availableLanguages.some(l => l.startsWith(`${language}/`))) {
logger.trace(`Skipping language ${language} as it's not available`);
continue; // Skip if language is not available
}
logger.trace(`Checking for ${language} files...`);
const files = await Promise.all(
detectionPatterns[language].map(async pattern => {
const result = await findUp(pattern, { cwd });
if (result) {
logger.trace(`Found ${language} file: ${result}`);
}
return result;
})
);
const hasLanguage = files.some(Boolean);
detectionResults[language] = hasLanguage;
logger.debug(`Language ${language} detected: ${hasLanguage}`);
if (hasLanguage) {
// Count test files for this language
languageCounts[language] = await countTestFiles(cwd, language);
logger.debug(`Found ${languageCounts[language]} test files for ${language}`);
}
}
// Get languages with detected files
const detectedLanguages = Object.keys(detectionResults).filter(language => detectionResults[language]);
logger.debug(`Detected languages: ${detectedLanguages.join(', ') || 'none'}`);
if (detectedLanguages.length === 0) {
logger.debug('No languages detected by project files');
return null; // No languages detected
}
if (detectedLanguages.length === 1) {
logger.debug(`Single language detected: ${detectedLanguages[0]}`);
return detectedLanguages[0];
}
// Multiple languages detected, choose the one with more test files
let primaryLanguage = detectedLanguages[0];
let maxCount = languageCounts[primaryLanguage] || 0;
for (const language of detectedLanguages) {
const count = languageCounts[language] || 0;
if (count > maxCount) {
maxCount = count;
primaryLanguage = language;
}
}
logger.debug(`Multiple languages detected, choosing ${primaryLanguage} with ${maxCount} test files`);
return primaryLanguage;
}
/**
* Detect language by file extensions
*
* @param {string} cwd - Current working directory
* @param {string[]} availableLanguages - Available languages
* @returns {Promise<string>} - Detected language
*/
async function detectByFileExtensions(cwd, availableLanguages) {
const extensionMapping = {
'.py': 'python',
'.js': 'node',
'.ts': 'node',
'.jsx': 'node',
'.tsx': 'node',
'.rb': 'ruby',
'.go': 'go',
'.java': 'java',
};
try {
// Get all files in directory recursively (limit depth to avoid performance issues)
const files = await globby(['**/*.*'], {
cwd,
deep: 3,
gitignore: true,
ignore: ['node_modules/**', '**/venv/**', '**/.git/**', '**/dist/**', '**/build/**']
});
// Count extensions
const extensionCounts = {};
for (const file of files) {
const ext = path.extname(file);
if (ext && extensionMapping[ext]) {
const language = extensionMapping[ext];
extensionCounts[language] = (extensionCounts[language] || 0) + 1;
}
}
// Find most common language that is available
let maxCount = 0;
let primaryLanguage = 'node'; // Default to node if nothing else is found
for (const [language, count] of Object.entries(extensionCounts)) {
if (count > maxCount &&
(availableLanguages.includes(language) ||
availableLanguages.some(l => l.startsWith(`${language}/`)))) {
maxCount = count;
primaryLanguage = language;
}
}
return primaryLanguage;
} catch (error) {
// Default to node if error
return 'node';
}
}
/**
* Get available languages from configuration
*
* @returns {Promise<string[]>} - Available languages
*/
async function getAvailableLanguages() {
try {
const data = await fs.readFile(LANGUAGE_SUPPORT_PATH, 'utf8');
const config = JSON.parse(data);
return config.supportedLanguages || [];
} catch (error) {
// If file doesn't exist, return defaults (node and python)
return ['node.js', 'python/pytest'];
}
}
/**
* Count test files based on language-specific patterns
*
* @param {string} cwd - Current working directory
* @param {string} language - Language to count test files for
* @returns {Promise<number>} - Number of test files
*/
async function countTestFiles(cwd, language) {
try {
// Define test file patterns for each language
const patterns = {
python: ['test_*.py', 'tests/**/*.py', '*/test_*.py', '*/tests/**/*.py'],
node: ['**/*.test.js', '**/*.test.ts', '**/*.spec.js', '**/*.spec.ts', '**/test/**/*.js', '**/tests/**/*.js'],
ruby: ['spec/**/*_spec.rb', 'test/**/*_test.rb'],
go: ['**/*_test.go'],
java: ['**/src/test/**/*.java', '**/test/**/*Test.java']
};
const languagePatterns = patterns[language] || [];
let count = 0;
for (const pattern of languagePatterns) {
// Use platform-agnostic path handling
// Instead of joining paths which could break glob patterns,
// use the cwd option in globby for cross-platform compatibility
const files = await globby(pattern, { cwd: cwd });
count += files.length;
}
return count;
} catch (error) {
return 0;
}
}