#!/usr/bin/env bash # Test coverage collection and threshold enforcement. # # Runs Rust tests with llvm-cov and frontend tests with vitest --coverage. # Reports line coverage percentages for each. # # After collecting coverage, writes a language-agnostic .coverage_report.json # at the project root in the standard format: # # { # "overall": , # "threshold": , # "files": [{ "path": , "coverage": }] # } # # Threshold: reads from COVERAGE_THRESHOLD env var, or .coverage_baseline file. # Default: 0% (any coverage passes; baseline is written on first run). # # Coverage can only go up: if current coverage is above the stored baseline, # the baseline is updated automatically. # # Exit codes: # 0 — all coverage at or above threshold # 1 — coverage below threshold set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" BASELINE_FILE="$PROJECT_ROOT/.coverage_baseline" RUST_COV_JSON="$PROJECT_ROOT/.coverage_rust_raw.json" # ── Load threshold ──────────────────────────────────────────────────────────── if [ -n "${COVERAGE_THRESHOLD:-}" ]; then THRESHOLD="$COVERAGE_THRESHOLD" elif [ -f "$BASELINE_FILE" ]; then THRESHOLD=$(cat "$BASELINE_FILE") else THRESHOLD=0 fi echo "=== Coverage threshold: ${THRESHOLD}% ===" echo "" PASS=true RUST_LINE_COV=0 FRONTEND_LINE_COV=0 # ── Rust coverage ───────────────────────────────────────────────────────────── echo "=== Running Rust tests with coverage ===" if cargo llvm-cov --version >/dev/null 2>&1; then # Run tests and generate both a text summary and a JSON report for per-file data. cargo llvm-cov \ --manifest-path "$PROJECT_ROOT/Cargo.toml" \ --summary-only \ 2>&1 # Generate JSON report from the already-collected profdata (no re-run). cargo llvm-cov report \ --manifest-path "$PROJECT_ROOT/Cargo.toml" \ --json \ --output-path "$RUST_COV_JSON" \ >/dev/null 2>&1 || true # Parse overall Rust line coverage from the JSON (more reliable than awk on text). if [ -f "$RUST_COV_JSON" ]; then RUST_LINE_COV=$(python3 -c " import json, sys with open('$RUST_COV_JSON') as f: data = json.load(f) pct = data['data'][0]['totals']['lines']['percent'] print(f'{pct:.1f}') " 2>/dev/null) || RUST_LINE_COV=0 fi else echo "cargo-llvm-cov not available; skipping Rust coverage" fi echo "Rust line coverage: ${RUST_LINE_COV}%" echo "" # ── Frontend coverage ───────────────────────────────────────────────────────── echo "=== Running frontend tests with coverage ===" FRONTEND_DIR="$PROJECT_ROOT/frontend" FRONTEND_LINE_COV=0 if [ -d "$FRONTEND_DIR" ]; then (cd "$FRONTEND_DIR" && npm run test:coverage 2>&1) || true # Parse overall from vitest's json-summary report (more reliable than text table). FRONTEND_SUMMARY="$FRONTEND_DIR/coverage/coverage-summary.json" if [ -f "$FRONTEND_SUMMARY" ]; then FRONTEND_LINE_COV=$(python3 -c " import json with open('$FRONTEND_SUMMARY') as f: data = json.load(f) pct = data['total']['lines']['pct'] print(f'{pct:.1f}') " 2>/dev/null) || FRONTEND_LINE_COV=0 fi else echo "No frontend/ directory found; skipping frontend coverage" fi echo "Frontend line coverage: ${FRONTEND_LINE_COV}%" echo "" # ── Overall (average of available measurements) ─────────────────────────────── if [ "$RUST_LINE_COV" != "0" ] && [ "$FRONTEND_LINE_COV" != "0" ]; then OVERALL=$(awk "BEGIN { printf \"%.1f\", ($RUST_LINE_COV + $FRONTEND_LINE_COV) / 2 }") elif [ "$RUST_LINE_COV" != "0" ]; then OVERALL="$RUST_LINE_COV" elif [ "$FRONTEND_LINE_COV" != "0" ]; then OVERALL="$FRONTEND_LINE_COV" else OVERALL=0 fi # ── Summary ─────────────────────────────────────────────────────────────────── echo "=== Coverage Summary ===" echo " Rust: ${RUST_LINE_COV}%" echo " Frontend: ${FRONTEND_LINE_COV}%" echo " Overall: ${OVERALL}%" echo " Threshold: ${THRESHOLD}%" echo "" # ── Threshold check ─────────────────────────────────────────────────────────── if awk "BEGIN { exit (($OVERALL + 0) < ($THRESHOLD + 0)) ? 0 : 1 }"; then echo "FAIL: Coverage ${OVERALL}% is below threshold ${THRESHOLD}%" PASS=false else echo "PASS: Coverage ${OVERALL}% meets threshold ${THRESHOLD}%" fi # ── Update baseline when coverage improves ──────────────────────────────────── if [ "$PASS" = "true" ]; then STORED_BASELINE="${THRESHOLD}" if awk "BEGIN { exit (($OVERALL + 0) > ($STORED_BASELINE + 0)) ? 0 : 1 }"; then echo "${OVERALL}" > "$BASELINE_FILE" echo "Baseline updated: ${STORED_BASELINE}% → ${OVERALL}%" fi fi # ── Write .coverage_report.json ─────────────────────────────────────────────── # This language-agnostic JSON file is consumed by the huskies `coverage` bot # command to show per-file improvement targets without any language-specific # logic in the server. FRONTEND_SUMMARY="${FRONTEND_DIR}/coverage/coverage-summary.json" OVERALL_VAL="$OVERALL" \ THRESHOLD_VAL="$THRESHOLD" \ RUST_COV_JSON="$RUST_COV_JSON" \ FRONTEND_SUMMARY="$FRONTEND_SUMMARY" \ PROJECT_ROOT="$PROJECT_ROOT" \ python3 - << 'PYEOF' import json, os, sys overall = float(os.environ["OVERALL_VAL"]) threshold = float(os.environ["THRESHOLD_VAL"]) rust_json = os.environ["RUST_COV_JSON"] frontend_summary = os.environ["FRONTEND_SUMMARY"] project_root = os.environ["PROJECT_ROOT"] + "/" files = [] # Collect per-file Rust coverage from cargo llvm-cov JSON output. if os.path.exists(rust_json): with open(rust_json) as f: data = json.load(f) for file_entry in data["data"][0]["files"]: path = file_entry["filename"] if path.startswith(project_root): path = path[len(project_root):] pct = file_entry["summary"]["lines"]["percent"] files.append({"path": path, "coverage": round(pct, 2)}) # Collect per-file frontend coverage from vitest json-summary output. if os.path.exists(frontend_summary): with open(frontend_summary) as f: data = json.load(f) for key, value in data.items(): if key == "total": continue path = key if path.startswith(project_root): path = path[len(project_root):] pct = value["lines"]["pct"] files.append({"path": path, "coverage": round(pct, 2)}) report = { "overall": overall, "threshold": threshold, "files": sorted(files, key=lambda x: x["coverage"]), } output_path = os.path.join(project_root, ".coverage_report.json") with open(output_path, "w") as f: json.dump(report, f, indent=2) f.write("\n") print(f"Coverage report written: .coverage_report.json ({len(files)} files)") PYEOF # ── Cleanup temp files ──────────────────────────────────────────────────────── rm -f "$RUST_COV_JSON" if [ "$PASS" = "false" ]; then exit 1 fi