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.051';
7 1     1   663 use strict;
  1         3  
  1         31  
8 1     1   5 use warnings;
  1         2  
  1         24  
9 1     1   584 use utf8;
  1         15  
  1         5  
10              
11 1     1   822 use TestRail::API;
  1         4  
  1         67  
12 1     1   638 use TestRail::Utils;
  1         5  
  1         44  
13 1     1   638 use TestRail::Utils::Find;
  1         5  
  1         55  
14              
15 1     1   823 use Getopt::Long qw{GetOptionsFromArray};
  1         10864  
  1         5  
16 1     1   712 use File::HomeDir qw{my_home};
  1         5644  
  1         72  
17 1     1   9 use JSON::MaybeXS ();
  1         3  
  1         18  
18 1     1   578 use Statistics::Descriptive;
  1         8404  
  1         39  
19 1     1   7 use List::MoreUtils qw{uniq};
  1         3  
  1         12  
20 1     1   740 use File::Basename qw{basename};
  1         2  
  1         2327  
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   50810 my %params = @_;
30 14         69 my $opts = {};
31              
32             #Parse config file if we are missing api url/key or user
33 14   50     428 my $homedir = my_home() || '.';
34 14 50       2043 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         1493 'h|help' => \$opts->{'help'},
56             );
57              
58 14 100       32950 if ( $opts->{help} ) { return ( '', TestRail::Utils::help() ); }
  1         26  
59              
60 13 50       32 die("No tests passed") unless scalar( @{ $params{'args'} } );
  13         54  
61              
62 13         37 $opts->{'browser'} = $params{'browser'};
63              
64 13         455 TestRail::Utils::interrogateUser( $opts, qw{apiurl user password} );
65              
66 13         374 my $tr = TestRail::Utils::getHandle($opts);
67 13         34 my $prior_search;
68 13         35 my $prior_runs = [];
69 13         22 my $prior_plans = [];
70 13         53 my $prior_hash = {};
71 13         32 my @prior_versions;
72 13 100       56 if ( $opts->{'cachefile'} ) {
73 2         6 foreach my $cf ( @{ $opts->{cachefile} } ) {
  2         9  
74 2 50 33     75 die("Prior search file '$cf' passed does not exist")
75             if $cf && !( -e $cf );
76 2         23 my $raw_text = '';
77 2 50       222 open( my $fh, '<', $cf ) or die "Could not open $cf";
78 2         146 while (<$fh>) {
79 2         18 $raw_text .= $_;
80             }
81 2         45 close($fh);
82 2         485 $prior_search = JSON::MaybeXS::decode_json($raw_text);
83 2         13 foreach my $key ( keys(%$prior_search) ) {
84 2   50     37 my $str_str = $prior_search->{search_string} // '';
85 2   50     23 my $str_pat = $opts->{pattern} // '';
86 2 50       18 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       19 $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       16 if ( exists $prior_hash->{$key} ) {
95 1         33 foreach my $plt (
96 1         15 keys( %{ $prior_hash->{$key}->{versions_by_status} } ) )
97             {
98 1 50       13 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         9 ->{$plt}
105             }
106             )
107             )
108             {
109 0         0 push( @prior_versions, $v );
110             }
111             }
112             }
113              
114 2         7 push( @$prior_runs, @{ $prior_search->{$key}->{'seen_runs'} } );
  2         89  
115             push( @$prior_plans,
116 2         7 @{ $prior_search->{$key}->{'seen_plans'} } );
  2         64  
117             }
118 2         946 @$prior_plans = uniq(@$prior_plans);
119 2         410 @$prior_runs = uniq(@$prior_runs);
120 2         62 $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         35 TestRail::Utils::Find::getResults( $tr, $opts, @{ $params{'args'} } );
  13         100  
127              
128             #Make sure subsequent runs keep ignoring the prior plans
129 13         4139 push( @$seen_plans, @$prior_plans );
130              
131 13         531 my $statuses = $tr->getPossibleTestStatuses();
132 13         75 my %status_map;
133 117         1049 @status_map{ map { $_->{'id'} } @$statuses } =
134 13         191 map { $_->{'label'} } @$statuses;
  117         265  
