Revision control

Copy as Markdown

#!/usr/bin/env perl
# Copyright (C) 2017 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
=head1 NAME
xml2html_testcocoon - convert a xml coverage report created by testcocoon to structured html reports.
=head1 SYNOPSIS
# Run command
$ ./xml2html_testcocoon.pl --xml "path/to/file.xml" --module "modulename" --output "path/to/output" --include "path/to/include" [options]
# Example
$ ./xml2html_testcocoon.pl --xml "$HOME/qtbase_coverage_report-20111125-2354.xml" --module qtbase --output "$HOME/coverage/results" --include "$HOME/git/base/qt/qtbase/src" --exclude "$HOME/git/base/qt/qtbase/src/3rdparty"
# Will generate:
#
# $HOME/coverage/results/qtbase_report.html
# $HOME/coverage/results/files/tests_<status>_qtbase.html
# $HOME/coverage/results/files/folder_<subfolders>_qtbase.html
# $HOME/coverage/results/files/untested_sources_qtbase.html
#
It is designed to parse a coverage xml report generated for Qt modules build with testcocoon and create html reports.
=head1 OPTIONS
=over
=item B<--help>
Print this message.
=item B<--xml> <file>
Required. Full path to xml file to analyze.
=item B<--module> <value>
Required. Name of the Qt5 module to analyze.
=item B<--output> <directory>
Required. Path to the output directory (will be created if needed).
=item B<--include> <path>
Required. Set the full path to a folder or a file to include for the code coverage calculus.
=item B<--exclude> <path>
Option. Set the full path to a folder or a file to exclude for the code coverage calculus.
Can have multiple --exclude options. Exclusions are processed after the inclusion.
=back
=cut
use FindBin;
use lib "$FindBin::Bin/../lib/perl5";
package QtQA::App::Xml2HtmlTestCocoon;
use strict;
use warnings;
use autodie;
use Carp;
use File::Basename qw(basename);
use File::Find::Rule;
use File::Path qw( mkpath );
use File::Spec::Functions;
use Getopt::Long qw(GetOptionsFromArray);
use List::Compare;
use Pod::Usage qw( pod2usage );
use XML::Simple;
sub new
{
my $class = shift;
my $self = bless {}, $class;
return $self;
}
sub run
{
my ($self, @args) = @_;
my $xml_file;
my $module_name;
my $output_dir;
my $include_path;
my @exclude_list;
GetOptionsFromArray( \@args,
'help|?' => sub { pod2usage(1) },
'xml=s' => \$xml_file,
'module=s' => \$module_name,
'output=s' => \$output_dir,
'include=s' => \$include_path,
'exclude=s' => \@exclude_list,
) || pod2usage(2);
if (!$xml_file || ! -e $xml_file) {
confess "Missing or invalid required '--xml' option";
}
if (!$module_name) {
confess "Missing required '--module' option";
}
if (!$output_dir) {
confess "Missing required '--output' option";
}
my $files_output_subfolder = 'files';
my $output_files = catfile($output_dir, $files_output_subfolder);
if (! -d $output_dir && ! mkpath( $output_dir )) {
confess "mkpath $output_dir: $!";
}
if (! -d "$output_files" && ! mkpath( $output_files )) {
confess "mkpath $output_files: $!";
}
if (!$include_path) {
confess "Missing required '--include' option";
}
my $include_path_regex = quotemeta($include_path);
# get file timestamp
my $timestamp = "n/a";
if ( $xml_file =~ m#([0-9]{8}-[0-9]{4})#) {
$timestamp = $1;
}
my $year = substr($timestamp,0,4);
my $month = substr($timestamp,4,2);
my $day = substr($timestamp,6,2);
my $hour = substr($timestamp,9,2);
my $minute = substr($timestamp,11,2);
my $separator = '[\\/\\\]';
my $topFolder_mask = $separator . '.*';
my $tested_mask = '/.*';
my $total_mask = '.*/';
# create object
my $xml = XML::Simple->new;
# read XML file
my $data = $xml->XMLin($xml_file);
my $sources = $data->{SourcesStatistics}->{Item};
my $tests = $data->{ExecutionList}->{ExecutionListItem};
my %hash_folders;
# Get all the source files under the included directory
my @files_in_tree_all = File::Find::Rule->file()->name( '*.c', '*.cpp' )->in($include_path);
my @files_in_tree;
foreach my $file (@files_in_tree_all) {
next if ($self->isExcluded($file, @exclude_list));
next if ($file =~ m/(^|[\/\\])(qrc|moc)_.*\.cpp$/);
$file = canonpath($file);
push @files_in_tree, $file;
}
my @files_tested;
foreach my $item (@{$sources}) {
my $file_name = $item->{ItemSource};
next unless ($file_name =~ m/$include_path_regex/);
push @files_tested, $file_name;
next if ($self->isExcluded($file_name, @exclude_list));
# Get the relative path of the current file to include under the $include path entered
my $subfolder_is_file = 0;
if ( $file_name eq $include_path ) {
$file_name = basename($include_path);
$subfolder_is_file = 1;
} else {
$file_name =~ s/${include_path_regex}${separator}{0,1}//;
}
my $subfolder = $file_name;
# Figure out the subfolder name under the $include path entered that contains the file.
# If the file is directly under the $include path, its name is used as a subfolder name.
# Those subfolder names are used to create the tree structure of the main html report.
$subfolder =~ s/${topFolder_mask}// if (!$subfolder_is_file);
my $coverage_ratio = $item->{ItemStatistic}->{ItemStatisticValue};
my $tested = $coverage_ratio;
$tested =~ s/${tested_mask}//;
my $total = $coverage_ratio;
$total =~ s/${total_mask}//;
push @{$hash_folders{$subfolder}}, { filename => $file_name, tested => $tested, total => $total };
}
my $lc = List::Compare->new(\@files_in_tree, \@files_tested);
my @untested_sources = $lc->get_unique;
my $untested_sources_html = $self->write_untested_sources($output_files, $module_name, @untested_sources);
my $untested_sources_count = scalar(@untested_sources);
my %passed_tests;
my %failed_tests;
my %unknown_tests;
my %unknown_saved_tests;
foreach my $test (@{$tests}) {
my $test_name = lc($test->{ExecutionListName});
if ( defined $test->{ExecutionListStatusPassed} ) {
$passed_tests{$test_name} = 1;
} elsif ( defined $test->{ExecutionListStatusFailed} ) {
$failed_tests{$test_name} = 1;
} else { #unknown
if ( $test_name =~ m/^tc_/ ) {
$test_name =~ s/^tc_//;
$unknown_tests{$test_name} = 1;
} else {
$unknown_saved_tests{$test_name} = 1;
}
}
}
# Identify tests with valid unknown status
foreach my $test (keys %unknown_tests) {
if (!$passed_tests{$test} && !$failed_tests{$test} && !$unknown_saved_tests{$test}) {
$unknown_saved_tests{$test} = 1;
}
}
# Sort tests
my @passed_tests = sort keys %passed_tests;
my @failed_tests = sort keys %failed_tests;
my @unknown_saved_tests = sort keys %unknown_saved_tests;
my $nb_failed_tests = @failed_tests;
my $nb_passed_tests = @passed_tests;
my $nb_unknown_saved_tests = @unknown_saved_tests;
my $nb_total_tests = $nb_failed_tests + $nb_passed_tests + $nb_unknown_saved_tests;
# Write a html report for each tests status and report tests count in main html
my $passed_html = '';
if ($nb_passed_tests > 0) {
$passed_html = $self->write_tests_html(\@passed_tests, 'passed', $output_files, $module_name);
}
my $failed_html = '';
if ($nb_failed_tests > 0) {
$failed_html= $self->write_tests_html(\@failed_tests, 'failed', $output_files, $module_name);
}
my $unknown_saved_html = '';
if ($nb_unknown_saved_tests > 0) {
$unknown_saved_html = $self->write_tests_html(\@unknown_saved_tests, 'unknown', $output_files, $module_name);
}
# Create a main html report for a module that reports tests counts and subfolders/global coverage results.
my $main_html = catfile($output_dir, "${module_name}_report.html");
open(my $MAIN, '>', $main_html);
$self->write_start_html_file($MAIN);
$self->write_title_element($MAIN, "Conditions coverage results for: $module_name");
$self->write_header2_element($MAIN, "Included directory:<br /><ul><li>$include_path</li></ul>" . $self->create_exclude_elements(@exclude_list) . "<br />Date: $day/$month/$year - $hour:$minute");
$self->write_header2_element($MAIN, "Source files (.c and .cpp) found under the included directory but not tested by the coverage analysis: " . $self->create_element_with_link($untested_sources_html, $untested_sources_count));
$self->write_tests_status_global($MAIN, $nb_total_tests, $self->create_element_with_link($passed_html, $nb_passed_tests), $self->create_element_with_link($failed_html, $nb_failed_tests), $self->create_element_with_link($unknown_saved_html, $nb_unknown_saved_tests) );
$self->write_start_table_folders($MAIN);
close($MAIN);
my $global_tested = 0;
my $global_total = 0;
# Write a html report for each subfolder
foreach my $subfolder (sort keys %hash_folders) {
my $tested = 0;
my $total = 0;
my $folder_link = '';
my @list_files = @{$hash_folders{$subfolder}};
if (@list_files == 1 ) {
# If only 1 file is found, no need for a dedicated html report
$subfolder = $list_files[0]->{filename};
$tested += $list_files[0]->{tested};
$total += $list_files[0]->{total};
} else {
my $folder_html = catfile($output_files, "/folder_${subfolder}_${module_name}.html");
open(my $FOLDER, '>', $folder_html);
my $folder_data = $self->write_folder_html($FOLDER, $hash_folders{$subfolder}, $subfolder, $include_path);
close($FOLDER);
$folder_link = $files_output_subfolder . '/' . basename($folder_html);
$tested = $folder_data->{folder_tested};
$total = $folder_data->{folder_total};
}
open($MAIN, '>>', $main_html);
$self->write_folder_element($MAIN, $self->create_element_with_link($folder_link, $subfolder), $tested, $total);
close($MAIN);
$global_tested += $tested;
$global_total += $total;
}
open($MAIN, '>>', $main_html); ## no critic
$self->write_folder_element($MAIN, '<p class="global">GLOBAL</p>', $global_tested, $global_total);
$self->write_end_table($MAIN);
$self->write_end_html_file($MAIN);
close($MAIN);
return;
}
sub isExcluded
{
my ($self, $file, @list_excluded) = @_;
foreach my $exclude ( @list_excluded ) {
return 1 if ($file =~ m/$exclude/);
}
return 0;
}
# Function to calculate coverage value
sub calculate_coverage
{
my ($self, $tested, $total) = @_;
my $coverage_rounded = 'n/a';
if ( $total != 0 ) {
my $coverage_value = $tested / $total * 100;
$coverage_rounded = sprintf('%.2f', $coverage_value);
}
return $coverage_rounded;
}
sub write_start_html_file
{
my ($self, $FILE) = @_;
print $FILE <<ENDHTML;
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /><style type="text/css">
p, li { white-space: pre-wrap; } body { color: black; } .global { color: red; }
</style></head><body style=" font-family:'Helvetica'; font-size:12pt; font-weight:400; font-style:normal;">
ENDHTML
return;
}
sub write_end_html_file
{
my ($self, $FILE) = @_;
print $FILE "</body>\n</HTML>\n";
return;
}
sub write_title_element
{
my ($self, $FILE, $title) = @_;
print $FILE <<ENDHTML;
<h1>$title</h1>
ENDHTML
return;
}
sub write_header2_element
{
my ($self, $FILE, $text) = @_;
print $FILE <<ENDHTML;
<h2>$text</h2>
ENDHTML
return;
}
sub write_start_table_folders
{
my ($self, $FILE) = @_;
print $FILE <<ENDHTML;
<table border="1" cellspacing="2">
<TR class="SourceFolderHeader">
<TD class="SourceFolderFileName">Filename</TD>
<TD class="SourceFolderTested">Tested Statements</TD>
<TD class="SourceFolderTotal">Total Statements</TD>
<TD class="SourceFolderCoverage">Coverage</TD>
</TR>
ENDHTML
return;
}
sub write_start_table_tests
{
my ($self, $FILE) = @_;
print $FILE <<ENDHTML;
<table border="1" cellspacing="2">
<TR class="TestsHeader">
<TD class="TestName">Test Name</TD>
<TD class="TestStatus">Tested Test Status</TD>
</TR>
ENDHTML
return;
}
sub write_end_table
{
my ($self, $FILE) = @_;
print $FILE "</table>\n";
return;
}
# Subroutine to write a html report for all files found in a given subfolder
sub write_folder_html
{
my ($self, $FILE, $data, $key, $include) = @_;
$self->write_start_html_file($FILE);
$self->write_title_element($FILE, "Conditions coverage results for \"$key\" under <br />$include");
$self->write_start_table_folders($FILE);
my $folder_tested = 0;
my $folder_total = 0;
foreach my $file ( @{$data} ) {
$self->write_folder_element($FILE, $file->{filename}, $file->{tested}, $file->{total});
$folder_tested += $file->{tested};
$folder_total += $file->{total};
}
$self->write_folder_element($FILE, '<p class="global">GLOBAL</p>', $folder_tested, $folder_total);
$self->write_end_table($FILE);
$self->write_end_html_file($FILE);
return { folder_tested => $folder_tested, folder_total => $folder_total};
}
sub write_folder_element
{
my ($self, $FILE, $filename, $tested, $total) = @_;
my $coverage = $self->calculate_coverage($tested, $total);
print $FILE <<ENDHTML;
<TR class="SourceFolderHeader">
<TD class="SourceFolderFileName">$filename</TD>
<TD class="SourceFolderTested">$tested</TD>
<TD class="SourceFolderTotal">$total</TD>
<TD class="SourceFolderCoverage">$coverage</TD>
</TR>
ENDHTML
return;
}
# Subroutine to write a html report for all tests with the same status
sub write_tests_html
{
my ($self, $tests, $status, $output_dir, $module_name ) = @_;
my $html = '';
my $number_of_tests = @{$tests};
return $html unless $number_of_tests;
$html = catfile($output_dir, "tests_${module_name}_$status.html");
open(my $FILE, '>', $html);
$self->write_start_html_file($FILE);
$self->write_title_element($FILE, "lists of tests with status $status for module $module_name");
$self->write_start_table_tests($FILE);
foreach my $test (@{$tests}) {
$self->write_tests_element($FILE, $test, $status);
}
$self->write_end_table($FILE);
$self->write_end_html_file($FILE);
close($FILE);
$html = basename($output_dir) . '/'. basename($html);
return $html;
}
# Subroutine to write one test status in the test html report
sub write_tests_status_global
{
my ($self, $FILE, $total, $passed, $failed, $unknown ) = @_;
print $FILE <<ENDHTML;
<table border="1" cellspacing="2">
<TR class="TestsHeader">
<TD class="TotalTests">Numbers of tests run</TD>
<TD class="PassedTests">Passed</TD>
<TD class="FailedTests">Failed</TD>
ENDHTML
if ( defined $unknown ) {
print $FILE "<TD class=\"UnknownTests\">Unknown</TD>\n";
}
print $FILE <<ENDHTML;
</TR>
<TR class="Tests">
<TD class="TotalTests">$total</TD>
<TD class="PassedTests">$passed</TD>
<TD class="FailedTests">$failed</TD>
ENDHTML
if ( defined $unknown ) {
print $FILE "<TD class=\"UnknownTests\">$unknown</TD>\n";
}
print $FILE "</TR>\n</table>\n";
return;
}
sub write_tests_element
{
my ($self, $FILE, $testname, $status) = @_;
print $FILE <<ENDHTML;
<TR class="TestsHeader">
<TD class="TestName">$testname</TD>
<TD class="TestStatus">$status</TD>
</TR>
ENDHTML
return;
}
sub create_element_with_link
{
my ($self, $link, $text) = @_;
return $text unless ($link);
my $pre_link = '';
my $post_link = '';
if ($link) {
$pre_link = "<a href=\"$link\">";
$post_link = '</a>';
}
return $pre_link . $text . $post_link;
}
sub create_exclude_elements
{
my ($self, @exclude_list) = @_;
my $elements = '';
return $elements unless (@exclude_list);
$elements = "\nExcluding: <ul>";
foreach my $exclude (@exclude_list) {
$elements .= "<li>$exclude</li>";
}
$elements .= '</ul>';
return $elements;
}
sub write_start_table_untested_sources
{
my ($self, $FILE) = @_;
print $FILE <<ENDHTML;
<table border="1" cellspacing="2">
<TR class="UntestedSourceHeader">
<TD class="UntestedSourceFileName">Filename</TD>
</TR>
ENDHTML
return;
}
sub write_untested_sources_element
{
my ($self, $FILE, $filename) = @_;
print $FILE <<ENDHTML;
<TR class="UntestedSourceHeader">
<TD class="UntestedSourceFileName">$filename</TD>
</TR>
ENDHTML
return;
}
sub write_untested_sources
{
my ($self, $output_dir, $module_name, @untested_sources) = @_;
my $html = '';
return $html unless @untested_sources;
$html = catfile($output_dir, "untested_sources_${module_name}.html");
open(my $UNTESTED, '>', $html);
$self->write_start_html_file($UNTESTED);
$self->write_title_element($UNTESTED, 'Source files not covered by the coverage analysis<br />Generated files (moc_*.cpp and qrc_*.cpp) and files found under the exclude paths don\'t appear in this report');
$self->write_start_table_untested_sources($UNTESTED);
foreach my $filename ( @untested_sources ) {
$self->write_untested_sources_element($UNTESTED, $filename);
}
$self->write_end_table($UNTESTED);
$self->write_end_html_file($UNTESTED);
close($UNTESTED);
$html = basename($output_dir) . '/' . basename($html);
return $html;
}
QtQA::App::Xml2HtmlTestCocoon->new()->run( @ARGV ) if (!caller);
1;