wireshark/tools/wrap-ci-test.py
Gerald Combs cd785cd8e5 tools+GitLab CI: Add a test report wrapper
Add wrap-test.py, which runs a CI test command and adds it to a JUnit
report which can be consumed by GitLab CI.

Have the script "tee" output so that errors and warnings show up in CI
job output and in the merge request test report.
2024-11-30 17:29:01 +00:00

130 lines
4.7 KiB
Python
Executable File

#!/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())