135              
136 13         154 my $out = '';
137 13         64 my $out_json = $prior_hash;
138              
139 13         173 foreach my $case ( keys(%$res) ) {
140 11         51 $out .= "#############################\n";
141 11         44 my $num_runs = 0;
142 11         37 my $casetotals = {};
143 11         31 my $versions_by_status = {};
144 11         25 my $defects = [];
145 11         30 my $seen_versions = [];
146 11         23 my $total_elapsed = 0;
147 11         27 my $avg_elapsed = 0;
148 11         23 my $median_runtime = 0;
149 11         35 my $elapsetotals = [];
150 11   50     217 $out_json->{$case} //= {};
151              
152 11         32 foreach my $casedef ( @{ $res->{$case} } ) {
  11         52  
153 4336         5594 $num_runs++;
154 4336         5759 my $cplatform = 'default';
155 0         0 $cplatform = join( ',', sort @{ $casedef->{config_ids} } )
156             if ref( $casedef->{config_ids} ) eq 'ARRAY'
157 4336 50 50     12030 && scalar( @{ $casedef->{config_ids} } );
  4336         10915  
158 4336   50     7198 $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         5256 foreach my $result ( @{ $casedef->{results} } ) {
  4336         10087  
162 6636 50       14727 if ( defined $result->{status_id} ) {
163              
164             #Assignment is handled as creating a new result with an undef status id
165 6636         11978 $casetotals->{ $result->{status_id} }++;
166 6636   100     12352 $versions_by_status->{$cplatform} //= {};
167 6636 100       11519 if ( $result->{version} ) {
168 1527         2622 push( @$seen_versions, $result->{version} );
169             $versions_by_status->{$cplatform}
170 1527   100     3732 ->{ $result->{version} } //= [];
171             push(
172             @{
173             $versions_by_status->{$cplatform}
174             ->{ $result->{version} }
175 1527         7985 },
176             {
177             $status_map{ $result->{status_id} } =>
178 1527         2033 "$tr->{apiurl}/index.php?/tests/view/$casedef->{id}"
179             }
180             );
181             }
182             }
183             push( @$defects, $result->{defects} )
184 6636 50 66     14701 if $result->{defects} && ref( $result->{defects} ) ne 'ARRAY';
185 1018         2262 push( @$defects, @{ $result->{defects} } )
186 6636 100 66     12621 if $result->{defects} && ref( $result->{defects} ) eq 'ARRAY';
187 6636         13407 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         340 @$seen_versions = uniq( ( @$seen_versions, @prior_versions ) );
193              
194 11         299 my @all_platforms = grep { $_ } uniq(
195             (
196 11         73 keys( %{ $out_json->{$case}->{versions_by_status} } ),
  11         362  
197             keys(%$versions_by_status)
198             )
199             );
200              
201 11         668 @$defects = uniq(@$defects);
202 11         146 foreach my $plt (@all_platforms) {
203 11   50     229 $out_json->{$case}->{versions_by_status}->{$plt} //= {};
204 11         29 foreach my $ver ( keys( %{ $versions_by_status->{$plt} } ) ) {
  11         61  
205 3         169 @{ $versions_by_status->{$plt}->{$ver} } =
206 1524         4208 sort { ( keys(%$a) )[0] cmp( keys(%$b) )[0] }
207 3         20 @{ $versions_by_status->{$plt}->{$ver} };
  3         203  
208             }
209 11         38 foreach my $v (@$seen_versions) {
210 3   50     135 $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     189 $out_json->{$case}->{seen_runs} //= [];
217 11   50     72 $out_json->{$case}->{seen_plans} //= [];
218 11   50     175 $out_json->{$case}->{elapsetotals} //= [];
219 11   50     69 $out_json->{$case}->{defects} //= [];
220 11         72 foreach my $status ( keys(%status_map) ) {
221 99   50     411 $out_json->{$case}->{ $status_map{$status} } //= 0;
222             }
223              
224 11         75 my $pattern_output = '';
225 11         160 $out_json->{$case}->{search_string} = $opts->{'pattern'};
226             $pattern_output = " using search string '$opts->{pattern}'"
227 11 100       82 if $opts->{'pattern'};
228              
229 11         84 $out .= "$case was present in $num_runs runs$pattern_output.\n";
230 11         38 $out_json->{$case}->{'num_runs'} += $num_runs;
231 11         22 push( @{ $out_json->{$case}->{'seen_runs'} }, @$seen_runs );
  11         896  
232 11         34 push( @{ $out_json->{$case}->{'seen_plans'} }, @$seen_plans );
  11         619  
233              
234             #Collect time statistics
235 11         34 push( @{ $out_json->{$case}->{elapsetotals} }, @$elapsetotals );
  11         974  
236 11         623 my $timestats = Statistics::Descriptive::Full->new();
237 11         2809 $timestats->add_data( @{ $out_json->{$case}->{elapsetotals} } );
  11         298  
238              
239 11   100     11185 $out_json->{$case}->{total_elapsed} = $timestats->sum() || 0;
240 11         376 $out .=
241             "Total time spent running this test: $out_json->{$case}->{total_elapsed} seconds\n";
242 11   100     138 $out_json->{$case}->{median_elapsed} = $timestats->median() || 0;
243 11         26594 $out .=
244             "Median time spent running this test: $out_json->{$case}->{median_elapsed} seconds\n";
245 11   100     55 $out_json->{$case}->{average_elapsed} = $timestats->mean() || 0;
246 11         310 $out .=
247             "Mean time spent running this test: $out_json->{$case}->{average_elapsed} seconds\n";
248             $out_json->{$case}->{stdev_elapsed} =
249 11   100     173 $timestats->standard_deviation() || 0;
250 11         781 $out .=
251             "Standard deviations of runtime in test: $out_json->{$case}->{stdev_elapsed}\n";
252 11   100     49 $out_json->{$case}->{max_elapsed} = $timestats->max() || 0;
253 11         131 $out .=
254             "Maximum time spent running this test: $out_json->{$case}->{max_elapsed} seconds\n";
255 11   100     51 $out_json->{$case}->{min_elapsed} = $timestats->min() || 0;
256 11         156 $out .=
257             "Minimum time spent running this test: $out_json->{$case}->{min_elapsed} seconds\n";
258 11   50     45 $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         74 foreach my $status ( keys(%$casetotals) ) {
263 18         83 $out .= "$status_map{$status}: $casetotals->{$status}\n";
264             $out_json->{$case}->{ $status_map{$status} } +=
265 18         63 $casetotals->{$status};
266             }
267              
268 11         42 foreach my $platform ( keys(%$versions_by_status) ) {
269 11         26 foreach
270 11         44 my $version ( keys( %{ $versions_by_status->{$platform} } ) )
271             {
272             $out .=
273             "Version $version in platform IDs ($platform) had statuses :\n"
274             . join( ',',
275 3         14 @{ $versions_by_status->{$platform}->{$version} } )
  3         1060  
276             . "\n";
277             push(
278             @{
279             $out_json->{$case}->{versions_by_status}->{$platform}
280 3         16 ->{$version}
281             },
282 3         16 @{ $versions_by_status->{$platform}->{$version} }
  3         167  
283             );
284             @{ $out_json->{$case}->{versions_by_status}->{$platform}
285 3         601 ->{$version} } = uniq(
286             @{
287 3         12 $out_json->{$case}->{versions_by_status}->{$platform}
288 3         2031 ->{$version}
289             }
290             );
291             }
292             }
293              
294 11 100       170 $out .= "\nDefects related to case:\n" . join( ',', @$defects ) . "\n"
295             if @$defects;
296 11         20 push( @{ $out_json->{$case}->{defects} }, @$defects );
  11         41  
297 11         626 @{ $out_json->{$case}->{defects} } =
298 11         24 uniq( @{ $out_json->{$case}->{defects} } );
  11         125  
299              
300             }
301              
302 13 100       107 if ( $opts->{'json'} ) {
303 5         99 my $coder = JSON::MaybeXS->new;
304 5         131 $coder->canonical(1);
305 5 100       4630 return ( $coder->encode($out_json), 0 ) unless $opts->{'perfile'};
306              
307 2 100       1814 die("no such directory $opts->{perfile}") unless -d $opts->{perfile};
308              
309 1         118 foreach my $test ( keys(%$out_json) ) {
310 1         225 my $tn = basename($test);
311 1 50       244 open( my $fh, '>', "$opts->{perfile}/$tn.json" )
312             or die "could not open $opts->{perfile}/$tn.json";
313 1         345 print $fh $coder->encode( { $test => $out_json->{$test} } );
314 1         98 close $fh;
315             }
316 1         3224 return ( '', 0 );
317             }
318              
319 8         26 $out .= "#############################";
320 8         14352 return ( $out, 0 );
321             }
322              
323             sub _elapsed2secs {
324 6640     6640   16609 my $stamp = shift;
325 6640 100       13016 return 0 if !$stamp;
326 5113         14903 my ($seconds) = $stamp =~ m/(\d*)s/;
327 5113         8864 my ($seconds_minutes) = $stamp =~ m/(\d*)m/;
328 5113         6709 my ($seconds_hours) = $stamp =~ m/(\d*)h/;
329 5113 100 100     17719 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.051
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__