File Coverage

blib/lib/TAP/Formatter/GitHubActions.pm
Criterion Covered Total %
statement 97 102 95.1
branch 22 34 64.7
condition 9 21 42.8
subroutine 16 17 94.1
pod 2 3 66.6
total 146 177 82.4


line stmt bran cond sub pod time code
1             package TAP::Formatter::GitHubActions;
2              
3 1     1   260060 use strict;
  1         2  
  1         32  
4 1     1   3 use warnings;
  1         2  
  1         60  
5 1     1   14 use v5.16;
  1         2  
6 1     1   5 use base 'TAP::Formatter::File';
  1         2  
  1         381  
7              
8             our $VERSION = '0.3.4';
9              
10 1     1   13813 use TAP::Formatter::GitHubActions::Error;
  1         2  
  1         25  
11 1     1   330 use TAP::Formatter::GitHubActions::ErrorGroup;
  1         2  
  1         23  
12 1     1   5 use TAP::Formatter::GitHubActions::Utils;
  1         1  
  1         46  
13 1     1   384 use TAP::Formatter::GitHubActions::ErrorAggregate;
  1         3  
  1         1050  
14              
15             my $MONOPHASIC_REGEX = qr/^Failed\stest .+\nat/;
16             my $RUNNING_IN_GHA = $ENV{GITHUB_ACTIONS};
17             my $GHA_SKIP_SUMMARY = $ENV{GHA_SKIP_SUMMARY};
18              
19             sub _stash_line_for_current_test {
20 77     77   180 my ($self, $parser, $line) = @_;
21 77         195 my $test_num = $parser->{tests_run};
22 77   100     476 my $stash = ($parser->{_msgs_for_test}{$test_num} //= []);
23 77         159 push @{$stash}, $line;
  77         317  
24             }
25              
26             sub _normalize_stash {
27 18     18   34 my $stash = shift;
28 18         29 my @new_stash;
29              
30             # First phase: Join all output, and segment it by "Failed test".
31 18         33 my @chunks = split(/(Failed test)/, join("\n", @{$stash}));
  18         211  
32              
33             # Second phase, heal the splits so we end up with every string in the stash
34             # beginning with "Failed test".
35 18         62 while (scalar @chunks) {
36 49         89 my $chunk = shift @chunks;
37             # skip empty lines (consequence of split)
38 49 100       128 next unless $chunk;
39              
40             # if we hit the separator
41 31 50 50     228 if ($chunk eq 'Failed test' && scalar @chunks) {
42             # Grab a new chunk
43 31         86 $chunk .= shift @chunks;
44             # Cleanup the newlines for named tests.
45 31 100       515 $chunk =~ s/\n/ / if $chunk =~ qr/$MONOPHASIC_REGEX/;
46             }
47              
48             # kill off any rouge newlines
49 31         106 chomp($chunk);
50 31         164 my $error = TAP::Formatter::GitHubActions::Error->from_output($chunk);
51 31 50       129 next unless $error;
52              
53 31         94 push @new_stash, $error;
54             }
55              
56             # Return it
57 18         73 return @new_stash;
58             }
59              
60             sub open_test {
61 5     5 1 130303 my ($self, $test, $parser) = @_;
62 5         124 my $session = $self->SUPER::open_test($test, $parser);
63              
64             # force verbosity to be able to read comments, yamls & unknowns.
65 5         1327 $self->verbosity(1);
66              
67             # We'll use the parser as a vessel, afaics there's one parser instance per
68             # parallel job.
69              
70             # We'll keep track of all output of a test with this.
71 5         59 $parser->{_msgs_for_test} = {};
72              
73             # In an ideal world, we'd just need to listen to `comment` and that should
74             # suffice, but `throws_ok` & `lives_ok` report via `unknown`...
75             # But this is real life...
76             # so...
77             my $handler = sub {
78 159     159   2198507 my $result = shift->raw;
79             # Skip Subtests
80 159 100       1077 return if $result =~ /Subtest/;
81             # Ignore anything that's not a comment (plans & tests)
82 154 100       756 return unless $result =~ /^\s*#/;
83             # Skip trailing end of tests/subtests
84 67 100       312 return if $result =~ m/Looks like you/;
85             # Cleanup comment start
86 58         373 $result =~ s/\s*# //;
87             # Test::Exception doens't indent the messages 🤡, excluding those keywords
88             # and anything that doesn't begin with a space.
89 58 50       323 return unless $result =~ m/^( |died|found|expecting)/;
90             # Skip lines only having whitespaces
91 58 50       306 return if $result =~ m/^ +$/;
92             # Cleanup indent (2 spaces)
93 58         208 $result =~ s/^ //;
94 58         219 $self->_stash_line_for_current_test($parser, $result);
95 5         140 };
96              
97 5         100 $parser->callback(comment => $handler);
98 5         590 $parser->callback(unknown => $handler);
99              
100             # Enable YAML Support
101 5         158 $parser->version(13);
102             $parser->callback(yaml => sub {
103 27     27   41042 my $yaml = shift->data;
104             # skip notes.
105 27 100       137 return if grep { $yaml->{emitter} eq $_ } qw(Test::More::note Test::More::diag);
  54         216  
106             # skip empty messages (prolly never happens?)
107 19 50       73 return unless $yaml->{message};
108 19         88 $self->_stash_line_for_current_test($parser, $yaml->{message});
109 5         90 });
110              
111 5         162 return $session;
112             }
113              
114       0 0   sub header { }
115              
116             sub _output_report_notice {
117 4     4   15 my ($self, $test) = @_;
118 4         8 my $workflow_url = '%WORKFLOW_URL%';
119              
120 12         54 my @workflow_vars = grep { $_ } @ENV{
121 4         33 qw(
122             GITHUB_SERVER_URL
123             GITHUB_REPOSITORY
124             GITHUB_RUN_ID
125             )
126             };
127              
128 4 0 33     3080 if (!$GHA_SKIP_SUMMARY && !!@workflow_vars) {
129 0         0 $workflow_url = sprintf("%s/%s/actions/runs/%s", @workflow_vars);
130             }
131              
132 4         62 my $file_marker_line = TAP::Formatter::GitHubActions::Utils::log_annotation_line(
133             type => 'notice',
134             filename => $test,
135             line => 1,
136             title => 'More details',
137             body => "See the full report in: $workflow_url"
138             );
139              
140 4         35 $self->_output("$file_marker_line\n");
141             }
142              
143             sub summary {
144 5     5 1 564977 my ($self, $aggregate, $interrupted) = @_;
145 5         53 $self->SUPER::summary($aggregate, $interrupted);
146              
147 5         3302 my $total = $aggregate->total;
148 5         24 my $passed = $aggregate->passed;
149              
150 5 100 66     66 return if ($total == $passed && !$aggregate->has_problems);
151              
152 4         16 $self->_output("\n= GitHub Actions Report =\n");
153              
154             # First print a mark at the beginning of the files reporting errors with a
155             # link to the workflow run for the full report.
156             #
157             # This is a workaround due to the fact that there's a hard limit on the
158             # amount of annotations rendered by GitHub on Pull requests.
159             #
160             # As of writting is a max of 10 annotations per step, 50 per workflow.
161             # To overcome this we'll write here to the Workflow Summary File.
162             # and link back in an anotation.
163             # see [0] & [1] & [2].
164 4         44 foreach my $test ($aggregate->descriptions) {
165 4         88 my ($parser) = $aggregate->parsers($test);
166 4 50 33     75 next if $parser->passed == $parser->tests_run && !$parser->exit;
167              
168 4         60 $self->_output_report_notice($test);
169             }
170              
171             # Now print all error annotations
172 4         74 foreach my $test ($aggregate->descriptions) {
173 4         43 my ($parser) = $aggregate->parsers($test);
174 4 50 33     82 next if $parser->passed == $parser->tests_run && !$parser->exit;
175              
176 4         66 $self->_dump_test_parser($test, $parser);
177             }
178             }
179              
180             sub _dump_test_parser {
181 4     4   14 my ($self, $test, $parser) = @_;
182              
183 4         75 my $error_aggregate = TAP::Formatter::GitHubActions::ErrorAggregate->new();
184              
185             # Transform messages into error objects and feed them into the error
186             # aggregate.
187 4         8 foreach my $test_num (sort keys %{$parser->{_msgs_for_test}}) {
  4         34  
188 18         47 my $stash = $parser->{_msgs_for_test}->{$test_num};
189 18         48 $error_aggregate->add(_normalize_stash($stash));
190             }
191              
192 4         48 my @error_groups = $error_aggregate->as_sorted_array();
193             # Print in GHA Annotation format
194 4         76 $self->_output($_->as_gha_summary_for($test)) for (@error_groups);
195              
196             # Skip if not running in GitHub Actions
197 4 50 33     156 return if !($RUNNING_IN_GHA && !$GHA_SKIP_SUMMARY);
198              
199             # Write full report on GHA Step Summary [2]
200 0 0 0       open(my $summary_fd, '>>', $ENV{GITHUB_STEP_SUMMARY}) or die "Unable to open " . $ENV{GITHUB_STEP_SUMMARY} . ": $!" if $RUNNING_IN_GHA;
201 0           print $summary_fd "## Failures in `$test`\n";
202 0           print $summary_fd $_->as_markdown_summary() for (@error_groups);
203 0           print $summary_fd "\n\n";
204             }
205              
206             # [0]: https://github.com/orgs/community/discussions/26680#discussioncomment-3252835
207             # [1]: https://github.com/orgs/community/discussions/68471
208             # [2]: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-job-summary
209              
210             1;
211             __END__