1#!/usr/bin/env python
2#
3# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
4#
5# SPDX-License-Identifier: MPL-2.0
6#
7# Convert automake .trs files into JUnit format suitable for Gitlab
8
9import argparse
10import os
11import sys
12from xml.etree import ElementTree
13from xml.etree.ElementTree import Element
14from xml.etree.ElementTree import SubElement
15
16
17# getting explicit encoding specification right for Python 2/3 would be messy,
18# so let's hope for the best
19def read_whole_text(filename):
20    with open(filename) as inf:  # pylint: disable-msg=unspecified-encoding
21        return inf.read().strip()
22
23
24def read_trs_result(filename):
25    result = None
26    with open(filename, "r") as trs:  # pylint: disable-msg=unspecified-encoding
27        for line in trs:
28            items = line.split()
29            if len(items) < 2:
30                raise ValueError("unsupported line in trs file", filename, line)
31            if items[0] != (":test-result:"):
32                continue
33            if result is not None:
34                raise NotImplementedError("double :test-result:", filename)
35            result = items[1].upper()
36
37    if result is None:
38        raise ValueError(":test-result: not found", filename)
39
40    return result
41
42
43def find_test_relative_path(source_dir, in_path):
44    """Return {in_path}.c if it exists, with fallback to {in_path}"""
45    candidates_relative = [in_path + ".c", in_path]
46    for relative in candidates_relative:
47        absolute = os.path.join(source_dir, relative)
48        if os.path.exists(absolute):
49            return relative
50    raise KeyError
51
52
53def err_out(exception):
54    raise exception
55
56
57def walk_trss(source_dir):
58    for cur_dir, _dirs, files in os.walk(source_dir, onerror=err_out):
59        for filename in files:
60            if not filename.endswith(".trs"):
61                continue
62
63            filename_prefix = filename[: -len(".trs")]
64            log_name = filename_prefix + ".log"
65            full_trs_path = os.path.join(cur_dir, filename)
66            full_log_path = os.path.join(cur_dir, log_name)
67            sub_dir = os.path.relpath(cur_dir, source_dir)
68            test_name = os.path.join(sub_dir, filename_prefix)
69
70            t = {
71                "name": test_name,
72                "full_log_path": full_log_path,
73                "rel_log_path": os.path.relpath(full_log_path, source_dir),
74            }
75            t["result"] = read_trs_result(full_trs_path)
76
77            # try to find dir/file path for a clickable link
78            try:
79                t["rel_file_path"] = find_test_relative_path(source_dir, test_name)
80            except KeyError:
81                pass  # no existing path found
82
83            yield t
84
85
86def append_testcase(testsuite, t):
87    # attributes taken from
88    # https://gitlab.com/gitlab-org/gitlab-foss/-/blob/master/lib/gitlab/ci/parsers/test/junit.rb
89    attrs = {"name": t["name"]}
90    if "rel_file_path" in t:
91        attrs["file"] = t["rel_file_path"]
92
93    testcase = SubElement(testsuite, "testcase", attrs)
94
95    # Gitlab accepts only [[ATTACHMENT| links for system-out, not raw text
96    s = SubElement(testcase, "system-out")
97    s.text = "[[ATTACHMENT|" + t["rel_log_path"] + "]]"
98    if t["result"].lower() == "pass":
99        return
100
101    # Gitlab shows output only for failed or skipped tests
102    if t["result"].lower() == "skip":
103        err = SubElement(testcase, "skipped")
104    else:
105        err = SubElement(testcase, "failure")
106    err.text = read_whole_text(t["full_log_path"])
107
108
109def gen_junit(results):
110    testsuites = Element("testsuites")
111    testsuite = SubElement(testsuites, "testsuite")
112    for test in results:
113        append_testcase(testsuite, test)
114    return testsuites
115
116
117def check_directory(path):
118    try:
119        os.listdir(path)
120        return path
121    except OSError as ex:
122        msg = "Path {} cannot be listed as a directory: {}".format(path, ex)
123        raise argparse.ArgumentTypeError(msg)
124
125
126def main():
127    parser = argparse.ArgumentParser(
128        description="Recursively search for .trs + .log files and compile "
129        "them into JUnit XML suitable for Gitlab. Paths in the "
130        "XML are relative to the specified top directory."
131    )
132    parser.add_argument(
133        "top_directory",
134        type=check_directory,
135        help="root directory where to start scanning for .trs files",
136    )
137    args = parser.parse_args()
138    junit = gen_junit(walk_trss(args.top_directory))
139
140    # encode results into file format, on Python 3 it produces bytes
141    xml = ElementTree.tostring(junit, "utf-8")
142    # use stdout as a binary file object, Python2/3 compatibility
143    output = getattr(sys.stdout, "buffer", sys.stdout)
144    output.write(xml)
145
146
147if __name__ == "__main__":
148    main()
149