Skip to content

Commit f3503e9

Browse files
author
Nick Ficano
committed
ci: enhance coverage checks and documentation
Added a new Python script to check line coverage against a specified threshold, integrated into the CI workflow. Updated the test workflow to run all test projects with coverage and enforce a minimum line coverage of 80%. Enhanced the CONTRIBUTING.md to reflect these changes and added a Codecov badge to the README for better visibility of coverage status.
1 parent 8808968 commit f3503e9

4 files changed

Lines changed: 88 additions & 9 deletions

File tree

.github/workflows/test.yml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,20 +75,23 @@ jobs:
7575
- name: Build
7676
run: dotnet build ARCP.slnx --configuration Release --no-restore
7777

78-
- name: Test (unit, with coverage)
78+
- name: Test (all test projects, with coverage)
7979
run: >
80-
dotnet test tests/Arcp.UnitTests/Arcp.UnitTests.fsproj
80+
dotnet test ARCP.slnx
8181
--configuration Release
8282
--no-build
8383
--verbosity normal
8484
--collect:"XPlat Code Coverage"
85-
--logger "trx;LogFileName=unit-tests.trx"
85+
--logger "trx;LogFilePrefix=tests"
8686
--logger "console;verbosity=normal"
8787
--results-directory ${{ github.workspace }}/TestResults
8888
89-
# coverlet.collector writes one coverage.cobertura.xml per test
90-
# project under TestResults/<guid>/. Non-blocking so a Codecov
91-
# outage cannot break CI.
89+
- name: Coverage gate (line coverage >= 80%)
90+
run: |
91+
python3 scripts/check-coverage.py --threshold 80 --results-dir "${{ github.workspace }}/TestResults"
92+
93+
# coverlet.collector writes one coverage.cobertura.xml per test project
94+
# under TestResults/<guid>/. Non-blocking so a Codecov outage cannot break CI.
9295
- name: Upload coverage to Codecov
9396
# codecov/codecov-action v6.0.1
9497
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1

CONTRIBUTING.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ The full coverage report is regenerated with:
103103
```sh
104104
dotnet test ARCP.slnx --collect:"XPlat Code Coverage" \
105105
--results-directory TestResults/review-coverage
106+
python3 scripts/check-coverage.py --threshold 80 \
107+
--results-dir TestResults/review-coverage
106108
reportgenerator \
107109
-reports:"TestResults/review-coverage/*/coverage.cobertura.xml" \
108110
-targetdir:"TestResults/coverage-report" \
@@ -111,9 +113,10 @@ reportgenerator \
111113

112114
Install the report tool once with
113115
`dotnet tool install -g dotnet-reportgenerator-globaltool`. The summary lands
114-
at `TestResults/coverage-report/Summary.txt`. The target is ≥ 80 % line
115-
coverage; transport and async-state-machine paths drive most of the
116-
remaining branch gaps and additions there are welcome.
116+
at `TestResults/coverage-report/Summary.txt`. CI enforces a minimum **80% line
117+
coverage** union across all test projects before uploading the reports to
118+
Codecov; transport and async-state-machine paths drive most of the remaining
119+
branch gaps and additions there are welcome.
117120

118121
## Coding standards
119122

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<p align="center">
66
<a href="https://www.nuget.org/packages/Arcp"><img alt="NuGet" src="https://img.shields.io/nuget/v/Arcp.svg"></a>
77
<a href="https://github.com/agentruntimecontrolprotocol/fsharp-sdk/actions/workflows/test.yml"><img alt="CI" src="https://github.com/agentruntimecontrolprotocol/fsharp-sdk/actions/workflows/test.yml/badge.svg"></a>
8+
<a href="https://codecov.io/gh/agentruntimecontrolprotocol/fsharp-sdk"><img alt="codecov" src="https://codecov.io/gh/agentruntimecontrolprotocol/fsharp-sdk/graph/badge.svg"></a>
89
<a href="https://github.com/agentruntimecontrolprotocol/spec/blob/main/docs/draft-arcp-1.1.md"><img alt="ARCP" src="https://img.shields.io/badge/ARCP-v1.1%20draft-blue"></a>
910
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-lightgrey"></a>
1011
</p>

scripts/check-coverage.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
"""Compute union line/branch coverage from cobertura reports and fail if line coverage
3+
is below the requested threshold. Run after `dotnet test ... --collect:"XPlat Code Coverage"`.
4+
5+
Usage:
6+
python3 scripts/check-coverage.py --threshold 80 --results-dir TestResults
7+
"""
8+
import argparse
9+
import sys
10+
import xml.etree.ElementTree as ET
11+
from collections import defaultdict
12+
from pathlib import Path
13+
14+
15+
def main() -> int:
16+
ap = argparse.ArgumentParser()
17+
ap.add_argument("--threshold", type=float, default=80.0, help="Minimum line-coverage percent.")
18+
ap.add_argument("--results-dir", type=Path, default=Path("TestResults"))
19+
args = ap.parse_args()
20+
21+
covered_lines: dict[str, set[int]] = defaultdict(set)
22+
valid_lines: dict[str, set[int]] = defaultdict(set)
23+
covered_branches: dict[str, set[tuple[int, int]]] = defaultdict(set)
24+
valid_branches: dict[str, set[tuple[int, int]]] = defaultdict(set)
25+
26+
reports = list(args.results_dir.rglob("coverage.cobertura.xml"))
27+
if not reports:
28+
print(f"No coverage.cobertura.xml found under {args.results_dir}", file=sys.stderr)
29+
return 1
30+
for cobertura in reports:
31+
tree = ET.parse(cobertura)
32+
for cls in tree.iter("class"):
33+
fname = cls.get("filename", "")
34+
for line in cls.iter("line"):
35+
ln = int(line.get("number", "0"))
36+
hits = int(line.get("hits", "0"))
37+
valid_lines[fname].add(ln)
38+
if hits > 0:
39+
covered_lines[fname].add(ln)
40+
if line.get("branch", "false").lower() == "true":
41+
cc = line.get("condition-coverage", "")
42+
if "(" in cc and "/" in cc:
43+
seg = cc[cc.index("(") + 1:cc.index(")")]
44+
try:
45+
cov, tot = (int(x) for x in seg.split("/"))
46+
except ValueError:
47+
continue
48+
for i in range(tot):
49+
valid_branches[fname].add((ln, i))
50+
for i in range(cov):
51+
covered_branches[fname].add((ln, i))
52+
53+
total_valid = sum(len(s) for s in valid_lines.values())
54+
total_covered = sum(len(covered_lines[f] & valid_lines[f]) for f in valid_lines)
55+
bvalid = sum(len(s) for s in valid_branches.values())
56+
bcov = sum(len(covered_branches[f] & valid_branches[f]) for f in valid_branches)
57+
line_pct = (100 * total_covered / total_valid) if total_valid else 0.0
58+
branch_pct = (100 * bcov / bvalid) if bvalid else 0.0
59+
60+
print(f"Reports merged: {len(reports)}")
61+
print(f"Line coverage: {total_covered}/{total_valid} = {line_pct:.2f}%")
62+
print(f"Branch coverage: {bcov}/{bvalid} = {branch_pct:.2f}%")
63+
print(f"Threshold: {args.threshold:.2f}% (line)")
64+
65+
if line_pct + 1e-9 < args.threshold:
66+
print(f"FAIL: line coverage {line_pct:.2f}% is below threshold {args.threshold:.2f}%", file=sys.stderr)
67+
return 1
68+
return 0
69+
70+
71+
if __name__ == "__main__":
72+
sys.exit(main())

0 commit comments

Comments
 (0)