4 # Add arbritrary commands to a GitLab CI compatible (JUnit) test report
5 # SPDX-License-Identifier: MIT
8 # wrap-ci-test --file foo.xml --suite "Suite" --case "Name" --command "command"
9 # wrap-ci-test --file foo.xml --suite "Suite" --case "Name" command [args] ...
11 # This script runs a command and adds it to a JUnit report which can then
12 # be used as a GitLab CI test report:
14 # https://docs.gitlab.com/ee/ci/testing/unit_test_reports.html
16 # Commands can be specified with the "--command" flag, which will run
17 # in a subshell, or as a list of extra arguments, which will be run
20 # Command output will be "teed". Scrubbed versions will be added to the
21 # report and unmodified versions will be printed to stdout and stderr.
23 # If the command exit code is nonzero it will be added to the report
26 # The wrapper will return the command exit code.
28 # JUnit report information can be found at
29 # https://github.com/testmoapp/junitxml
30 # https://www.ibm.com/docs/en/developer-for-zos/14.2?topic=formats-junit-xml-format
40 import xml
.etree
.ElementTree
as ET
44 parser
= argparse
.ArgumentParser(usage
='\n %(prog)s [options] --command "command"\n %(prog)s [options] command ...')
45 parser
.add_argument('--file', required
=True, type=pathlib
.Path
, help='The JUnit-compatible XML file')
46 parser
.add_argument('--suite', required
=True, help='The testsuite_el name')
47 parser
.add_argument('--case', required
=True, help='The testcase name')
48 parser
.add_argument('--command', help='The command to run if no extra arguments are provided')
50 args
, command_list
= parser
.parse_known_args()
52 if (args
.command
and len(command_list
) > 0) or (args
.command
is None and len(command_list
) == 0):
53 sys
.stderr
.write('Error: The command must be provided via the --command flag or extra arguments.\n')
57 tree
= ET
.parse(args
.file)
58 testsuites_el
= tree
.getroot()
59 except FileNotFoundError
:
60 testsuites_el
= ET
.Element('testsuites')
61 tree
= ET
.ElementTree(testsuites_el
)
63 sys
.stderr
.write(f
'Error: {args.file} is invalid.\n')
66 suites_time
= float(testsuites_el
.get('time', 0.0))
67 suites_tests
= int(testsuites_el
.get('tests', 0)) + 1
68 suites_failures
= int(testsuites_el
.get('failures', 0))
70 testsuite_el
= testsuites_el
.find(f
'./testsuite[@name="{args.suite}"]')
71 if testsuite_el
is None:
72 testsuite_el
= ET
.Element('testsuite', attrib
={'name': args
.suite
})
73 testsuites_el
.append(testsuite_el
)
75 suite_time
= float(testsuite_el
.get('time', 0.0))
76 suite_tests
= int(testsuite_el
.get('tests', 0)) + 1
77 suite_failures
= int(testsuite_el
.get('failures', 0))
79 testcase_el
= ET
.Element('testcase', attrib
={'name': args
.case
})
80 testsuite_el
.append(testcase_el
)
83 proc_args
= args
.command
86 proc_args
= command_list
89 start_time
= time
.perf_counter()
90 proc
= subprocess
.run(proc_args
, shell
=in_shell
, encoding
='UTF-8', errors
='replace', capture_output
=True)
91 case_time
= time
.perf_counter() - start_time
93 testcase_el
.set('time', f
'{case_time}')
94 testsuite_el
.set('time', f
'{suite_time + case_time}')
95 testsuites_el
.set('time', f
'{suites_time + case_time}')
97 # XXX Try to interleave them?
98 sys
.stdout
.write(proc
.stdout
)
99 sys
.stderr
.write(proc
.stderr
)
101 # Remove ANSI control sequences and escape other invalid characters
102 # https://stackoverflow.com/a/14693789/82195
103 ansi_seq_re
= re
.compile(r
'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
104 scrubbed_stdout
= html
.escape(ansi_seq_re
.sub('', proc
.stdout
), quote
=False)
105 scrubbed_stderr
= html
.escape(ansi_seq_re
.sub('', proc
.stderr
), quote
=False)
107 if proc
.returncode
!= 0:
108 failure_el
= ET
.Element('failure')
109 failure_el
.text
= f
'{scrubbed_stdout}{scrubbed_stderr}'
110 testcase_el
.append(failure_el
)
111 testsuite_el
.set('failures', f
'{suite_failures + 1}')
112 testsuites_el
.set('failures', f
'{suites_failures + 1}')
114 system_out_el
= ET
.Element('system-out')
115 system_out_el
.text
= f
'{scrubbed_stdout}'
116 testcase_el
.append(system_out_el
)
117 system_err_el
= ET
.Element('system-err')
118 system_err_el
.text
= f
'{scrubbed_stderr}'
119 testcase_el
.append(system_err_el
)
121 testsuite_el
.set('tests', f
'{suite_tests}')
122 testsuites_el
.set('tests', f
'{suites_tests}')
124 tree
.write(args
.file, encoding
='UTF-8', xml_declaration
=True)
126 return proc
.returncode
128 if __name__
== '__main__':