blob: a14242fcdf389905208f722d6fe5d346932957a9 [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Parse Maven Surefire/Failsafe JUnit XML reports and print a Markdown test summary.
Adapted from Apache Kafka's .github/scripts/junit.py (summary format), reworked for Maven Surefire XML.
Intended for GitHub Actions: junit_summary.py [--path DIR] >> "$GITHUB_STEP_SUMMARY"
Optional env: JUNIT_REPORT_URL (link to the archived test report artifact).
Prints nothing and exits 0 when no reports are found. Always exits 0.
"""
import argparse
import dataclasses
import html
import os
import sys
import xml.etree.ElementTree as ET
from glob import glob
PASSED = "✅ PASSED"
FAILED = "❌ FAILED"
FLAKY = "⚠️ FLAKY"
SKIPPED = "🙈 SKIPPED"
FAIL_TAGS = frozenset(("failure", "error"))
FLAKY_TAGS = frozenset(("flakyFailure", "flakyError"))
MESSAGE_LIMIT = 300
@dataclasses.dataclass
class TestCase:
module: str
class_name: str
test_name: str
time: float
status: str # passed | failed | flaky | skipped
message: str = ""
def module_name(xml_path):
# Works for both layouts: <module>/target/surefire-reports/TEST-*.xml (normal run)
# and target/<check>/<module-path>/TEST-*.xml (failed tests moved by _mvn_unit_report.sh).
parts = os.path.normpath(xml_path).split(os.sep)[:-1]
while parts and parts[-1] in ("surefire-reports", "failsafe-reports", "target"):
parts.pop()
return parts[-1] if parts else "-"
def clean_message(elem):
message = " ".join((elem.get("message") or (elem.text or "")).split())
if len(message) > MESSAGE_LIMIT:
# JUnit 5 appends "==> expected: ... but was: ..." after the custom message; when a test embeds
# a whole log as the custom message, drop it and keep the tail with the actual failure
idx = message.find(" ==> expected:")
if idx != -1:
message = message[idx + len(" ==> "):]
if len(message) > MESSAGE_LIMIT:
message = message[:MESSAGE_LIMIT] + "..."
return message
def parse_report(xml_path):
module = module_name(xml_path)
cases = []
for testcase in ET.parse(xml_path).getroot().iter("testcase"):
failure = next((c for c in testcase if c.tag in FAIL_TAGS), None)
flaky = next((c for c in testcase if c.tag in FLAKY_TAGS), None)
skipped = next((c for c in testcase if c.tag == "skipped"), None)
status, message = "passed", ""
if failure is not None:
status, message = "failed", clean_message(failure)
elif flaky is not None:
status, message = "flaky", clean_message(flaky)
elif skipped is not None:
status = "skipped"
cases.append(TestCase(module, testcase.get("classname") or "", testcase.get("name") or "",
float(testcase.get("time") or 0), status, message))
return cases
def format_time(seconds):
minutes, secs = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
if hours:
return "%dh%dm%ds" % (hours, minutes, secs)
if minutes:
return "%dm%ds" % (minutes, secs)
return "%ds" % secs
def cell(text):
return html.escape(" ".join(str(text).split())).replace("|", "\\|")
def render_table(title, header, rows):
lines = ["<details><summary><b>%s (%d)</b></summary>" % (title, len(rows)), ""]
lines.append("|" + "|".join(header) + "|")
lines.append("|" + "|".join("---" for _ in header) + "|")
lines.extend("|" + "|".join(cell(value) for value in row) + "|" for row in rows)
lines.extend(["", "</details>", ""])
return lines
def render_summary(cases):
def select(status):
return [c for c in cases if c.status == status]
def full_name(case):
return "%s.%s" % (case.class_name, case.test_name)
passed, failed, flaky, skipped = select("passed"), select("failed"), select("flaky"), select("skipped")
lines = ["## Test Summary", ""]
# sum of per-test times, not wall clock (parallel forks make wall clock much shorter)
lines.append("%d tests run in %s (total test time):" % (len(cases), format_time(sum(c.time for c in cases))))
for group, label in ((passed, PASSED), (failed, FAILED), (flaky, FLAKY), (skipped, SKIPPED)):
if group:
emoji, word = label.split(" ", 1)
lines.append("- %s %d %s" % (emoji, len(group), word))
lines.append("")
report_url = os.environ.get("JUNIT_REPORT_URL")
if report_url:
lines.extend(["[Download test artifacts](%s)" % report_url, ""])
if failed:
lines.extend(render_table(FAILED, ["Module", "Test", "Message", "Time"],
[[c.module, full_name(c), c.message, format_time(c.time)] for c in failed]))
if flaky:
lines.extend(render_table(FLAKY, ["Module", "Test", "Message", "Time"],
[[c.module, full_name(c), c.message, format_time(c.time)] for c in flaky]))
if skipped:
lines.extend(render_table(SKIPPED, ["Module", "Test"],
[[c.module, full_name(c)] for c in skipped]))
return "\n".join(lines) + "\n"
REPORT_PATTERNS = (
os.path.join("**", "surefire-reports", "TEST-*.xml"),
os.path.join("**", "failsafe-reports", "TEST-*.xml"),
os.path.join("target", "*", "**", "TEST-*.xml"),
)
def find_reports(base):
found = set()
for pattern in REPORT_PATTERNS:
found.update(glob(os.path.join(base, pattern), recursive=True))
# junit.sh keeps per-iteration copies under target/<check>/iterationN/ when ITERATIONS>1;
# exclude them like _mvn_unit_report.sh does, so reruns are not counted multiple times.
return sorted(path for path in found if "/iteration" not in path.replace(os.sep, "/"))
def main(argv=None):
parser = argparse.ArgumentParser(description="Print a Markdown summary of JUnit XML test reports.")
parser.add_argument("--path", default=".", help="directory to scan for TEST-*.xml reports")
try:
args = parser.parse_args(argv)
except SystemExit:
return 0
# This script only decorates the step summary; it must NEVER fail the check, so exit 0 no matter what.
try:
cases = []
for report in find_reports(args.path):
try:
cases.extend(parse_report(report))
except Exception as e:
print("Skipping unreadable report %s: %s" % (report, e), file=sys.stderr)
if cases:
print(render_summary(cases), end="")
except Exception as e:
print("junit_summary failed: %s" % e, file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())