File Coverage

bin/testrail-results
Criterion Covered Total %
statement 214 219 97.7
branch 38 48 79.1
condition 38 57 66.6
subroutine 14 14 100.0
pod n/a
total 304 338 89.9


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2             # ABSTRACT: List results for specified test(s).
3             # PODNAME: TestRail::Bin::Results
4              
5             package TestRail::Bin::Results;
6             $TestRail::Bin::Results::VERSION = '0.050';
7 1     1   663 use strict;
  1         2  
  1         30  
8 1     1   6 use warnings;
  1         2  
  1         23  
9 1     1   586 use utf8;
  1         15  
  1         5  
10              
11 1     1   795 use TestRail::API;
  1         6  
  1         64  
12 1     1   644 use TestRail::Utils;
  1         5  
  1         42  
13 1     1   694 use TestRail::Utils::Find;
  1         5  
  1         54  
14              
15 1     1   823 use Getopt::Long qw{GetOptionsFromArray};
  1         10926  
  1         5  
16 1     1   721 use File::HomeDir qw{my_home};
  1         5629  
  1         107  
17 1     1   8 use JSON::MaybeXS ();
  1         2  
  1         19  
18 1     1   545 use Statistics::Descriptive;
  1         8434  
  1         38  
19 1     1   10 use List::MoreUtils qw{uniq};
  1         2  
  1         11  
20 1     1   732 use File::Basename qw{basename};
  1         3  
  1         2392  
21              
22             if ( !caller() ) {
23             my ( $out, $code ) = run( 'args' => \@ARGV );
24             print "$out\n";
25             exit $code;
26             }
27              
28             sub run {
29 14     14   54181 my %params = @_;
30 14         71 my $opts = {};
31              
32             #Parse config file if we are missing api url/key or user
33 14   50     311 my $homedir = my_home() || '.';
34 14 50       2033 if ( -e $homedir . '/.testrailrc' ) {
35 0         0 $opts = TestRail::Utils::parseConfig($homedir);
36             }
37              
38             GetOptionsFromArray(
39             $params{'args'},
40             'apiurl=s' => \$opts->{'apiurl'},
41             'password=s' => \$opts->{'password'},
42             'user=s' => \$opts->{'user'},
43             'j|project=s@' => \$opts->{'projects'},
44             'p|plan=s@' => \$opts->{'plans'},
45             'r|run=s@' => \$opts->{'runs'},
46             'e|encoding=s' => \$opts->{'encoding'},
47             'g|grep=s' => \$opts->{'pattern'},
48             'd|defect=s@' => \$opts->{'defects'},
49             'v|version=s@' => \$opts->{'versions'},
50             'c|cachefile=s@' => \$opts->{'cachefile'},
51             'f|fast' => \$opts->{'fast'},
52             'json' => \$opts->{'json'},
53             'perfile=s' => \$opts->{'perfile'},
54             'm|merged' => \$opts->{'merged'},
55 14         1409 'h|help' => \$opts->{'help'},
56             );
57              
58 14 100       33382 if ( $opts->{help} ) { return ( '', TestRail::Utils::help() ); }
  1         24  
59              
60 13 50       43 die("No tests passed") unless scalar( @{ $params{'args'} } );
  13         56  
61              
62 13         44 $opts->{'browser'} = $params{'browser'};
63              
64 13         443 TestRail::Utils::interrogateUser( $opts, qw{apiurl user password} );
65              
66 13         283 my $tr = TestRail::Utils::getHandle($opts);
67 13         37 my $prior_search;
68 13         30 my $prior_runs = [];
69 13         29 my $prior_plans = [];
70 13         31 my $prior_hash = {};
71 13         51 my @prior_versions;
72 13 100       65 if ( $opts->{'cachefile'} ) {
73 2         28 foreach my $cf ( @{ $opts->{cachefile} } ) {
  2         12  
74 2 50 33     94 die("Prior search file '$cf' passed does not exist")
75             if $cf && !( -e $cf );
76 2         18 my $raw_text = '';
77 2 50       242 open( my $fh, '<', $cf ) or die "Could not open $cf";
78 2         157 while (<$fh>) {
79 2         17 $raw_text .= $_;
80             }
81 2         48 close($fh);
82 2         601 $prior_search = JSON::MaybeXS::decode_json($raw_text);
83 2         14 foreach my $key ( keys(%$prior_search) ) {
84 2   50     49 my $str_str = $prior_search->{search_string} // '';
85 2   50     42 my $str_pat = $opts->{pattern} // '';
86 2 50       11 if ( $str_str ne $str_pat ) {
87 0         0 print
88             "Cached data in $cf has search pattern mismatch, skipping...\n";
89 0         0 next;
90             }
91 2 100       20 $prior_hash->{$key} = $prior_search->{$key} if $opts->{merged};
92              
93             #Ensure that we have all versions represented for every test watched in case we add platforms
94 2 100       11 if ( exists $prior_hash->{$key} ) {
95 1         14 foreach my $plt (
96 1         14 keys( %{ $prior_hash->{$key}->{versions_by_status} } ) )
97             {
98 1 50       23 delete $prior_hash->{$key}->{versions_by_status}->{$plt}
99             if !$plt;
100 1         10 foreach my $v (
101             keys(
102             %{
103             $prior_hash->{$key}->{versions_by_status}
104 1         8 ->{$plt}
105             }
106             )
107             )
108             {
109 0         0 push( @prior_versions, $v );
110             }
111             }
112             }
113              
114 2         8 push( @$prior_runs, @{ $prior_search->{$key}->{'seen_runs'} } );
  2         83  
115             push( @$prior_plans,
116 2         5 @{ $prior_search->{$key}->{'seen_plans'} } );
  2         69  
117             }
118 2         908 @$prior_plans = uniq(@$prior_plans);
119 2         519 @$prior_runs = uniq(@$prior_runs);
120 2         70 $opts->{'plan_ids'} = $prior_plans;
121 2         17 $opts->{'run_ids'} = $prior_runs;
122             }
123             }
124              
125             my ( $res, $seen_plans, $seen_runs ) =
126 13         32 TestRail::Utils::Find::getResults( $tr, $opts, @{ $params{'args'} } );
  13         216  
127              
128             #Make sure subsequent runs keep ignoring the prior plans
129 13         4068 push( @$seen_plans, @$prior_plans );
130              
131 13         609 my $statuses = $tr->getPossibleTestStatuses();
132 13         49 my %status_map;
133 117         1038 @status_map{ map { $_->{'id'} } @$statuses } =
134 13         179 map { $_->{'label'} } @$statuses;
  117         280  
135              
136 13         155 my $out = '';
137 13         81 my $out_json = $prior_hash;
138              
139 13         192 foreach my $case ( keys(%$res) ) {
140 11         55 $out .= "#############################\n";
141 11         32 my $num_runs = 0;
142 11         38 my $casetotals = {};
143 11         59 my $versions_by_status = {};
144 11         27 my $defects = [];
145 11         39 my $seen_versions = [];
146 11         29 my $total_elapsed = 0;
147 11         21 my $avg_elapsed = 0;
148 11         44 my $median_runtime = 0;
149 11         24 my $elapsetotals = [];
150 11   50     214 $out_json->{$case} //= {};
151              
152 11         28 foreach my $casedef ( @{ $res->{$case} } ) {
  11         46  
153 4336         5631 $num_runs++;
154 4336         5546 my $cplatform = 'default';
155 0         0 $cplatform = join( ',', sort @{ $casedef->{config_ids} } )
156             if ref( $casedef->{config_ids} ) eq 'ARRAY'
157 4336 50 50     11425 && scalar( @{ $casedef->{config_ids} } );
  4336         10638  
158 4336   50     6960 $cplatform ||= 'default'
159             ; #XXX catch blank platform info, this can happen in some plans apparently
160             #$out .= "Found case '$case' in run $casedef->{run_id}\n";
161 4336         5255 foreach my $result ( @{ $casedef->{results} } ) {
  4336         9500  
162 6636 50       14830 if ( defined $result->{status_id} ) {
163              
164             #Assignment is handled as creating a new result with an undef status id
165 6636         11282 $casetotals->{ $result->{status_id} }++;
166 6636   100     13648 $versions_by_status->{$cplatform} //= {};
167 6636 100       11112 if ( $result->{version} ) {
168 1527         2648 push( @$seen_versions, $result->{version} );
169             $versions_by_status->{$cplatform}
170 1527   100     3349 ->{ $result->{version} } //= [];
171             push(
172             @{
173             $versions_by_status->{$cplatform}
174             ->{ $result->{version} }
175 1527         5788 },
176             {
177             $status_map{ $result->{status_id} } =>
178 1527         1943 "$tr->{apiurl}/index.php?/tests/view/$casedef->{id}"
179             }
180             );
181             }
182             }
183             push( @$defects, $result->{defects} )
184 6636 50 66     13538 if $result->{defects} && ref( $result->{defects} ) ne 'ARRAY';
185 1018         1856 push( @$defects, @{ $result->{defects} } )
186 6636 100 66     12664 if $result->{defects} && ref( $result->{defects} ) eq 'ARRAY';
187 6636         12730 push( @$elapsetotals, _elapsed2secs( $result->{'elapsed'} ) );
188             }
189             }
190              
191             #Ensure versions_by_status has an entry per platform for every single version seen
192 11         344 @$seen_versions = uniq( ( @$seen_versions, @prior_versions ) );
193              
194 11         188 my @all_platforms = grep { $_ } uniq(
195             (
196 11         93 keys( %{ $out_json->{$case}->{versions_by_status} } ),
  11         379  
197             keys(%$versions_by_status)
198             )
199             );
200              
201 11         514 @$defects = uniq(@$defects);
202 11         66 foreach my $plt (@all_platforms) {
203 11   50     206 $out_json->{$case}->{versions_by_status}->{$plt} //= {};
204 11         32 foreach my $ver ( keys( %{ $versions_by_status->{$plt} } ) ) {
  11         56  
205 3         140 @{ $versions_by_status->{$plt}->{$ver} } =
206 1524         3181 sort { ( keys(%$a) )[0] cmp( keys(%$b) )[0] }
207 3         7 @{ $versions_by_status->{$plt}->{$ver} };
  3         163  
208             }
209 11         34 foreach my $v (@$seen_versions) {
210 3   50     30 $out_json->{$case}->{versions_by_status}->{$plt}->{$v} //= [];
211             }
212             }
213              
214             #Initialize out_json correctly
215 11   50     186 $out_json->{$case}->{num_runs} //= 0;
216 11   50     83 $out_json->{$case}->{seen_runs} //= [];
217 11   50     91 $out_json->{$case}->{seen_plans} //= [];
218 11   50     260 $out_json->{$case}->{elapsetotals} //= [];
219 11   50     74 $out_json->{$case}->{defects} //= [];
220 11         64 foreach my $status ( keys(%status_map) ) {
221 99   50     396 $out_json->{$case}->{ $status_map{$status} } //= 0;
222             }
223              
224 11         80 my $pattern_output = '';
225 11         75 $out_json->{$case}->{search_string} = $opts->{'pattern'};
226             $pattern_output = " using search string '$opts->{pattern}'"
227 11 100       43 if $opts->{'pattern'};
228              
229 11         81 $out .= "$case was present in $num_runs runs$pattern_output.\n";
230 11         40 $out_json->{$case}->{'num_runs'} += $num_runs;
231 11         21 push( @{ $out_json->{$case}->{'seen_runs'} }, @$seen_runs );
  11         781  
232 11         35 push( @{ $out_json->{$case}->{'seen_plans'} }, @$seen_plans );
  11         609  
233              
234             #Collect time statistics
235 11         29 push( @{ $out_json->{$case}->{elapsetotals} }, @$elapsetotals );
  11         870  
236 11         564 my $timestats = Statistics::Descriptive::Full->new();
237 11         2870 $timestats->add_data( @{ $out_json->{$case}->{elapsetotals} } );
  11         455  
238              
239 11   100     11105 $out_json->{$case}->{total_elapsed} = $timestats->sum() || 0;
240 11         285 $out .=
241             "Total time spent running this test: $out_json->{$case}->{total_elapsed} seconds\n";
242 11   100     165 $out_json->{$case}->{median_elapsed} = $timestats->median() || 0;
243 11         26382 $out .=
244             "Median time spent running this test: $out_json->{$case}->{median_elapsed} seconds\n";
245 11   100     51 $out_json->{$case}->{average_elapsed} = $timestats->mean() || 0;
246 11         183 $out .=
247             "Mean time spent running this test: $out_json->{$case}->{average_elapsed} seconds\n";
248             $out_json->{$case}->{stdev_elapsed} =
249 11   100     100 $timestats->standard_deviation() || 0;
250 11         984 $out .=
251             "Standard deviations of runtime in test: $out_json->{$case}->{stdev_elapsed}\n";
252 11   100     54 $out_json->{$case}->{max_elapsed} = $timestats->max() || 0;
253 11         142 $out .=
254             "Maximum time spent running this test: $out_json->{$case}->{max_elapsed} seconds\n";
255 11   100     42 $out_json->{$case}->{min_elapsed} = $timestats->min() || 0;
256 11         135 $out .=
257             "Minimum time spent running this test: $out_json->{$case}->{min_elapsed} seconds\n";
258 11   50     49 $out_json->{$case}->{times_executed} = $timestats->count() || 0;
259 11         151 $out .=
260             "Num times this test has been executed: $out_json->{$case}->{times_executed}\n";
261              
262 11         72 foreach my $status ( keys(%$casetotals) ) {
263 18         92 $out .= "$status_map{$status}: $casetotals->{$status}\n";
264             $out_json->{$case}->{ $status_map{$status} } +=
265 18         56 $casetotals->{$status};
266             }
267              
268 11         41 foreach my $platform ( keys(%$versions_by_status) ) {
269 11         23 foreach
270 11         40 my $version ( keys( %{ $versions_by_status->{$platform} } ) )
271             {
272             $out .=
273             "Version $version in platform IDs ($platform) had statuses :\n"
274             . join( ',',
275 3         11 @{ $versions_by_status->{$platform}->{$version} } )
  3         668  
276             . "\n";
277             push(
278             @{
279             $out_json->{$case}->{versions_by_status}->{$platform}
280 3         28 ->{$version}
281             },
282 3         14 @{ $versions_by_status->{$platform}->{$version} }
  3         146  
283             );
284             @{ $out_json->{$case}->{versions_by_status}->{$platform}
285 3         502 ->{$version} } = uniq(
286             @{
287 3         20 $out_json->{$case}->{versions_by_status}->{$platform}
288 3         1483 ->{$version}
289             }
290             );
291             }
292             }
293              
294 11 100       179 $out .= "\nDefects related to case:\n" . join( ',', @$defects ) . "\n"
295             if @$defects;
296 11         28 push( @{ $out_json->{$case}->{defects} }, @$defects );
  11         44  
297 11         595 @{ $out_json->{$case}->{defects} } =
298 11         24 uniq( @{ $out_json->{$case}->{defects} } );
  11         58  
299              
300             }
301              
302 13 100       93 if ( $opts->{'json'} ) {
303 5         82 my $coder = JSON::MaybeXS->new;
304 5         134 $coder->canonical(1);
305 5 100       5700 return ( $coder->encode($out_json), 0 ) unless $opts->{'perfile'};
306              
307 2 100       3847 die("no such directory $opts->{perfile}") unless -d $opts->{perfile};
308              
309 1         16 foreach my $test ( keys(%$out_json) ) {
310 1         198 my $tn = basename($test);
311 1 50       203 open( my $fh, '>', "$opts->{perfile}/$tn.json" )
312             or die "could not open $opts->{perfile}/$tn.json";
313 1         189 print $fh $coder->encode( { $test => $out_json->{$test} } );
314 1         87 close $fh;
315             }
316 1         1570 return ( '', 0 );
317             }
318              
319 8         23 $out .= "#############################";
320 8         9686 return ( $out, 0 );
321             }
322              
323             sub _elapsed2secs {
324 6640     6640   15884 my $stamp = shift;
325 6640 100       12364 return 0 if !$stamp;
326 5113         14357 my ($seconds) = $stamp =~ m/(\d*)s/;
327 5113         8933 my ($seconds_minutes) = $stamp =~ m/(\d*)m/;
328 5113         6711 my ($seconds_hours) = $stamp =~ m/(\d*)h/;
329 5113 100 100     17445 return ( $seconds || 0 ) +
    100          
330             ( $seconds_minutes ? $seconds_minutes * 60 : 0 ) +
331             ( $seconds_hours ? $seconds_hours * 3600 : 0 );
332             }
333              
334             1;
335              
336             =pod
337              
338             =encoding UTF-8
339              
340             =head1 NAME
341              
342             TestRail::Bin::Results - List results for specified test(s).
343              
344             =head1 VERSION
345              
346             version 0.050
347              
348             =head1 SYNOPSIS
349              
350             testrail-results [OPTIONS] test1 test2 ...
351              
352             require `which testrail-results`;
353             TestRail::Bin::Results::run('args' => \@args);
354              
355             =head1 DESCRIPTION
356              
357             testrail-results - List results for specified test(s).
358              
359             Searches across multiple runs (and projects) for results for broad-based metrics; especially useful for diagnosing unreliable tests, and establishing defect density for certain features.
360              
361             Can be used as the modulino TestRail::Bin::Tests.
362             Has a single 'run' function which accepts a hash with the 'args' parameter being the array of arguments.
363              
364             =head1 WARNING
365              
366             Searching across all projects can take a very long time for highly active TestRail Installations.
367             However, cross project metrics are also very useful.
368              
369             As such, the results from prior searches in json mode may be provided, and the runs previously analyzed therein will not be investigated again.
370              
371             It is up to the caller to integrate this data into their analysis as may be appropriate.
372              
373             =head1 PARAMETERS:
374              
375             =head2 MANDATORY PARAMETERS
376              
377             =over 4
378              
379             --apiurl : full URL to get to TestRail index document
380              
381             --password : Your TestRail Password, or a valid API key (TestRail 4.2 and above).
382              
383             --user : Your TestRail User Name.
384              
385             =back
386              
387             All mandatory options not passed with the above switches, or in your ~/.testrailrc will be prompted for.
388              
389             =head2 SEMI-OPTIONAL PARAMETERS
390              
391             =over 4
392              
393             -e --encoding : Character encoding of arguments. Defaults to UTF-8. See L for supported encodings.
394              
395             =back
396              
397             =head2 OPTIONAL PARAMETERS
398              
399             =over 4
400              
401             -j --project : Restrict search to provided project name. May be passed multiple times.
402              
403             -r --run : Restrict search to runs with the provided name. May be passed multiple times.
404              
405             -p --plan : Restrict search to plans with the provided name. May be passed multiple times.
406              
407             -g --grep : Restrict results printed to those matching the provided pattern. Great for looking for specific failure conditions.
408              
409             -v --version : Restrict results printed to those tested on the provided version(s). May be passed multiple times.
410              
411             -d --defect : Restrict results printed to those related to the provided defect(s). May be passed multiple times.
412              
413             -c --cachefile : Load the provided file as a place to pick up your search from. May be passed multiple times.
414              
415             -f --fast : Reduce the number of required HTTP requests at the cost of historical data (previous statuses in runs). Speeds up gathering a great deal when checking many tests.
416              
417             --json : Print results as a JSON serialization.
418              
419             -m --merged : In merge the output with the cached data provided (if any).
420              
421             --perfile : Output JSON summary data per file to the provided directory when in json mode
422              
423             =back
424              
425             =head1 CONFIGURATION FILE
426              
427             In your \$HOME, (or the current directory, if your system has no concept of a home directory) put a file called .testrailrc with key=value syntax separated by newlines.
428             Valid Keys are the same as documented by L.
429             All options specified thereby are overridden by passing the command-line switches above.
430              
431             =head1 MISCELLANEOUS OPTIONS:
432              
433             =over 4
434              
435             --help : show this output
436              
437             =back
438              
439             =head1 NOTES
440              
441             One of the primary purposes of this script is to create CPANTesters style matrices per version/platform.
442             In --json mode, the 'versions_by_status' attribute will return a data structure looking like so:
443              
444             =over 4
445              
446             =item platform_ids: a comma separated value of the platform IDs the test was run on (sorted to prevent order oddities). This value is a hash key.
447              
448             =over 4
449              
450             =item versions: the direct version string reported. This key corresponds to an array value holding hashrefs with status IDs pointing towards result links, sorted by ID.
451              
452             =back
453              
454             =back
455              
456             The consumer of this should be able to render a color-coded table, much like CPANTesters does.
457              
458             =head1 SPECIAL THANKS
459              
460             Thanks to cPanel Inc, for graciously funding the creation of this distribution.
461              
462             =head1 AUTHOR
463              
464             George S. Baugh
465              
466             =head1 SOURCE
467              
468             The development version is on github at L
469             and may be cloned from L
470              
471             =head1 COPYRIGHT AND LICENSE
472              
473             This software is copyright (c) 2021 by George S. Baugh.
474              
475             This is free software; you can redistribute it and/or modify it under
476             the same terms as the Perl 5 programming language system itself.
477              
478             =cut
479              
480             __END__