diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index 626e1fd1a..4df77a015 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -202,7 +202,7 @@ jobs: # --dist loadgroup is required: it is the only xdist scheduler # that honors pytest.mark.xdist_group, which we use to keep # HOME-mutating tests serialized on a single worker. - PYTEST_EXTRA_ARGS: "--splits 4 --group ${{ matrix.shard }} -n 2 --dist loadgroup --maxfail 10" + PYTEST_EXTRA_ARGS: "--splits 4 --group ${{ matrix.shard }} -n 2 --dist loadgroup --maxfail 10 --cov" run: | chmod +x scripts/test-integration.sh uv run ./scripts/test-integration.sh @@ -210,6 +210,23 @@ jobs: # headroom for the slowest shard (HOME-group tests). timeout-minutes: 15 + - name: Rename coverage data for shard + if: always() + run: | + if [ -f .coverage ]; then + mv .coverage .coverage.shard-${{ matrix.shard }} + fi + + - name: Upload coverage data + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-coverage-shard-${{ matrix.shard }} + path: .coverage.shard-${{ matrix.shard }} + include-hidden-files: true + retention-days: 7 + if-no-files-found: ignore + # Fan-in job that preserves the gate-required check name # "Integration Tests (Linux)". Branch protection / merge-gate.yml # require this exact name; shard jobs above are not required directly. @@ -219,6 +236,40 @@ jobs: if: always() runs-on: ubuntu-24.04 steps: + # Coverage summary runs first (with if: always()) so we get + # observability even when some shards fail. The shard-result + # gate runs last so it can still fail the job. + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install coverage + run: pip install "coverage[toml]" + + - name: Download shard coverage + if: always() + uses: actions/download-artifact@v4 + with: + pattern: integration-coverage-shard-* + path: coverage-shards/ + merge-multiple: true + continue-on-error: true + + - name: Combine and summarise coverage + if: always() + run: | + if find coverage-shards -type f -name '.coverage*' 2>/dev/null | grep -q .; then + coverage combine coverage-shards/ + coverage json -o coverage.json + python3 scripts/coverage-summary.py coverage.json --title "Integration Test Coverage" + else + echo "No coverage shard files found; skipping integration coverage summary." + fi + continue-on-error: true + - name: Aggregate shard results run: | if [[ "${{ needs.integration-tests-shard.result }}" != "success" ]]; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dbcf335e..0ba806ed7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,8 +106,15 @@ jobs: - name: Install dependencies run: uv sync --extra dev --extra build - - name: Run tests - run: uv run pytest tests/unit tests/test_console.py -n auto --dist worksteal + - name: Run tests with coverage + run: uv run pytest tests/unit tests/test_console.py -n auto --dist worksteal --cov --cov-report=term-missing:skip-covered --cov-report=json:coverage.json + + - name: Coverage summary + if: always() + run: | + if [ -f coverage.json ]; then + python3 scripts/coverage-summary.py coverage.json --title "Unit Test Coverage" + fi - name: Install UPX run: | diff --git a/pyproject.toml b/pyproject.toml index e5250b1e3..c3113013f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,6 +129,23 @@ python_version = "3.12" warn_return_any = true warn_unused_configs = true +[tool.coverage.run] +source = ["src/apm_cli"] +branch = true +parallel = true + +[tool.coverage.report] +show_missing = true +skip_empty = true +# Exclude lines that are not meant to be covered +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@overload", +] + [tool.pytest.ini_options] addopts = "-m 'not benchmark and not live'" markers = [ diff --git a/scripts/coverage-summary.py b/scripts/coverage-summary.py new file mode 100755 index 000000000..027906aba --- /dev/null +++ b/scripts/coverage-summary.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Render a coverage.json file as a Markdown summary. + +Usage: + python3 scripts/coverage-summary.py coverage.json [--title "My Title"] + +Writes Markdown to stdout and, if GITHUB_STEP_SUMMARY is set, appends to +that file so the summary appears in the GitHub Actions job summary panel. + +Exit codes: + 0 Always -- missing coverage.json is treated as a soft warning, not + an error, so CI steps that set ``if: always()`` never fail here. +""" + +import argparse +import json +import os +import pathlib +import sys + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Render a coverage.json file as a Markdown summary." + ) + parser.add_argument( + "coverage_json", + metavar="COVERAGE_JSON", + help="Path to coverage.json produced by `coverage json`.", + ) + parser.add_argument( + "--title", + default="Code Coverage Report", + help="Heading text for the summary (default: 'Code Coverage Report').", + ) + return parser.parse_args() + + +def _build_markdown(data: dict, title: str) -> str: + totals = data.get("totals", {}) + pct = totals.get("percent_covered_display", "N/A") + stmts = totals.get("num_statements", 0) + miss = totals.get("missing_lines", 0) + covered = totals.get("covered_lines", stmts - miss) + + lines: list[str] = [] + lines.append(f"## {title}") + lines.append("") + lines.append(f"**Overall: {pct}%** ({covered:,}/{stmts:,} statements)") + lines.append("") + lines.append("| Metric | Value |") + lines.append("|--------|-------|") + lines.append(f"| Statements | {stmts:,} |") + lines.append(f"| Covered | {covered:,} |") + lines.append(f"| Missed | {miss:,} |") + lines.append(f"| Coverage | {pct}% |") + lines.append("") + + files = data.get("files", {}) + ranked = sorted( + files.items(), + key=lambda kv: kv[1].get("summary", {}).get("percent_covered", 100.0), + ) + # Always show bottom-10 files so the section appears even when + # overall coverage is high (acceptance criteria: "collapsible + # lowest-coverage files" in every summary). + bottom = ranked[:10] + + if bottom: + lines.append("
") + lines.append("Lowest-coverage files") + lines.append("") + lines.append("| File | Stmts | Miss | Cover |") + lines.append("|------|-------|------|-------|") + for fpath, fdata in bottom: + s = fdata.get("summary", {}) + # Strip common prefix to keep the table narrow. + short = fpath.replace("src/apm_cli/", "") + fp = s.get("percent_covered_display", "?") + lines.append( + f"| `{short}` | {s.get('num_statements', 0)}" + f" | {s.get('missing_lines', 0)} | {fp}% |" + ) + lines.append("") + lines.append("
") + lines.append("") + + return "\n".join(lines) + "\n" + + +def main() -> None: + args = _parse_args() + coverage_path = pathlib.Path(args.coverage_json) + + if not coverage_path.exists(): + print( + f"[!] coverage-summary: {coverage_path} not found -- skipping summary.", + file=sys.stderr, + ) + sys.exit(0) + + try: + data = json.loads(coverage_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + print( + f"[x] coverage-summary: failed to read {coverage_path}: {exc}", + file=sys.stderr, + ) + sys.exit(0) + + md = _build_markdown(data, args.title) + print(md, end="") + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "") + if summary_path: + try: + with open(summary_path, "a", encoding="utf-8") as fh: + fh.write(md) + except OSError as exc: + print( + f"[!] coverage-summary: could not write to GITHUB_STEP_SUMMARY: {exc}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main()