|
| 1 | +#!/usr/bin/env python3 |
| 2 | +from __future__ import annotations |
| 3 | + |
| 4 | +import os |
| 5 | +import sys |
| 6 | +import xml.etree.ElementTree as ET |
| 7 | +from pathlib import Path |
| 8 | + |
| 9 | + |
| 10 | +def parse_junit(path: Path) -> dict[str, int | str]: |
| 11 | + root = ET.parse(path).getroot() |
| 12 | + suites = [root] if root.tag == "testsuite" else root.findall("testsuite") |
| 13 | + tests = sum(int(suite.attrib.get("tests", 0)) for suite in suites) |
| 14 | + failures = sum(int(suite.attrib.get("failures", 0)) for suite in suites) |
| 15 | + errors = sum(int(suite.attrib.get("errors", 0)) for suite in suites) |
| 16 | + skipped = sum(int(suite.attrib.get("skipped", 0)) for suite in suites) |
| 17 | + passed = max(tests - failures - errors - skipped, 0) |
| 18 | + executed = max(tests - skipped, 0) |
| 19 | + pass_rate = (passed / executed * 100.0) if executed else 0.0 |
| 20 | + return { |
| 21 | + "name": path.name, |
| 22 | + "tests": tests, |
| 23 | + "passed": passed, |
| 24 | + "failures": failures, |
| 25 | + "errors": errors, |
| 26 | + "skipped": skipped, |
| 27 | + "executed": executed, |
| 28 | + "pass_rate": round(pass_rate, 1), |
| 29 | + } |
| 30 | + |
| 31 | + |
| 32 | +def render_summary(entries: list[dict[str, int | str]]) -> str: |
| 33 | + tests = sum(int(entry["tests"]) for entry in entries) |
| 34 | + passed = sum(int(entry["passed"]) for entry in entries) |
| 35 | + failures = sum(int(entry["failures"]) for entry in entries) |
| 36 | + errors = sum(int(entry["errors"]) for entry in entries) |
| 37 | + skipped = sum(int(entry["skipped"]) for entry in entries) |
| 38 | + executed = sum(int(entry["executed"]) for entry in entries) |
| 39 | + pass_rate = (passed / executed * 100.0) if executed else 0.0 |
| 40 | + |
| 41 | + lines = [ |
| 42 | + "## Test Summary", |
| 43 | + "", |
| 44 | + f"- Total cases: `{tests}`", |
| 45 | + f"- Passed: `{passed}`", |
| 46 | + f"- Failed: `{failures}`", |
| 47 | + f"- Errors: `{errors}`", |
| 48 | + f"- Skipped: `{skipped}`", |
| 49 | + f"- Pass rate (executed cases): `{pass_rate:.1f}%`", |
| 50 | + "", |
| 51 | + "| Report | Tests | Passed | Failed | Errors | Skipped | Pass rate |", |
| 52 | + "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", |
| 53 | + ] |
| 54 | + |
| 55 | + for entry in sorted(entries, key=lambda value: str(value["name"])): |
| 56 | + lines.append( |
| 57 | + "| {name} | {tests} | {passed} | {failures} | {errors} | {skipped} | {pass_rate:.1f}% |".format( |
| 58 | + **entry |
| 59 | + ) |
| 60 | + ) |
| 61 | + |
| 62 | + return "\n".join(lines) + "\n" |
| 63 | + |
| 64 | + |
| 65 | +def main() -> int: |
| 66 | + report_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() |
| 67 | + reports = sorted(report_root.rglob("*.xml")) |
| 68 | + summary_path = os.getenv("GITHUB_STEP_SUMMARY") |
| 69 | + |
| 70 | + if not reports: |
| 71 | + content = "## Test Summary\n\nNo JUnit XML reports were found.\n" |
| 72 | + if summary_path: |
| 73 | + with open(summary_path, "a", encoding="utf-8") as handle: |
| 74 | + handle.write(content) |
| 75 | + else: |
| 76 | + sys.stdout.write(content) |
| 77 | + return 0 |
| 78 | + |
| 79 | + entries = [parse_junit(path) for path in reports] |
| 80 | + content = render_summary(entries) |
| 81 | + |
| 82 | + if summary_path: |
| 83 | + with open(summary_path, "a", encoding="utf-8") as handle: |
| 84 | + handle.write(content) |
| 85 | + else: |
| 86 | + sys.stdout.write(content) |
| 87 | + |
| 88 | + return 0 |
| 89 | + |
| 90 | + |
| 91 | +if __name__ == "__main__": |
| 92 | + raise SystemExit(main()) |
0 commit comments