2026-03-22 19:07:07 +00:00
|
|
|
#!/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.
|
|
|
|
|
#
|
2026-04-04 15:07:37 +00:00
|
|
|
# After collecting coverage, writes a language-agnostic .coverage_report.json
|
|
|
|
|
# at the project root in the standard format:
|
|
|
|
|
#
|
|
|
|
|
# {
|
|
|
|
|
# "overall": <float>,
|
|
|
|
|
# "threshold": <float>,
|
|
|
|
|
# "files": [{ "path": <string>, "coverage": <float> }]
|
|
|
|
|
# }
|
|
|
|
|
#
|
2026-03-22 19:07:07 +00:00
|
|
|
# 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"
|
2026-04-04 15:07:37 +00:00
|
|
|
RUST_COV_JSON="$PROJECT_ROOT/.coverage_rust_raw.json"
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
# ── 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
|
2026-04-04 15:07:37 +00:00
|
|
|
# Run tests and generate both a text summary and a JSON report for per-file data.
|
|
|
|
|
cargo llvm-cov \
|
2026-03-22 19:07:07 +00:00
|
|
|
--manifest-path "$PROJECT_ROOT/Cargo.toml" \
|
|
|
|
|
--summary-only \
|
2026-04-04 15:07:37 +00:00
|
|
|
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
|
2026-03-22 19:07:07 +00:00
|
|
|
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
|
2026-04-04 15:07:37 +00:00
|
|
|
(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
|
2026-03-22 19:07:07 +00:00
|
|
|
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
|
|
|
|
|
|
2026-04-04 15:07:37 +00:00
|
|
|
# ── 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"
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
if [ "$PASS" = "false" ]; then
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|