added cli-covarage tool and fixed more tests

This commit is contained in:
Boki 2025-06-26 14:23:01 -04:00
parent b63e58784c
commit b845a8eade
57 changed files with 11917 additions and 295 deletions

View file

@ -0,0 +1,363 @@
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import type { CoverageReport, PackageCoverage } from '../types';
export class HtmlCompactReporter {
report(coverage: CoverageReport, outputDir: string): void {
const htmlDir = join(outputDir, 'html');
mkdirSync(htmlDir, { recursive: true });
const html = this.generateHTML(coverage);
writeFileSync(join(htmlDir, 'index.html'), html);
this.writeStyles(htmlDir);
console.log(`HTML coverage report written to: ${join(htmlDir, 'index.html')}`);
}
private generateHTML(report: CoverageReport): string {
const timestamp = new Date(report.timestamp).toLocaleString();
const { overall, packages } = report;
// Sort packages by line coverage descending
const sortedPackages = [...packages].sort((a, b) => b.lines.percentage - a.lines.percentage);
// Filter packages with 0% coverage separately
const coveredPackages = sortedPackages.filter(p => p.lines.percentage > 0);
const uncoveredPackages = sortedPackages.filter(p => p.lines.percentage === 0);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coverage Report</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container-compact">
<div class="header">
<div class="title">Coverage Report</div>
<div class="overall">
<span class="label">Overall:</span>
${this.formatMetric('L', overall.lines)}
${this.formatMetric('F', overall.functions)}
${this.formatMetric('B', overall.branches)}
${this.formatMetric('S', overall.statements)}
</div>
<div class="timestamp">${timestamp}</div>
</div>
<table class="coverage-table">
<thead>
<tr>
<th>Package</th>
<th>L</th>
<th>F</th>
<th>B</th>
<th>S</th>
<th>Details</th>
</tr>
</thead>
<tbody>
${coveredPackages.map(pkg => this.generatePackageRow(pkg, report.config.thresholds)).join('\n')}
${uncoveredPackages.length > 0 ? `
<tr class="separator">
<td colspan="6">Uncovered Packages (${uncoveredPackages.length})</td>
</tr>
${uncoveredPackages.map(pkg => this.generateUncoveredRow(pkg)).join('\n')}
` : ''}
</tbody>
</table>
</div>
<script>
function toggleDetails(pkg) {
const row = document.getElementById('details-' + pkg);
const btn = document.getElementById('btn-' + pkg);
if (row.style.display === 'none' || !row.style.display) {
row.style.display = 'table-row';
btn.textContent = '';
} else {
row.style.display = 'none';
btn.textContent = '+';
}
}
</script>
</body>
</html>`;
}
private formatMetric(label: string, metric: any): string {
const percentage = metric.percentage.toFixed(1);
const threshold = this.getThreshold(label);
const cssClass = this.getCoverageClass(metric.percentage, threshold);
return `<span class="metric ${cssClass}">${label}: ${percentage}%</span>`;
}
private getThreshold(label: string): number {
const map: Record<string, number> = { L: 80, F: 80, B: 80, S: 80 };
return map[label] || 80;
}
private getCoverageClass(percentage: number, threshold: number = 80): string {
if (percentage >= threshold) return 'good';
if (percentage >= threshold * 0.9) return 'warn';
return 'bad';
}
private generatePackageRow(pkg: PackageCoverage, thresholds: any): string {
const id = pkg.name.replace(/[@/]/g, '-');
const hasFiles = pkg.files && pkg.files.length > 0;
return `
<tr>
<td class="pkg-name">${pkg.name}</td>
<td class="${this.getCoverageClass(pkg.lines.percentage, thresholds.lines)}">${pkg.lines.percentage.toFixed(1)}</td>
<td class="${this.getCoverageClass(pkg.functions.percentage, thresholds.functions)}">${pkg.functions.percentage.toFixed(1)}</td>
<td class="${this.getCoverageClass(pkg.branches.percentage, thresholds.branches)}">${pkg.branches.percentage.toFixed(1)}</td>
<td class="${this.getCoverageClass(pkg.statements.percentage, thresholds.statements)}">${pkg.statements.percentage.toFixed(1)}</td>
<td class="details-btn">
${hasFiles ? `<button id="btn-${id}" onclick="toggleDetails('${id}')">+</button>` : ''}
</td>
</tr>
${hasFiles ? `
<tr id="details-${id}" class="details-row" style="display:none">
<td colspan="6">
<div class="file-details">
<table class="files-table">
<thead>
<tr>
<th>File</th>
<th>L</th>
<th>F</th>
<th>B</th>
<th>S</th>
</tr>
</thead>
<tbody>
${pkg.files.map(f => this.generateFileRow(f)).join('\n')}
</tbody>
</table>
</div>
</td>
</tr>` : ''}`;
}
private generateUncoveredRow(pkg: PackageCoverage): string {
return `
<tr class="uncovered">
<td class="pkg-name">${pkg.name}</td>
<td colspan="4" class="zero">0.0</td>
<td></td>
</tr>`;
}
private generateFileRow(file: any): string {
const shortPath = this.shortenPath(file.path);
return `
<tr>
<td class="file-path" title="${file.path}">${shortPath}</td>
<td class="${this.getCoverageClass(file.lines.percentage)}">${file.lines.percentage.toFixed(1)}</td>
<td class="${this.getCoverageClass(file.functions.percentage)}">${file.functions.percentage.toFixed(1)}</td>
<td class="${this.getCoverageClass(file.branches.percentage)}">${file.branches.percentage.toFixed(1)}</td>
<td class="${this.getCoverageClass(file.statements.percentage)}">${file.statements.percentage.toFixed(1)}</td>
</tr>`;
}
private shortenPath(path: string): string {
const parts = path.split('/');
const relevantParts = [];
let foundSrc = false;
for (let i = parts.length - 1; i >= 0; i--) {
relevantParts.unshift(parts[i]);
if (parts[i] === 'src' || parts[i] === 'test') {
foundSrc = true;
if (i > 0) relevantParts.unshift(parts[i - 1]);
break;
}
}
return foundSrc ? relevantParts.join('/') : parts.slice(-3).join('/');
}
private writeStyles(htmlDir: string): void {
const css = `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
font-size: 13px;
line-height: 1.4;
color: #333;
background: #f5f5f5;
}
.container-compact {
max-width: 1000px;
margin: 20px auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #2d3748;
color: white;
}
.title {
font-size: 16px;
font-weight: 600;
}
.overall {
display: flex;
gap: 12px;
align-items: center;
}
.overall .label {
opacity: 0.8;
}
.metric {
padding: 2px 8px;
border-radius: 3px;
font-weight: 500;
font-size: 12px;
}
.metric.good { background: #48bb78; color: white; }
.metric.warn { background: #ed8936; color: white; }
.metric.bad { background: #f56565; color: white; }
.timestamp {
font-size: 11px;
opacity: 0.7;
}
.coverage-table {
width: 100%;
border-collapse: collapse;
}
.coverage-table th {
background: #f7fafc;
padding: 8px 12px;
text-align: left;
font-weight: 600;
font-size: 12px;
border-bottom: 1px solid #e2e8f0;
color: #4a5568;
}
.coverage-table td {
padding: 6px 12px;
border-bottom: 1px solid #f0f0f0;
}
.coverage-table tbody tr:hover {
background: #f9f9f9;
}
.pkg-name {
font-weight: 500;
color: #2d3748;
}
.good { color: #22863a; font-weight: 600; }
.warn { color: #b08800; font-weight: 600; }
.bad { color: #dc3545; font-weight: 600; }
.zero { color: #999; text-align: center; }
.details-btn button {
width: 20px;
height: 20px;
border: 1px solid #cbd5e0;
background: white;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
line-height: 1;
color: #4a5568;
}
.details-btn button:hover {
background: #e2e8f0;
}
.separator td {
background: #f7fafc;
font-weight: 600;
color: #718096;
padding: 4px 12px;
font-size: 11px;
}
.uncovered {
opacity: 0.6;
}
.details-row td {
padding: 0;
background: #f9f9f9;
}
.file-details {
padding: 12px 24px;
}
.files-table {
width: 100%;
font-size: 12px;
}
.files-table th {
background: #edf2f7;
padding: 4px 8px;
font-weight: 500;
}
.files-table td {
padding: 3px 8px;
border-bottom: 1px solid #e2e8f0;
}
.file-path {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #4a5568;
}
/* Compact mode for smaller screens */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.coverage-table {
font-size: 12px;
}
.coverage-table th,
.coverage-table td {
padding: 4px 8px;
}
}
`;
writeFileSync(join(htmlDir, 'styles.css'), css);
}
}

View file

@ -0,0 +1,404 @@
import { writeFileSync, mkdirSync, copyFileSync } from 'fs';
import { join, dirname } from 'path';
import Handlebars from 'handlebars';
import type { CoverageReport } from '../types';
export class HtmlReporter {
private template: HandlebarsTemplateDelegate;
constructor() {
this.registerHelpers();
this.template = this.compileTemplate();
}
report(coverage: CoverageReport, outputDir: string): void {
const htmlDir = join(outputDir, 'html');
mkdirSync(htmlDir, { recursive: true });
// Generate main report
const html = this.template({
coverage,
timestamp: new Date(coverage.timestamp).toLocaleString(),
thresholds: coverage.config.thresholds,
});
writeFileSync(join(htmlDir, 'index.html'), html);
// Write CSS
this.writeStyles(htmlDir);
console.log(`HTML coverage report written to: ${join(htmlDir, 'index.html')}`);
}
private registerHelpers(): void {
Handlebars.registerHelper('percentage', (value: number) => value.toFixed(1));
Handlebars.registerHelper('coverageClass', (percentage: number, threshold?: number) => {
if (!threshold) return 'neutral';
if (percentage >= threshold) return 'good';
if (percentage >= threshold * 0.9) return 'warning';
return 'bad';
});
Handlebars.registerHelper('coverageIcon', (percentage: number, threshold?: number) => {
if (!threshold) return '';
if (percentage >= threshold) return '✓';
if (percentage >= threshold * 0.9) return '⚠';
return '✗';
});
Handlebars.registerHelper('shortenPath', (path: string) => {
const parts = path.split('/');
if (parts.length > 4) {
return '.../' + parts.slice(-3).join('/');
}
return path;
});
}
private compileTemplate(): HandlebarsTemplateDelegate {
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stock Bot Coverage Report</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Stock Bot Coverage Report</h1>
<p class="timestamp">Generated: {{timestamp}}</p>
</header>
<section class="summary">
<h2>Overall Coverage</h2>
<div class="metrics">
<div class="metric {{coverageClass coverage.overall.lines.percentage thresholds.lines}}">
<div class="metric-name">Lines</div>
<div class="metric-value">
{{percentage coverage.overall.lines.percentage}}%
<span class="icon">{{coverageIcon coverage.overall.lines.percentage thresholds.lines}}</span>
</div>
<div class="metric-detail">
{{coverage.overall.lines.covered}} / {{coverage.overall.lines.total}}
</div>
</div>
<div class="metric {{coverageClass coverage.overall.functions.percentage thresholds.functions}}">
<div class="metric-name">Functions</div>
<div class="metric-value">
{{percentage coverage.overall.functions.percentage}}%
<span class="icon">{{coverageIcon coverage.overall.functions.percentage thresholds.functions}}</span>
</div>
<div class="metric-detail">
{{coverage.overall.functions.covered}} / {{coverage.overall.functions.total}}
</div>
</div>
<div class="metric {{coverageClass coverage.overall.branches.percentage thresholds.branches}}">
<div class="metric-name">Branches</div>
<div class="metric-value">
{{percentage coverage.overall.branches.percentage}}%
<span class="icon">{{coverageIcon coverage.overall.branches.percentage thresholds.branches}}</span>
</div>
<div class="metric-detail">
{{coverage.overall.branches.covered}} / {{coverage.overall.branches.total}}
</div>
</div>
<div class="metric {{coverageClass coverage.overall.statements.percentage thresholds.statements}}">
<div class="metric-name">Statements</div>
<div class="metric-value">
{{percentage coverage.overall.statements.percentage}}%
<span class="icon">{{coverageIcon coverage.overall.statements.percentage thresholds.statements}}</span>
</div>
<div class="metric-detail">
{{coverage.overall.statements.covered}} / {{coverage.overall.statements.total}}
</div>
</div>
</div>
</section>
<section class="packages">
<h2>Package Coverage</h2>
<table>
<thead>
<tr>
<th>Package</th>
<th>Lines</th>
<th>Functions</th>
<th>Branches</th>
<th>Statements</th>
</tr>
</thead>
<tbody>
{{#each coverage.packages}}
<tr>
<td class="package-name">{{name}}</td>
<td class="{{coverageClass lines.percentage ../thresholds.lines}}">
{{percentage lines.percentage}}%
</td>
<td class="{{coverageClass functions.percentage ../thresholds.functions}}">
{{percentage functions.percentage}}%
</td>
<td class="{{coverageClass branches.percentage ../thresholds.branches}}">
{{percentage branches.percentage}}%
</td>
<td class="{{coverageClass statements.percentage ../thresholds.statements}}">
{{percentage statements.percentage}}%
</td>
</tr>
{{/each}}
</tbody>
</table>
</section>
{{#each coverage.packages}}
<section class="package-details">
<h3>{{name}}</h3>
<div class="file-list">
<table>
<thead>
<tr>
<th>File</th>
<th>Lines</th>
<th>Functions</th>
<th>Branches</th>
<th>Statements</th>
</tr>
</thead>
<tbody>
{{#each files}}
<tr>
<td class="file-path">{{shortenPath path}}</td>
<td class="{{coverageClass lines.percentage}}">
{{percentage lines.percentage}}%
</td>
<td class="{{coverageClass functions.percentage}}">
{{percentage functions.percentage}}%
</td>
<td class="{{coverageClass branches.percentage}}">
{{percentage branches.percentage}}%
</td>
<td class="{{coverageClass statements.percentage}}">
{{percentage statements.percentage}}%
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
</section>
{{/each}}
</div>
</body>
</html>
`;
return Handlebars.compile(template);
}
private writeStyles(htmlDir: string): void {
const css = `
:root {
--color-good: #4caf50;
--color-warning: #ff9800;
--color-bad: #f44336;
--color-neutral: #9e9e9e;
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--text-primary: #212121;
--text-secondary: #757575;
--border-color: #e0e0e0;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 3rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.timestamp {
color: var(--text-secondary);
}
.summary {
background: var(--bg-secondary);
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.metric {
text-align: center;
padding: 1.5rem;
border-radius: 8px;
background: var(--bg-primary);
}
.metric.good {
border: 2px solid var(--color-good);
}
.metric.warning {
border: 2px solid var(--color-warning);
}
.metric.bad {
border: 2px solid var(--color-bad);
}
.metric.neutral {
border: 2px solid var(--color-neutral);
}
.metric-name {
font-size: 0.875rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metric-value {
font-size: 2rem;
font-weight: bold;
margin: 0.5rem 0;
}
.metric-detail {
font-size: 0.875rem;
color: var(--text-secondary);
}
.icon {
margin-left: 0.5rem;
}
.packages {
background: var(--bg-secondary);
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.875rem;
}
.package-name {
font-weight: 500;
}
.good {
color: var(--color-good);
font-weight: 500;
}
.warning {
color: var(--color-warning);
font-weight: 500;
}
.bad {
color: var(--color-bad);
font-weight: 500;
}
.neutral {
color: var(--color-neutral);
}
.package-details {
background: var(--bg-secondary);
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.package-details h3 {
margin-bottom: 1rem;
}
.file-path {
font-family: 'Courier New', Courier, monospace;
font-size: 0.875rem;
}
.file-list {
max-height: 400px;
overflow-y: auto;
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.metrics {
grid-template-columns: 1fr;
}
table {
font-size: 0.875rem;
}
th, td {
padding: 0.5rem;
}
}
`;
writeFileSync(join(htmlDir, 'styles.css'), css);
}
}

View file

@ -0,0 +1,38 @@
import { TerminalReporter } from './terminal';
import { JsonReporter } from './json';
import { MarkdownReporter } from './markdown';
import { HtmlReporter } from './html';
import { HtmlCompactReporter } from './html-compact';
import { TextReporter } from './text';
import type { CoverageReport, ReporterType } from '../types';
export class ReporterManager {
private reporters = {
terminal: new TerminalReporter(),
json: new JsonReporter(),
markdown: new MarkdownReporter(),
html: new HtmlCompactReporter(), // Use compact HTML by default
'html-full': new HtmlReporter(), // Keep full HTML as option
text: new TextReporter(),
};
async report(coverage: CoverageReport, reporters: ReporterType[], outputDir: string): Promise<void> {
for (const reporterType of reporters) {
try {
const reporter = this.reporters[reporterType];
if (!reporter) {
console.warn(`Unknown reporter type: ${reporterType}`);
continue;
}
if (reporterType === 'terminal') {
reporter.report(coverage);
} else {
reporter.report(coverage, outputDir);
}
} catch (error) {
console.error(`Error running ${reporterType} reporter:`, error);
}
}
}
}

View file

@ -0,0 +1,91 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import type { CoverageReport } from '../types';
export class JsonReporter {
report(coverage: CoverageReport, outputDir: string): void {
const outputPath = join(outputDir, 'coverage.json');
// Create a clean report without circular references
const cleanReport = {
timestamp: coverage.timestamp,
summary: {
lines: {
total: coverage.overall.lines.total,
covered: coverage.overall.lines.covered,
percentage: coverage.overall.lines.percentage,
},
functions: {
total: coverage.overall.functions.total,
covered: coverage.overall.functions.covered,
percentage: coverage.overall.functions.percentage,
},
branches: {
total: coverage.overall.branches.total,
covered: coverage.overall.branches.covered,
percentage: coverage.overall.branches.percentage,
},
statements: {
total: coverage.overall.statements.total,
covered: coverage.overall.statements.covered,
percentage: coverage.overall.statements.percentage,
},
},
packages: coverage.packages.map(pkg => ({
name: pkg.name,
path: pkg.path,
lines: {
total: pkg.lines.total,
covered: pkg.lines.covered,
percentage: pkg.lines.percentage,
},
functions: {
total: pkg.functions.total,
covered: pkg.functions.covered,
percentage: pkg.functions.percentage,
},
branches: {
total: pkg.branches.total,
covered: pkg.branches.covered,
percentage: pkg.branches.percentage,
},
statements: {
total: pkg.statements.total,
covered: pkg.statements.covered,
percentage: pkg.statements.percentage,
},
files: pkg.files.map(file => ({
path: file.path,
lines: {
total: file.lines.total,
covered: file.lines.covered,
percentage: file.lines.percentage,
},
functions: {
total: file.functions.total,
covered: file.functions.covered,
percentage: file.functions.percentage,
},
branches: {
total: file.branches.total,
covered: file.branches.covered,
percentage: file.branches.percentage,
},
statements: {
total: file.statements.total,
covered: file.statements.covered,
percentage: file.statements.percentage,
},
})),
})),
config: {
thresholds: coverage.config.thresholds,
exclude: coverage.config.exclude,
reporters: coverage.config.reporters,
},
};
writeFileSync(outputPath, JSON.stringify(cleanReport, null, 2));
console.log(`JSON coverage report written to: ${outputPath}`);
}
}

View file

@ -0,0 +1,165 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import type { CoverageReport, PackageCoverage, CoverageMetric } from '../types';
export class MarkdownReporter {
report(coverage: CoverageReport, outputDir: string): void {
const outputPath = join(outputDir, 'coverage.md');
const content = this.generateMarkdown(coverage);
writeFileSync(outputPath, content);
console.log(`Markdown coverage report written to: ${outputPath}`);
}
private generateMarkdown(coverage: CoverageReport): string {
const lines: string[] = [];
// Header
lines.push('# Coverage Report');
lines.push('');
lines.push(`Generated: ${new Date(coverage.timestamp).toLocaleString()}`);
lines.push('');
// Overall Summary
lines.push('## Overall Coverage');
lines.push('');
lines.push(this.generateSummaryTable(coverage.overall, coverage.config.thresholds));
lines.push('');
// Package Details
if (coverage.packages.length > 0) {
lines.push('## Package Coverage');
lines.push('');
lines.push(this.generatePackageTable(coverage));
lines.push('');
// Detailed package breakdowns
lines.push('## Package Details');
lines.push('');
for (const pkg of coverage.packages) {
lines.push(`### ${pkg.name}`);
lines.push('');
lines.push(this.generatePackageDetails(pkg));
lines.push('');
}
}
// Thresholds
lines.push('## Coverage Thresholds');
lines.push('');
lines.push('| Metric | Threshold | Actual | Status |');
lines.push('|--------|-----------|---------|---------|');
const metrics = ['lines', 'functions', 'branches', 'statements'] as const;
for (const metric of metrics) {
const threshold = coverage.config.thresholds[metric];
const actual = coverage.overall[metric].percentage;
const status = this.getStatus(actual, threshold);
lines.push(`| ${this.capitalize(metric)} | ${threshold || 'N/A'}% | ${actual.toFixed(1)}% | ${status} |`);
}
return lines.join('\n');
}
private generateSummaryTable(overall: CoverageReport['overall'], thresholds: any): string {
const lines: string[] = [];
lines.push('| Metric | Coverage | Total | Covered |');
lines.push('|--------|----------|--------|----------|');
const metrics = ['lines', 'functions', 'branches', 'statements'] as const;
for (const metric of metrics) {
const data = overall[metric];
const icon = this.getIcon(data.percentage, thresholds[metric]);
lines.push(
`| ${this.capitalize(metric)} | ${data.percentage.toFixed(1)}% ${icon} | ${data.total} | ${data.covered} |`
);
}
return lines.join('\n');
}
private generatePackageTable(coverage: CoverageReport): string {
const lines: string[] = [];
lines.push('| Package | Lines | Functions | Branches | Statements |');
lines.push('|---------|--------|-----------|----------|------------|');
for (const pkg of coverage.packages) {
lines.push(
`| ${pkg.name} | ${this.formatMetric(pkg.lines)} | ${this.formatMetric(pkg.functions)} | ${this.formatMetric(pkg.branches)} | ${this.formatMetric(pkg.statements)} |`
);
}
lines.push(`| **Overall** | **${this.formatMetric(coverage.overall.lines)}** | **${this.formatMetric(coverage.overall.functions)}** | **${this.formatMetric(coverage.overall.branches)}** | **${this.formatMetric(coverage.overall.statements)}** |`);
return lines.join('\n');
}
private generatePackageDetails(pkg: PackageCoverage): string {
const lines: string[] = [];
// Summary
lines.push(`**Coverage Summary:**`);
lines.push(`- Lines: ${pkg.lines.percentage.toFixed(1)}% (${pkg.lines.covered}/${pkg.lines.total})`);
lines.push(`- Functions: ${pkg.functions.percentage.toFixed(1)}% (${pkg.functions.covered}/${pkg.functions.total})`);
lines.push(`- Branches: ${pkg.branches.percentage.toFixed(1)}% (${pkg.branches.covered}/${pkg.branches.total})`);
lines.push(`- Statements: ${pkg.statements.percentage.toFixed(1)}% (${pkg.statements.covered}/${pkg.statements.total})`);
lines.push('');
// File breakdown (top 10 least covered)
const sortedFiles = [...pkg.files]
.sort((a, b) => a.lines.percentage - b.lines.percentage)
.slice(0, 10);
if (sortedFiles.length > 0) {
lines.push('**Least Covered Files:**');
lines.push('');
lines.push('| File | Lines | Functions | Branches |');
lines.push('|------|--------|-----------|----------|');
for (const file of sortedFiles) {
const shortPath = this.shortenPath(file.path);
lines.push(
`| ${shortPath} | ${file.lines.percentage.toFixed(1)}% | ${file.functions.percentage.toFixed(1)}% | ${file.branches.percentage.toFixed(1)}% |`
);
}
}
return lines.join('\n');
}
private formatMetric(metric: CoverageMetric): string {
return `${metric.percentage.toFixed(1)}%`;
}
private getIcon(percentage: number, threshold?: number): string {
if (!threshold) return '';
if (percentage >= threshold) return '✅';
if (percentage >= threshold * 0.9) return '⚠️';
return '❌';
}
private getStatus(percentage: number, threshold?: number): string {
if (!threshold) return '';
if (percentage >= threshold) return '✅ Pass';
return '❌ Fail';
}
private capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
private shortenPath(path: string): string {
const parts = path.split('/');
if (parts.length > 4) {
return '.../' + parts.slice(-3).join('/');
}
return path;
}
}

View file

@ -0,0 +1,176 @@
import chalk from 'chalk';
import { table } from 'table';
import type { CoverageReport, PackageCoverage, CoverageMetric } from '../types';
export class TerminalReporter {
report(coverage: CoverageReport): void {
console.log('\n' + chalk.bold.cyan('═'.repeat(60)));
console.log(chalk.bold.cyan(' Stock Bot Coverage Report'));
console.log(chalk.bold.cyan('═'.repeat(60)) + '\n');
// Package-level coverage
if (coverage.packages.length > 0) {
this.printPackageTable(coverage);
}
// Overall summary
this.printOverallSummary(coverage);
// Threshold warnings
this.printThresholdWarnings(coverage);
}
private printPackageTable(coverage: CoverageReport): void {
const data: any[][] = [
[
chalk.bold('Package'),
chalk.bold('Lines'),
chalk.bold('Functions'),
chalk.bold('Branches'),
chalk.bold('Statements'),
],
];
for (const pkg of coverage.packages) {
data.push([
chalk.cyan(pkg.name),
this.formatMetric(pkg.lines, coverage.config.thresholds.lines),
this.formatMetric(pkg.functions, coverage.config.thresholds.functions),
this.formatMetric(pkg.branches, coverage.config.thresholds.branches),
this.formatMetric(pkg.statements, coverage.config.thresholds.statements),
]);
}
// Add separator
data.push([
chalk.gray('─'.repeat(20)),
chalk.gray('─'.repeat(10)),
chalk.gray('─'.repeat(10)),
chalk.gray('─'.repeat(10)),
chalk.gray('─'.repeat(10)),
]);
// Add overall
data.push([
chalk.bold('Overall'),
this.formatMetric(coverage.overall.lines, coverage.config.thresholds.lines),
this.formatMetric(coverage.overall.functions, coverage.config.thresholds.functions),
this.formatMetric(coverage.overall.branches, coverage.config.thresholds.branches),
this.formatMetric(coverage.overall.statements, coverage.config.thresholds.statements),
]);
const config = {
border: {
topBody: '─',
topJoin: '┬',
topLeft: '┌',
topRight: '┐',
bottomBody: '─',
bottomJoin: '┴',
bottomLeft: '└',
bottomRight: '┘',
bodyLeft: '│',
bodyRight: '│',
bodyJoin: '│',
joinBody: '─',
joinLeft: '├',
joinRight: '┤',
joinJoin: '┼',
},
};
console.log(table(data, config));
}
private formatMetric(metric: CoverageMetric, threshold?: number): string {
const percentage = metric.percentage.toFixed(1);
const icon = this.getIcon(metric.percentage, threshold);
const color = this.getColor(metric.percentage, threshold);
return color(`${percentage}% ${icon}`);
}
private getIcon(percentage: number, threshold?: number): string {
if (!threshold) return '';
if (percentage >= threshold) return '✓';
if (percentage >= threshold * 0.9) return '⚠';
return '✗';
}
private getColor(percentage: number, threshold?: number): (text: string) => string {
if (!threshold) return chalk.white;
if (percentage >= threshold) return chalk.green;
if (percentage >= threshold * 0.9) return chalk.yellow;
return chalk.red;
}
private printOverallSummary(coverage: CoverageReport): void {
const { packages, config } = coverage;
const passingPackages = packages.filter(pkg =>
this.packageMeetsThresholds(pkg, config.thresholds)
).length;
const warningPackages = packages.filter(pkg => {
const meetsThresholds = this.packageMeetsThresholds(pkg, config.thresholds);
const almostMeets = this.packageAlmostMeetsThresholds(pkg, config.thresholds);
return !meetsThresholds && almostMeets;
}).length;
const failingPackages = packages.length - passingPackages - warningPackages;
console.log(chalk.green(`${passingPackages} packages meet coverage thresholds`));
if (warningPackages > 0) {
console.log(chalk.yellow(`${warningPackages} packages below threshold`));
}
if (failingPackages > 0) {
console.log(chalk.red(`${failingPackages} packages critically low`));
}
}
private packageMeetsThresholds(pkg: PackageCoverage, thresholds: any): boolean {
return (
(!thresholds.lines || pkg.lines.percentage >= thresholds.lines) &&
(!thresholds.functions || pkg.functions.percentage >= thresholds.functions) &&
(!thresholds.branches || pkg.branches.percentage >= thresholds.branches) &&
(!thresholds.statements || pkg.statements.percentage >= thresholds.statements)
);
}
private packageAlmostMeetsThresholds(pkg: PackageCoverage, thresholds: any): boolean {
return (
(!thresholds.lines || pkg.lines.percentage >= thresholds.lines * 0.9) &&
(!thresholds.functions || pkg.functions.percentage >= thresholds.functions * 0.9) &&
(!thresholds.branches || pkg.branches.percentage >= thresholds.branches * 0.9) &&
(!thresholds.statements || pkg.statements.percentage >= thresholds.statements * 0.9)
);
}
private printThresholdWarnings(coverage: CoverageReport): void {
const { overall, config } = coverage;
const failures: string[] = [];
if (config.thresholds.lines && overall.lines.percentage < config.thresholds.lines) {
failures.push(`Lines: ${overall.lines.percentage.toFixed(1)}% < ${config.thresholds.lines}%`);
}
if (config.thresholds.functions && overall.functions.percentage < config.thresholds.functions) {
failures.push(`Functions: ${overall.functions.percentage.toFixed(1)}% < ${config.thresholds.functions}%`);
}
if (config.thresholds.branches && overall.branches.percentage < config.thresholds.branches) {
failures.push(`Branches: ${overall.branches.percentage.toFixed(1)}% < ${config.thresholds.branches}%`);
}
if (config.thresholds.statements && overall.statements.percentage < config.thresholds.statements) {
failures.push(`Statements: ${overall.statements.percentage.toFixed(1)}% < ${config.thresholds.statements}%`);
}
if (failures.length > 0) {
console.log('\n' + chalk.red.bold('Coverage thresholds not met:'));
failures.forEach(failure => console.log(chalk.red(`${failure}`)));
}
console.log('\n' + chalk.gray(`Run 'stock-bot-coverage --reporter html' for detailed report`));
}
}

View file

@ -0,0 +1,206 @@
import { writeFileSync } from 'fs';
import { join } from 'path';
import type { CoverageReport, PackageCoverage, CoverageMetric } from '../types';
export class TextReporter {
report(coverage: CoverageReport, outputDir: string): void {
const outputPath = join(outputDir, 'coverage.txt');
const content = this.generateText(coverage);
writeFileSync(outputPath, content);
console.log(`Text coverage report written to: ${outputPath}`);
}
private generateText(coverage: CoverageReport): string {
const lines: string[] = [];
const width = 80;
// Header
lines.push('='.repeat(width));
lines.push(this.center('STOCK BOT COVERAGE REPORT', width));
lines.push('='.repeat(width));
lines.push('');
lines.push(`Generated: ${new Date(coverage.timestamp).toLocaleString()}`);
lines.push('');
// Overall Summary
lines.push('-'.repeat(width));
lines.push('OVERALL COVERAGE');
lines.push('-'.repeat(width));
lines.push('');
const overall = coverage.overall;
lines.push(this.formatMetricLine('Lines', overall.lines, coverage.config.thresholds.lines));
lines.push(this.formatMetricLine('Functions', overall.functions, coverage.config.thresholds.functions));
lines.push(this.formatMetricLine('Branches', overall.branches, coverage.config.thresholds.branches));
lines.push(this.formatMetricLine('Statements', overall.statements, coverage.config.thresholds.statements));
lines.push('');
// Package Summary
if (coverage.packages.length > 0) {
lines.push('-'.repeat(width));
lines.push('PACKAGE COVERAGE');
lines.push('-'.repeat(width));
lines.push('');
// Table header
lines.push(this.padRight('Package', 30) +
this.padLeft('Lines', 10) +
this.padLeft('Funcs', 10) +
this.padLeft('Branch', 10) +
this.padLeft('Stmts', 10));
lines.push('-'.repeat(70));
// Package rows
for (const pkg of coverage.packages) {
lines.push(
this.padRight(pkg.name, 30) +
this.padLeft(this.formatPercent(pkg.lines.percentage), 10) +
this.padLeft(this.formatPercent(pkg.functions.percentage), 10) +
this.padLeft(this.formatPercent(pkg.branches.percentage), 10) +
this.padLeft(this.formatPercent(pkg.statements.percentage), 10)
);
}
lines.push('-'.repeat(70));
lines.push(
this.padRight('TOTAL', 30) +
this.padLeft(this.formatPercent(overall.lines.percentage), 10) +
this.padLeft(this.formatPercent(overall.functions.percentage), 10) +
this.padLeft(this.formatPercent(overall.branches.percentage), 10) +
this.padLeft(this.formatPercent(overall.statements.percentage), 10)
);
lines.push('');
// Detailed breakdowns
lines.push('-'.repeat(width));
lines.push('PACKAGE DETAILS');
lines.push('-'.repeat(width));
for (const pkg of coverage.packages) {
lines.push('');
lines.push(`Package: ${pkg.name}`);
lines.push(`Path: ${pkg.path}`);
lines.push('');
// Coverage details
lines.push(` Lines......: ${this.formatMetricDetail(pkg.lines)}`);
lines.push(` Functions..: ${this.formatMetricDetail(pkg.functions)}`);
lines.push(` Branches...: ${this.formatMetricDetail(pkg.branches)}`);
lines.push(` Statements.: ${this.formatMetricDetail(pkg.statements)}`);
lines.push('');
// File list (top 5 least covered)
if (pkg.files.length > 0) {
lines.push(' Least covered files:');
const sortedFiles = [...pkg.files]
.sort((a, b) => a.lines.percentage - b.lines.percentage)
.slice(0, 5);
for (const file of sortedFiles) {
const shortPath = this.shortenPath(file.path);
lines.push(` ${this.padRight(shortPath, 40)} ${this.formatPercent(file.lines.percentage)}`);
}
}
}
}
// Threshold Summary
lines.push('');
lines.push('-'.repeat(width));
lines.push('THRESHOLD SUMMARY');
lines.push('-'.repeat(width));
lines.push('');
const thresholds = coverage.config.thresholds;
const results = this.checkThresholds(coverage);
lines.push(`Status: ${results.passed ? 'PASS' : 'FAIL'}`);
lines.push('');
if (results.failures.length > 0) {
lines.push('Failed thresholds:');
for (const failure of results.failures) {
lines.push(` - ${failure.metric}: ${failure.actual.toFixed(1)}% < ${failure.expected}% (required)`);
}
} else {
lines.push('All coverage thresholds met!');
}
lines.push('');
lines.push('='.repeat(width));
return lines.join('\n');
}
private formatMetricLine(name: string, metric: CoverageMetric, threshold?: number): string {
const status = this.getStatus(metric.percentage, threshold);
return `${this.padRight(name + ':', 15)} ${this.padRight(this.formatPercent(metric.percentage), 10)} ` +
`(${metric.covered}/${metric.total}) ${status}`;
}
private formatMetricDetail(metric: CoverageMetric): string {
return `${this.formatPercent(metric.percentage)} (${metric.covered}/${metric.total})`;
}
private formatPercent(percentage: number): string {
return `${percentage.toFixed(1)}%`;
}
private getStatus(percentage: number, threshold?: number): string {
if (!threshold) return '';
if (percentage >= threshold) return '[PASS]';
if (percentage >= threshold * 0.9) return '[WARN]';
return '[FAIL]';
}
private checkThresholds(coverage: CoverageReport): {
passed: boolean;
failures: Array<{ metric: string; expected: number; actual: number }>;
} {
const failures: Array<{ metric: string; expected: number; actual: number }> = [];
const { thresholds } = coverage.config;
const { overall } = coverage;
const metrics = ['lines', 'functions', 'branches', 'statements'] as const;
for (const metric of metrics) {
const threshold = thresholds[metric];
if (threshold && overall[metric].percentage < threshold) {
failures.push({
metric: metric.charAt(0).toUpperCase() + metric.slice(1),
expected: threshold,
actual: overall[metric].percentage,
});
}
}
return {
passed: failures.length === 0,
failures,
};
}
private center(text: string, width: number): string {
const padding = Math.max(0, width - text.length);
const leftPad = Math.floor(padding / 2);
const rightPad = padding - leftPad;
return ' '.repeat(leftPad) + text + ' '.repeat(rightPad);
}
private padRight(text: string, width: number): string {
return text + ' '.repeat(Math.max(0, width - text.length));
}
private padLeft(text: string, width: number): string {
return ' '.repeat(Math.max(0, width - text.length)) + text;
}
private shortenPath(path: string): string {
const parts = path.split('/');
if (parts.length > 4) {
return '.../' + parts.slice(-3).join('/');
}
return path;
}
}