diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index a55f237..5c271ac 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -70,6 +70,12 @@ jobs: restore-keys: | nuget-${{ runner.os }}- - run: make unit + # Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote. + - name: Unit test summary + if: always() + run: | + [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 + python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY" # Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build. frontend: diff --git a/.gitignore b/.gitignore index ae54b38..04c68f4 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ tests/e2e/node_modules/ tests/e2e/test-results/ tests/e2e/playwright-report/ __pycache__/ +TestResults/ diff --git a/Makefile b/Makefile index 80975da..69a5ade 100644 --- a/Makefile +++ b/Makefile @@ -70,8 +70,9 @@ build: dotnet build $(SLN) -c Release ## unit: run unit tests (excludes the container-backed Integration lane) +# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally. unit: - dotnet test $(SLN) -c Release --filter "Category!=Integration" + dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults ## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` diff --git a/infra/trx-summary.py b/infra/trx-summary.py new file mode 100644 index 0000000..ca67f67 --- /dev/null +++ b/infra/trx-summary.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Render a per-test-project table from .trx files for a Gitea job summary (#136). + +Reads every *.trx in the given directory (default: TestResults), pulls each project's +counters + assembly name, and prints a GitHub/Gitea-flavoured markdown table to stdout. +The CI step redirects that into $GITHUB_STEP_SUMMARY. Stdlib only. +""" +import glob +import os +import sys +import xml.etree.ElementTree as ET + +NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"} + + +def project_name(root): + # The test assembly path, e.g. …/services/domain/Big.Tests/bin/…/big.tests.dll. Prefer the + # owning service folder (services/) so "domain" shows rather than the opaque "big.tests"; + # fall back to the assembly basename for projects outside services/ (e.g. tests/acceptance). + ut = root.find(".//t:TestDefinitions/t:UnitTest", NS) + storage = ut.get("storage") if ut is not None else None + if not storage: + return None + parts = storage.replace("\\", "/").split("/") + if "services" in parts: + return parts[parts.index("services") + 1] + base = os.path.basename(parts[-1]) + return base[:-4] if base.lower().endswith(".dll") else base + + +def parse(path): + root = ET.parse(path).getroot() + c = root.find(".//t:ResultSummary/t:Counters", NS) + if c is None: + return None + total = int(c.get("total", 0)) + if total == 0: # e.g. the Integration project, filtered out of the unit run + return None + executed = int(c.get("executed", 0)) + passed = int(c.get("passed", 0)) + failed = int(c.get("failed", 0)) + int(c.get("error", 0)) + skipped = total - executed + return { + "name": project_name(root) or os.path.basename(path), + "passed": passed, "failed": failed, "skipped": skipped, "total": total, + } + + +def main(results_dir): + rows = [r for r in (parse(p) for p in sorted(glob.glob(os.path.join(results_dir, "*.trx")))) if r] + if not rows: + print("_No test results found._") + return 0 + rows.sort(key=lambda r: r["name"]) + print("## ✅ Unit tests\n") + print("| Project | Result | Passed | Failed | Skipped | Total |") + print("| ------- | :----: | -----: | -----: | ------: | ----: |") + for r in rows: + status = "❌" if r["failed"] else "✅" + print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "TestResults"))