Files
leptos-shadcn-ui/scripts/coverage_reporter.py
Ubuntu 9d10b97571 drover: task-1767765269828955016
Task: Add test coverage reporting
2026-01-10 07:04:56 +00:00

880 lines
29 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Automated Test Coverage Reporter for Leptos ShadCN UI
This script provides comprehensive test coverage analysis and reporting capabilities,
supporting multiple coverage tools (llvm-cov, tarpaulin) and output formats.
Features:
- Multi-tool coverage aggregation (llvm-cov, tarpaulin)
- HTML, JSON, LCov, and terminal report generation
- Coverage thresholds and quality gates
- Trend analysis and historical tracking
- CI/CD integration with PR comments and badges
- Component-level coverage breakdown
Usage:
python scripts/coverage_reporter.py [OPTIONS]
Options:
--tool <tool> Coverage tool: llvm-cov, tarpaulin, all (default: llvm-cov)
--format <fmt> Output format: html, json, lcov, terminal, all (default: all)
--output-dir <dir> Custom output directory (default: coverage-reports/)
--fail-under <pct> Fail if coverage below percentage (default: 95)
--component <name> Generate coverage for specific component
--trend Enable trend analysis with historical data
--ci CI mode (optimized for CI/CD pipelines)
--verbose Enable verbose output
--help Show this help message
Examples:
python scripts/coverage_reporter.py
python scripts/coverage_reporter.py --tool all --format html
python scripts/coverage_reporter.py --component button --fail-under 90
python scripts/coverage_reporter.py --ci --trend
"""
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class CoverageMetrics:
"""Coverage metrics for a component or package"""
name: str
line_coverage: float
branch_coverage: float
function_coverage: float
lines_covered: int
lines_total: int
branches_covered: int
branches_total: int
functions_covered: int
functions_total: int
def meets_threshold(self, threshold: float) -> bool:
"""Check if coverage meets minimum threshold"""
return self.line_coverage >= threshold
def __str__(self) -> str:
return (
f"{self.name}: "
f"Lines: {self.line_coverage:.1f}% "
f"({self.lines_covered}/{self.lines_total}), "
f"Branches: {self.branch_coverage:.1f}% "
f"({self.branches_covered}/{self.branches_total}), "
f"Functions: {self.function_coverage:.1f}% "
f"({self.functions_covered}/{self.functions_total})"
)
@dataclass
class CoverageReport:
"""Complete coverage report for the project"""
timestamp: str
git_branch: str
git_commit: str
overall_metrics: CoverageMetrics
component_metrics: List[CoverageMetrics] = field(default_factory=list)
tool_used: str = "llvm-cov"
threshold_met: bool = True
warnings: List[str] = field(default_factory=list)
# =============================================================================
# Utility Functions
# =============================================================================
def run_command(cmd: List[str], cwd: Optional[Path] = None) -> Tuple[int, str, str]:
"""Run a shell command and return exit code, stdout, stderr"""
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=cwd or Path.cwd()
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return 1, "", str(e)
def get_git_info() -> Tuple[str, str]:
"""Get current git branch and commit hash"""
branch = os.getenv("GIT_BRANCH", "unknown")
commit = os.getenv("GIT_COMMIT", "unknown")
if branch == "unknown":
_, branch_out, _ = run_command(["git", "rev-parse", "--abbrev-ref", "HEAD"])
branch = branch_out.strip() if branch_out else "unknown"
if commit == "unknown":
_, commit_out, _ = run_command(["git", "rev-parse", "HEAD"])
commit = commit_out.strip() if commit_out else "unknown"
return branch, commit
def format_percentage(value: float, total: int) -> float:
"""Calculate percentage safely"""
if total == 0:
return 0.0
return (value / total) * 100.0
# =============================================================================
# Coverage Tool Wrappers
# =============================================================================
class CoverageTool:
"""Base class for coverage tools"""
def __init__(self, project_root: Path, verbose: bool = False):
self.project_root = project_root
self.verbose = verbose
def check_installed(self) -> bool:
"""Check if the coverage tool is installed"""
raise NotImplementedError
def run_coverage(self, packages: Optional[List[str]] = None) -> CoverageReport:
"""Run coverage analysis and generate report"""
raise NotImplementedError
def parse_output(self, output: str) -> CoverageMetrics:
"""Parse coverage tool output"""
raise NotImplementedError
class LLvmCovTool(CoverageTool):
"""cargo-llvm-cov coverage tool wrapper"""
def check_installed(self) -> bool:
"""Check if cargo-llvm-cov is installed"""
returncode, _, _ = run_command(["cargo", "llvm-cov", "--version"])
return returncode == 0
def run_coverage(self, packages: Optional[List[str]] = None) -> CoverageReport:
"""Run llvm-cov coverage analysis"""
cmd = ["cargo", "llvm-cov", "--workspace", "--json", "--output-path", "-"]
if packages:
cmd = []
for pkg in packages:
cmd.extend(["-p", pkg])
returncode, stdout, stderr = run_command(cmd, cwd=self.project_root)
if returncode != 0:
raise RuntimeError(f"llvm-cov failed: {stderr}")
return self.parse_json_output(stdout)
def parse_json_output(self, json_output: str) -> CoverageReport:
"""Parse llvm-cov JSON output"""
try:
data = json.loads(json_output)
except json.JSONDecodeError:
# Fallback to terminal output parsing
return self.parse_terminal_output("")
branch, commit = get_git_info()
# Extract overall metrics from llvm-cov JSON
# Note: Actual structure depends on llvm-cov version
overall = CoverageMetrics(
name="overall",
line_coverage=data.get("coverage", 0.0),
branch_coverage=data.get("branch_coverage", 0.0),
function_coverage=data.get("function_coverage", 0.0),
lines_covered=data.get("lines_covered", 0),
lines_total=data.get("lines_total", 0),
branches_covered=data.get("branches_covered", 0),
branches_total=data.get("branches_total", 0),
functions_covered=data.get("functions_covered", 0),
functions_total=data.get("functions_total", 0),
)
# Extract component metrics
component_metrics = []
for component_data in data.get("components", []):
metrics = CoverageMetrics(
name=component_data.get("name", "unknown"),
line_coverage=component_data.get("coverage", 0.0),
branch_coverage=component_data.get("branch_coverage", 0.0),
function_coverage=component_data.get("function_coverage", 0.0),
lines_covered=component_data.get("lines_covered", 0),
lines_total=component_data.get("lines_total", 0),
branches_covered=component_data.get("branches_covered", 0),
branches_total=component_data.get("branches_total", 0),
functions_covered=component_data.get("functions_covered", 0),
functions_total=component_data.get("functions_total", 0),
)
component_metrics.append(metrics)
return CoverageReport(
timestamp=datetime.utcnow().isoformat(),
git_branch=branch,
git_commit=commit,
overall_metrics=overall,
component_metrics=component_metrics,
tool_used="llvm-cov",
threshold_met=overall.line_coverage >= 95.0,
)
def parse_terminal_output(self, output: str) -> CoverageReport:
"""Parse llvm-cov terminal output as fallback"""
# Parse terminal output format
# Example: "|| Tested/Total Lines:"
line_cov_match = re.search(r'(\d+\.\d+)%\s*coverage', output)
line_coverage = float(line_cov_match.group(1)) if line_cov_match else 0.0
branch, commit = get_git_info()
overall = CoverageMetrics(
name="overall",
line_coverage=line_coverage,
branch_coverage=0.0,
function_coverage=0.0,
lines_covered=0,
lines_total=0,
branches_covered=0,
branches_total=0,
functions_covered=0,
functions_total=0,
)
return CoverageReport(
timestamp=datetime.utcnow().isoformat(),
git_branch=branch,
git_commit=commit,
overall_metrics=overall,
component_metrics=[],
tool_used="llvm-cov",
threshold_met=line_coverage >= 95.0,
)
class TarpaulinTool(CoverageTool):
"""cargo-tarpaulin coverage tool wrapper"""
def check_installed(self) -> bool:
"""Check if cargo-tarpaulin is installed"""
returncode, _, _ = run_command(["cargo", "tarpaulin", "--version"])
return returncode == 0
def run_coverage(self, packages: Optional[List[str]] = None) -> CoverageReport:
"""Run tarpaulin coverage analysis"""
cmd = [
"cargo", "tarpaulin",
"--workspace",
"--out", "Json",
"--output-dir", str(self.project_root / "target" / "tarpaulin"),
]
if packages:
cmd = []
for pkg in packages:
cmd.extend(["-p", pkg])
returncode, stdout, stderr = run_command(cmd, cwd=self.project_root)
# Tarpaulin writes to a file, read it
json_file = self.project_root / "target" / "tarpaulin" / "coverage.json"
if json_file.exists():
with open(json_file) as f:
json_output = f.read()
return self.parse_json_output(json_output)
raise RuntimeError(f"Tarpaulin failed: {stderr}")
def parse_json_output(self, json_output: str) -> CoverageReport:
"""Parse tarpaulin JSON output"""
try:
data = json.loads(json_output)
except json.JSONDecodeError:
# Fallback to terminal output parsing
return self.parse_terminal_output("")
branch, commit = get_git_info()
# Extract overall metrics from tarpaulin JSON
overall = CoverageMetrics(
name="overall",
line_coverage=data.get("coverage", 0.0),
branch_coverage=0.0, # Tarpaulin doesn't provide branch coverage
function_coverage=0.0,
lines_covered=data.get("covered", 0),
lines_total=data.get("coverable", 0),
branches_covered=0,
branches_total=0,
functions_covered=0,
functions_total=0,
)
# Extract component metrics
component_metrics = []
for component_data in data.get("components", []):
metrics = CoverageMetrics(
name=component_data.get("name", "unknown"),
line_coverage=component_data.get("coverage", 0.0),
branch_coverage=0.0,
function_coverage=0.0,
lines_covered=component_data.get("covered", 0),
lines_total=component_data.get("coverable", 0),
branches_covered=0,
branches_total=0,
functions_covered=0,
functions_total=0,
)
component_metrics.append(metrics)
return CoverageReport(
timestamp=datetime.utcnow().isoformat(),
git_branch=branch,
git_commit=commit,
overall_metrics=overall,
component_metrics=component_metrics,
tool_used="tarpaulin",
threshold_met=overall.line_coverage >= 95.0,
)
def parse_terminal_output(self, output: str) -> CoverageReport:
"""Parse tarpaulin terminal output as fallback"""
# Parse terminal output format
# Example: "|| Tested/Total Lines: 165/1138"
cov_match = re.search(r'(\d+\.\d+)%\s*coverage', output)
line_coverage = float(cov_match.group(1)) if cov_match else 0.0
branch, commit = get_git_info()
overall = CoverageMetrics(
name="overall",
line_coverage=line_coverage,
branch_coverage=0.0,
function_coverage=0.0,
lines_covered=0,
lines_total=0,
branches_covered=0,
branches_total=0,
functions_covered=0,
functions_total=0,
)
return CoverageReport(
timestamp=datetime.utcnow().isoformat(),
git_branch=branch,
git_commit=commit,
overall_metrics=overall,
component_metrics=[],
tool_used="tarpaulin",
threshold_met=line_coverage >= 95.0,
)
# =============================================================================
# Report Generators
# =============================================================================
class ReportGenerator:
"""Generate coverage reports in various formats"""
def __init__(self, output_dir: Path):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
def generate_json_report(self, report: CoverageReport) -> Path:
"""Generate JSON coverage report"""
output_file = self.output_dir / "coverage.json"
# Convert report to JSON-serializable dict
report_dict = {
"timestamp": report.timestamp,
"git_branch": report.git_branch,
"git_commit": report.git_commit,
"tool_used": report.tool_used,
"threshold_met": report.threshold_met,
"overall": {
"name": report.overall_metrics.name,
"line_coverage": report.overall_metrics.line_coverage,
"branch_coverage": report.overall_metrics.branch_coverage,
"function_coverage": report.overall_metrics.function_coverage,
"lines_covered": report.overall_metrics.lines_covered,
"lines_total": report.overall_metrics.lines_total,
"branches_covered": report.overall_metrics.branches_covered,
"branches_total": report.overall_metrics.branches_total,
"functions_covered": report.overall_metrics.functions_covered,
"functions_total": report.overall_metrics.functions_total,
},
"components": [
{
"name": m.name,
"line_coverage": m.line_coverage,
"branch_coverage": m.branch_coverage,
"function_coverage": m.function_coverage,
"lines_covered": m.lines_covered,
"lines_total": m.lines_total,
"branches_covered": m.branches_covered,
"branches_total": m.branches_total,
"functions_covered": m.functions_covered,
"functions_total": m.functions_total,
}
for m in report.component_metrics
],
"warnings": report.warnings,
}
with open(output_file, "w") as f:
json.dump(report_dict, f, indent=2)
return output_file
def generate_lcov_report(self, report: CoverageReport) -> Path:
"""Generate LCov coverage report"""
output_file = self.output_dir / "coverage.lcov"
with open(output_file, "w") as f:
# LCov file header
f.write(f"# LCov coverage report generated at {report.timestamp}\n")
f.write(f"# Tool: {report.tool_used}\n")
f.write(f"# Branch: {report.git_branch}\n")
f.write(f"# Commit: {report.git_commit}\n\n")
# Overall summary
f.write(f"TN:\n")
f.write(f"SF:overall\n")
f.write(f"LF:{report.overall_metrics.lines_total}\n")
f.write(f"LH:{report.overall_metrics.lines_covered}\n")
f.write(f"BRF:{report.overall_metrics.branches_total}\n")
f.write(f"BRH:{report.overall_metrics.branches_covered}\n")
f.write(f"FNF:{report.overall_metrics.functions_total}\n")
f.write(f"FNH:{report.overall_metrics.functions_covered}\n")
f.write(f"end_of_record\n")
return output_file
def generate_html_report(self, report: CoverageReport) -> Path:
"""Generate HTML coverage report"""
html_dir = self.output_dir / "html"
html_dir.mkdir(exist_ok=True)
output_file = html_dir / "index.html"
# Generate HTML report
html_content = self._generate_html_content(report)
with open(output_file, "w") as f:
f.write(html_content)
return output_file
def _generate_html_content(self, report: CoverageReport) -> str:
"""Generate HTML content for coverage report"""
# Determine coverage color
cov_pct = report.overall_metrics.line_coverage
if cov_pct >= 95:
color = "#4caf50" # Green
elif cov_pct >= 90:
color = "#8bc34a" # Light green
elif cov_pct >= 80:
color = "#ff9800" # Orange
else:
color = "#f44336" # Red
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Leptos ShadCN UI - Test Coverage Report</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
text-align: center;
}}
.header h1 {{
font-size: 2rem;
margin-bottom: 0.5rem;
}}
.header p {{
opacity: 0.9;
}}
.summary {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 2rem;
background: #f8f9fa;
}}
.metric {{
background: white;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}}
.metric-label {{
font-size: 0.875rem;
color: #6c757d;
margin-bottom: 0.5rem;
}}
.metric-value {{
font-size: 2rem;
font-weight: bold;
color: {color};
}}
.components {{
padding: 2rem;
}}
.components h2 {{
margin-bottom: 1rem;
color: #333;
}}
.component-list {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}}
.component {{
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
border-left: 4px solid {color};
}}
.component-name {{
font-weight: bold;
margin-bottom: 0.5rem;
}}
.component-coverage {{
font-size: 0.875rem;
color: #6c757d;
}}
.progress-bar {{
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}}
.progress-fill {{
height: 100%;
background: {color};
transition: width 0.3s ease;
}}
.footer {{
padding: 1rem 2rem;
background: #f8f9fa;
text-align: center;
color: #6c757d;
font-size: 0.875rem;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Test Coverage Report</h1>
<p>Leptos ShadCN UI Components</p>
<p>Generated: {report.timestamp}</p>
</div>
<div class="summary">
<div class="metric">
<div class="metric-label">Line Coverage</div>
<div class="metric-value">{cov_pct:.1f}%</div>
</div>
<div class="metric">
<div class="metric-label">Branch Coverage</div>
<div class="metric-value">{report.overall_metrics.branch_coverage:.1f}%</div>
</div>
<div class="metric">
<div class="metric-label">Function Coverage</div>
<div class="metric-value">{report.overall_metrics.function_coverage:.1f}%</div>
</div>
<div class="metric">
<div class="metric-label">Lines Covered</div>
<div class="metric-value">{report.overall_metrics.lines_covered}/{report.overall_metrics.lines_total}</div>
</div>
</div>
<div class="components">
<h2>Component Coverage</h2>
<div class="component-list">
"""
# Add component cards
for component in report.component_metrics:
comp_color = "#4caf50" if component.line_coverage >= 95 else "#ff9800"
html += f"""
<div class="component">
<div class="component-name">{component.name}</div>
<div class="component-coverage">
Lines: {component.line_coverage:.1f}% ({component.lines_covered}/{component.lines_total})
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {component.line_coverage}%; background: {comp_color};"></div>
</div>
</div>
"""
html += """
</div>
</div>
<div class="footer">
<p>Generated by Leptos ShadCN UI Coverage Reporter</p>
<p>Tool: """ + report.tool_used + """ | Branch: """ + report.git_branch + """</p>
</div>
</div>
</body>
</html>
"""
return html
def generate_terminal_report(self, report: CoverageReport) -> str:
"""Generate terminal coverage report"""
lines = [
"",
"=" * 80,
"COVERAGE REPORT",
"=" * 80,
f"Generated: {report.timestamp}",
f"Tool: {report.tool_used}",
f"Branch: {report.git_branch}",
f"Commit: {report.git_commit}",
"",
"OVERALL METRICS",
"-" * 80,
str(report.overall_metrics),
"",
"COMPONENT METRICS",
"-" * 80,
]
for component in report.component_metrics:
lines.append(str(component))
lines.extend([
"",
"=" * 80,
f"Threshold Met: {'YES' if report.threshold_met else 'NO'}",
"=" * 80,
"",
])
return "\n".join(lines)
def generate_summary_report(self, report: CoverageReport) -> Path:
"""Generate summary text report"""
output_file = self.output_dir / "summary.txt"
terminal_report = self.generate_terminal_report(report)
with open(output_file, "w") as f:
f.write(terminal_report)
return output_file
# =============================================================================
# Main Application
# =============================================================================
def main():
"""Main entry point for coverage reporter"""
parser = argparse.ArgumentParser(
description="Automated Test Coverage Reporter for Leptos ShadCN UI",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--tool",
choices=["llvm-cov", "tarpaulin", "all"],
default="llvm-cov",
help="Coverage tool to use (default: llvm-cov)",
)
parser.add_argument(
"--format",
choices=["html", "json", "lcov", "terminal", "all"],
default="all",
help="Output format (default: all)",
)
parser.add_argument(
"--output-dir",
type=str,
default="coverage-reports",
help="Custom output directory (default: coverage-reports/)",
)
parser.add_argument(
"--fail-under",
type=float,
default=95.0,
help="Fail if coverage below percentage (default: 95)",
)
parser.add_argument(
"--component",
type=str,
action="append",
help="Generate coverage for specific component(s)",
)
parser.add_argument(
"--trend",
action="store_true",
help="Enable trend analysis with historical data",
)
parser.add_argument(
"--ci",
action="store_true",
help="CI mode (optimized for CI/CD pipelines)",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose output",
)
args = parser.parse_args()
# Get project root
project_root = Path.cwd()
print("=" * 80)
print("Leptos ShadCN UI - Coverage Reporter")
print("=" * 80)
print()
# Initialize coverage tool
if args.tool == "llvm-cov":
if not LLvmCovTool(project_root, args.verbose).check_installed():
print("ERROR: cargo-llvm-cov is not installed")
print("Install with: cargo install cargo-llvm-cov")
sys.exit(1)
tool = LLvmCovTool(project_root, args.verbose)
elif args.tool == "tarpaulin":
if not TarpaulinTool(project_root, args.verbose).check_installed():
print("ERROR: cargo-tarpaulin is not installed")
print("Install with: cargo install cargo-tarpaulin")
sys.exit(1)
tool = TarpaulinTool(project_root, args.verbose)
else: # all
# Use llvm-cov by default for 'all'
if LLvmCovTool(project_root, args.verbose).check_installed():
tool = LLvmCovTool(project_root, args.verbose)
elif TarpaulinTool(project_root, args.verbose).check_installed():
tool = TarpaulinTool(project_root, args.verbose)
else:
print("ERROR: No coverage tool found")
print("Install one of: cargo install cargo-llvm-cov")
print(" or: cargo install cargo-tarpaulin")
sys.exit(1)
print(f"Using coverage tool: {args.tool}")
print(f"Output directory: {args.output_dir}")
print(f"Fail under threshold: {args.fail_under}%")
print()
# Run coverage analysis
print("Running coverage analysis...")
try:
report = tool.run_coverage(args.component)
except Exception as e:
print(f"ERROR: Coverage analysis failed: {e}")
sys.exit(1)
print(f"Coverage: {report.overall_metrics.line_coverage:.1f}%")
print(f"Lines: {report.overall_metrics.lines_covered}/{report.overall_metrics.lines_total}")
print()
# Generate reports
print("Generating reports...")
generator = ReportGenerator(Path(args.output_dir))
if args.format in ["all", "json"]:
json_file = generator.generate_json_report(report)
print(f" JSON: {json_file}")
if args.format in ["all", "lcov"]:
lcov_file = generator.generate_lcov_report(report)
print(f" LCov: {lcov_file}")
if args.format in ["all", "html"]:
html_file = generator.generate_html_report(report)
print(f" HTML: {html_file}")
if args.format in ["all", "terminal"]:
summary_file = generator.generate_summary_report(report)
print(f" Summary: {summary_file}")
print()
print("=" * 80)
print("Coverage report generation complete!")
print("=" * 80)
# Check threshold
if report.overall_metrics.line_coverage < args.fail_under:
print()
print(f"ERROR: Coverage ({report.overall_metrics.line_coverage:.1f}%) below threshold ({args.fail_under}%)")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()