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.052';
7 1     1   581 use strict;
  1         1  
  1         28  
8 1     1   5 use warnings;
  1         2  
  1         20  
9 1     1   474 use utf8;
  1         13  
  1         4  
10              
11 1     1   691 use TestRail::API;
  1         6  
  1         63  
12 1     1   581 use TestRail::Utils;
  1         3  
  1         38  
13 1     1   585 use TestRail::Utils::Find;
  1         4  
  1         47  
14              
15 1     1   797 use Getopt::Long qw{GetOptionsFromArray};
  1         9113  
  1         4  
16 1     1   649 use File::HomeDir qw{my_home};
  1         5068  
  1         61  
17 1     1   6 use JSON::MaybeXS ();
  1         2  
  1         15  
18 1     1   479 use Statistics::Descriptive;
  1         7096  
  1         32  
19 1     1   7 use List::MoreUtils qw{uniq};
  1         2  
  1         9  
20 1     1   641 use File::Basename qw{basename};
  1         2  
  1         2032  
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   42741 my %params = @_;
30 14         78 my $opts = {};
31              
32             #Parse config file if we are missing api url/key or user
33 14   50     329 my $homedir = my_home() || '.';
34 14 50       1614 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         1514 'h|help' => \$opts->{'help'},
56             );
57              
58 14 100       28075 if ( $opts->{help} ) { return ( '', TestRail::Utils::help() ); }
  1         50  
59              
60 13 50       25 die("No tests passed") unless scalar( @{ $params{'args'} } );
  13         51  
61              
62 13         40 $opts->{'browser'} = $params{'browser'};
63              
64 13         406 TestRail::Utils::interrogateUser( $opts, qw{apiurl user password} );
65              
66 13         286 my $tr = TestRail::Utils::getHandle($opts);
67 13         28 my $prior_search;
68 13         34 my $prior_runs = [];
69 13         28 my $prior_plans = [];
70 13         32 my $prior_hash = {};
71 13         25 my @prior_versions;
72 13 100       65 if ( $opts->{'cachefile'} ) {
73 2         4 foreach my $cf ( @{ $opts->{cachefile} } ) {
  2         6  
74 2 50 33     63 die("Prior search file '$cf' passed does not exist")
75             if $cf && !( -e $cf );
76 2         14 my $raw_text = '';
77 2 50       173 open( my $fh, '<', $cf ) or die "Could not open $cf";
78 2         122 while (<$fh>) {
79 2         14 $raw_text .= $_;
80             }
81 2         29 close($fh);
82 2         363 $prior_search = JSON::MaybeXS::decode_json($raw_text);
83 2         13 foreach my $key ( keys(%$prior_search) ) {
84 2   50     50 my $str_str = $prior_search->{search_string} // '';
85 2   50     26 my $str_pat = $opts->{pattern} // '';
86 2 50       9 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       14 $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       9 if ( exists $prior_hash->{$key} ) {
95 1         21 foreach my $plt (
96 1         13 keys( %{ $prior_hash->{$key}->{versions_by_status} } ) )
97             {
98 1 50       70 delete $prior_hash->{$key}->{versions_by_status}->{$plt}
99             if !$plt;
100 1         3 foreach my $v (
101             keys(
102             %{
103             $prior_hash->{$key}->{versions_by_status}
104 1         5 ->{$plt}
105             }
106             )
107             )
108             {
109 0         0 push( @prior_versions, $v );
110             }
111             }
112             }
113              
114 2         4 push( @$prior_runs, @{ $prior_search->{$key}->{'seen_runs'} } );
  2         66  
115             push( @$prior_plans,
116 2         8 @{ $prior_search->{$key}->{'seen_plans'} } );
  2         58  
117             }
118 2         619 @$prior_plans = uniq(@$prior_plans);
119 2         361 @$prior_runs = uniq(@$prior_runs);
120 2         73 $opts->{'plan_ids'} = $prior_plans;
121 2         15 $opts->{'run_ids'} = $prior_runs;
122             }
123             }
124              
125             my ( $res, $seen_plans, $seen_runs ) =
126 13         33 TestRail::Utils::Find::getResults( $tr, $opts, @{ $params{'args'} } );
  13         96  
127              
128             #Make sure subsequent runs keep ignoring the prior plans
129 13         3728 push( @$seen_plans, @$prior_plans );
130              
131 13         489 my $statuses = $tr->getPossibleTestStatuses();
132 13         55 my %status_map;
133 117         826 @status_map{ map { $_->{'id'} } @$statuses } =
134 13         172 map { $_->{'label'} } @$statuses;
  117         238  
