citty-test-utils
Version:
Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.
503 lines (462 loc) âĸ 18.5 kB
JavaScript
/**
* @fileoverview Coverage analysis subcommand
* @description Analyze test coverage using AST-based pattern matching for accurate results
*/
import { defineCommand } from 'citty'
import { EnhancedASTCLIAnalyzer } from '../../core/coverage/enhanced-ast-cli-analyzer.js'
import { resolveCLIEntry, getCLIEntryArgs } from '../../core/utils/cli-entry-resolver.js'
import {
validateCLIPath,
buildAnalysisMetadata,
} from '../../core/utils/analysis-report-utils.js'
import { writeFileSync } from 'fs'
export const coverageCommand = defineCommand({
meta: {
name: 'coverage',
description: 'đ Analyze test coverage using AST-based pattern matching for accurate results',
},
args: {
...getCLIEntryArgs(),
'test-dir': {
type: 'string',
description: 'Directory containing test files',
default: 'test',
},
format: {
type: 'string',
description: 'Output format (text, json, html)',
default: 'text',
},
output: {
type: 'string',
description: 'Output file path (optional)',
},
threshold: {
type: 'string',
description: 'Coverage threshold percentage (e.g., 80)',
default: '0',
},
trends: {
type: 'boolean',
description: 'Include trend analysis',
default: false,
},
verbose: {
type: 'boolean',
description: 'Enable detailed output',
default: false,
},
'include-patterns': {
type: 'string',
description: 'Comma-separated file patterns to include',
default: '.test.mjs,.test.js,.spec.mjs,.spec.js',
},
'exclude-patterns': {
type: 'string',
description: 'Comma-separated patterns to exclude',
default: 'node_modules,.git,coverage',
},
},
run: async (ctx) => {
const {
'entry-file': entryFile,
'cli-file': cliFile,
'cli-path': cliPath,
'test-dir': testDir,
format,
output,
threshold,
trends,
verbose,
'include-patterns': includePatterns,
'exclude-patterns': excludePatterns,
} = ctx.args
try {
// Resolve CLI entry point (supports --entry-file, --cli-file, auto-detection)
const finalCLIPath = await resolveCLIEntry({
entryFile,
cliFile,
cliPath,
verbose,
})
const analyzer = new EnhancedASTCLIAnalyzer({
cliPath: finalCLIPath,
testDir,
includePatterns: includePatterns.split(',').map((p) => p.trim()),
excludePatterns: excludePatterns.split(',').map((p) => p.trim()),
verbose,
})
if (verbose) {
console.log('đ Starting test coverage analysis...')
console.log(`CLI Path: ${finalCLIPath}`)
console.log(`Test Directory: ${testDir}`)
console.log(`Format: ${format}`)
console.log(`Threshold: ${threshold}%`)
console.log(`Trends: ${trends}`)
}
// Perform coverage analysis with better error handling
let report
try {
report = await analyzer.analyze()
// Validate report structure
if (!report || !report.coverage || !report.coverage.summary) {
throw new Error(
'Invalid analysis result: missing coverage data. ' +
'This may occur with complex CLI structures. ' +
'Try using --cli-path to specify exact CLI file.'
)
}
} catch (analysisError) {
// Provide helpful error message for complex projects
if (analysisError.message.includes('Cannot convert undefined or null')) {
console.error('â Coverage analysis failed for this CLI structure.')
console.error('')
console.error('This is a known issue with complex CLI architectures.')
console.error('Possible solutions:')
console.error(' 1. Try the "discover" command instead for CLI structure analysis')
console.error(' 2. Use "recommend" command for test recommendations')
console.error(' 3. Check that your CLI file has valid citty commands')
console.error('')
console.error(`Debug info: CLI Path = ${finalCLIPath}`)
if (verbose) {
console.error('')
console.error('Full error:')
console.error(analysisError.stack)
}
process.exit(1)
}
throw analysisError
}
// Generate coverage report
const coverageReport = generateCoverageReport(report, {
cliPath,
testDir,
format,
threshold: parseFloat(threshold),
trends,
verbose,
})
// Check threshold
const overallCoverage = report.coverage.summary.overall.percentage
if (parseFloat(threshold) > 0 && overallCoverage < parseFloat(threshold)) {
console.error(`â Coverage threshold not met: ${overallCoverage.toFixed(1)}% < ${threshold}%`)
process.exit(1)
}
if (output) {
writeFileSync(output, coverageReport)
console.log(`â
Coverage analysis saved to: ${output}`)
} else {
console.log(coverageReport)
}
} catch (error) {
console.error(`â Coverage analysis failed: ${error.message}`)
if (verbose) {
console.error(error.stack)
}
process.exit(1)
}
},
})
/**
* Generate coverage report
* @param {Object} report - Analysis report
* @param {Object} options - Report options
* @returns {string} Formatted coverage report
*/
function generateCoverageReport(report, options) {
const { format, threshold, trends, verbose } = options
switch (format.toLowerCase()) {
case 'json':
return generateJSONCoverageReport(report, options)
case 'html':
return generateHTMLCoverageReport(report, options)
case 'text':
default:
return generateTextCoverageReport(report, options)
}
}
/**
* Generate text format coverage report
* @param {Object} report - Analysis report
* @param {Object} options - Report options
* @returns {string} Text coverage report
*/
function generateTextCoverageReport(report, options) {
const { cliPath, testDir, threshold, trends, verbose } = options
const lines = []
lines.push('đ Test Coverage Analysis Report')
lines.push('='.repeat(40))
lines.push('')
// Summary
lines.push('đ Coverage Summary:')
lines.push(` CLI Path: ${cliPath}`)
lines.push(` Test Directory: ${testDir}`)
lines.push(` Analysis Method: ${report.metadata.analysisMethod}`)
lines.push(` Threshold: ${threshold}%`)
lines.push('')
// Coverage Statistics
const coverage = report.coverage.summary
lines.push('đ Coverage Statistics:')
// Handle new hierarchy structure
if (coverage.mainCommand) {
lines.push(
` Main Command: ${coverage.mainCommand.tested}/${coverage.mainCommand.total} (${coverage.mainCommand.percentage.toFixed(1)}%)`
)
} else if (coverage.commands) {
lines.push(
` Commands: ${coverage.commands.tested}/${coverage.commands.total} (${coverage.commands.percentage.toFixed(1)}%)`
)
}
if (coverage.subcommands) {
lines.push(
` Subcommands: ${coverage.subcommands.tested}/${coverage.subcommands.total} (${coverage.subcommands.percentage.toFixed(1)}%)`
)
}
lines.push(
` Flags: ${coverage.flags.tested}/${coverage.flags.total} (${coverage.flags.percentage.toFixed(1)}%)`
)
lines.push(
` Options: ${coverage.options.tested}/${coverage.options.total} (${coverage.options.percentage.toFixed(1)}%)`
)
lines.push(
` Overall: ${coverage.overall.tested}/${coverage.overall.total} (${coverage.overall.percentage.toFixed(1)}%)`
)
lines.push('')
// Threshold Status
if (threshold > 0) {
const status = coverage.overall.percentage >= threshold ? 'â
' : 'â'
lines.push(`đ¯ Threshold Status: ${status} ${coverage.overall.percentage.toFixed(1)}% >= ${threshold}%`)
lines.push('')
}
// Command Coverage Details with null/undefined safety
lines.push('đ Command Coverage Details:')
const commands = report.commands || {}
for (const [name, command] of Object.entries(commands)) {
const status = command.tested ? 'â
' : 'â'
lines.push(` ${status} ${name}: ${command.description || 'No description'}`)
// Subcommands
if (command.subcommands && Object.keys(command.subcommands).length > 0) {
for (const [subName, subcommand] of Object.entries(command.subcommands)) {
const subStatus = subcommand.tested ? 'â
' : 'â'
const imported = subcommand.imported ? ' (imported)' : ''
lines.push(` ${subStatus} ${name} ${subName}: ${subcommand.description || 'No description'}${imported}`)
}
}
// Flags
if (command.flags && Object.keys(command.flags).length > 0) {
for (const [flagName, flag] of Object.entries(command.flags)) {
const flagStatus = flag.tested ? 'â
' : 'â'
lines.push(` ${flagStatus} --${flagName}: ${flag.description || 'No description'}`)
}
}
// Options
if (command.options && Object.keys(command.options).length > 0) {
for (const [optionName, option] of Object.entries(command.options)) {
const optionStatus = option.tested ? 'â
' : 'â'
lines.push(` ${optionStatus} --${optionName}: ${option.description || 'No description'}`)
}
}
}
lines.push('')
// Global Options Coverage with null/undefined safety
const globalOptions = report.globalOptions || {}
if (Object.keys(globalOptions).length > 0) {
lines.push('đ Global Options Coverage:')
for (const [name, option] of Object.entries(globalOptions)) {
const status = option.tested ? 'â
' : 'â'
const type = option.isFlag ? 'flag' : 'option'
lines.push(` ${status} --${name} (${type}): ${option.description || 'No description'}`)
}
lines.push('')
}
// Untested Items
const details = report.coverage.details
if (details.untestedCommands.length > 0) {
lines.push('â Untested Commands:')
details.untestedCommands.forEach((cmd) => {
lines.push(` - ${cmd.name}: ${cmd.description}`)
})
lines.push('')
}
if (details.untestedSubcommands.length > 0) {
lines.push('â Untested Subcommands:')
details.untestedSubcommands.forEach((subcmd) => {
const imported = subcmd.imported ? ' (imported)' : ''
lines.push(` - ${subcmd.command} ${subcmd.subcommand}: ${subcmd.description}${imported}`)
})
lines.push('')
}
if (details.untestedFlags.length > 0) {
lines.push('â Untested Flags:')
details.untestedFlags.forEach((flag) => {
const global = flag.global ? ' (global)' : ''
lines.push(` - --${flag.name}: ${flag.description}${global}`)
})
lines.push('')
}
if (details.untestedOptions.length > 0) {
lines.push('â Untested Options:')
details.untestedOptions.forEach((option) => {
const global = option.global ? ' (global)' : ''
lines.push(` - --${option.name}: ${option.description}${global}`)
})
lines.push('')
}
// Trend Analysis
if (trends) {
lines.push('đ Trend Analysis:')
lines.push(' Coverage trends over time would be displayed here')
lines.push(' (Requires historical data collection)')
lines.push('')
}
// Analysis Metadata
if (verbose) {
lines.push('âšī¸ Analysis Metadata:')
lines.push(` Analyzed At: ${new Date(report.metadata.analyzedAt).toLocaleString()}`)
lines.push(` CLI Path: ${report.metadata.cliPath}`)
lines.push(` Test Directory: ${report.metadata.testDir}`)
lines.push(` Analysis Method: ${report.metadata.analysisMethod}`)
lines.push(` Total Test Files: ${report.metadata.totalTestFiles}`)
lines.push(` Total Commands: ${report.metadata.totalCommands}`)
lines.push(` Total Subcommands: ${report.metadata.totalSubcommands || 0}`)
lines.push(` Total Flags: ${report.metadata.totalFlags}`)
lines.push(` Total Options: ${report.metadata.totalOptions}`)
lines.push('')
}
return lines.join('\n')
}
/**
* Generate JSON format coverage report
* @param {Object} report - Analysis report
* @param {Object} options - Report options
* @returns {string} JSON coverage report
*/
function generateJSONCoverageReport(report, options) {
const { threshold, trends, verbose } = options
const coverageReport = {
metadata: buildAnalysisMetadata({
cliPath: report.metadata.cliPath,
additionalFields: {
threshold,
trends,
verbose,
testDir: report.metadata.testDir,
},
}),
coverage: report.coverage,
commands: report.commands,
globalOptions: report.globalOptions,
recommendations: report.recommendations,
}
if (trends) {
coverageReport.trends = {
message: 'Trend analysis requires historical data collection',
available: false,
}
}
return JSON.stringify(coverageReport, null, 2)
}
/**
* Generate HTML format coverage report
* @param {Object} report - Analysis report
* @param {Object} options - Report options
* @returns {string} HTML coverage report
*/
function generateHTMLCoverageReport(report, options) {
const { cliPath, testDir, threshold, trends, verbose } = options
const coverage = report.coverage.summary
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Coverage Analysis Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.header { background: #f5f5f5; padding: 20px; border-radius: 5px; }
.summary { background: #e8f4fd; padding: 15px; border-radius: 5px; margin: 20px 0; }
.coverage-item { margin: 10px 0; }
.coverage-bar { background: #ddd; height: 20px; border-radius: 10px; overflow: hidden; }
.coverage-fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); }
.untested { background: #f44336; }
.tested { background: #4CAF50; }
.command { margin: 15px 0; padding: 10px; border-left: 4px solid #2196F3; }
.subcommand { margin: 5px 0 5px 20px; }
.flag, .option { margin: 5px 0 5px 40px; font-size: 0.9em; }
.status { font-weight: bold; }
.metadata { background: #f9f9f9; padding: 15px; border-radius: 5px; margin-top: 20px; }
</style>
</head>
<body>
<div class="header">
<h1>đ Test Coverage Analysis Report</h1>
<p><strong>CLI Path:</strong> ${cliPath}</p>
<p><strong>Test Directory:</strong> ${testDir}</p>
<p><strong>Analysis Method:</strong> ${report.metadata.analysisMethod}</p>
<p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
</div>
<div class="summary">
<h2>đ Coverage Summary</h2>
<div class="coverage-item">
<strong>Commands:</strong> ${coverage.commands.tested}/${coverage.commands.total} (${coverage.commands.percentage.toFixed(1)}%)
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${coverage.commands.percentage}%"></div>
</div>
</div>
${coverage.subcommands ? `
<div class="coverage-item">
<strong>Subcommands:</strong> ${coverage.subcommands.tested}/${coverage.subcommands.total} (${coverage.subcommands.percentage.toFixed(1)}%)
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${coverage.subcommands.percentage}%"></div>
</div>
</div>
` : ''}
<div class="coverage-item">
<strong>Flags:</strong> ${coverage.flags.tested}/${coverage.flags.total} (${coverage.flags.percentage.toFixed(1)}%)
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${coverage.flags.percentage}%"></div>
</div>
</div>
<div class="coverage-item">
<strong>Options:</strong> ${coverage.options.tested}/${coverage.options.total} (${coverage.options.percentage.toFixed(1)}%)
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${coverage.options.percentage}%"></div>
</div>
</div>
<div class="coverage-item">
<strong>Overall:</strong> ${coverage.overall.tested}/${coverage.overall.total} (${coverage.overall.percentage.toFixed(1)}%)
<div class="coverage-bar">
<div class="coverage-fill ${coverage.overall.percentage >= threshold ? 'tested' : 'untested'}" style="width: ${coverage.overall.percentage}%"></div>
</div>
</div>
</div>
<h2>đ Command Coverage Details</h2>
${Object.entries(report.commands).map(([name, command]) => `
<div class="command">
<div class="status ${command.tested ? 'tested' : 'untested'}">${command.tested ? 'â
' : 'â'} ${name}: ${command.description || 'No description'}</div>
${command.subcommands ? Object.entries(command.subcommands).map(([subName, subcommand]) => `
<div class="subcommand ${subcommand.tested ? 'tested' : 'untested'}">${subcommand.tested ? 'â
' : 'â'} ${name} ${subName}: ${subcommand.description || 'No description'}</div>
`).join('') : ''}
${command.flags ? Object.entries(command.flags).map(([flagName, flag]) => `
<div class="flag ${flag.tested ? 'tested' : 'untested'}">${flag.tested ? 'â
' : 'â'} --${flagName}: ${flag.description || 'No description'}</div>
`).join('') : ''}
${command.options ? Object.entries(command.options).map(([optionName, option]) => `
<div class="option ${option.tested ? 'tested' : 'untested'}">${option.tested ? 'â
' : 'â'} --${optionName}: ${option.description || 'No description'}</div>
`).join('') : ''}
</div>
`).join('')}
${verbose ? `
<div class="metadata">
<h2>âšī¸ Analysis Metadata</h2>
<p><strong>Analyzed At:</strong> ${new Date(report.metadata.analyzedAt).toLocaleString()}</p>
<p><strong>Total Test Files:</strong> ${report.metadata.totalTestFiles}</p>
<p><strong>Total Commands:</strong> ${report.metadata.totalCommands}</p>
<p><strong>Total Subcommands:</strong> ${report.metadata.totalSubcommands || 0}</p>
<p><strong>Total Flags:</strong> ${report.metadata.totalFlags}</p>
<p><strong>Total Options:</strong> ${report.metadata.totalOptions}</p>
</div>
` : ''}
</body>
</html>`
}