#!/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__qtbase.html # $HOME/coverage/results/files/folder__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> Required. Full path to xml file to analyze. =item B<--module> Required. Name of the Qt5 module to analyze. =item B<--output> Required. Path to the output directory (will be created if needed). =item B<--include> Required. Set the full path to a folder or a file to include for the code coverage calculus. =item B<--exclude> 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:
  • $include_path
" . $self->create_exclude_elements(@exclude_list) . "
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, '

GLOBAL

', $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 return; } sub write_end_html_file { my ($self, $FILE) = @_; print $FILE "\n\n"; return; } sub write_title_element { my ($self, $FILE, $title) = @_; print $FILE <$title ENDHTML return; } sub write_header2_element { my ($self, $FILE, $text) = @_; print $FILE <$text ENDHTML return; } sub write_start_table_folders { my ($self, $FILE) = @_; print $FILE < Filename Tested Statements Total Statements Coverage ENDHTML return; } sub write_start_table_tests { my ($self, $FILE) = @_; print $FILE < Test Name Tested Test Status ENDHTML return; } sub write_end_table { my ($self, $FILE) = @_; print $FILE "\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
$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, '

GLOBAL

', $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 < $filename $tested $total $coverage 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 < Numbers of tests run Passed Failed ENDHTML if ( defined $unknown ) { print $FILE "Unknown\n"; } print $FILE < $total $passed $failed ENDHTML if ( defined $unknown ) { print $FILE "$unknown\n"; } print $FILE "\n\n"; return; } sub write_tests_element { my ($self, $FILE, $testname, $status) = @_; print $FILE < $testname $status 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 = ""; $post_link = ''; } return $pre_link . $text . $post_link; } sub create_exclude_elements { my ($self, @exclude_list) = @_; my $elements = ''; return $elements unless (@exclude_list); $elements = "\nExcluding:
    "; foreach my $exclude (@exclude_list) { $elements .= "
  • $exclude
  • "; } $elements .= '
'; return $elements; } sub write_start_table_untested_sources { my ($self, $FILE) = @_; print $FILE < Filename ENDHTML return; } sub write_untested_sources_element { my ($self, $FILE, $filename) = @_; print $FILE < $filename 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
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;