Skip to content

Commit 1011a12

Browse files
pbaughmannuclearsandwich
authored andcommitted
Add runner option to ament_add_test (#174)
* ament_cmake allow speficiation of a different test runner - By default, still uses run_test.py - Example use case: ament_cmake_ros can use a test runner that sets a ROS_DOMAIN_ID Signed-off-by: Pete Baughman <[email protected]> * ament_cmake move run_test.py to a python module - This should let us see the history Signed-off-by: Pete Baughman <[email protected]> * ament_cmake refactor run_test.py into an importable python module - Adds an ament_cmake_test python package Signed-off-by: Pete Baughman <[email protected]> Signed-off-by: Steven! Ragnarök <[email protected]>
1 parent 9141c28 commit 1011a12

File tree

5 files changed

+364
-338
lines changed

5 files changed

+364
-338
lines changed

ament_cmake_test/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ cmake_minimum_required(VERSION 3.5)
33
project(ament_cmake_test NONE)
44

55
find_package(ament_cmake_core REQUIRED)
6+
find_package(ament_cmake_python REQUIRED)
7+
8+
ament_python_install_package(${PROJECT_NAME})
69

710
ament_package(
811
CONFIG_EXTRAS "ament_cmake_test-extras.cmake"
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
# Copyright 2014-2015 Open Source Robotics Foundation, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import argparse
16+
import codecs
17+
import errno
18+
import os
19+
import re
20+
import subprocess
21+
import sys
22+
from xml.etree.ElementTree import ElementTree
23+
from xml.etree.ElementTree import ParseError
24+
from xml.sax.saxutils import quoteattr
25+
26+
27+
def separate_env_vars(env_str, env_argument_name, parser):
28+
try:
29+
index = env_str.index('=')
30+
except ValueError:
31+
parser.error("--%s argument '%s' contains no equal sign" % (env_argument_name, env_str))
32+
key = env_str[0:index]
33+
value = env_str[index + 1:]
34+
return key, value
35+
36+
37+
def main(argv=sys.argv[1:]):
38+
parser = argparse.ArgumentParser(
39+
description='Run the test command passed as an argument and ensures'
40+
'that the expected result file is generated.')
41+
parser.add_argument(
42+
'result_file', help='The path to the xunit result file')
43+
parser.add_argument(
44+
'--package-name',
45+
help="The package name to be used as a prefix for the 'classname' "
46+
'attributes in gtest result files')
47+
parser.add_argument(
48+
'--command',
49+
nargs='+',
50+
help='The test command to execute. '
51+
'It must be passed after other arguments since it collects all '
52+
'following options.')
53+
parser.add_argument(
54+
'--env',
55+
nargs='+',
56+
help='Extra environment variables to set when running, e.g. FOO=foo BAR=bar')
57+
parser.add_argument(
58+
'--append-env',
59+
nargs='+',
60+
help='Extra environment variables to append, or set, when running, e.g. FOO=foo BAR=bar')
61+
parser.add_argument(
62+
'--output-file',
63+
help='The path to the output log file')
64+
parser.add_argument(
65+
'--generate-result-on-success',
66+
action='store_true',
67+
default=False,
68+
help='Generate a result file if the command returns with code zero')
69+
parser.add_argument(
70+
'--skip-test',
71+
action='store_true',
72+
default=False,
73+
help='Skip the test')
74+
75+
if '--command' in argv:
76+
index = argv.index('--command')
77+
argv, command = argv[0:index + 1] + ['dummy'], argv[index + 1:]
78+
args = parser.parse_args(argv)
79+
args.command = command
80+
81+
# if result file exists remove it before test execution
82+
if os.path.exists(args.result_file):
83+
os.remove(args.result_file)
84+
85+
# create folder if necessary
86+
if not os.path.exists(os.path.dirname(args.result_file)):
87+
try:
88+
os.makedirs(os.path.dirname(args.result_file))
89+
except OSError as e:
90+
# catch case where folder has been created in the mean time
91+
if e.errno != errno.EEXIST:
92+
raise
93+
94+
if args.skip_test:
95+
# generate a skipped test result file
96+
skipped_result_file = _generate_result(args.result_file, skip=True)
97+
with open(args.result_file, 'w') as h:
98+
h.write(skipped_result_file)
99+
return 0
100+
101+
# generate result file with one failed test
102+
# in case the command segfaults or timeouts and does not generate one
103+
failure_result_file = _generate_result(
104+
args.result_file,
105+
failure_message='The test did not generate a result file.')
106+
with open(args.result_file, 'w') as h:
107+
h.write(failure_result_file)
108+
109+
# collect output / exception to generate more detailed result file
110+
# if the command fails to generate it
111+
output_handle = None
112+
if args.output_file:
113+
output_path = os.path.dirname(args.output_file)
114+
if not os.path.exists(output_path):
115+
os.makedirs(output_path)
116+
output_handle = open(args.output_file, 'wb')
117+
118+
try:
119+
return _run_test(parser, args, failure_result_file, output_handle)
120+
finally:
121+
if output_handle:
122+
output_handle.close()
123+
124+
125+
def _run_test(parser, args, failure_result_file, output_handle):
126+
output = ''
127+
128+
def log(msg, **kwargs):
129+
print(msg, **kwargs)
130+
if output_handle:
131+
output_handle.write((msg + '\n').encode())
132+
output_handle.flush()
133+
134+
env = None
135+
if args.env or args.append_env:
136+
env = dict(os.environ)
137+
if args.env:
138+
log('-- run_test.py: extra environment variables:')
139+
previous_key = None
140+
updated_env_keys = set()
141+
for env_str in args.env:
142+
# if CMake has split a single value containing semicolons
143+
# into multiple arguments they are put back together here
144+
if previous_key and '=' not in env_str:
145+
key = previous_key
146+
value = env[key] + ';' + env_str
147+
else:
148+
key, value = separate_env_vars(env_str, 'env', parser)
149+
env[key] = value
150+
updated_env_keys.add(key)
151+
previous_key = key
152+
for key in updated_env_keys:
153+
log(' - {0}={1}'.format(key, env[key]))
154+
if args.append_env:
155+
log('-- run_test.py: extra environment variables to append:')
156+
previous_key = None
157+
for env_str in args.append_env:
158+
# if CMake has split a single value containing semicolons
159+
# into multiple arguments they are put back together here
160+
if previous_key and '=' not in env_str:
161+
key = previous_key
162+
value = env[key] + ';' + env_str
163+
log(' - {0}+={1}'.format(key, env_str))
164+
else:
165+
key, value = separate_env_vars(env_str, 'append-env', parser)
166+
log(' - {0}+={1}'.format(key, value))
167+
if key not in env:
168+
env[key] = ''
169+
if not env[key].endswith(os.pathsep):
170+
env[key] += os.pathsep
171+
env[key] += value
172+
previous_key = key
173+
174+
log("-- run_test.py: invoking following command in '%s':\n - %s" %
175+
(os.getcwd(), ' '.join(args.command)))
176+
if output_handle:
177+
output_handle.write('\n'.encode())
178+
output_handle.flush()
179+
180+
try:
181+
proc = subprocess.Popen(
182+
args.command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
183+
env=env)
184+
while True:
185+
line = proc.stdout.readline()
186+
if not line:
187+
break
188+
decoded_line = line.decode()
189+
print(decoded_line, end='')
190+
output += decoded_line
191+
if output_handle:
192+
output_handle.write(line)
193+
output_handle.flush()
194+
proc.wait()
195+
rc = proc.returncode
196+
if output_handle:
197+
# separate progress of this script from subprocess output
198+
output_handle.write('\n\n'.encode())
199+
log('-- run_test.py: return code ' + str(rc), file=sys.stderr if rc else sys.stdout)
200+
except Exception as e:
201+
if output_handle:
202+
# separate subprocess output from progress of this script
203+
output_handle.write('\n\n'.encode())
204+
log('-- run_test.py: invocation failed: ' + str(e), file=sys.stderr)
205+
output += str(e)
206+
rc = 1
207+
208+
if not rc and args.generate_result_on_success:
209+
# generate result file with one passed test
210+
# if it was expected that no result file was generated
211+
# and the command returned with code zero
212+
log("-- run_test.py: generate result file '%s' with successful test" % args.result_file)
213+
success_result_file = _generate_result(args.result_file)
214+
with open(args.result_file, 'w') as h:
215+
h.write(success_result_file)
216+
217+
elif os.path.exists(args.result_file):
218+
# check if content of result file has actually changed
219+
with open(args.result_file, 'r') as h:
220+
content = h.read()
221+
222+
if content == failure_result_file:
223+
log("-- run_test.py: generate result file '%s' with failed test" % args.result_file,
224+
file=sys.stderr)
225+
# regenerate result file to include output / exception of the invoked command
226+
failure_result_file = _generate_result(
227+
args.result_file,
228+
failure_message='The test did not generate a result file:\n\n' + output)
229+
with open(args.result_file, 'w') as h:
230+
h.write(failure_result_file)
231+
else:
232+
# prefix classname attributes
233+
if args.result_file.endswith('.gtest.xml') and args.package_name:
234+
prefix = ' classname="'
235+
pattern = '%s(?!%s)' % (prefix, args.package_name)
236+
new_content = re.sub(
237+
pattern, prefix + args.package_name + '.', content)
238+
if new_content != content:
239+
log(
240+
'-- run_test.py: inject classname prefix into gtest '
241+
"result file '%s'" % args.result_file)
242+
with open(args.result_file, 'w') as h:
243+
h.write(new_content)
244+
245+
log("-- run_test.py: verify result file '%s'" % args.result_file)
246+
# if result file exists ensure that it contains valid xml
247+
# unit test suites are not good about screening out
248+
# illegal unicode characters
249+
tree = None
250+
try:
251+
tree = ElementTree(None, args.result_file)
252+
except ParseError as e:
253+
modified = _tidy_xml(args.result_file)
254+
if not modified:
255+
log("Invalid XML in result file '%s': %s" %
256+
(args.result_file, str(e)), file=sys.stderr)
257+
else:
258+
try:
259+
tree = ElementTree(None, args.result_file)
260+
except ParseError as e:
261+
log("Invalid XML in result file '%s' (even after trying to tidy it): %s" %
262+
(args.result_file, str(e)), file=sys.stderr)
263+
264+
if not tree:
265+
# set error code when result file is not parsable
266+
rc = 1
267+
else:
268+
# set error code when result file contains errors or failures
269+
root = tree.getroot()
270+
num_errors = int(root.attrib.get('errors', 0))
271+
num_failures = int(root.attrib.get('failures', 0))
272+
if num_errors or num_failures:
273+
rc = 1
274+
275+
# ensure that a result file exists at the end
276+
if not rc and not os.path.exists(args.result_file):
277+
log('-- run_test.py: override return code since no result file was '
278+
'generated', file=sys.stderr)
279+
rc = 1
280+
281+
return rc
282+
283+
284+
def _generate_result(result_file, *, failure_message=None, skip=False):
285+
# the generated result file must be readable
286+
# by any of the Jenkins test result report publishers
287+
pkgname = os.path.basename(os.path.dirname(result_file))
288+
testname = os.path.splitext(os.path.basename(result_file))[0]
289+
failure_message = '<failure message=%s/>' % quoteattr(failure_message) \
290+
if failure_message else ''
291+
skipped_message = \
292+
'<skipped type="skip" message="">![CDATA[Test Skipped by developer]]</skipped>' \
293+
if skip else ''
294+
return """<?xml version="1.0" encoding="UTF-8"?>
295+
<testsuite name="%s" tests="1" failures="%d" time="0" errors="0" skip="%d">
296+
<testcase classname="%s" name="%s.missing_result" status="%s" time="0">
297+
%s%s
298+
</testcase>
299+
</testsuite>\n""" % \
300+
(
301+
pkgname,
302+
1 if failure_message else 0,
303+
1 if skip else 0,
304+
pkgname, testname,
305+
'notrun' if skip else 'run',
306+
failure_message, skipped_message
307+
)
308+
309+
310+
def _tidy_xml(filename):
311+
assert os.path.isfile(filename)
312+
313+
# try reading utf-8 first then iso
314+
# this is ugly but the files in question do not declare a unicode type
315+
data = None
316+
for encoding in ['utf-8', 'iso8859-1']:
317+
f = None
318+
try:
319+
f = codecs.open(filename, 'r', encoding)
320+
data = f.read()
321+
break
322+
except ValueError:
323+
continue
324+
finally:
325+
if f:
326+
f.close()
327+
328+
if data is None:
329+
return False
330+
331+
try:
332+
char = unichr
333+
except NameError:
334+
char = chr
335+
re_xml_illegal = (
336+
'([%s-%s%s-%s%s-%s%s-%s])' +
337+
'|' +
338+
'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])') % \
339+
(char(0x0000), char(0x0008), char(0x000b), char(0x000c),
340+
char(0x000e), char(0x001f), char(0xfffe), char(0xffff),
341+
char(0xd800), char(0xdbff), char(0xdc00), char(0xdfff),
342+
char(0xd800), char(0xdbff), char(0xdc00), char(0xdfff),
343+
char(0xd800), char(0xdbff), char(0xdc00), char(0xdfff))
344+
safe_xml_regex = re.compile(re_xml_illegal)
345+
346+
for match in safe_xml_regex.finditer(data):
347+
data = data[:match.start()] + '?' + data[match.end():]
348+
349+
with open(filename, 'w') as h:
350+
h.write(data)
351+
return True

ament_cmake_test/cmake/ament_add_test.cmake

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
# :type COMMAND: list of strings
2626
# :param OUTPUT_FILE: the path of the file to pipe the output to
2727
# :type OUTPUT_FILE: string
28+
# :param RUNNER: the path to the test runner script (default: run_test.py).
29+
# :type RUNNER: string
2830
# :param TIMEOUT: the test timeout in seconds, default: 60
2931
# :type TIMEOUT: integer
3032
# :param WORKING_DIRECTORY: the working directory for invoking the
@@ -50,7 +52,7 @@
5052
function(ament_add_test testname)
5153
cmake_parse_arguments(ARG
5254
"GENERATE_RESULT_FOR_RETURN_CODE_ZERO;SKIP_TEST"
53-
"OUTPUT_FILE;RESULT_FILE;TIMEOUT;WORKING_DIRECTORY"
55+
"OUTPUT_FILE;RESULT_FILE;RUNNER;TIMEOUT;WORKING_DIRECTORY"
5456
"APPEND_ENV;APPEND_LIBRARY_DIRS;COMMAND;ENV"
5557
${ARGN})
5658
if(ARG_UNPARSED_ARGUMENTS)
@@ -64,6 +66,9 @@ function(ament_add_test testname)
6466
if(NOT ARG_RESULT_FILE)
6567
set(ARG_RESULT_FILE "${AMENT_TEST_RESULTS_DIR}/${PROJECT_NAME}/${testname}.xml")
6668
endif()
69+
if(NOT ARG_RUNNER)
70+
set(ARG_RUNNER "${ament_cmake_test_DIR}/run_test.py")
71+
endif()
6772
if(NOT ARG_TIMEOUT)
6873
set(ARG_TIMEOUT 60)
6974
endif()
@@ -76,7 +81,7 @@ function(ament_add_test testname)
7681
endif()
7782

7883
# wrap command with run_test script to ensure test result generation
79-
set(cmd_wrapper "${PYTHON_EXECUTABLE}" "-u" "${ament_cmake_test_DIR}/run_test.py"
84+
set(cmd_wrapper "${PYTHON_EXECUTABLE}" "-u" "${ARG_RUNNER}"
8085
"${ARG_RESULT_FILE}"
8186
"--package-name" "${PROJECT_NAME}")
8287
if(ARG_SKIP_TEST)

0 commit comments

Comments
 (0)