added cli-covarage tool and fixed more tests
This commit is contained in:
parent
b63e58784c
commit
b845a8eade
57 changed files with 11917 additions and 295 deletions
363
tools/coverage-cli/src/reporters/html-compact.ts
Normal file
363
tools/coverage-cli/src/reporters/html-compact.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
404
tools/coverage-cli/src/reporters/html.ts
Normal file
404
tools/coverage-cli/src/reporters/html.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
tools/coverage-cli/src/reporters/index.ts
Normal file
38
tools/coverage-cli/src/reporters/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
tools/coverage-cli/src/reporters/json.ts
Normal file
91
tools/coverage-cli/src/reporters/json.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
165
tools/coverage-cli/src/reporters/markdown.ts
Normal file
165
tools/coverage-cli/src/reporters/markdown.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
176
tools/coverage-cli/src/reporters/terminal.ts
Normal file
176
tools/coverage-cli/src/reporters/terminal.ts
Normal 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`));
|
||||
}
|
||||
}
|
||||
206
tools/coverage-cli/src/reporters/text.ts
Normal file
206
tools/coverage-cli/src/reporters/text.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue