File Coverage

lib/Test/Harness/KS.pm
Criterion Covered Total %
statement 63 271 23.2
branch 2 104 1.9
condition 0 9 0.0
subroutine 18 36 50.0
pod 9 15 60.0
total 92 435 21.1


line stmt bran cond sub pod time code
1             package Test::Harness::KS;
2             # ABSTRACT: Harness the power of clover and junit in one easy to use wrapper.
3             #
4             # Copyright 2018 National Library of Finland
5             # Copyright 2017 KohaSuomi
6              
7             =NAME
8            
9             Test::Harness::KS
10            
11             =SYNOPSIS
12            
13             Runs given test files and generates clover, html and junit test reports to the given directory.
14            
15             Automatically sorts given test files by directory and deduplicates them.
16            
17             See
18             test-harness-ks --help
19             for commandline usage
20            
21             =cut
22              
23             ##Pragmas
24 3     3   451091 use Modern::Perl;
  3         26  
  3         24  
25 3     3   1852 use Carp::Always;
  3         4148  
  3         18  
26 3     3   1625 use autodie;
  3         44649  
  3         15  
27 3     3   22317 use English; #Use verbose alternatives for perl's strange $0 and $\ etc.
  3         7367  
  3         31  
28 3     3   2998 use Try::Tiny;
  3         6449  
  3         179  
29 3     3   24 use Scalar::Util qw(blessed);
  3         7  
  3         141  
30 3     3   18 use Cwd;
  3         13  
  3         581  
31              
32             ##Testing harness libraries
33             sub loadJUnit() {
34 0     0 0 0   require TAP::Harness::JUnit;
35             }
36             sub loadCover() {
37 0     0 0 0   require Devel::Cover; #Require coverage testing and extensions for it. These are not actually used in this package directly, but Dist::Zilla uses this data to autogenerate the dependencies
38 0         0   require Devel::Cover::Report::Clover;
39 0         0   require Template;
40 0         0   require Perl::Tidy;
41 0         0   require Pod::Coverage::CountParents;
42 0         0   require Test::Differences;
43             }
44              
45              
46             ##Remote modules
47 3     3   726 use IPC::Cmd;
  3         54833  
  3         145  
48 3     3   23 use File::Basename;
  3         5  
  3         199  
49 3     3   40 use File::Path qw(make_path);
  3         5  
  3         183  
50 3     3   1675 use Params::Validate qw(:all);
  3         20079  
  3         577  
51 3     3   1315 use Data::Dumper;
  3         13718  
  3         219  
52 3     3   1929 use Storable;
  3         9891  
  3         183  
53              
54 3     3   2597 use Log::Log4perl qw(get_logger);
  3         140118  
  3         29  
55 3     3   287 use Log::Log4perl::Level;
  3         7  
  3         20  
56              
57             =head2 new
58            
59             Creates a new Test runner
60            
61             Configure log verbosity by initializing Log::Log4perl beforehand, otherwise the internal logging defaults to WARN
62            
63             @params HashRef: {
64             resultsDir => String, directory, must be writable. Where the test deliverables are brought
65             tar => Boolean
66             cover => Boolean
67             junit => Boolean
68             testFiles => ARRAYRef, list of files to test
69             dryRun => Boolean
70             lib => ARRAYRef or undef, list of extra include directories for the test files
71             }
72            
73             =cut
74              
75             my $validationTestFilesCallbacks = {
76               'files exist' => sub {
77                 die "not an array" unless (ref($_[0]) eq 'ARRAY');
78                 die "is empty" unless (scalar(@{$_[0]}));
79              
80                 my @errors;
81                 foreach my $file (@{$_[0]}) {
82                   push(@errors, "$file is not readable") unless (-r $file);
83                 }
84                 return 1 unless @errors;
85                 die "files are not readable:\n".join("\n",@errors);
86               },
87             };
88             my $validationNew = {
89               resultsDir => {
90                 callbacks => {
91                   'resultsDir is writable' => sub {
92                     if ($_[0]) {
93                       return (-w $_[0]);
94                     }
95                     else {
96                       return 1 if (-w File::Basename::dirname($0));
97                       die "No --results-dir was passed, so defaulting to the directory of the program used to call me '".File::Basename::dirname($0)."'. Unfortunately that directory is not writable by this process and I don't know where to save the test deliverables."
98                     }
99                   },
100                 },
101               },
102               tar => {default => 0},
103               cover => {default => 0},
104               junit => {default => 0},
105               dryRun => {default => 0},
106               lib => {
107                 default => [],
108                 callbacks => {
109                   'lib is an array or undef' => sub {
110                     return 1 unless ($_[0]);
111                     if (ref($_[0]) eq 'ARRAY') {
112                       return 1;
113                     }
114                     else {
115                       die "param lib is not an array";
116                     }
117                   },
118                 },
119               },
120               testFiles => {
121                 callbacks => $validationTestFilesCallbacks,
122               },
123               dbDiff => {default => 0},
124               dbUser => {default => undef},
125               dbPass => {default => undef},
126               dbHost => {default => undef},
127               dbPort => {default => undef},
128               dbDatabase => {default => undef},
129               dbSocket => {default => undef},
130               dbDiffIgnoreTables => {default => undef}
131             };
132              
133 3     3   3531 use fields qw(resultsDir tar cover junit dryRun lib testFiles testFilesByDir dbDiff dbUser dbPass dbHost dbPort dbDatabase dbSocket dbDiffIgnoreTables);
  3         5185  
  3         19  
