From 3e611b7fb79e283b1f8a25b34b9aaddd0a512164 Mon Sep 17 00:00:00 2001 From: Ken Conley Date: Wed, 27 Oct 2010 01:14:39 +0000 Subject: [PATCH] creating now rosunit package as a low-level library for rostest that is also compliant with the ros stack --- tools/rosunit/CMakeLists.txt | 30 +++ tools/rosunit/Makefile | 1 + tools/rosunit/manifest.xml | 15 ++ tools/rosunit/rosdoc.yaml | 1 + tools/rosunit/src/rosunit/junitxml.py | 358 ++++++++++++++++++++++++++ 5 files changed, 405 insertions(+) create mode 100644 tools/rosunit/CMakeLists.txt create mode 100644 tools/rosunit/Makefile create mode 100644 tools/rosunit/manifest.xml create mode 100644 tools/rosunit/rosdoc.yaml create mode 100644 tools/rosunit/src/rosunit/junitxml.py diff --git a/tools/rosunit/CMakeLists.txt b/tools/rosunit/CMakeLists.txt new file mode 100644 index 00000000..f8f1c9cc --- /dev/null +++ b/tools/rosunit/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 2.4.6) +include($ENV{ROS_ROOT}/core/rosbuild/rosbuild.cmake) + +# Set the build type. Options are: +# Coverage : w/ debug symbols, w/o optimization, w/ code-coverage +# Debug : w/ debug symbols, w/o optimization +# Release : w/o debug symbols, w/ optimization +# RelWithDebInfo : w/ debug symbols, w/ optimization +# MinSizeRel : w/o debug symbols, w/ optimization, stripped binaries +#set(ROS_BUILD_TYPE RelWithDebInfo) + +rosbuild_init() + +#set the default path for built executables to the "bin" directory +set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) +#set the default path for built libraries to the "lib" directory +set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/lib) + +#uncomment if you have defined messages +#rosbuild_genmsg() +#uncomment if you have defined services +#rosbuild_gensrv() + +#common commands for building c++ executables and libraries +#rosbuild_add_library(${PROJECT_NAME} src/example.cpp) +#target_link_libraries(${PROJECT_NAME} another_library) +#rosbuild_add_boost_directories() +#rosbuild_link_boost(${PROJECT_NAME} thread) +#rosbuild_add_executable(example examples/example.cpp) +#target_link_libraries(example ${PROJECT_NAME}) diff --git a/tools/rosunit/Makefile b/tools/rosunit/Makefile new file mode 100644 index 00000000..b75b928f --- /dev/null +++ b/tools/rosunit/Makefile @@ -0,0 +1 @@ +include $(shell rospack find mk)/cmake.mk \ No newline at end of file diff --git a/tools/rosunit/manifest.xml b/tools/rosunit/manifest.xml new file mode 100644 index 00000000..d735725c --- /dev/null +++ b/tools/rosunit/manifest.xml @@ -0,0 +1,15 @@ + + + + Unit-testing package for ROS. This is a lower-level library for rostest and handles unit tests, whereas rostest handles integration tests. + + + Ken Conley + BSD + + http://ros.org/wiki/rosunit + + + + + diff --git a/tools/rosunit/rosdoc.yaml b/tools/rosunit/rosdoc.yaml new file mode 100644 index 00000000..36870d2d --- /dev/null +++ b/tools/rosunit/rosdoc.yaml @@ -0,0 +1 @@ + - builder: epydoc \ No newline at end of file diff --git a/tools/rosunit/src/rosunit/junitxml.py b/tools/rosunit/src/rosunit/junitxml.py new file mode 100644 index 00000000..21ce4b19 --- /dev/null +++ b/tools/rosunit/src/rosunit/junitxml.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python +# Software License Agreement (BSD License) +# +# Copyright (c) 2008, Willow Garage, Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. +# * Neither the name of Willow Garage, Inc. nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# +# Revision $Id: xmlresults.py 11858 2010-10-26 23:32:02Z kwc $ + +""" +Library for reading and manipulating Ant JUnit XML result files. +""" + +import os +import sys +import string +import codecs +import re + +from xml.dom.minidom import parse, parseString +from xml.dom import Node as DomNode + +import roslib.rosenv + +class TestInfo(object): + """ + Common container for 'error' and 'failure' results + """ + + def __init__(self, type_, text): + """ + @param type_: type attribute from xml + @type type_: str + @param text: text property from xml + @type text: str + """ + self.type = type_ + self.text = text + +class TestError(TestInfo): + """ + 'error' result container + """ + def xml(self): + return u''%(self.type, self.text) + +class TestFailure(TestInfo): + """ + 'failure' result container + """ + def xml(self): + return u''%(self.type, self.text) + + +class TestCaseResult(object): + """ + 'testcase' result container + """ + + def __init__(self, name): + """ + @param name: name of testcase + @type name: str + """ + self.name = name + self.failures = [] + self.errors = [] + self.time = 0.0 + self.classname = '' + + def _passed(self): + """ + @return: True if test passed + @rtype: bool + """ + return not self.errors and not self.failures + ## bool: True if test passed without errors or failures + passed = property(_passed) + + def _failure_description(self): + """ + @return: description of testcase failure + @rtype: str + """ + if self.failures: + tmpl = "[%s][FAILURE]"%self.name + tmpl = tmpl + '-'*(80-len(tmpl)) + tmpl = tmpl+"\n%s\n"+'-'*80+"\n\n" + return '\n'.join(tmpl%x.text for x in self.failures) + return '' + + def _error_description(self): + """ + @return: description of testcase error + @rtype: str + """ + if self.errors: + tmpl = "[%s][ERROR]"%self.name + tmpl = tmpl + '-'*(80-len(tmpl)) + tmpl = tmpl+"\n%s\n"+'-'*80+"\n\n" + return '\n'.join(tmpl%x.text for x in self.errors) + return '' + + def _description(self): + """ + @return: description of testcase result + @rtype: str + """ + if self.passed: + return "[%s][passed]\n"%self.name + else: + return self._failure_description()+\ + self._error_description() + ## str: printable description of testcase result + description = property(_description) + def add_failure(self, failure): + """ + @param failure TestFailure + """ + self.failures.append(failure) + + def add_error(self, error): + """ + @param failure TestError + """ + self.errors.append(error) + + def xml(self): + return u' \n'%(self.classname, self.name, self.time)+\ + '\n '.join([f.xml() for f in self.failures])+\ + '\n '.join([e.xml() for e in self.errors])+\ + ' ' + +class Result(object): + __slots__ = ['name', 'num_errors', 'num_failures', 'num_tests', \ + 'test_case_results', 'system_out', 'system_err', 'time'] + def __init__(self, name, num_errors, num_failures, num_tests): + self.name = name + self.num_errors = num_errors + self.num_failures = num_failures + self.num_tests = num_tests + self.test_case_results = [] + self.system_out = '' + self.system_err = '' + self.time = 0.0 + + def accumulate(self, r): + """ + Add results from r to this result + @param r: results to aggregate with this result + @type r: Result + """ + self.num_errors += r.num_errors + self.num_failures += r.num_failures + self.num_tests += r.num_tests + self.test_case_results.extend(r.test_case_results) + if r.system_out: + self.system_out += '\n'+r.system_out + if r.system_err: + self.system_err += '\n'+r.system_err + + def add_test_case_result(self, r): + """ + Add results from a testcase to this result container + @param r: TestCaseResult + @type r: TestCaseResult + """ + self.test_case_results.append(r) + + def xml(self): + """ + @return: document as unicode (UTF-8 declared) XML according to Ant JUnit spec + """ + return u''+\ + ''%\ + (self.name, self.num_tests, self.num_errors, self.num_failures, self.time)+\ + '\n'.join([tc.xml() for tc in self.test_case_results])+\ + ' '%self.system_out+\ + ' '%self.system_err+\ + '' + +def _text(tag): + return reduce(lambda x, y: x + y, [c.data for c in tag.childNodes if c.nodeType in [DomNode.TEXT_NODE, DomNode.CDATA_SECTION_NODE]], "").strip() + +def _load_suite_results(test_suite_name, test_suite, result): + nodes = [n for n in test_suite.childNodes \ + if n.nodeType == DomNode.ELEMENT_NODE] + for node in nodes: + name = node.tagName + if name == 'testsuite': + # for now we flatten this hierarchy + _load_suite_results(test_suite_name, node, result) + elif name == 'system-out': + if _text(node): + system_out = "[%s] stdout"%test_suite_name + "-"*(71-len(test_suite_name)) + system_out += '\n'+_text(node) + result.system_out += system_out + elif name == 'system-err': + if _text(node): + system_err = "[%s] stderr"%test_suite_name + "-"*(71-len(test_suite_name)) + system_err += '\n'+_text(node) + result.system_err += system_err + elif name == 'testcase': + name = node.getAttribute('name') or 'unknown' + classname = node.getAttribute('classname') or 'unknown' + + # mangle the classname for some sense of uniformity + # between rostest/unittest/gtest + if '__main__.' in classname: + classname = classname[classname.find('__main__.')+9:] + if classname == 'rostest.rostest.RosTest': + classname = 'rostest' + elif not classname.startswith(result.name): + classname = "%s.%s"%(result.name,classname) + + time = node.getAttribute('time') or 0.0 + tc_result = TestCaseResult("%s/%s"%(test_suite_name,name)) + tc_result.classname = classname + tc_result.time = time + result.add_test_case_result(tc_result) + for d in [n for n in node.childNodes \ + if n.nodeType == DomNode.ELEMENT_NODE]: + # convert 'message' attributes to text elements to keep + # python unittest and gtest consistent + if d.tagName == 'failure': + message = d.getAttribute('message') or '' + text = _text(d) or message + x = TestFailure(d.getAttribute('type') or '', text) + tc_result.add_failure(x) + elif d.tagName == 'error': + message = d.getAttribute('message') or '' + text = _text(d) or message + x = TestError(d.getAttribute('type') or '', text) + tc_result.add_error(x) + +## #603: unit test suites are not good about screening out illegal +## unicode characters. This little recipe I from http://boodebr.org/main/python/all-about-python-and-unicode#UNI_XML +## screens these out +RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \ + u'|' + \ + u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % \ + (unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), + unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), + unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff)) +_safe_xml_regex = re.compile(RE_XML_ILLEGAL) + +def _read_file_safe_xml(test_file): + """ + read in file, screen out unsafe unicode characters + """ + try: + # this is ugly, but the files in question that are problematic + # do not declare unicode type. + try: + f = codecs.open(test_file, "r", "utf-8" ) + x = f.read() + except: + f.close() + f = codecs.open(test_file, "r", "iso8859-1" ) + x = f.read() + + for match in _safe_xml_regex.finditer(x): + x = x[:match.start()] + "?" + x[match.end():] + return x.encode("utf-8") + finally: + f.close() + +def read(test_file, test_name): + """ + Read in the test_result file + @param test_file: test file path + @type test_file: str + @param test_name: name of test + @type test_name: str + @return: test results + @rtype: Result + """ + try: + xml_str = _read_file_safe_xml(test_file) + test_suite = parseString(xml_str).getElementsByTagName('testsuite') + except Exception, e: + import traceback + traceback.print_exc() + print "WARN: cannot read test result file [%s]: %s"%(test_file, str(e)) + return Result(test_name, 0, 0, 0) + if not test_suite: + print "WARN: test result file [%s] contains no results"%test_file + return Result(test_name, 0, 0, 0) + test_suite = test_suite[0] + vals = [test_suite.getAttribute(attr) for attr in ['errors', 'failures', 'tests']] + vals = [v or 0 for v in vals] + err, fail, tests = [string.atoi(val) for val in vals] + + result = Result(test_name, err, fail, tests) + result.time = test_suite.getAttribute('time') or 0.0 + + # Create a prefix based on the test result filename. The idea is to + # disambiguate the case when tests of the same name are provided in + # different .xml files. We use the name of the parent directory + test_file_base = os.path.basename(os.path.dirname(test_file)) + fname = os.path.basename(test_file) + if fname.startswith('TEST-'): + fname = fname[5:] + if fname.endswith('.xml'): + fname = fname[:-4] + test_file_base = "%s.%s"%(test_file_base, fname) + _load_suite_results(test_file_base, test_suite, result) + return result + +def read_all(filter=[]): + """ + Read in the test_results and aggregate into a single Result object + @param filter: list of packages that should be processed + @type filter: [str] + @return: aggregated result + @rtype: L{Result} + """ + dir_ = roslib.rosenv.get_test_results_dir() + root_result = Result('ros', 0, 0, 0) + if not os.path.exists(dir_): + return root_result + for d in os.listdir(dir_): + if filter and not d in filter: + continue + subdir = os.path.join(dir_, d) + if os.path.isdir(subdir): + for file in os.listdir(subdir): + if file.endswith('.xml'): + file = os.path.join(subdir, file) + result = read(file, os.path.basename(subdir)) + root_result.accumulate(result) + return root_result