261 lines
6.2 KiB
Python
Executable File
261 lines
6.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Check test coverage and enforce 80% minimum threshold for Grey Haven projects.
|
|
|
|
Analyzes coverage reports from Vitest or pytest and provides detailed
|
|
breakdown of coverage by file, function, and line.
|
|
|
|
Usage:
|
|
# Check Vitest coverage
|
|
python scripts/coverage_check.py
|
|
|
|
# Check pytest coverage
|
|
python scripts/coverage_check.py --backend pytest
|
|
|
|
# Show detailed file-by-file breakdown
|
|
python scripts/coverage_check.py --detailed
|
|
|
|
# Check coverage and fail if below threshold
|
|
python scripts/coverage_check.py --strict
|
|
|
|
# Generate coverage report if missing
|
|
python scripts/coverage_check.py --generate
|
|
|
|
Always run with --help first to see all options.
|
|
"""
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
|
|
def run_command(cmd: str, capture: bool = True) -> tuple[bool, str]:
|
|
"""Run a shell command and return success status and output."""
|
|
result = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
capture_output=capture,
|
|
text=True
|
|
)
|
|
return result.returncode == 0, result.stdout if capture else ""
|
|
|
|
|
|
def check_vitest_coverage(detailed: bool = False) -> dict:
|
|
"""Check Vitest coverage from coverage/coverage-summary.json."""
|
|
coverage_file = Path("coverage/coverage-summary.json")
|
|
|
|
if not coverage_file.exists():
|
|
print("ERROR: Coverage report not found: coverage/coverage-summary.json")
|
|
print(" Run tests with coverage first:")
|
|
print(" doppler run --config test -- vitest run --coverage")
|
|
sys.exit(1)
|
|
|
|
with open(coverage_file) as f:
|
|
coverage_data = json.load(f)
|
|
|
|
# Total coverage
|
|
total = coverage_data["total"]
|
|
|
|
results = {
|
|
"lines": total["lines"]["pct"],
|
|
"statements": total["statements"]["pct"],
|
|
"functions": total["functions"]["pct"],
|
|
"branches": total["branches"]["pct"],
|
|
}
|
|
|
|
# Detailed breakdown by file
|
|
if detailed:
|
|
print("\n Coverage by File:")
|
|
print(f"{'File':<50} {'Lines':<10} {'Funcs':<10} {'Branches':<10}")
|
|
print("=" * 80)
|
|
|
|
for file_path, file_data in coverage_data.items():
|
|
if file_path == "total":
|
|
continue
|
|
|
|
# Shorten file path for display
|
|
short_path = file_path.replace(os.getcwd(), ".")
|
|
if len(short_path) > 47:
|
|
short_path = "..." + short_path[-44:]
|
|
|
|
lines_pct = file_data["lines"]["pct"]
|
|
funcs_pct = file_data["functions"]["pct"]
|
|
branches_pct = file_data["branches"]["pct"]
|
|
|
|
# Color code based on coverage
|
|
if lines_pct < 80:
|
|
status = "[BELOW 80%]"
|
|
elif lines_pct < 90:
|
|
status = "[80-90%]"
|
|
else:
|
|
status = "[ABOVE 90%]"
|
|
|
|
print(f"{status} {short_path:<47} {lines_pct:<9.1f}% {funcs_pct:<9.1f}% {branches_pct:<9.1f}%")
|
|
|
|
return results
|
|
|
|
|
|
def check_pytest_coverage(detailed: bool = False) -> dict:
|
|
"""Check pytest coverage from .coverage file."""
|
|
coverage_file = Path(".coverage")
|
|
|
|
if not coverage_file.exists():
|
|
print("ERROR: Coverage report not found: .coverage")
|
|
print(" Run tests with coverage first:")
|
|
print(" doppler run --config test -- pytest --cov=app")
|
|
sys.exit(1)
|
|
|
|
# Use coverage.py to get report
|
|
success, output = run_command("coverage report --format=total")
|
|
|
|
if not success:
|
|
print("ERROR: Failed to generate coverage report")
|
|
sys.exit(1)
|
|
|
|
# Parse total coverage percentage
|
|
total_coverage = float(output.strip().rstrip("%"))
|
|
|
|
# Get detailed report if requested
|
|
if detailed:
|
|
print("\n Coverage by File:")
|
|
success, detailed_output = run_command("coverage report")
|
|
print(detailed_output)
|
|
|
|
# pytest coverage doesn't separate by type, so we use total for all
|
|
results = {
|
|
"lines": total_coverage,
|
|
"statements": total_coverage,
|
|
"functions": total_coverage,
|
|
"branches": total_coverage,
|
|
}
|
|
|
|
return results
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Check test coverage and enforce thresholds",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Check Vitest coverage
|
|
python scripts/coverage_check.py
|
|
|
|
# Check pytest coverage with detailed breakdown
|
|
python scripts/coverage_check.py --backend pytest --detailed
|
|
|
|
# Generate coverage and check (strict mode)
|
|
python scripts/coverage_check.py --generate --strict
|
|
|
|
Coverage Thresholds:
|
|
Minimum 80% coverage required for:
|
|
- Lines
|
|
- Functions
|
|
- Branches
|
|
- Statements
|
|
|
|
Backends:
|
|
vitest - Vitest (TypeScript/React) - default
|
|
pytest - pytest (Python/FastAPI)
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--backend",
|
|
default="vitest",
|
|
choices=["vitest", "pytest"],
|
|
help="Test backend to check coverage for (default: vitest)"
|
|
)
|
|
parser.add_argument(
|
|
"--detailed",
|
|
action="store_true",
|
|
help="Show detailed file-by-file breakdown"
|
|
)
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="Exit with error if coverage below 80 percent"
|
|
)
|
|
parser.add_argument(
|
|
"--generate",
|
|
action="store_true",
|
|
help="Generate coverage report before checking"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
print(f"\n{'=' * 70}")
|
|
print(f" Coverage Check - {args.backend.upper()}")
|
|
print(f"{'=' * 70}")
|
|
|
|
# Generate coverage if requested
|
|
if args.generate:
|
|
print("\n→ Generating coverage report...")
|
|
|
|
if args.backend == "vitest":
|
|
cmd = "doppler run --config test -- vitest run --coverage"
|
|
else:
|
|
cmd = "doppler run --config test -- pytest --cov=app --cov-report=term --cov-report=html"
|
|
|
|
success, _ = run_command(cmd, capture=False)
|
|
|
|
if not success:
|
|
print("\nERROR: Failed to generate coverage")
|
|
sys.exit(1)
|
|
|
|
# Check coverage
|
|
if args.backend == "vitest":
|
|
coverage = check_vitest_coverage(args.detailed)
|
|
else:
|
|
coverage = check_pytest_coverage(args.detailed)
|
|
|
|
# Display summary
|
|
print(f"\n{'=' * 70}")
|
|
print(" Coverage Summary")
|
|
print(f"{'=' * 70}")
|
|
|
|
threshold = 80.0
|
|
all_pass = True
|
|
|
|
for metric, value in coverage.items():
|
|
if value >= threshold:
|
|
status = "SUCCESS:"
|
|
else:
|
|
status = "ERROR:"
|
|
all_pass = False
|
|
|
|
print(f"{status} {metric.capitalize():<15} {value:>6.2f}% (threshold: {threshold}%)")
|
|
|
|
# Overall result
|
|
print(f"\n{'=' * 70}")
|
|
if all_pass:
|
|
print(" SUCCESS: All coverage thresholds met!")
|
|
else:
|
|
print(" ERROR: Coverage below 80% threshold")
|
|
|
|
print(f"{'=' * 70}")
|
|
|
|
# Additional info
|
|
if not all_pass:
|
|
print("\nTIP: Tips to improve coverage:")
|
|
print(" • Add unit tests for uncovered functions")
|
|
print(" • Add integration tests for API endpoints")
|
|
print(" • Add edge case tests for conditionals")
|
|
print(" • Test error handling paths")
|
|
|
|
if args.backend == "vitest":
|
|
print("\n View detailed report: coverage/index.html")
|
|
else:
|
|
print("\n View detailed report: htmlcov/index.html")
|
|
|
|
# Exit with error in strict mode if coverage below threshold
|
|
if args.strict and not all_pass:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|