| #!/bin/env python |
| # |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # |
| # |
| |
| # $Id$ |
| """ |
| gen_junit_report.py -- The script is to generate the junit report for |
| Subversion tests. The script uses the log file, tests.log created by |
| "make check" process. It parses the log file and generate the junit |
| files for each test separately in the specified output directory. The |
| script can take --log-file and --output-dir arguments. |
| """ |
| |
| import sys |
| import os |
| import getopt |
| |
| def replace_from_map(data, encode): |
| """replace substrings in DATA with replacements defined in ENCODING""" |
| for pattern, replacement in encode.items(): |
| data = data.replace(pattern, replacement) |
| return data |
| |
| xml_encode_map = { |
| '&': '&', |
| '<': '<', |
| '>': '>', |
| '"': '"', |
| "'": ''', |
| } |
| |
| def xml_encode(data): |
| """encode the xml characters in the data""" |
| return replace_from_map(data, xml_encode_map) |
| |
| special_encode_map = { |
| ']]>': ']]]]><![CDATA[>', # CDATA terminator sequence |
| '\000': '␀', # U+2400 SYMBOL FOR NULL |
| '\001': '␁', # U+2401 SYMBOL FOR START OF HEADING |
| '\002': '␂', # U+2402 SYMBOL FOR START OF TEXT |
| '\003': '␃', # U+2403 SYMBOL FOR END OF TEXT |
| '\004': '␄', # U+2404 SYMBOL FOR END OF TRANSMISSION |
| '\005': '␅', # U+2405 SYMBOL FOR ENQUIRY |
| '\006': '␆', # U+2406 SYMBOL FOR ACKNOWLEDGE |
| '\007': '␇', # U+2407 SYMBOL FOR BELL |
| '\010': '␈', # U+2408 SYMBOL FOR BACKSPACE |
| '\011': '␉', # U+2409 SYMBOL FOR HORIZONTAL TABULATION |
| #'\012': '␊', # U+240A SYMBOL FOR LINE FEED |
| '\013': '␋', # U+240B SYMBOL FOR VERTICAL TABULATION |
| '\014': '␌', # U+240C SYMBOL FOR FORM FEED |
| #'\015': '␍', # U+240D SYMBOL FOR CARRIAGE RETURN |
| '\016': '␎', # U+240E SYMBOL FOR SHIFT OUT |
| '\017': '␏', # U+240F SYMBOL FOR SHIFT IN |
| '\020': '␐', # U+2410 SYMBOL FOR DATA LINK ESCAPE |
| '\021': '␑', # U+2411 SYMBOL FOR DEVICE CONTROL ONE |
| '\022': '␒', # U+2412 SYMBOL FOR DEVICE CONTROL TWO |
| '\023': '␓', # U+2413 SYMBOL FOR DEVICE CONTROL THREE |
| '\024': '␔', # U+2414 SYMBOL FOR DEVICE CONTROL FOUR |
| '\025': '␕', # U+2415 SYMBOL FOR NEGATIVE ACKNOWLEDGE |
| '\026': '␖', # U+2416 SYMBOL FOR SYNCHRONOUS IDLE |
| '\027': '␗', # U+2417 SYMBOL FOR END OF TRAMSNISSION BLOCK |
| '\030': '␘', # U+2418 SYMBOL FOR CANCEL |
| '\031': '␙', # U+2419 SYMBOL FOR END OF MEDIUM |
| '\032': '␚', # U+241A SYMBOL FOR SUBSTITUTE |
| '\033': '␛', # U+241B SYMBOL FOR ESCAPE |
| '\034': '␜', # U+241C SYMBOL FOR FILE SEPARATOR |
| '\035': '␝', # U+241D SYMBOL FOR GROUP SEPARATOR |
| '\036': '␞', # U+241E SYMBOL FOR RECORD SEPARATOR |
| '\037': '␟', # U+241F SYMBOL FOR UNIT SEPARATOR |
| } |
| |
| def escape_special_characters(data): |
| """remove special characters in test failure reasons""" |
| if data: |
| data = replace_from_map(data, special_encode_map) |
| return data |
| |
| def start_junit(): |
| """define the beginning of xml document""" |
| head = """<?xml version="1.0" encoding="UTF-8"?>""" |
| return head |
| |
| def start_testsuite(test_name): |
| """start testsuite. The value for the attributes are replaced later |
| when the junit file handling is concluded""" |
| sub_test_name = test_name.replace('.', '-') |
| start = """<testsuite time="ELAPSED_%s" tests="TOTAL_%s" name="%s" |
| failures="FAIL_%s" errors="FAIL_%s" skipped="SKIP_%s">""" % \ |
| (test_name, test_name, sub_test_name, test_name, test_name, test_name) |
| return start |
| |
| def junit_testcase_ok(test_name, casename): |
| """mark the test case as PASSED""" |
| casename = xml_encode(casename) |
| sub_test_name = test_name.replace('.', '-') |
| case = """<testcase time="ELAPSED_CASE_%s" name="%s" classname="%s"/>""" % \ |
| (test_name, casename, sub_test_name) |
| return case |
| |
| def junit_testcase_fail(test_name, casename, reason=None): |
| """mark the test case as FAILED""" |
| casename = xml_encode(casename) |
| sub_test_name = test_name.replace('.', '-') |
| reason = escape_special_characters(reason) |
| case = """<testcase time="ELAPSED_CASE_%s" name="%s" classname="%s"> |
| <failure type="Failed"><![CDATA[%s]]></failure> |
| </testcase>""" % (test_name, casename, sub_test_name, reason) |
| return case |
| |
| def junit_testcase_xfail(test_name, casename, reason=None): |
| """mark the test case as XFAILED""" |
| casename = xml_encode(casename) |
| sub_test_name = test_name.replace('.', '-') |
| reason = escape_special_characters(reason) |
| case = """<testcase time="ELAPSED_CASE_%s" name="%s" classname="%s"> |
| <system-out><![CDATA[%s]]></system-out> |
| </testcase>""" % (test_name, casename, sub_test_name, reason) |
| return case |
| |
| def junit_testcase_skip(test_name, casename): |
| """mark the test case as SKIPPED""" |
| casename = xml_encode(casename) |
| sub_test_name = test_name.replace('.', '-') |
| case = """<testcase time="ELAPSED_CASE_%s" name="%s" classname="%s"> |
| <skipped message="Skipped"/> |
| </testcase>""" % (test_name, casename, sub_test_name) |
| return case |
| |
| def end_testsuite(): |
| """mark the end of testsuite""" |
| end = """</testsuite>""" |
| return end |
| |
| def update_stat(test_name, junit, count): |
| """update the test statistics in the junit string""" |
| junit_str = '\n'.join(junit) |
| t_count = count[test_name] |
| total = float(t_count['pass'] + t_count['fail'] + t_count['skip']) |
| elapsed = float(t_count['elapsed']) |
| case_time = 0 |
| if total > 0: # there are tests with no test cases |
| case_time = elapsed/total |
| |
| total_patt = 'TOTAL_%s' % test_name |
| fail_patt = 'FAIL_%s' % test_name |
| skip_patt = 'SKIP_%s' % test_name |
| elapsed_patt = 'ELAPSED_%s' % test_name |
| elapsed_case_patt = 'ELAPSED_CASE_%s' % test_name |
| |
| # replace the pattern in junit string with actual statistics |
| junit_str = junit_str.replace(total_patt, "%s" % total) |
| junit_str = junit_str.replace(fail_patt, "%s" % t_count['fail']) |
| junit_str = junit_str.replace(skip_patt, "%s" % t_count['skip']) |
| junit_str = junit_str.replace(elapsed_patt, "%.3f" % elapsed) |
| junit_str = junit_str.replace(elapsed_case_patt, "%.3f" % case_time) |
| return junit_str |
| |
| def main(): |
| """main method""" |
| try: |
| opts, args = getopt.getopt(sys.argv[1:], 'l:d:h', |
| ['log-file=', 'output-dir=', 'help']) |
| except getopt.GetoptError as err: |
| usage(err) |
| |
| log_file = None |
| output_dir = None |
| for opt, value in opts: |
| if (opt in ('-h', '--help')): |
| usage() |
| elif (opt in ('-l', '--log-file')): |
| log_file = value |
| elif (opt in ('-d', '--output-dir')): |
| output_dir = value |
| else: |
| usage('Unable to recognize option') |
| |
| if not log_file or not output_dir: |
| usage("The options --log-file and --output-dir are mandatory") |
| |
| # create junit output directory, if not exists |
| if not os.path.exists(output_dir): |
| print("Directory '%s' not exists, creating ..." % output_dir) |
| try: |
| os.makedirs(output_dir) |
| except OSError as err: |
| sys.stderr.write("ERROR: %s\n" % err) |
| sys.exit(1) |
| patterns = { |
| 'start' : 'START:', |
| 'end' : 'END:', |
| 'pass' : 'PASS:', |
| 'skip' : 'SKIP:', |
| 'fail' : 'FAIL:', |
| 'xfail' : 'XFAIL:', |
| 'elapsed' : 'ELAPSED:' |
| } |
| |
| junit = [] |
| junit.append(start_junit()) |
| reason = None |
| count = {} |
| fp = None |
| try: |
| fp = open(log_file, 'r') |
| except IOError as err: |
| sys.stderr.write("ERROR: %s\n" % err) |
| sys.exit(1) |
| |
| for line in fp.readlines(): |
| line = line.strip() |
| if line.startswith(patterns['start']): |
| reason = "" |
| test_name = line.split(' ')[1] |
| # replace '.' in test name with '_' to avoid confusing class |
| # name in test result displayed in the CI user interface |
| test_name.replace('.', '_') |
| count[test_name] = { |
| 'pass' : 0, |
| 'skip' : 0, |
| 'fail' : 0, |
| 'xfail' : 0, |
| 'elapsed' : 0, |
| 'total' : 0 |
| } |
| junit.append(start_testsuite(test_name)) |
| elif line.startswith(patterns['end']): |
| junit.append(end_testsuite()) |
| elif line.startswith(patterns['pass']): |
| reason = "" |
| casename = line.strip(patterns['pass']).strip() |
| junit.append(junit_testcase_ok(test_name, casename)) |
| count[test_name]['pass'] += 1 |
| elif line.startswith(patterns['skip']): |
| reason = "" |
| casename = line.strip(patterns['skip']).strip() |
| junit.append(junit_testcase_skip(test_name, casename)) |
| count[test_name]['skip'] += 1 |
| elif line.startswith(patterns['fail']): |
| casename = line.strip(patterns['fail']).strip() |
| junit.append(junit_testcase_fail(test_name, casename, reason)) |
| count[test_name]['fail'] += 1 |
| reason = "" |
| elif line.startswith(patterns['xfail']): |
| casename = line.strip(patterns['xfail']).strip() |
| junit.append(junit_testcase_xfail(test_name, casename, reason)) |
| count[test_name]['pass'] += 1 |
| reason = "" |
| elif line.startswith(patterns['elapsed']): |
| reason = "" |
| elapsed = line.split(' ')[2].strip() |
| (hrs, mins, secs) = elapsed.split(':') |
| secs_taken = int(hrs)*24 + int(mins)*60 + float(secs) |
| count[test_name]['elapsed'] = secs_taken |
| |
| junit_str = update_stat(test_name, junit, count) |
| test_junit_file = os.path.join(output_dir, |
| "%s.junit.xml" % test_name) |
| w_fp = open (test_junit_file, 'w') |
| w_fp.writelines(junit_str) |
| w_fp.close() |
| junit = [] |
| elif len(line): |
| reason = "%s\n%s" % (reason, line) |
| fp.close() |
| |
| def usage(errorMsg=None): |
| script_name = os.path.basename(sys.argv[0]) |
| sys.stdout.write("""USAGE: %s: [--help|h] --log-file|l --output-dir|d |
| |
| Options: |
| --help|-h Display help message |
| --log-file|l The log file to parse for generating junit xml files |
| --output-dir|d The directory to create the junit xml file for each |
| test |
| """ % script_name) |
| if errorMsg is not None: |
| sys.stderr.write("\nERROR: %s\n" % errorMsg) |
| sys.exit(1) |
| sys.exit(0) |
| |
| if __name__ == '__main__': |
| main() |