#!/usr/bin/perl =head1 NAME tap-to-junit-xml - convert perl-style TAP test output to JUnit-style XML =head1 SYNOPSIS tap-to-junit-xml "test suite name" [ outputprefix ] < tap_output.log =head1 DESCRIPTION Parse test suite output in TAP (Test Anything Protocol, C) format, and produce XML output in a similar format to that produced by the ant task. This is useful for consumption by continuous-integration systems like Hudson (C). C<"test suite name"> is a descriptive string used as the B attribute on the top-level node of the output XML. If C is specified, multi-file output will be generated, with multiple XML files created using C as their start of their filenames. This prefix may contain slashes, in which case the files will be placed into a directory hierarchy accordingly (although care should be taken to ensure these directories exist in advance). If C is not specified, a single XML file will be generated on STDOUT. =head1 DEPENDENCIES TAP::Parser Time::HiRes XML::Generator =head1 BUGS - Output is optimized for Hudson, and may not look quite as good in other UIs. - Doesn't do anything with the STDERR from tests. - Doesn't fill in the 'errors' attribute in the element. - Doesn't handle "todo" or "skip" - Doesn't get the elapsed time for each 'test' (i.e. assertion.) =head1 AUTHOR original, junit_xml.pl, by Matisse Enzer ; see C. pretty much entirely rewritten by Justin Mason , Feb 2008. =head1 VERSION Mar 27 2008 jm =head1 COPYRIGHT & LICENSE Copyright (c) 2007 Matisse Enzer. All Rights Reserved. This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =cut use strict; use warnings; my $opt_suitename = shift @ARGV; my $opt_multifile = 0; my $opt_mfprefix; if (defined $ARGV[0]) { $opt_multifile = 1; $opt_mfprefix = $ARGV[0]; } use TAP::Parser; use Time::HiRes qw(gettimeofday tv_interval); use XML::Generator qw(:noimport); # should the 'Test Summary Report' at the end of a test suite be displayed # as if it was a testcase? in my opinion, no my $HIDE_TEST_SUMMARY_REPORT = 1; my $suite_name = $opt_suitename || 'make test'; my $safe_suite_name = $suite_name; $safe_suite_name =~ s/[^-:_A-Za-z0-9]+/_/gs; # TODO: it'd be nice to respect 'Universal desirable behavior #1' from # http://testanything.org/wiki/index.php/TAP_Consumers -- 'Should work on the # TAP as a stream (ie. as each line is received) rather than wait until all the # TAP is received'. But it seems TAP::Parser itself doesn't support it! # maybe when TAP::Parser does that, we'll do it too. my $tout = join("", ); my $tap = TAP::Parser->new( { tap => $tout } ); my $xmlgen = XML::Generator->new( ':pretty', ':std'); my $xmlgenunescaped = XML::Generator->new( ':pretty', ':std', 'escape' => 'unescaped'); my @properties = _get_properties($xmlgen); my $test_results = _parse_tests( $tap, $xmlgen ); if ($opt_multifile) { _gen_junit_multifile_xml( $xmlgen, \@properties, $test_results ); } else { print STDOUT _get_junit_xml( $xmlgen, \@properties, $test_results ); } exit; #------------------------------------------------------------------------------- sub _get_junit_xml { my ( $xmlgen, $properties, $test_results ) = @_; my $xml = "\n" . $xmlgen->testsuites({ name => $suite_name, }, @$test_results); return $xml; } sub _gen_junit_multifile_xml { my ( $xmlgen, $properties, $test_results ) = @_; my $count = 1; foreach my $testsuite (@$test_results) { open OUT, ">${opt_mfprefix}.${count}.xml" or die "cannot write ${opt_mfprefix}-${count}.xml"; print OUT "\n"; print OUT $testsuite; close OUT; $count++; } } sub _parse_tests { my ( $parser, $xmlgen ) = @_; my $ctx = { testsuites => [ ], test_name => 'notest', plan_ntests => 0, case_id => 0, }; _new_ctx($ctx); my $lastunk = ''; # unknown t/basic_lint......... # plan 1..1 # comment # Running under perl version 5.008008 for linux # comment # Current time local: Thu Jan 24 17:44:30 2008 # comment # Current time GMT: Thu Jan 24 17:44:30 2008 # comment # Using Test.pm version 1.25 # unknown /usr/bin/perl -T -w ../spamassassin.raw -C log/test_rules_copy --siteconfigpath log/localrules.tmp -p log/test_default.cf -L --lint # unknown Checking anything # test ok 1 # test ok 2 # unknown t/basic_meta......... # plan 1..2 # comment # Running under perl version 5.008008 for linux # comment # Current time local: Thu Jan 24 17:44:31 2008 # comment # Current time GMT: Thu Jan 24 17:44:31 2008 # comment # Using Test.pm version 1.25 # test not ok 1 # comment # Failed test 1 in t/basic_meta.t at line 91 # test ok 2 # unknown Failed 1/2 subtests # unknown t/basic_obj_api...... # plan 1..4 # comment # Running under perl version 5.008008 for linux # comment # Current time local: Thu Jan 24 17:44:33 2008 # comment # Current time GMT: Thu Jan 24 17:44:33 2008 # comment # Using Test.pm version 1.25 # test ok 1 # test ok 2 # test ok 3 # test ok 4 # test ok 9 # unknown # unknown Test Summary Report # unknown ------------------- # unknown t/basic_meta.t (Wstat: 0 Tests: 2 Failed: 1) # unknown Failed test: 1 # unknown Files=3, Tests=7, 6 wallclock secs ( 0.01 usr 0.00 sys + 4.39 cusr 0.23 csys = 4.63 CPU) # unknown Result: FAIL # unknown Failed 1/3 test programs. 1/7 subtests failed. # unknown make: *** [test_dynamic] Error 255 while ( my $r = $parser->next ) { my $t = $r->type; my $s = $r->as_string; $s =~ s/\s+$//; # warn "JMD $t $s"; if ($t eq 'unknown') { $lastunk = $s; # PERL_DL_NONLAZY=1 /usr/bin/perl "-MExtUtils::Command::MM" "-e" "test_harness(1, 'blib/lib', 'blib/arch')" t/basic_* # if ($s =~ /test_harness\(.*?\)" (.+)$/) { # $suite_name = $1; # } if ($s =~ /^Test Summary Report$/) { # create a block for the summary $ctx->{plan_ntests} = 0; $ctx->{test_name} = "Test Summary Report"; $ctx->{case_tests} = 1; _finish_test_block($ctx); } elsif ($s =~ /^Result: FAIL$/) { $ctx->{case_tests}++; $ctx->{case_failures}++; my $test_case = { classname => test_name_to_classname($ctx->{test_name}), name => 'result', 'time' => 0, }; my $failure = $xmlgen->failure({ type => "OverallTestsFailed", message => $s }, "__FAILUREMESSAGETODO__"); if (!$HIDE_TEST_SUMMARY_REPORT) { push @{$ctx->{test_cases}}, $xmlgen->testcase($test_case, $failure); } } elsif ($s =~ /^(\S+?)\.\.\.+1\.\.(\d+?)\s*$/) { # perl 5.6.x "Test" format plan line # unknown t/basic_lint....................1..1 my ($name, $nt) = ($1,$2); if ($ctx->{plan_ntests}) { # only if there have been tests planned _finish_test_block($ctx); } $ctx->{plan_ntests} = $nt+0; $ctx->{test_name} = "$name.t"; } } elsif ($t eq 'plan') { if ($ctx->{plan_ntests}) { # only if there have been tests planned _finish_test_block($ctx); } $ctx->{plan_ntests} = 0; $s =~ /(\d+)$/ and $ctx->{plan_ntests} = $1+0; $ctx->{test_name} = $lastunk; $ctx->{test_name} =~ s/\.*\s*$//gs; $ctx->{test_name} .= ".t"; } elsif ($t eq 'test') { my $ntest = 0; if ($s =~ /(?:not |)\S+ (\d+)/) { $ntest = $1+0; } if ($ntest > $ctx->{plan_ntests}) { # jump in test numbers, more than planned; this is probably TAP::Parser's wierdness. # (when it sees the "ok" line at the end of a test case with no number, # it outputs the current total number of tests so far.) next; } # clean this up in a Hudson-compatible way; ":" and "/" are out, "." also causes # trouble by creating an extra "directory" in the results my $test_case = { classname => test_name_to_classname($ctx->{test_name}), name => sprintf("test %6d", $ntest), # space-padding ensures ordering 'time' => 0, }; $ctx->{case_tests}++; my $failure = undef; if ($s =~ /^not /i) { $ctx->{case_failures}++; $failure = $xmlgen->failure({ type => "TAPTestFailed", message => $s }, "__FAILUREMESSAGETODO__"); push @{$ctx->{test_cases}}, $xmlgen->testcase($test_case, $failure); } else { push @{$ctx->{test_cases}}, $xmlgen->testcase($test_case); } } $ctx->{sysout} .= $s."\n"; } if (scalar(@{$ctx->{test_cases}}) == 0 && scalar(@{$ctx->{testsuites}}) == 0) { # no tests found! create a block containing *something* at least $ctx->{case_tests}++; my $test_case = { classname => test_name_to_classname($ctx->{test_name}), name => 'result', 'time' => 0, }; push @{$ctx->{test_cases}}, $xmlgen->testcase($test_case); } _finish_test_block($ctx); return $ctx->{testsuites}; } sub _new_ctx { my $ctx = shift; $ctx->{start_time} = [gettimeofday]; $ctx->{test_cases} = []; $ctx->{case_tests} = 0; $ctx->{case_failures} = 0; $ctx->{case_time} = 0; $ctx->{case_id}++; $ctx->{sysout} = ''; return $ctx; } sub _finish_test_block { my $ctx = shift; $ctx->{sysout} =~ s/\n\S+\.*\s*\n$/\n/s; # remove next test's "t/foo....." line my $elapsed_time = 0; # TODO #my $elapsed_time = tv_interval( $ctx->{start_time}, [gettimeofday] ); # clean it up to valid Java packagename format (or at least something Hudson will # consume) my $name = $ctx->{test_name}; $name =~ s/[^-:_A-Za-z0-9]+/_/gs; $name = "$safe_suite_name.$name"; # a "directory" for the suite name my $testsuite = { 'time' => $elapsed_time, 'name' => $name, tests => $ctx->{case_tests}, failures => $ctx->{case_failures}, 'id' => $ctx->{case_id}, errors => 0, }; my @fixedcases = (); foreach my $tc (@{$ctx->{test_cases}}) { if ($tc =~ s/__FAILUREMESSAGETODO__/ cdata($ctx->{sysout}) /ges) { push @fixedcases, \$tc; # inhibits escaping! } else { push @fixedcases, $tc; } } # use "unescaped"; we have already fixed escaping on these strings. # note that a reference means 'this is unescaped', bizarrely. push @{$ctx->{testsuites}}, $xmlgenunescaped->testsuite($testsuite, @fixedcases, \("\n".cdata($ctx->{sysout})."\n"), \("")); _new_ctx($ctx); }; sub cdata { my $s = shift; $s =~ s/\]\]>/\](warning: defanged by tap-to-junit-xml)\]>/gs; return ''; } sub _get_properties { my $xmlgen = shift; my @props; foreach my $key ( sort keys %ENV ) { push @props, $xmlgen->property( { name => "$key", value => $ENV{$key} } ); } return @props; } sub test_name_to_classname { my $safe = shift; $safe =~ s/[^-:_A-Za-z0-9]+/_/gs; $safe = "$safe_suite_name.$safe"; # a "directory" for the suite name $safe; } __END__ # JUnit references: # http://www.nabble.com/JUnit-4-XML-schematized--td13946472.html # http://jra1mw.cvs.cern.ch:8180/cgi-bin/jra1mw.cgi/org.glite.testing.unit/config/JUnitXSchema.xsd?view=markup # skipped tests: # https://hudson.dev.java.net/issues/show_bug.cgi?id=1251 # Hudson source: # http://fisheye5.cenqua.com/browse/hudson/hudson/main/core/src/main/java/hudson/tasks/junit/CaseResult.java