135              
136 13         171 my $out = '';
137 13         53 my $out_json = $prior_hash;
138              
139 13         150 foreach my $case ( keys(%$res) ) {
140 11         48 $out .= "#############################\n";
141 11         35 my $num_runs = 0;
142 11         28 my $casetotals = {};
143 11         26 my $versions_by_status = {};
144 11         21 my $defects = [];
145 11         31 my $seen_versions = [];
146 11         20 my $total_elapsed = 0;
147 11         22 my $avg_elapsed = 0;
148 11         16 my $median_runtime = 0;
149 11         22 my $elapsetotals = [];
150 11   50     166 $out_json->{$case} //= {};
151              
152 11         23 foreach my $casedef ( @{ $res->{$case} } ) {
  11         46  
153 4336         4723 $num_runs++;
154 4336         4619 my $cplatform = 'default';
155 0         0 $cplatform = join( ',', sort @{ $casedef->{config_ids} } )
156             if ref( $casedef->{config_ids} ) eq 'ARRAY'
157 4336 50 50     10043 && scalar( @{ $casedef->{config_ids} } );
  4336         8838  
158 4336   50     6171 $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         4258 foreach my $result ( @{ $casedef->{results} } ) {
  4336         8287  
162 6636 50       12733 if ( defined $result->{status_id} ) {
163              
164             #Assignment is handled as creating a new result with an undef status id
165 6636         9640 $casetotals->{ $result->{status_id} }++;
166 6636   100     10312 $versions_by_status->{$cplatform} //= {};
167 6636 100       10535 if ( $result->{version} ) {
168 1527         2070 push( @$seen_versions, $result->{version} );
169             $versions_by_status->{$cplatform}
170 1527   100     3061 ->{ $result->{version} } //= [];
171             push(
172             @{
173             $versions_by_status->{$cplatform}
174             ->{ $result->{version} }
175 1527         6860 },
176             {
177             $status_map{ $result->{status_id} } =>
178 1527         1653 "$tr->{apiurl}/index.php?/tests/view/$casedef->{id}"
179             }
180             );
181             }
182             }
183             push( @$defects, $result->{defects} )
184 6636 50 66     11242 if $result->{defects} && ref( $result->{defects} ) ne 'ARRAY';
185 1018         1966 push( @$defects, @{ $result->{defects} } )
186 6636 100 66     10197 if $result->{defects} && ref( $result->{defects} ) eq 'ARRAY';
187 6636         11030 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         313 @$seen_versions = uniq( ( @$seen_versions, @prior_versions ) );
193              
194 11         132 my @all_platforms = grep { $_ } uniq(
195             (
196 11         37 keys( %{ $out_json->{$case}->{versions_by_status} } ),
  11         382  
197             keys(%$versions_by_status)
198             )
199             );
200              
201 11         411 @$defects = uniq(@$defects);
202 11         36 foreach my $plt (@all_platforms) {
203 11   50     195 $out_json->{$case}->{versions_by_status}->{$plt} //= {};
204 11         23 foreach my $ver ( keys( %{ $versions_by_status->{$plt} } ) ) {
  11         53  
205 3         160 @{ $versions_by_status->{$plt}->{$ver} } =
206 1524         3751 sort { ( keys(%$a) )[0] cmp( keys(%$b) )[0] }
207 3         11 @{ $versions_by_status->{$plt}->{$ver} };
  3         188  
208             }
209 11         33 foreach my $v (@$seen_versions) {
210 3   50     35 $out_json->{$case}->{versions_by_status}->{$plt}->{$v} //= [];
211             }
212             }
213              
214             #Initialize out_json correctly
215 11   50     179 $out_json->{$case}->{num_runs} //= 0;
216 11   50     148 $out_json->{$case}->{seen_runs} //= [];
217 11   50     65 $out_json->{$case}->{seen_plans} //= [];
218 11   50     60 $out_json->{$case}->{elapsetotals} //= [];
219 11   50     194 $out_json->{$case}->{defects} //= [];
220 11         68 foreach my $status ( keys(%status_map) ) {
221 99   50     357 $out_json->{$case}->{ $status_map{$status} } //= 0;
222             }
223              
224 11         56 my $pattern_output = '';
225 11         146 $out_json->{$case}->{search_string} = $opts->{'pattern'};
226             $pattern_output = " using search string '$opts->{pattern}'"
227 11 100       54 if $opts->{'pattern'};
228              
229 11         116 $out .= "$case was present in $num_runs runs$pattern_output.\n";
230 11         119 $out_json->{$case}->{'num_runs'} += $num_runs;
231 11         22 push( @{ $out_json->{$case}->{'seen_runs'} }, @$seen_runs );
  11         826  
232 11         36 push( @{ $out_json->{$case}->{'seen_plans'} }, @$seen_plans );
  11         543  
233              
234             #Collect time statistics
235 11         24 push( @{ $out_json->{$case}->{elapsetotals} }, @$elapsetotals );
  11         1011  
236 11         606 my $timestats = Statistics::Descriptive::Full->new();
237 11         2428 $timestats->add_data( @{ $out_json->{$case}->{elapsetotals} } );
  11         324  
238              
239 11   100     8911 $out_json->{$case}->{total_elapsed} = $timestats->sum() || 0;
240 11         251 $out .=
241             "Total time spent running this test: $out_json->{$case}->{total_elapsed} seconds\n";
242 11   100     172 $out_json->{$case}->{median_elapsed} = $timestats->median() || 0;
243 11         22113 $out .=
244             "Median time spent running this test: $out_json->{$case}->{median_elapsed} seconds\n";
245 11   100     40 $out_json->{$case}->{average_elapsed} = $timestats->mean() || 0;
246 11         145 $out .=
247             "Mean time spent running this test: $out_json->{$case}->{average_elapsed} seconds\n";
248             $out_json->{$case}->{stdev_elapsed} =
249 11   100     61 $timestats->standard_deviation() || 0;
250 11         631 $out .=
251             "Standard deviations of runtime in test: $out_json->{$case}->{stdev_elapsed}\n";
252 11   100     42 $out_json->{$case}->{max_elapsed} = $timestats->max() || 0;
253 11         110 $out .=
254             "Maximum time spent running this test: $out_json->{$case}->{max_elapsed} seconds\n";
255 11   100     32 $out_json->{$case}->{min_elapsed} = $timestats->min() || 0;
256 11         127 $out .=
257             "Minimum time spent running this test: $out_json->{$case}->{min_elapsed} seconds\n";
258 11   50     34 $out_json->{$case}->{times_executed} = $timestats->count() || 0;
259 11         97 $out .=
260             "Num times this test has been executed: $out_json->{$case}->{times_executed}\n";
261              
262 11         70 foreach my $status ( keys(%$casetotals) ) {
263 18         61 $out .= "$status_map{$status}: $casetotals->{$status}\n";
264             $out_json->{$case}->{ $status_map{$status} } +=
265 18         53 $casetotals->{$status};
266             }
267              
268 11         39 foreach my $platform ( keys(%$versions_by_status) ) {
269 11         22 foreach
270 11         35 my $version ( keys( %{ $versions_by_status->{$platform} } ) )
271             {
272             $out .=
273             "Version $version in platform IDs ($platform) had statuses :\n"
274             . join( ',',
275 3         12 @{ $versions_by_status->{$platform}->{$version} } )
  3         876  
276             . "\n";
277             push(
278             @{
279             $out_json->{$case}->{versions_by_status}->{$platform}
280 3         15 ->{$version}
281             },
282 3         15 @{ $versions_by_status->{$platform}->{$version} }
  3         138  
283             );
284             @{ $out_json->{$case}->{versions_by_status}->{$platform}
285 3         533 ->{$version} } = uniq(
286             @{
287 3         10 $out_json->{$case}->{versions_by_status}->{$platform}
288 3         1819 ->{$version}
289             }
290             );
291             }
292             }
293              
294 11 100       126 $out .= "\nDefects related to case:\n" . join( ',', @$defects ) . "\n"
295             if @$defects;
296 11         22 push( @{ $out_json->{$case}->{defects} }, @$defects );
  11         41  
297 11         560 @{ $out_json->{$case}->{defects} } =
298 11         20 uniq( @{ $out_json->{$case}->{defects} } );
  11         53  
299              
300             }
301              
302 13 100       81 if ( $opts->{'json'} ) {
303 5         95 my $coder = JSON::MaybeXS->new;
304 5         125 $coder->canonical(1);
305 5 100       3612 return ( $coder->encode($out_json), 0 ) unless $opts->{'perfile'};
306              
307 2 100       2621 die("no such directory $opts->{perfile}") unless -d $opts->{perfile};
308              
309 1         23 foreach my $test ( keys(%$out_json) ) {
310 1         213 my $tn = basename($test);
311 1 50       240 open( my $fh, '>', "$opts->{perfile}/$tn.json" )
312             or die "could not open $opts->{perfile}/$tn.json";
313 1         360 print $fh $coder->encode( { $test => $out_json->{$test} } );
314 1         94 close $fh;
315             }
316 1         3971 return ( '', 0 );
317             }
318              
319 8         27 $out .= "#############################";
320 8         13738 return ( $out, 0 );
321             }
322              
323             sub _elapsed2secs {
324 6640     6640   13912 my $stamp = shift;
325 6640 100       10591 return 0 if !$stamp;
326 5113         11737 my ($seconds) = $stamp =~ m/(\d*)s/;
327 5113         7597 my ($seconds_minutes) = $stamp =~ m/(\d*)m/;
328 5113         5682 my ($seconds_hours) = $stamp =~ m/(\d*)h/;
329 5113 100 100     14576 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.052
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) 2022 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__