UNPKG

citty-test-utils

Version:

Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.

384 lines (336 loc) 10.4 kB
/** * @fileoverview Test Discovery Module * @description Discovers and analyzes test files to determine what commands and arguments are tested */ import { readFileSync, existsSync, readdirSync, statSync } from 'fs' import { join, extname, basename } from 'path' /** * Test Discovery Module * Handles discovery and analysis of test files */ export class TestDiscovery { constructor(options = {}) { this.options = { testDir: options.testDir || 'test', includePatterns: options.includePatterns || [ '.test.mjs', '.test.js', '.spec.mjs', '.spec.js', ], excludePatterns: options.excludePatterns || ['node_modules', '.git', 'coverage'], verbose: options.verbose || false, ...options, } this.tests = new Map() } /** * Discover and analyze all test files * @param {Object} options - Discovery options */ async discoverTests(options) { if (options.verbose) { console.log('🧪 Discovering test files...') } try { const testFiles = this.findTestFiles(options.testDir, options) for (const testFile of testFiles) { await this.analyzeTestFile(testFile, options) } if (options.verbose) { console.log(`🧪 Analyzed ${testFiles.length} test files`) } } catch (error) { throw new Error(`Test discovery failed: ${error.message}`) } } /** * Find all test files in the directory * @param {string} dir - Directory to search * @param {Object} options - Search options * @returns {Array} Array of test file paths */ findTestFiles(dir, options) { const testFiles = [] if (!existsSync(dir)) { if (options.verbose) { console.log(`⚠️ Test directory does not exist: ${dir}`) } return testFiles } const items = readdirSync(dir) for (const item of items) { const fullPath = join(dir, item) const stat = statSync(fullPath) // Skip excluded patterns if (options.excludePatterns.some((pattern) => item.includes(pattern))) { continue } if (stat.isDirectory()) { // Recursively search subdirectories testFiles.push(...this.findTestFiles(fullPath, options)) } else if (stat.isFile()) { // Check if file matches include patterns if (options.includePatterns.some((pattern) => item.includes(pattern))) { testFiles.push(fullPath) } } } return testFiles } /** * Analyze a test file to determine what commands and arguments it tests * @param {string} testFile - Path to test file * @param {Object} options - Analysis options */ async analyzeTestFile(testFile, options) { try { const content = readFileSync(testFile, 'utf8') const fileName = basename(testFile) if (options.verbose) { console.log(`🧪 Analyzing test file: ${fileName}`) } // Extract command usage patterns const commandPatterns = this.extractCommandPatterns(content) const argumentPatterns = this.extractArgumentPatterns(content) const flagPatterns = this.extractFlagPatterns(content) const optionPatterns = this.extractOptionPatterns(content) // Store test information this.tests.set(testFile, { fileName, path: testFile, commands: commandPatterns, arguments: argumentPatterns, flags: flagPatterns, options: optionPatterns, content, }) if (options.verbose) { console.log( `🧪 Found ${commandPatterns.size} commands, ${argumentPatterns.size} arguments, ${flagPatterns.size} flags, ${optionPatterns.size} options in ${fileName}` ) } } catch (error) { if (options.verbose) { console.log(`⚠️ Failed to analyze test file ${testFile}: ${error.message}`) } } } /** * Extract command usage patterns from test content * @param {string} content - Test file content * @returns {Map} Map of discovered commands */ extractCommandPatterns(content) { const commands = new Map() // Patterns to match command usage const patterns = [ // Direct command calls: runLocalCitty(['command', ...]) /runLocalCitty\(\[['"`]([^'"`]+)['"`]/g, // Command strings: 'ctu command' /['"`]ctu\s+([a-zA-Z-]+)['"`]/g, // Node CLI calls: node src/cli.mjs command /node\s+src\/cli\.mjs\s+([a-zA-Z-]+)/g, // Command arrays: ['command', 'subcommand'] /\[['"`]([a-zA-Z-]+)['"`]\s*,/g, ] for (const pattern of patterns) { let match while ((match = pattern.exec(content)) !== null) { const command = match[1] if (command && !commands.has(command)) { commands.set(command, { name: command, patterns: [match[0]], tested: true, }) } } } return commands } /** * Extract argument usage patterns from test content * @param {string} content - Test file content * @returns {Map} Map of discovered arguments */ extractArgumentPatterns(content) { const argumentMap = new Map() // Patterns to match argument usage const patterns = [ // Positional arguments: ['command', 'arg1', 'arg2'] /\[['"`][^'"`]+['"`]\s*,\s*['"`]([^'"`]+)['"`]/g, // Argument variables: const arg = 'value' /const\s+(\w*arg\w*)\s*=\s*['"`]([^'"`]+)['"`]/g, // Argument usage in strings: `command ${arg}` /`[^`]*\$\{(\w+)\}[^`]*`/g, ] for (const pattern of patterns) { let match while ((match = pattern.exec(content)) !== null) { const argName = match[1] if (argName && !argumentMap.has(argName)) { argumentMap.set(argName, { name: argName, patterns: [match[0]], tested: true, }) } } } return argumentMap } /** * Extract flag usage patterns from test content * @param {string} content - Test file content * @returns {Map} Map of discovered flags */ extractFlagPatterns(content) { const flags = new Map() // Patterns to match flag usage const patterns = [ // Boolean flags: --verbose, --json, --help /--([a-zA-Z-]+)(?=\s|$|'|"|`)/g, // Short flags: -v, -j, -h /-[a-zA-Z](?=\s|$|'|"|`)/g, // Flag arrays: ['--verbose', '--json'] /\[['"`]--([a-zA-Z-]+)['"`]/g, ] for (const pattern of patterns) { let match while ((match = pattern.exec(content)) !== null) { const flagName = match[1] || match[0].substring(1) // Handle short flags if (flagName && !flags.has(flagName)) { flags.set(flagName, { name: flagName, patterns: [match[0]], tested: true, isShort: !flagName.includes('-'), }) } } } return flags } /** * Extract option usage patterns from test content * @param {string} content - Test file content * @returns {Map} Map of discovered options */ extractOptionPatterns(content) { const options = new Map() // Patterns to match option usage const patterns = [ // Options with values: --option value, --option=value /--([a-zA-Z-]+)\s*[=:]\s*['"`]?([^'"`\s]+)['"`]?/g, // Option arrays: ['--option', 'value'] /\[['"`]--([a-zA-Z-]+)['"`]\s*,\s*['"`]([^'"`]+)['"`]/g, // Option objects: { option: 'value' } /{\s*([a-zA-Z-]+)\s*:\s*['"`]([^'"`]+)['"`]/g, ] for (const pattern of patterns) { let match while ((match = pattern.exec(content)) !== null) { const optionName = match[1] const optionValue = match[2] if (optionName && !options.has(optionName)) { options.set(optionName, { name: optionName, value: optionValue, patterns: [match[0]], tested: true, }) } } } return options } /** * Get discovered tests * @returns {Map} Map of discovered tests */ getTests() { return this.tests } /** * Get all tested commands across all test files * @returns {Map} Map of all tested commands */ getAllTestedCommands() { const allCommands = new Map() for (const [testFile, test] of this.tests) { for (const [commandName, command] of test.commands) { if (!allCommands.has(commandName)) { allCommands.set(commandName, { name: commandName, tested: true, testFiles: [], }) } allCommands.get(commandName).testFiles.push(testFile) } } return allCommands } /** * Get all tested arguments across all test files * @returns {Map} Map of all tested arguments */ getAllTestedArguments() { const allArguments = new Map() for (const [testFile, test] of this.tests) { for (const [argName, arg] of test.arguments) { if (!allArguments.has(argName)) { allArguments.set(argName, { name: argName, tested: true, testFiles: [], }) } allArguments.get(argName).testFiles.push(testFile) } } return allArguments } /** * Get all tested flags across all test files * @returns {Map} Map of all tested flags */ getAllTestedFlags() { const allFlags = new Map() for (const [testFile, test] of this.tests) { for (const [flagName, flag] of test.flags) { if (!allFlags.has(flagName)) { allFlags.set(flagName, { name: flagName, tested: true, testFiles: [], isShort: flag.isShort, }) } allFlags.get(flagName).testFiles.push(testFile) } } return allFlags } /** * Get all tested options across all test files * @returns {Map} Map of all tested options */ getAllTestedOptions() { const allOptions = new Map() for (const [testFile, test] of this.tests) { for (const [optionName, option] of test.options) { if (!allOptions.has(optionName)) { allOptions.set(optionName, { name: optionName, tested: true, testFiles: [], }) } allOptions.get(optionName).testFiles.push(testFile) } } return allOptions } }