130 lines
4.7 KiB
Python
130 lines
4.7 KiB
Python
|
#!/usr/bin/env python3
|
||
|
|
||
|
#
|
||
|
# Add arbritrary commands to a GitLab CI compatible (JUnit) test report
|
||
|
# SPDX-License-Identifier: MIT
|
||
|
#
|
||
|
# Usage:
|
||
|
# wrap-ci-test --file foo.xml --suite "Suite" --case "Name" --command "command"
|
||
|
# wrap-ci-test --file foo.xml --suite "Suite" --case "Name" command [args] ...
|
||
|
|
||
|
# This script runs a command and adds it to a JUnit report which can then
|
||
|
# be used as a GitLab CI test report:
|
||
|
#
|
||
|
# https://docs.gitlab.com/ee/ci/testing/unit_test_reports.html
|
||
|
#
|
||
|
# Commands can be specified with the "--command" flag, which will run
|
||
|
# in a subshell, or as a list of extra arguments, which will be run
|
||
|
# directly.
|
||
|
#
|
||
|
# Command output will be "teed". Scrubbed versions will be added to the
|
||
|
# report and unmodified versions will be printed to stdout and stderr.
|
||
|
#
|
||
|
# If the command exit code is nonzero it will be added to the report
|
||
|
# as a failure.
|
||
|
#
|
||
|
# The wrapper will return the command exit code.
|
||
|
|
||
|
# JUnit report information can be found at
|
||
|
# https://github.com/testmoapp/junitxml
|
||
|
# https://www.ibm.com/docs/en/developer-for-zos/14.2?topic=formats-junit-xml-format
|
||
|
|
||
|
|
||
|
import argparse
|
||
|
import html
|
||
|
import time
|
||
|
import pathlib
|
||
|
import re
|
||
|
import subprocess
|
||
|
import sys
|
||
|
import xml.etree.ElementTree as ET
|
||
|
|
||
|
|
||
|
def main():
|
||
|
parser = argparse.ArgumentParser(usage='\n %(prog)s [options] --command "command"\n %(prog)s [options] command ...')
|
||
|
parser.add_argument('--file', required=True, type=pathlib.Path, help='The JUnit-compatible XML file')
|
||
|
parser.add_argument('--suite', required=True, help='The testsuite_el name')
|
||
|
parser.add_argument('--case', required=True, help='The testcase name')
|
||
|
parser.add_argument('--command', help='The command to run if no extra arguments are provided')
|
||
|
|
||
|
args, command_list = parser.parse_known_args()
|
||
|
|
||
|
if (args.command and len(command_list) > 0) or (args.command is None and len(command_list) == 0):
|
||
|
sys.stderr.write('Error: The command must be provided via the --command flag or extra arguments.\n')
|
||
|
sys.exit(1)
|
||
|
|
||
|
try:
|
||
|
tree = ET.parse(args.file)
|
||
|
testsuites_el = tree.getroot()
|
||
|
except FileNotFoundError:
|
||
|
testsuites_el = ET.Element('testsuites')
|
||
|
tree = ET.ElementTree(testsuites_el)
|
||
|
except ET.ParseError:
|
||
|
sys.stderr.write(f'Error: {args.file} is invalid.\n')
|
||
|
sys.exit(1)
|
||
|
|
||
|
suites_time = float(testsuites_el.get('time', 0.0))
|
||
|
suites_tests = int(testsuites_el.get('tests', 0)) + 1
|
||
|
suites_failures = int(testsuites_el.get('failures', 0))
|
||
|
|
||
|
testsuite_el = testsuites_el.find(f'./testsuite[@name="{args.suite}"]')
|
||
|
if testsuite_el is None:
|
||
|
testsuite_el = ET.Element('testsuite', attrib={'name': args.suite})
|
||
|
testsuites_el.append(testsuite_el)
|
||
|
|
||
|
suite_time = float(testsuite_el.get('time', 0.0))
|
||
|
suite_tests = int(testsuite_el.get('tests', 0)) + 1
|
||
|
suite_failures = int(testsuite_el.get('failures', 0))
|
||
|
|
||
|
testcase_el = ET.Element('testcase', attrib={'name': args.case})
|
||
|
testsuite_el.append(testcase_el)
|
||
|
|
||
|
if args.command:
|
||
|
proc_args = args.command
|
||
|
in_shell = True
|
||
|
else:
|
||
|
proc_args = command_list
|
||
|
in_shell = False
|
||
|
|
||
|
start_time = time.perf_counter()
|
||
|
proc = subprocess.run(proc_args, shell=in_shell, encoding='UTF-8', errors='replace', capture_output=True)
|
||
|
case_time = time.perf_counter() - start_time
|
||
|
|
||
|
testcase_el.set('time', f'{case_time}')
|
||
|
testsuite_el.set('time', f'{suite_time + case_time}')
|
||
|
testsuites_el.set('time', f'{suites_time + case_time}')
|
||
|
|
||
|
# XXX Try to interleave them?
|
||
|
sys.stdout.write(proc.stdout)
|
||
|
sys.stderr.write(proc.stderr)
|
||
|
|
||
|
# Remove ANSI control sequences and escape other invalid characters
|
||
|
# https://stackoverflow.com/a/14693789/82195
|
||
|
ansi_seq_re = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
|
||
|
scrubbed_stdout = html.escape(ansi_seq_re.sub('', proc.stdout), quote=False)
|
||
|
scrubbed_stderr = html.escape(ansi_seq_re.sub('', proc.stderr), quote=False)
|
||
|
|
||
|
if proc.returncode != 0:
|
||
|
failure_el = ET.Element('failure')
|
||
|
failure_el.text = f'{scrubbed_stdout}{scrubbed_stderr}'
|
||
|
testcase_el.append(failure_el)
|
||
|
testsuite_el.set('failures', f'{suite_failures + 1}')
|
||
|
testsuites_el.set('failures', f'{suites_failures + 1}')
|
||
|
else:
|
||
|
system_out_el = ET.Element('system-out')
|
||
|
system_out_el.text = f'{scrubbed_stdout}'
|
||
|
testcase_el.append(system_out_el)
|
||
|
system_err_el = ET.Element('system-err')
|
||
|
system_err_el.text = f'{scrubbed_stderr}'
|
||
|
testcase_el.append(system_err_el)
|
||
|
|
||
|
testsuite_el.set('tests', f'{suite_tests}')
|
||
|
testsuites_el.set('tests', f'{suites_tests}')
|
||
|
|
||
|
tree.write(args.file, encoding='UTF-8', xml_declaration=True)
|
||
|
|
||
|
return proc.returncode
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
sys.exit(main())
|