| |
| """ |
| Unified Coverage Aggregation Script |
| |
| Combines pytest JSON coverage (backend), Jest JSON coverage (frontend), |
| jest-expo JSON coverage (mobile), and cargo-tarpaulin JSON coverage (desktop) |
| into a single unified report with per-platform breakdown. |
| |
| Usage: |
| python backend/tests/scripts/aggregate_coverage.py [OPTIONS] |
| |
| Examples: |
| python backend/tests/scripts/aggregate_coverage.py --format text |
| python backend/tests/scripts/aggregate_coverage.py --format json |
| python backend/tests/scripts/aggregate_coverage.py --format markdown |
| python backend/tests/scripts/aggregate_coverage.py --output unified_coverage.json |
| """ |
|
|
| import argparse |
| import json |
| import sys |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Dict, Any, Optional |
|
|
|
|
| def load_pytest_coverage(path: Path) -> Dict[str, Any]: |
| """ |
| Load pytest coverage.json from backend tests. |
| |
| Args: |
| path: Path to coverage.json (pytest format) |
| |
| Returns: |
| Dict with platform='python', coverage_pct, covered, total metrics |
| Returns coverage_pct=0.0 and error message if file not found |
| """ |
| result = { |
| "platform": "python", |
| "coverage_pct": 0.0, |
| "covered": 0, |
| "total": 0, |
| "branches_covered": 0, |
| "branches_total": 0, |
| "branch_coverage_pct": 0.0, |
| "file": str(path), |
| } |
|
|
| if not path.exists(): |
| result["error"] = "file not found" |
| return result |
|
|
| try: |
| with open(path, 'r') as f: |
| coverage_data = json.load(f) |
|
|
| |
| totals = coverage_data.get("totals", {}) |
|
|
| result["coverage_pct"] = totals.get("percent_covered", 0.0) |
| result["covered"] = totals.get("covered_lines", 0) |
| result["total"] = totals.get("num_statements", 0) |
| result["branches_covered"] = totals.get("covered_branches", 0) |
| result["branches_total"] = totals.get("num_branches", 0) |
|
|
| |
| if result["branches_total"] > 0: |
| result["branch_coverage_pct"] = ( |
| result["branches_covered"] / result["branches_total"] * 100 |
| ) |
| else: |
| result["branch_coverage_pct"] = 0.0 |
|
|
| except (json.JSONDecodeError, IOError) as e: |
| result["error"] = str(e) |
|
|
| return result |
|
|
|
|
| def load_jest_coverage(path: Path) -> Dict[str, Any]: |
| """ |
| Load Jest coverage-final.json from frontend tests. |
| |
| Args: |
| path: Path to coverage-final.json (Jest format) |
| |
| Returns: |
| Dict with platform='javascript', coverage_pct, covered, total metrics |
| Returns coverage_pct=0.0 and error message if file not found |
| """ |
| result = { |
| "platform": "javascript", |
| "coverage_pct": 0.0, |
| "covered": 0, |
| "total": 0, |
| "branches_covered": 0, |
| "branches_total": 0, |
| "branch_coverage_pct": 0.0, |
| "file": str(path), |
| } |
|
|
| if not path.exists(): |
| result["error"] = "file not found" |
| return result |
|
|
| try: |
| with open(path, 'r') as f: |
| coverage_data = json.load(f) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| total_statements = 0 |
| covered_statements = 0 |
| total_branches = 0 |
| covered_branches = 0 |
|
|
| for file_path, file_data in coverage_data.items(): |
| |
| if "node_modules" in file_path or "__tests__" in file_path: |
| continue |
|
|
| |
| statements = file_data.get("s", {}) |
| for stmt_id, count in statements.items(): |
| total_statements += 1 |
| if count > 0: |
| covered_statements += 1 |
|
|
| |
| branches = file_data.get("b", {}) |
| for branch_id, counts in branches.items(): |
| if isinstance(counts, list) and len(counts) >= 2: |
| total_branches += len(counts) |
| covered_branches += sum(1 for c in counts if c > 0) |
|
|
| result["covered"] = covered_statements |
| result["total"] = total_statements |
| result["branches_covered"] = covered_branches |
| result["branches_total"] = total_branches |
|
|
| |
| if result["total"] > 0: |
| result["coverage_pct"] = (result["covered"] / result["total"] * 100) |
| else: |
| result["coverage_pct"] = 0.0 |
|
|
| if result["branches_total"] > 0: |
| result["branch_coverage_pct"] = ( |
| result["branches_covered"] / result["branches_total"] * 100 |
| ) |
| else: |
| result["branch_coverage_pct"] = 0.0 |
|
|
| except (json.JSONDecodeError, IOError) as e: |
| result["error"] = str(e) |
|
|
| return result |
|
|
|
|
| def load_jest_expo_coverage(path: Path) -> Dict[str, Any]: |
| """ |
| Load jest-expo coverage-final.json from mobile tests. |
| |
| Args: |
| path: Path to coverage-final.json (jest-expo format, same as Jest) |
| |
| Returns: |
| Dict with platform='mobile', coverage_pct, covered, total metrics |
| Returns coverage_pct=0.0 and error message if file not found |
| """ |
| result = { |
| "platform": "mobile", |
| "coverage_pct": 0.0, |
| "covered": 0, |
| "total": 0, |
| "branches_covered": 0, |
| "branches_total": 0, |
| "branch_coverage_pct": 0.0, |
| "file": str(path), |
| } |
|
|
| if not path.exists(): |
| result["error"] = "file not found" |
| return result |
|
|
| try: |
| with open(path, 'r') as f: |
| coverage_data = json.load(f) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| total_statements = 0 |
| covered_statements = 0 |
| total_branches = 0 |
| covered_branches = 0 |
|
|
| for file_path, file_data in coverage_data.items(): |
| |
| if "node_modules" in file_path or "__tests__" in file_path: |
| continue |
|
|
| |
| statements = file_data.get("s", {}) |
| for stmt_id, count in statements.items(): |
| total_statements += 1 |
| if count > 0: |
| covered_statements += 1 |
|
|
| |
| branches = file_data.get("b", {}) |
| for branch_id, counts in branches.items(): |
| if isinstance(counts, list) and len(counts) >= 2: |
| total_branches += len(counts) |
| covered_branches += sum(1 for c in counts if c > 0) |
|
|
| result["covered"] = covered_statements |
| result["total"] = total_statements |
| result["branches_covered"] = covered_branches |
| result["branches_total"] = total_branches |
|
|
| |
| if result["total"] > 0: |
| result["coverage_pct"] = (result["covered"] / result["total"] * 100) |
| else: |
| result["coverage_pct"] = 0.0 |
|
|
| if result["branches_total"] > 0: |
| result["branch_coverage_pct"] = ( |
| result["branches_covered"] / result["branches_total"] * 100 |
| ) |
| else: |
| result["branch_coverage_pct"] = 0.0 |
|
|
| except (json.JSONDecodeError, IOError) as e: |
| result["error"] = str(e) |
|
|
| return result |
|
|
|
|
| def load_tarpaulin_coverage(path: Path) -> Dict[str, Any]: |
| """ |
| Load cargo-tarpaulin coverage.json from desktop tests. |
| |
| Args: |
| path: Path to coverage.json (tarpaulin format) |
| |
| Returns: |
| Dict with platform='rust', coverage_pct, covered, total metrics |
| Returns coverage_pct=0.0 and error message if file not found |
| """ |
| result = { |
| "platform": "rust", |
| "coverage_pct": 0.0, |
| "covered": 0, |
| "total": 0, |
| "branches_covered": 0, |
| "branches_total": 0, |
| "branch_coverage_pct": 0.0, |
| "file": str(path), |
| } |
|
|
| if not path.exists(): |
| result["error"] = "file not found" |
| return result |
|
|
| try: |
| with open(path, 'r') as f: |
| coverage_data = json.load(f) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| total_covered = 0 |
| total_lines = 0 |
|
|
| files = coverage_data.get("files", {}) |
| for file_path, file_data in files.items(): |
| stats = file_data.get("stats", {}) |
| total_covered += stats.get("covered", 0) |
| total_lines += stats.get("coverable", 0) |
|
|
| result["covered"] = total_covered |
| result["total"] = total_lines |
|
|
| |
| if result["total"] > 0: |
| result["coverage_pct"] = (result["covered"] / result["total"] * 100) |
| else: |
| result["coverage_pct"] = 0.0 |
|
|
| |
| result["branches_covered"] = 0 |
| result["branches_total"] = 0 |
| result["branch_coverage_pct"] = 0.0 |
|
|
| except (json.JSONDecodeError, IOError) as e: |
| result["error"] = str(e) |
|
|
| return result |
|
|
|
|
| def load_e2e_results(path: Path) -> Dict[str, Any]: |
| """ |
| Load E2E test results from unified aggregator. |
| |
| Args: |
| path: Path to e2e_unified.json (from e2e_aggregator.py) |
| |
| Returns: |
| Dict with platform='e2e', total_tests, passed, failed metrics |
| Returns error status if file not found |
| """ |
| result = { |
| "platform": "e2e", |
| "total_tests": 0, |
| "passed": 0, |
| "failed": 0, |
| "pass_rate": 0.0, |
| "duration_seconds": 0, |
| "platforms": {}, |
| "file": str(path), |
| } |
|
|
| if not path.exists(): |
| result["error"] = "file not found" |
| result["status"] = "not_run" |
| result["message"] = "E2E results not found" |
| return result |
|
|
| try: |
| with open(path, 'r') as f: |
| e2e_data = json.load(f) |
|
|
| |
| aggregate = e2e_data.get("aggregate", {}) |
| platforms = e2e_data.get("platforms", []) |
|
|
| result["total_tests"] = aggregate.get("total_tests", 0) |
| result["passed"] = aggregate.get("total_passed", 0) |
| result["failed"] = aggregate.get("total_failed", 0) |
| result["pass_rate"] = aggregate.get("pass_rate", 0.0) |
| result["duration_seconds"] = aggregate.get("total_duration_seconds", 0) |
| result["status"] = "success" |
|
|
| |
| for platform in platforms: |
| platform_name = platform.get("platform", "unknown") |
| result["platforms"][platform_name] = { |
| "total": platform.get("total", 0), |
| "passed": platform.get("passed", 0), |
| "failed": platform.get("failed", 0), |
| "duration": platform.get("duration", 0), |
| } |
|
|
| except (json.JSONDecodeError, IOError) as e: |
| result["error"] = str(e) |
| result["status"] = "error" |
| result["message"] = f"Failed to load E2E results: {e}" |
|
|
| return result |
|
|
|
|
| def aggregate_coverage( |
| pytest_path: Path, |
| jest_path: Path, |
| jest_expo_path: Optional[Path] = None, |
| tarpaulin_path: Optional[Path] = None, |
| e2e_results_path: Optional[Path] = None |
| ) -> Dict[str, Any]: |
| """ |
| Aggregate coverage from all platforms. |
| |
| Computes overall coverage as weighted average: |
| (covered_backend + covered_frontend + covered_mobile + covered_rust) / |
| (total_backend + total_frontend + total_mobile + total_rust) |
| |
| Args: |
| pytest_path: Path to pytest coverage.json |
| jest_path: Path to Jest coverage-final.json |
| jest_expo_path: Optional path to jest-expo coverage-final.json |
| tarpaulin_path: Optional path to tarpaulin coverage.json |
| |
| Returns: |
| Dict with platforms, overall, timestamp keys |
| """ |
| python_coverage = load_pytest_coverage(pytest_path) |
| javascript_coverage = load_jest_coverage(jest_path) |
| mobile_coverage = load_jest_expo_coverage(jest_expo_path) if jest_expo_path else None |
| rust_coverage = load_tarpaulin_coverage(tarpaulin_path) if tarpaulin_path else None |
|
|
| |
| platforms = { |
| "python": python_coverage, |
| "javascript": javascript_coverage, |
| } |
| if mobile_coverage: |
| platforms["mobile"] = mobile_coverage |
| if rust_coverage: |
| platforms["rust"] = rust_coverage |
|
|
| |
| total_covered = python_coverage["covered"] + javascript_coverage["covered"] |
| total_lines = python_coverage["total"] + javascript_coverage["total"] |
|
|
| total_branches_covered = ( |
| python_coverage["branches_covered"] + javascript_coverage["branches_covered"] |
| ) |
| total_branches = ( |
| python_coverage["branches_total"] + javascript_coverage["branches_total"] |
| ) |
|
|
| |
| if mobile_coverage: |
| total_covered += mobile_coverage["covered"] |
| total_lines += mobile_coverage["total"] |
| total_branches_covered += mobile_coverage["branches_covered"] |
| total_branches += mobile_coverage["branches_total"] |
|
|
| |
| if rust_coverage: |
| total_covered += rust_coverage["covered"] |
| total_lines += rust_coverage["total"] |
| total_branches_covered += rust_coverage["branches_covered"] |
| total_branches += rust_coverage["branches_total"] |
|
|
| overall_coverage_pct = 0.0 |
| if total_lines > 0: |
| overall_coverage_pct = (total_covered / total_lines * 100) |
|
|
| overall_branch_coverage_pct = 0.0 |
| if total_branches > 0: |
| overall_branch_coverage_pct = ( |
| total_branches_covered / total_branches * 100 |
| ) |
|
|
| result = { |
| "platforms": platforms, |
| "overall": { |
| "coverage_pct": round(overall_coverage_pct, 2), |
| "covered": total_covered, |
| "total": total_lines, |
| "branch_coverage_pct": round(overall_branch_coverage_pct, 2), |
| "branches_covered": total_branches_covered, |
| "branches_total": total_branches, |
| }, |
| "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), |
| } |
|
|
| |
| if e2e_results_path: |
| e2e_results = load_e2e_results(e2e_results_path) |
| result["e2e_tests"] = { |
| "status": e2e_results.get("status", "not_run"), |
| "total_tests": e2e_results.get("total_tests", 0), |
| "passed": e2e_results.get("passed", 0), |
| "failed": e2e_results.get("failed", 0), |
| "pass_rate": e2e_results.get("pass_rate", 0.0), |
| "duration_seconds": e2e_results.get("duration_seconds", 0), |
| "platforms": e2e_results.get("platforms", {}), |
| } |
| if "error" in e2e_results: |
| result["e2e_tests"]["error"] = e2e_results["error"] |
| result["e2e_tests"]["message"] = e2e_results.get("message", "Unknown error") |
|
|
| return result |
| if total_lines > 0: |
| overall_coverage_pct = (total_covered / total_lines * 100) |
|
|
| overall_branch_coverage_pct = 0.0 |
| if total_branches > 0: |
| overall_branch_coverage_pct = ( |
| total_branches_covered / total_branches * 100 |
| ) |
|
|
| return { |
| "platforms": platforms, |
| "overall": { |
| "coverage_pct": round(overall_coverage_pct, 2), |
| "covered": total_covered, |
| "total": total_lines, |
| "branch_coverage_pct": round(overall_branch_coverage_pct, 2), |
| "branches_covered": total_branches_covered, |
| "branches_total": total_branches, |
| }, |
| "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), |
| } |
|
|
|
|
| def generate_report( |
| aggregate_data: Dict[str, Any], |
| format: str = "text" |
| ) -> str: |
| """ |
| Generate coverage report in specified format. |
| |
| Args: |
| aggregate_data: Aggregated coverage data |
| format: Output format (json, text, markdown) |
| |
| Returns: |
| Formatted report string |
| """ |
| if format == "json": |
| return json.dumps(aggregate_data, indent=2) |
|
|
| elif format == "markdown": |
| return generate_markdown_report(aggregate_data) |
|
|
| else: |
| return generate_text_report(aggregate_data) |
|
|
|
|
| def generate_text_report(aggregate_data: Dict[str, Any]) -> str: |
| """Generate human-readable text report.""" |
| lines = [] |
| lines.append("=" * 80) |
| lines.append("UNIFIED COVERAGE REPORT") |
| lines.append("=" * 80) |
| lines.append(f"Generated: {aggregate_data['timestamp']}") |
| lines.append("") |
|
|
| |
| overall = aggregate_data["overall"] |
| lines.append("OVERALL COVERAGE") |
| lines.append("-" * 80) |
| lines.append(f" Line Coverage: {overall['coverage_pct']:6.2f}% ({overall['covered']:7d} / {overall['total']:7d} lines)") |
| lines.append(f" Branch Coverage: {overall['branch_coverage_pct']:6.2f}% ({overall['branches_covered']:7d} / {overall['branches_total']:7d} branches)") |
| lines.append("") |
|
|
| |
| if "e2e_tests" in aggregate_data: |
| e2e = aggregate_data["e2e_tests"] |
| lines.append("E2E TEST RESULTS") |
| lines.append("-" * 80) |
| if e2e.get("status") == "not_run": |
| lines.append(f" Status: {e2e.get('message', 'Not run')}") |
| elif e2e.get("status") == "error": |
| lines.append(f" Status: ERROR - {e2e.get('message', 'Unknown error')}") |
| else: |
| lines.append(f" Total Tests: {e2e['total_tests']:5d}") |
| lines.append(f" Passed: {e2e['passed']:5d}") |
| lines.append(f" Failed: {e2e['failed']:5d}") |
| lines.append(f" Pass Rate: {e2e['pass_rate']:6.2f}%") |
| lines.append(f" Duration: {e2e['duration_seconds']:5d}s") |
| if e2e.get("platforms"): |
| lines.append("") |
| lines.append(" Platform Breakdown:") |
| for platform_name, platform_data in e2e["platforms"].items(): |
| lines.append(f" {platform_name.upper()}: {platform_data['passed']}/{platform_data['total']} passed ({platform_data.get('duration', 0)}s)") |
| lines.append("") |
|
|
| |
| lines.append("PLATFORM BREAKDOWN") |
| lines.append("-" * 80) |
|
|
| for platform_name, platform_data in aggregate_data["platforms"].items(): |
| lines.append(f"\n{platform_name.upper()}:") |
| lines.append(f" File: {platform_data['file']}") |
|
|
| if "error" in platform_data: |
| lines.append(f" Status: ERROR - {platform_data['error']}") |
| else: |
| lines.append(f" Line Coverage: {platform_data['coverage_pct']:6.2f}% ({platform_data['covered']:7d} / {platform_data['total']:7d} lines)") |
| lines.append(f" Branch Coverage: {platform_data['branch_coverage_pct']:6.2f}% ({platform_data['branches_covered']:7d} / {platform_data['branches_total']:7d} branches)") |
|
|
| lines.append("") |
| lines.append("=" * 80) |
|
|
| return "\n".join(lines) |
|
|
|
|
| def generate_markdown_report(aggregate_data: Dict[str, Any]) -> str: |
| """Generate markdown table report (for PR comments).""" |
| lines = [] |
|
|
| lines.append("## Unified Coverage Report") |
| lines.append("") |
| lines.append(f"**Generated:** {aggregate_data['timestamp']}") |
| lines.append("") |
|
|
| |
| overall = aggregate_data["overall"] |
| lines.append("### Overall Coverage") |
| lines.append("") |
| lines.append("| Metric | Coverage | Details |") |
| lines.append("|--------|----------|---------|") |
| lines.append(f"| **Line Coverage** | **{overall['coverage_pct']:.2f}%** | {overall['covered']:,} / {overall['total']:,} lines |") |
| lines.append(f"| **Branch Coverage** | **{overall['branch_coverage_pct']:.2f}%** | {overall['branches_covered']:,} / {overall['branches_total']:,} branches |") |
| lines.append("") |
|
|
| |
| if "e2e_tests" in aggregate_data: |
| e2e = aggregate_data["e2e_tests"] |
| lines.append("### E2E Test Results") |
| lines.append("") |
|
|
| if e2e.get("status") == "not_run": |
| lines.append(f"**Status:** {e2e.get('message', 'Not run')}") |
| lines.append("") |
| elif e2e.get("status") == "error": |
| lines.append(f"**Status:** ❌ ERROR - {e2e.get('message', 'Unknown error')}") |
| lines.append("") |
| else: |
| lines.append("| Metric | Value |") |
| lines.append("|--------|-------|") |
| lines.append(f"| **Total Tests** | **{e2e['total_tests']}** |") |
| lines.append(f"| **Passed** | **{e2e['passed']}** |") |
| lines.append(f"| **Failed** | **{e2e['failed']}** |") |
| lines.append(f"| **Pass Rate** | **{e2e['pass_rate']:.2f}%** |") |
| lines.append(f"| **Duration** | **{e2e['duration_seconds']}s** |") |
| lines.append("") |
|
|
| if e2e.get("platforms"): |
| lines.append("#### Platform Breakdown") |
| lines.append("") |
| lines.append("| Platform | Tests | Passed | Failed | Duration |") |
| lines.append("|----------|-------|--------|--------|----------|") |
| for platform_name, platform_data in e2e["platforms"].items(): |
| lines.append(f"| **{platform_name}** | {platform_data['total']} | {platform_data['passed']} | {platform_data['failed']} | {platform_data.get('duration', 0)}s |") |
| lines.append("") |
|
|
| |
| lines.append("### Platform Breakdown") |
| lines.append("") |
| lines.append("| Platform | Line Coverage | Branch Coverage | Status |") |
| lines.append("|----------|---------------|-----------------|--------|") |
|
|
| for platform_name, platform_data in aggregate_data["platforms"].items(): |
| if "error" in platform_data: |
| status = f"❌ {platform_data['error']}" |
| line_cov = "N/A" |
| branch_cov = "N/A" |
| else: |
| status = "✅ OK" |
| line_cov = f"{platform_data['coverage_pct']:.2f}%" |
| branch_cov = f"{platform_data['branch_coverage_pct']:.2f}%" |
|
|
| lines.append(f"| **{platform_name}** | {line_cov} | {branch_cov} | {status} |") |
|
|
| lines.append("") |
|
|
| return "\n".join(lines) |
|
|
|
|
| def main(): |
| """Main execution entry point.""" |
| parser = argparse.ArgumentParser( |
| description="Aggregate coverage from backend (pytest), frontend (Jest), mobile (jest-expo), and desktop (tarpaulin) tests" |
| ) |
| parser.add_argument( |
| "--pytest-coverage", |
| type=Path, |
| default=Path(__file__).parent.parent / "coverage_reports" / "metrics" / "coverage.json", |
| help="Path to pytest coverage.json (default: backend/tests/coverage_reports/metrics/coverage.json)" |
| ) |
| parser.add_argument( |
| "--jest-coverage", |
| type=Path, |
| default=Path(__file__).parent.parent.parent.parent / "frontend-nextjs" / "coverage" / "coverage-final.json", |
| help="Path to Jest coverage-final.json (default: frontend-nextjs/coverage/coverage-final.json)" |
| ) |
| parser.add_argument( |
| "--mobile-coverage", |
| type=Path, |
| default=Path(__file__).parent.parent.parent.parent / "mobile" / "coverage" / "coverage-final.json", |
| help="Path to jest-expo coverage-final.json (default: mobile/coverage/coverage-final.json)" |
| ) |
| parser.add_argument( |
| "--desktop-coverage", |
| type=Path, |
| default=Path(__file__).parent.parent.parent.parent / "frontend-nextjs" / "src-tauri" / "coverage" / "coverage.json", |
| help="Path to tarpaulin coverage.json (default: frontend-nextjs/src-tauri/coverage/coverage.json)" |
| ) |
| parser.add_argument( |
| "--e2e-results", |
| type=Path, |
| default=None, |
| help="Path to unified E2E test results JSON (from e2e_aggregator.py)" |
| ) |
| parser.add_argument( |
| "--output", |
| type=Path, |
| default=Path(__file__).parent / "coverage_reports" / "unified" / "coverage.json", |
| help="Output path for unified coverage JSON (default: backend/tests/scripts/coverage_reports/unified/coverage.json)" |
| ) |
| parser.add_argument( |
| "--format", |
| type=str, |
| choices=["json", "text", "markdown"], |
| default="text", |
| help="Output format (default: text)" |
| ) |
|
|
| args = parser.parse_args() |
|
|
| |
| aggregate_data = aggregate_coverage( |
| args.pytest_coverage, |
| args.jest_coverage, |
| args.mobile_coverage, |
| args.desktop_coverage, |
| args.e2e_results |
| ) |
|
|
| |
| args.output.parent.mkdir(parents=True, exist_ok=True) |
|
|
| |
| with open(args.output, 'w') as f: |
| json.dump(aggregate_data, f, indent=2) |
|
|
| |
| report = generate_report(aggregate_data, args.format) |
| print(report) |
|
|
| |
| for platform_name, platform_data in aggregate_data["platforms"].items(): |
| if "error" in platform_data: |
| print(f"\n⚠️ WARNING: {platform_name} coverage file not found or invalid: {platform_data['file']}", |
| file=sys.stderr) |
|
|
| print(f"\n✅ Unified coverage saved to: {args.output}") |
|
|
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|