-
Notifications
You must be signed in to change notification settings - Fork 623
HDDS-14072. Show structured test summary in CI result #10857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
chihsuan
wants to merge
9
commits into
apache:master
Choose a base branch
from
chihsuan:HDDS-14072
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
6309b9e
HDDS-14072. Add surefire XML parser for CI test summary
chihsuan 4b9182a
HDDS-14072. Render Markdown test summary with failed and flaky tables
chihsuan a089d4c
HDDS-14072. Add CLI entry and report discovery to junit_summary
chihsuan 56819ae
HDDS-14072. Show structured test summary in CI step summary
chihsuan ec0d238
HDDS-14072. Run junit_summary unit tests in bats check
chihsuan 89ac20a
HDDS-14072. Credit Kafka junit.py in module docstring
chihsuan eb03e5c
HDDS-14072. Remove quarantined-test table from flaky split summary
chihsuan 3289c1b
HDDS-14072. Render summary counts as bullet list, omit zero counts
chihsuan 503540b
HDDS-14072. Keep JUnit 5 assertion tail when custom message floods fa…
chihsuan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| #!/usr/bin/env bash | ||
|
|
||
| # 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. | ||
|
|
||
| @test "junit_summary.py unit tests" { | ||
| cd "${BATS_TEST_DIRNAME}" | ||
| run python3 -m unittest test_junit_summary | ||
| [ "$status" -eq 0 ] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| #!/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] + "..." | ||
|
Comment on lines
+72
to
+73
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe limit by line number? not sure how kafka handle the truncation. |
||
| 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()) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe limit by line number? not sure how kafka handle the truncation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested with real CI reports. Line-based truncation actually would not help our worst cases.
For flooded assertion messages, the useful part (
==> expected: ... but was: ...) is on the last line, while forwaitFortimeout thread dumps it is on the first line.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've improved the message truncation in 503540b.
When a message exceeds the char limit and contains JUnit 5's
==> expected:marker, the summary now keeps that tail (the actual failure) instead of the head of the embedded log. Short messages are unchanged. Verified against Surefire XMLs from recent failed CI runs.Please take another look, thanks! 🙏
CI: https://github.com/chihsuan/ozone/actions/runs/30546554137