134              
135             sub new {
136 0 0   0 1 0   unless (Log::Log4perl->initialized()) { Log::Log4perl->easy_init( Log::Log4perl::Level::to_priority( 'WARN' ) ); }
  0         0  
137              
138 0         0   my $class = shift;
139 0         0   my $params = validate(@_, $validationNew);
140              
141 0         0   my $self = Storable::dclone($params);
142 0         0   bless($self, $class);
143              
144 0         0   $self->{testFilesByDir} = _sortFilesByDir($self->{testFiles});
145              
146 0 0       0   loadJUnit() if $self->{junit};
147 0 0       0   loadCover() if $self->{cover};
148              
149 0         0   return $self;
150             }
151              
152             sub run {
153 0     0 0 0   my ($self) = @_;
154              
155             # $self->changeWorkingDir();
156 0         0   $self->prepareTestResultDirectories();
157 0 0       0   $self->clearCoverDb() if $self->{cover};
158 0         0   $self->runharness();
159 0 0       0   $self->createCoverReport() if $self->{cover};
160 0 0       0   $self->tar() if $self->{tar};
161             # $self->revertWorkingDir();
162             }
163              
164             =head2 changeWorkingDir
165            
166             Change to the given --results-dir
167             or to the directory of the calling script.
168            
169             =cut
170              
171             sub changeWorkingDir {
172 0     0 1 0   my ($self) = @_;
173              
174 0         0   $self->{oldWorkingDir} = Cwd::getcwd();
175 0 0       0   chdir $self->{resultsDir} || File::Basename::dirname($0);
176             }
177              
178             sub revertWorkingDir {
179 0     0 0 0   my ($self) = @_;
180              
181 0 0       0   die "\$self->{oldWorkingDir} is not known when reverting to the old working directory?? This should never happen!!" unless $self->{oldWorkingDir};
182 0         0   chdir $self->{oldWorkingDir};
183             }
184              
185             sub prepareTestResultDirectories {
186 0     0 0 0   my ($self) = @_;
187 0         0   $self->getTestResultFileAndDirectoryPaths($self->{resultsDir});
188 0 0       0   mkdir $self->{testResultsDir} unless -d $self->{testResultsDir};
189 0 0       0   $self->_shell("rm", "-r $self->{junitDir}") if -e $self->{junitDir};
190 0 0       0   $self->_shell("rm", "-r $self->{coverDir}") if -e $self->{coverDir};
191 0 0       0   $self->_shell("rm", "-r $self->{dbDiffDir}") if -e $self->{dbDiffDir};
192 0 0       0   mkdir $self->{junitDir} unless -d $self->{junitDir};
193 0 0       0   mkdir $self->{coverDir} unless -d $self->{coverDir};
194 0 0       0   mkdir $self->{dbDiffDir} unless -d $self->{dbDiffDir};
195 0 0       0   unlink $self->{testResultsArchive} if -e $self->{testResultsArchive};
196             }
197              
198             =head2 getTestResultFileAndDirectoryPaths
199             @STATIC
200            
201             Injects paths to the given HASHRef.
202            
203             Centers the relevant path calculation logic so the paths can be accessed from external tests as well.
204            
205             =cut
206              
207             sub getTestResultFileAndDirectoryPaths {
208 1     1 1 4064   my ($hash, $resultsDir) = @_;
209 1         4   $hash->{testResultsDir} = $resultsDir.'/testResults';
210 1         4   $hash->{testResultsArchive} = 'testResults.tar.gz';
211 1         3   $hash->{junitDir} = $hash->{testResultsDir}.'/junit';
212 1         4   $hash->{coverDir} = $hash->{testResultsDir}.'/cover';
213 1         3   $hash->{cover_dbDir} = $hash->{testResultsDir}.'/cover_db';
214 1         3   $hash->{dbDiffDir} = $hash->{testResultsDir}.'/dbDiff';
215             }
216              
217             =head2 clearCoverDb
218            
219             Empty previous coverage test results
220            
221             =cut
222              
223             sub clearCoverDb {
224 0     0 1 0   my ($self) = @_;
225 0         0   $self->_shell('cover', "-delete $self->{cover_dbDir}");
226             }
227              
228             =head2 createCoverReport
229            
230             Create Cover coverage reports
231            
232             =cut
233              
234             sub createCoverReport {
235 0     0 1 0   my ($self) = @_;
236 0         0   $self->_shell('cover', "-report clover -report html -outputdir $self->{coverDir} $self->{cover_dbDir}");
237             }
238              
239             =head2 tar
240            
241             Create a tar.gz-package out of test deliverables
242             Package contains
243            
244             testResults/cover/clover.xml
245             testResults/junit/*.xml
246            
247             =cut
248              
249             sub tar {
250 0     0 1 0   my ($self) = @_;
251 0         0   my $baseDir = $self->{resultsDir};
252              
253             #Choose directories that need archiving
254 0         0   my @archivable;
255 0 0       0   push(@archivable, $self->{junitDir}) if $self->{junit};
256 0 0       0   push(@archivable, $self->{coverDir}) if $self->{cover};
257 0         0   my @dirs = map { my $a = $_; $a =~ s/\Q$baseDir\E\/?//; $a;} @archivable; #Change absolute path to relative
  0         0  
  0         0  
  0         0  
258 0         0   my $cwd = Cwd::getcwd();
259 0         0   chdir $baseDir;
260 0         0   $self->_shell("tar", "-czf $self->{testResultsArchive} @dirs");
261 0         0   chdir $cwd;
262             }
263              
264             =head2 runharness
265            
266             Runs all given test files
267            
268             =cut
269              
270             sub runharness {
271 0     0 1 0   my ($self) = @_;
272              
273 0 0       0   if ($self->{isDbDiff}) {
274 0         0     $self->databaseDiff(); # Initialize first mysqldump before running any tests
275               }
276              
277 0         0   foreach my $dir (sort keys %{$self->{testFilesByDir}}) {
  0         0  
278 0         0     my @tests = sort @{$self->{testFilesByDir}->{$dir}};
  0         0  
279 0 0       0     unless (scalar(@tests)) {
280 0         0         get_logger()->logdie("\@tests is empty?");
281                 }
282             ##Prepare test harness params
283 0         0     my $dirToPackage = $dir;
284 0         0     $dirToPackage =~ s!^\./!!; #Drop leading "current"-dir chars
285 0         0     $dirToPackage =~ s!/!\.!gsm; #Change directories to dot-separated packages
286 0         0     my $xmlfile = $self->{testResultsDir}.'/junit'.'/'.$dirToPackage.'.xml';
287 0         0     my @exec = (
288                     $EXECUTABLE_NAME,
289                     '-w',
290                 );
291 0 0       0     push(@exec, "-MDevel::Cover=-db,$self->{cover_dbDir},-silent,1,-coverage,all") if $self->{cover};
292 0         0     foreach my $lib (@{$self->{lib}}) {
  0         0  
293 0         0       push(@exec, "-I$lib");
294                 }
295              
296 0 0       0     if ($self->{dryRun}) {
297 0         0         print "TAP::Harness::JUnit would run tests with this config:\nxmlfile => $xmlfile\npackage => $dirToPackage\nexec => @exec\ntests => @tests\n";
298                 }
299                 else {
300 0         0       my $harness;
301 0 0       0       if ($self->{junit}) {
302                     $harness = TAP::Harness::JUnit->new({
303                         xmlfile => $xmlfile,
304                         package => "",
305                         verbosity => get_logger()->is_debug(),
306                         namemangle => 'perl',
307                         callbacks => {
308                           after_test => sub {
309                             $self->databaseDiff({
310                               test => shift->[0], parser => shift
311 0 0   0   0                 }) if $self->{isDbDiff};
312                           },
313                         },
314 0         0             exec => \@exec,
315                     });
316 0         0         $harness->runtests(@tests);
317                   }
318                   else {
319                     $harness = TAP::Harness->new({
320                         verbosity => get_logger()->is_debug(),
321                         callbacks => {
322                           after_test => sub {
323                             $self->databaseDiff({
324                               test => shift->[0], parser => shift
325                             }) if $self->{isDbDiff}
326 0 0   0   0               },
327                         },
328 0         0             exec => \@exec,
329                     });
330 0         0         $harness->runtests(@tests);
331                   }
332                 }
333               }
334             }
335              
336             =head2 databaseDiff
337            
338             Diffs two mysqldumps and finds changes to INSERT INTO queries. Collects names of
339             the tables that have new INSERTs.
340            
341             =cut
342              
343             sub databaseDiff {
344 0     0 1 0     my ($self, $params) = @_;
345              
346 0         0     my $test = $params->{test};
347              
348 0         0     my $user = $self->{dbUser};
349 0         0     my $pass = $self->{dbPass};
350 0         0     my $host = $self->{dbHost};
351 0         0     my $port = $self->{dbPort};
352 0         0     my $db = $self->{dbDatabase};
353 0         0     my $sock = $self->{dbSocket};
354              
355 0 0       0     unless (defined $user) {
356 0         0         die 'KSTestHarness->databaseDiff(): Parameter dbUser undefined';
357                 }
358 0 0       0     unless (defined $host) {
359 0         0         die 'KSTestHarness->databaseDiff(): Parameter dbHost undefined';
360                 }
361 0 0       0     unless (defined $port) {
362 0         0         die 'KSTestHarness->databaseDiff(): Parameter dbPort undefined';
363                 }
364 0 0       0     unless (defined $db) {
365 0         0         die 'KSTestHarness->databaseDiff(): Parameter dbDatabase undefined';
366                 }
367              
368 0   0     0     $self->{tmpDbDiffDir} ||= '/tmp/KSTestHarness/dbDiff';
369 0         0     my $path = $self->{tmpDbDiffDir};
370 0 0       0     unless (-e $path) {
371 0         0         make_path($path);
372                 }
373              
374 0         0     my @mysqldumpargs = (
375                     'mysqldump',
376                     '-u', $user,
377                     '-h', $host,
378                     '-P', $port
379                 );
380              
381 0 0       0     push @mysqldumpargs, "-p$pass" if defined $pass;
382              
383 0 0       0     if ($sock) {
384 0         0         push @mysqldumpargs, '--protocol=socket';
385 0         0         push @mysqldumpargs, '-S';
386 0         0         push @mysqldumpargs, $sock;
387                 }
388 0         0     push @mysqldumpargs, $db;
389              
390 0 0 0     0     unless ($test && -e "$path/previous.sql") {
391 0         0         eval { $self->_shell(@mysqldumpargs, '>', "$path/previous.sql"); };
  0         0  
392                 }
393 0 0       0     return 1 unless defined $test;
394              
395 0         0     eval { $self->_shell(@mysqldumpargs, '>', "$path/current.sql"); };
  0         0  
396              
397 0         0     my $diff;
398 0         0     eval {
399 0         0         $self->_shell(
400                         'git', 'diff', '--color-words', '--no-index', '-U0',
401                         "$path/previous.sql", "$path/current.sql"
402                     );
403                 };
404 0         0     my @tables;
405 0 0       0     if ($diff = $@) {
406             # Remove everything else except INSERT INTO queries
407 0         0         $diff =~ s/(?!^.*INSERT INTO .*$)^.+//mg;
408 0         0         $diff =~ s/^\n*//mg;
409 0         0         @tables = $diff =~ /^INSERT INTO `(.*)`/mg; # Collect names of tables
410 0 0       0         if ($self->{dbDiffIgnoreTables}) {
411 0         0           foreach my $table (@{$self->{dbDiffIgnoreTables}}) {
  0         0  
412 0 0       0             if (grep(/$table/, @tables)) {
413 0         0               @tables = grep { $_ ne $table } @tables;
  0         0  
414                         }
415                       }
416                     }
417 0 0       0         if (@tables) {
418 0 0       0             if ($params->{parser}) {
419                           $self->_add_failed_test_dynamically(
420 0         0                   $params->{parser}, "Test $test leaking test data to following ".
421                               "tables:\n". Data::Dumper::Dumper(@tables)
422                           );
423                         }
424 0         0             get_logger()->info("New inserts at tables:\n" . Data::Dumper::Dumper(@tables));
425 0         0             my $filename = dirname($test);
426 0         0             make_path("$self->{dbDiffDir}/$filename");
427 0         0             open my $fh, '>>', "$self->{dbDiffDir}/$test.out";
428 0         0             print $fh $diff;
429 0         0             close $fh;
430                     }
431                 }
432              
433 0         0     $self->_shell('mv', "$path/current.sql", "$path/previous.sql");
434              
435 0         0     return @tables;
436             }
437              
438             sub _sortFilesByDir {
439 0     0   0     my ($files) = @_;
440 0 0       0     unless (ref($files) eq 'ARRAY') {
441 0         0         get_config()->logdie("\$files is not an ARRAYRef");
442                 }
443 0 0       0     unless (scalar(@$files)) {
444 0         0         get_config()->logdie("\$files is an ampty array?");
445                 }
446              
447             #deduplicate files
448 0         0     my (%seen, @files);
449 0         0     @files = grep !$seen{$_}++, @$files;
450              
451             #Sort by dirs
452 0         0     my %dirsWithFiles;
453 0         0     foreach my $f (@files) {
454 0         0         my $dir = File::Basename::dirname($f);
455 0 0       0         $dirsWithFiles{$dir} = [] unless $dirsWithFiles{$dir};
456 0         0         push (@{$dirsWithFiles{$dir}}, $f);
  0         0  
457                 }
458 0         0     return \%dirsWithFiles;
459             }
460              
461             =head2 _add_failed_test_dynamically
462            
463             Dynamically generates a failed test and pushes the result to the end of
464             TAP::Parser::Result->{__results} for JUnit.
465            
466             C<$parser> is an instance of TAP::Harness::JUnit::Parser
467             C<$desc> is a custom description for the test
468            
469             =cut
470              
471             sub _add_failed_test_dynamically {
472 0     0   0   my ($self, $parser, $desc) = @_;
473              
474 0   0     0   $desc ||= 'Dynamically failed test';
475 0         0   my $test_num = $parser->tests_run+1;
476 0         0   my @plan_split = split(/\.\./, $parser->{plan});
477 0         0   my $plan = $plan_split[0].'..'.++$plan_split[1];
478 0         0   $parser->{plan} = $plan;
479              
480 0 0       0   if (ref($parser) eq 'TAP::Harness::JUnit::Parser') {
481 0         0     my $failed = {};
482 0         0     $failed->{ok} = 'not ok';
483 0         0     $failed->{test_num} = $test_num;
484 0         0     $failed->{description} = $desc;
485 0         0     $failed->{raw} = "not ok $test_num - $failed->{description}";
486 0         0     $failed->{type} = 'test';
487 0         0     $failed->{__end_time} = 0;
488 0         0     $failed->{__start_time} = 0;
489 0         0     $failed->{directive} = '';
490 0         0     $failed->{explanation} = '';
491 0         0     bless $failed, 'TAP::Parser::Result::Test';
492              
493 0         0     push @{$parser->{__results}}, $failed;
  0         0  
494 0         0     $parser->{__results}->[0]->{raw} = $plan;
495 0         0     $parser->{__results}->[0]->{tests_planned}++;
496               }
497 0         0   push @{$parser->{failed}}, $test_num;
  0         0  
498 0         0   push @{$parser->{actual_failed}}, $test_num;
  0         0  
499               
500 0         0   $parser->{tests_planned}++;
501 0         0   $parser->{tests_run}++;
502 0         0   print "not ok $test_num - $desc";
503              
504 0         0   return $parser;
505             }
506              
507             sub _shell {
508 0     0   0   my ($self, $program, @params) = @_;
509 0 0       0   my $programPath = IPC::Cmd::can_run($program) or die "$program is not installed!";
510 0         0   my $cmd = "$programPath @params";
511              
512 0 0       0   if ($self->{dryRun}) {
513 0         0     print "$cmd\n";
514               }
515               else {
516 0         0     my( $success, $error_message, $full_buf, $stdout_buf, $stderr_buf ) =
517                     IPC::Cmd::run( command => $cmd, verbose => 0 );
518 0         0     my $exitCode = ${^CHILD_ERROR_NATIVE} >> 8;
519 0         0     my $killSignal = ${^CHILD_ERROR_NATIVE} & 127;
520 0         0     my $coreDumpTriggered = ${^CHILD_ERROR_NATIVE} & 128;
521 0 0       0     die "Shell command: $cmd\n exited with code '$exitCode'. Killed by signal '$killSignal'.".(($coreDumpTriggered) ? ' Core dumped.' : '')."\nERROR MESSAGE: $error_message\nSTDOUT:\n@$stdout_buf\nSTDERR:\n@$stderr_buf\nCWD:".Cwd::getcwd()
    0          
522                     if $exitCode != 0;
523 0   0     0     get_logger->info("CMD: $cmd\nERROR MESSAGE: ".($error_message // '')."\nSTDOUT:\n@$stdout_buf\nSTDERR:\n@$stderr_buf\nCWD:".Cwd::getcwd());
524 0         0     return "@$full_buf";
525               }
526             }
527              
528             =head2 parseOtherTests
529             @STATIC
530            
531             Parses the given blob of file names and paths invoked from god-knows what ways of shell-magic.
532             Tries to normalize them into something the Test::Harness::* can understand.
533            
534             @param1 ARRAYRef of Strings, which might or might not contain separated textual lists of filepaths.
535             @returns ARRAYRef of Strings, Normalized test file paths
536            
537             =cut
538              
539             sub parseOtherTests {
540 1     1 1 5354     my ($files) = @_;
541 1         27     my @files = split(/(?:,|\s)+/, join(',', @$files));
542              
543 1         2     my @warnings;
544 1         6     for (my $i=0 ; $i<@files ; $i++) {
545 9         18         my $f = $files[$i];
546 9 50       28         if ($f !~ /\.t\b/) {
547 0 0       0             push(@warnings, "File '$f' doesn't look like a Perl test file, as it doesn't have .t ending, ignoring it.") unless (-d $f);
548 0         0             $files[$i] = undef;
549                     }
550                 }
551 1 50       3     if (@warnings) {
552 0 0       0         get_logger->warn(join("\n", @warnings)) if @warnings;
553 0         0         @files = grep { defined $_ } @files;
  0         0  
554                 }
555 1         4     return \@files;
556             }
557              
558             =head2 findfiles
559             @STATIC
560            
561             Helper to the shell command 'find'
562            
563             @param1 String, Dir to look from
564             @param2 String, selector used in the -name -parameter
565             @param3 Integer, -maxdepth, the depth of directories 'find' keeps looking into
566             @returns ARRAYRef of Strings, filepaths found
567            
568             =cut
569              
570             sub findFiles {
571 0     0 0       my ($dir, $selector, $maxDepth) = @_;
572 0 0             $maxDepth = 999 unless(defined($maxDepth));
573 0               my $files = `/usr/bin/find $dir -maxdepth $maxDepth -name '$selector'`;
574 0               my @files = split(/\n/, $files);
575 0               return \@files;
576             }
577              
578             1;
579