xref: /llvm-project/.ci/generate_test_report.py (revision 1b199d19902a752433c397377567ff381261e94a)
1# Script to parse many JUnit XML result files and send a report to the buildkite
2# agent as an annotation.
3#
4# To run the unittests:
5# python3 -m unittest discover -p generate_test_report.py
6
7import argparse
8import os
9import subprocess
10import unittest
11from io import StringIO
12from junitparser import JUnitXml, Failure
13from textwrap import dedent
14
15
16def junit_from_xml(xml):
17    return JUnitXml.fromfile(StringIO(xml))
18
19
20class TestReports(unittest.TestCase):
21    def test_title_only(self):
22        self.assertEqual(_generate_report("Foo", 0, []), ("", "success"))
23
24    def test_no_tests_in_testsuite(self):
25        self.assertEqual(
26            _generate_report(
27                "Foo",
28                1,
29                [
30                    junit_from_xml(
31                        dedent(
32                            """\
33          <?xml version="1.0" encoding="UTF-8"?>
34          <testsuites time="0.00">
35          <testsuite name="Empty" tests="0" failures="0" skipped="0" time="0.00">
36          </testsuite>
37          </testsuites>"""
38                        )
39                    )
40                ],
41            ),
42            ("", None),
43        )
44
45    def test_no_failures(self):
46        self.assertEqual(
47            _generate_report(
48                "Foo",
49                0,
50                [
51                    junit_from_xml(
52                        dedent(
53                            """\
54          <?xml version="1.0" encoding="UTF-8"?>
55          <testsuites time="0.00">
56          <testsuite name="Passed" tests="1" failures="0" skipped="0" time="0.00">
57          <testcase classname="Bar/test_1" name="test_1" time="0.00"/>
58          </testsuite>
59          </testsuites>"""
60                        )
61                    )
62                ],
63            ),
64            (
65                dedent(
66                    """\
67              # Foo
68
69              * 1 test passed"""
70                ),
71                "success",
72            ),
73        )
74
75    def test_no_failures_build_failed(self):
76        self.assertEqual(
77            _generate_report(
78                "Foo",
79                1,
80                [
81                    junit_from_xml(
82                        dedent(
83                            """\
84          <?xml version="1.0" encoding="UTF-8"?>
85          <testsuites time="0.00">
86          <testsuite name="Passed" tests="1" failures="0" skipped="0" time="0.00">
87          <testcase classname="Bar/test_1" name="test_1" time="0.00"/>
88          </testsuite>
89          </testsuites>"""
90                        )
91                    )
92                ],
93                buildkite_info={
94                    "BUILDKITE_ORGANIZATION_SLUG": "organization_slug",
95                    "BUILDKITE_PIPELINE_SLUG": "pipeline_slug",
96                    "BUILDKITE_BUILD_NUMBER": "build_number",
97                    "BUILDKITE_JOB_ID": "job_id",
98                },
99            ),
100            (
101                dedent(
102                    """\
103              # Foo
104
105              * 1 test passed
106
107              All tests passed but another part of the build **failed**.
108
109              [Download](https://buildkite.com/organizations/organization_slug/pipelines/pipeline_slug/builds/build_number/jobs/job_id/download.txt) the build's log file to see the details."""
110                ),
111                "error",
112            ),
113        )
114
115    def test_report_single_file_single_testsuite(self):
116        self.assertEqual(
117            _generate_report(
118                "Foo",
119                1,
120                [
121                    junit_from_xml(
122                        dedent(
123                            """\
124          <?xml version="1.0" encoding="UTF-8"?>
125          <testsuites time="8.89">
126          <testsuite name="Bar" tests="4" failures="2" skipped="1" time="410.63">
127          <testcase classname="Bar/test_1" name="test_1" time="0.02"/>
128          <testcase classname="Bar/test_2" name="test_2" time="0.02">
129            <skipped message="Reason"/>
130          </testcase>
131          <testcase classname="Bar/test_3" name="test_3" time="0.02">
132            <failure><![CDATA[Output goes here]]></failure>
133          </testcase>
134          <testcase classname="Bar/test_4" name="test_4" time="0.02">
135            <failure><![CDATA[Other output goes here]]></failure>
136          </testcase>
137          </testsuite>
138          </testsuites>"""
139                        )
140                    )
141                ],
142            ),
143            (
144                dedent(
145                    """\
146          # Foo
147
148          * 1 test passed
149          * 1 test skipped
150          * 2 tests failed
151
152          ## Failed Tests
153          (click to see output)
154
155          ### Bar
156          <details>
157          <summary>Bar/test_3/test_3</summary>
158
159          ```
160          Output goes here
161          ```
162          </details>
163          <details>
164          <summary>Bar/test_4/test_4</summary>
165
166          ```
167          Other output goes here
168          ```
169          </details>"""
170                ),
171                "error",
172            ),
173        )
174
175    MULTI_SUITE_OUTPUT = (
176        dedent(
177            """\
178        # ABC and DEF
179
180        * 1 test passed
181        * 1 test skipped
182        * 2 tests failed
183
184        ## Failed Tests
185        (click to see output)
186
187        ### ABC
188        <details>
189        <summary>ABC/test_2/test_2</summary>
190
191        ```
192        ABC/test_2 output goes here
193        ```
194        </details>
195
196        ### DEF
197        <details>
198        <summary>DEF/test_2/test_2</summary>
199
200        ```
201        DEF/test_2 output goes here
202        ```
203        </details>"""
204        ),
205        "error",
206    )
207
208    def test_report_single_file_multiple_testsuites(self):
209        self.assertEqual(
210            _generate_report(
211                "ABC and DEF",
212                1,
213                [
214                    junit_from_xml(
215                        dedent(
216                            """\
217          <?xml version="1.0" encoding="UTF-8"?>
218          <testsuites time="8.89">
219          <testsuite name="ABC" tests="2" failures="1" skipped="0" time="410.63">
220          <testcase classname="ABC/test_1" name="test_1" time="0.02"/>
221          <testcase classname="ABC/test_2" name="test_2" time="0.02">
222            <failure><![CDATA[ABC/test_2 output goes here]]></failure>
223          </testcase>
224          </testsuite>
225          <testsuite name="DEF" tests="2" failures="1" skipped="1" time="410.63">
226          <testcase classname="DEF/test_1" name="test_1" time="0.02">
227            <skipped message="reason"/>
228          </testcase>
229          <testcase classname="DEF/test_2" name="test_2" time="0.02">
230            <failure><![CDATA[DEF/test_2 output goes here]]></failure>
231          </testcase>
232          </testsuite>
233          </testsuites>"""
234                        )
235                    )
236                ],
237            ),
238            self.MULTI_SUITE_OUTPUT,
239        )
240
241    def test_report_multiple_files_multiple_testsuites(self):
242        self.assertEqual(
243            _generate_report(
244                "ABC and DEF",
245                1,
246                [
247                    junit_from_xml(
248                        dedent(
249                            """\
250          <?xml version="1.0" encoding="UTF-8"?>
251          <testsuites time="8.89">
252          <testsuite name="ABC" tests="2" failures="1" skipped="0" time="410.63">
253          <testcase classname="ABC/test_1" name="test_1" time="0.02"/>
254          <testcase classname="ABC/test_2" name="test_2" time="0.02">
255            <failure><![CDATA[ABC/test_2 output goes here]]></failure>
256          </testcase>
257          </testsuite>
258          </testsuites>"""
259                        )
260                    ),
261                    junit_from_xml(
262                        dedent(
263                            """\
264          <?xml version="1.0" encoding="UTF-8"?>
265          <testsuites time="8.89">
266          <testsuite name="DEF" tests="2" failures="1" skipped="1" time="410.63">
267          <testcase classname="DEF/test_1" name="test_1" time="0.02">
268            <skipped message="reason"/>
269          </testcase>
270          <testcase classname="DEF/test_2" name="test_2" time="0.02">
271            <failure><![CDATA[DEF/test_2 output goes here]]></failure>
272          </testcase>
273          </testsuite>
274          </testsuites>"""
275                        )
276                    ),
277                ],
278            ),
279            self.MULTI_SUITE_OUTPUT,
280        )
281
282    def test_report_dont_list_failures(self):
283        self.assertEqual(
284            _generate_report(
285                "Foo",
286                1,
287                [
288                    junit_from_xml(
289                        dedent(
290                            """\
291          <?xml version="1.0" encoding="UTF-8"?>
292          <testsuites time="0.02">
293          <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02">
294          <testcase classname="Bar/test_1" name="test_1" time="0.02">
295            <failure><![CDATA[Output goes here]]></failure>
296          </testcase>
297          </testsuite>
298          </testsuites>"""
299                        )
300                    )
301                ],
302                list_failures=False,
303            ),
304            (
305                dedent(
306                    """\
307          # Foo
308
309          * 1 test failed
310
311          Failed tests and their output was too large to report. Download the build's log file to see the details."""
312                ),
313                "error",
314            ),
315        )
316
317    def test_report_dont_list_failures_link_to_log(self):
318        self.assertEqual(
319            _generate_report(
320                "Foo",
321                1,
322                [
323                    junit_from_xml(
324                        dedent(
325                            """\
326          <?xml version="1.0" encoding="UTF-8"?>
327          <testsuites time="0.02">
328          <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02">
329          <testcase classname="Bar/test_1" name="test_1" time="0.02">
330            <failure><![CDATA[Output goes here]]></failure>
331          </testcase>
332          </testsuite>
333          </testsuites>"""
334                        )
335                    )
336                ],
337                list_failures=False,
338                buildkite_info={
339                    "BUILDKITE_ORGANIZATION_SLUG": "organization_slug",
340                    "BUILDKITE_PIPELINE_SLUG": "pipeline_slug",
341                    "BUILDKITE_BUILD_NUMBER": "build_number",
342                    "BUILDKITE_JOB_ID": "job_id",
343                },
344            ),
345            (
346                dedent(
347                    """\
348          # Foo
349
350          * 1 test failed
351
352          Failed tests and their output was too large to report. [Download](https://buildkite.com/organizations/organization_slug/pipelines/pipeline_slug/builds/build_number/jobs/job_id/download.txt) the build's log file to see the details."""
353                ),
354                "error",
355            ),
356        )
357
358    def test_report_size_limit(self):
359        self.assertEqual(
360            _generate_report(
361                "Foo",
362                1,
363                [
364                    junit_from_xml(
365                        dedent(
366                            """\
367          <?xml version="1.0" encoding="UTF-8"?>
368          <testsuites time="0.02">
369          <testsuite name="Bar" tests="1" failures="1" skipped="0" time="0.02">
370          <testcase classname="Bar/test_1" name="test_1" time="0.02">
371            <failure><![CDATA[Some long output goes here...]]></failure>
372          </testcase>
373          </testsuite>
374          </testsuites>"""
375                        )
376                    )
377                ],
378                size_limit=128,
379            ),
380            (
381                dedent(
382                    """\
383          # Foo
384
385          * 1 test failed
386
387          Failed tests and their output was too large to report. Download the build's log file to see the details."""
388                ),
389                "error",
390            ),
391        )
392
393
394# Set size_limit to limit the byte size of the report. The default is 1MB as this
395# is the most that can be put into an annotation. If the generated report exceeds
396# this limit and failures are listed, it will be generated again without failures
397# listed. This minimal report will always fit into an annotation.
398# If include failures is False, total number of test will be reported but their names
399# and output will not be.
400def _generate_report(
401    title,
402    return_code,
403    junit_objects,
404    size_limit=1024 * 1024,
405    list_failures=True,
406    buildkite_info=None,
407):
408    if not junit_objects:
409        # Note that we do not post an empty report, therefore we can ignore a
410        # non-zero return code in situations like this.
411        #
412        # If we were going to post a report, then yes, it would be misleading
413        # to say we succeeded when the final return code was non-zero.
414        return ("", "success")
415
416    failures = {}
417    tests_run = 0
418    tests_skipped = 0
419    tests_failed = 0
420
421    for results in junit_objects:
422        for testsuite in results:
423            tests_run += testsuite.tests
424            tests_skipped += testsuite.skipped
425            tests_failed += testsuite.failures
426
427            for test in testsuite:
428                if (
429                    not test.is_passed
430                    and test.result
431                    and isinstance(test.result[0], Failure)
432                ):
433                    if failures.get(testsuite.name) is None:
434                        failures[testsuite.name] = []
435                    failures[testsuite.name].append(
436                        (test.classname + "/" + test.name, test.result[0].text)
437                    )
438
439    if not tests_run:
440        return ("", None)
441
442    style = "success"
443    # Either tests failed, or all tests passed but something failed to build.
444    if tests_failed or return_code != 0:
445        style = "error"
446
447    report = [f"# {title}", ""]
448
449    tests_passed = tests_run - tests_skipped - tests_failed
450
451    def plural(num_tests):
452        return "test" if num_tests == 1 else "tests"
453
454    if tests_passed:
455        report.append(f"* {tests_passed} {plural(tests_passed)} passed")
456    if tests_skipped:
457        report.append(f"* {tests_skipped} {plural(tests_skipped)} skipped")
458    if tests_failed:
459        report.append(f"* {tests_failed} {plural(tests_failed)} failed")
460
461    if buildkite_info is not None:
462        log_url = (
463            "https://buildkite.com/organizations/{BUILDKITE_ORGANIZATION_SLUG}/"
464            "pipelines/{BUILDKITE_PIPELINE_SLUG}/builds/{BUILDKITE_BUILD_NUMBER}/"
465            "jobs/{BUILDKITE_JOB_ID}/download.txt".format(**buildkite_info)
466        )
467        download_text = f"[Download]({log_url})"
468    else:
469        download_text = "Download"
470
471    if not list_failures:
472        report.extend(
473            [
474                "",
475                "Failed tests and their output was too large to report. "
476                f"{download_text} the build's log file to see the details.",
477            ]
478        )
479    elif failures:
480        report.extend(["", "## Failed Tests", "(click to see output)"])
481
482        for testsuite_name, failures in failures.items():
483            report.extend(["", f"### {testsuite_name}"])
484            for name, output in failures:
485                report.extend(
486                    [
487                        "<details>",
488                        f"<summary>{name}</summary>",
489                        "",
490                        "```",
491                        output,
492                        "```",
493                        "</details>",
494                    ]
495                )
496    elif return_code != 0:
497        # No tests failed but the build was in a failed state. Bring this to the user's
498        # attention.
499        report.extend(
500            [
501                "",
502                "All tests passed but another part of the build **failed**.",
503                "",
504                f"{download_text} the build's log file to see the details.",
505            ]
506        )
507
508    report = "\n".join(report)
509    if len(report.encode("utf-8")) > size_limit:
510        return _generate_report(
511            title,
512            return_code,
513            junit_objects,
514            size_limit,
515            list_failures=False,
516            buildkite_info=buildkite_info,
517        )
518
519    return report, style
520
521
522def generate_report(title, return_code, junit_files, buildkite_info):
523    return _generate_report(
524        title,
525        return_code,
526        [JUnitXml.fromfile(p) for p in junit_files],
527        buildkite_info=buildkite_info,
528    )
529
530
531if __name__ == "__main__":
532    parser = argparse.ArgumentParser()
533    parser.add_argument(
534        "title", help="Title of the test report, without Markdown formatting."
535    )
536    parser.add_argument("context", help="Annotation context to write to.")
537    parser.add_argument("return_code", help="The build's return code.", type=int)
538    parser.add_argument("junit_files", help="Paths to JUnit report files.", nargs="*")
539    args = parser.parse_args()
540
541    # All of these are required to build a link to download the log file.
542    env_var_names = [
543        "BUILDKITE_ORGANIZATION_SLUG",
544        "BUILDKITE_PIPELINE_SLUG",
545        "BUILDKITE_BUILD_NUMBER",
546        "BUILDKITE_JOB_ID",
547    ]
548    buildkite_info = {k: v for k, v in os.environ.items() if k in env_var_names}
549    if len(buildkite_info) != len(env_var_names):
550        buildkite_info = None
551
552    report, style = generate_report(
553        args.title, args.return_code, args.junit_files, buildkite_info
554    )
555
556    if report:
557        p = subprocess.Popen(
558            [
559                "buildkite-agent",
560                "annotate",
561                "--context",
562                args.context,
563                "--style",
564                style,
565            ],
566            stdin=subprocess.PIPE,
567            stderr=subprocess.PIPE,
568            universal_newlines=True,
569        )
570
571        # The report can be larger than the buffer for command arguments so we send
572        # it over stdin instead.
573        _, err = p.communicate(input=report)
574        if p.returncode:
575            raise RuntimeError(f"Failed to send report to buildkite-agent:\n{err}")
576