-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathJunit.php
More file actions
220 lines (184 loc) · 7.41 KB
/
Copy pathJunit.php
File metadata and controls
220 lines (184 loc) · 7.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
<?php
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* Italix Testing - JUnit XML report
*
* @package Italix\Testing
*/
declare(strict_types=1);
namespace Italix\Testing;
/**
* The results as JUnit XML, which is the only format CI reads.
*
* `Runner` prints for a person: a tick, a name, and the details only when the
* name is not enough. Nothing machine-reads that, so on a build server a suite
* either "passed" or "failed" and the twelve assertions inside it are a wall of
* scrollback. Every CI in common use — GitHub Actions, GitLab, Jenkins,
* Buildkite — consumes JUnit XML and turns it into per-assertion annotations on
* the diff, a history of which test is flaky, and a failure summary that names
* the assertion rather than the file.
*
* This is emitted **as well as** the human output, not instead of it. The two
* audiences want different things and neither has to lose.
*
* ## Sections become `classname`
*
* JUnit has two levels of name and CI tools group by the outer one, which they
* call `classname` because the format was written for Java. These suites have
* two levels too — `section()` and `test()` — so they map across directly, and
* a section reading "the two halves of a message are not equally trustworthy"
* arrives in the build report as the heading it already was.
*
* ## Turning it on
*
* ITALIX_JUNIT=build/junit php src/Libs/Italix/I18n/tests/I18nTest.php
*
* A path ending in `/`, or naming a directory that exists, receives one file
* per suite named after the suite. Anything else is taken as the file to write.
* The variable is read from the environment rather than from `$argv` because
* these suites are plain scripts with no argument parsing, and because a parent
* runner that starts thirty of them can set it once.
*
* @see Runner::junit_to()
*/
final class Junit
{
public const ENV_VAR = 'ITALIX_JUNIT';
private function __construct()
{
}
/**
* Where this suite's report should be written, or null when nothing asked
* for one.
*
* @param string|null $configured_c an explicit path, or null to read the environment
*/
public static function destination(string $suite_c, ?string $configured_c = null): ?string
{
$target_c = $configured_c;
if ($target_c === null) {
$from_env = getenv(self::ENV_VAR);
$target_c = ($from_env === false || $from_env === '') ? null : $from_env;
}
if ($target_c === null) {
return null;
}
$is_dir_flag = substr($target_c, -1) === '/'
|| substr($target_c, -1) === DIRECTORY_SEPARATOR
|| is_dir($target_c);
if (!$is_dir_flag) {
return $target_c;
}
return rtrim($target_c, '/' . DIRECTORY_SEPARATOR) . '/' . self::slug($suite_c) . '.xml';
}
/**
* The report.
*
* @param array<int, array<string, mixed>> $cases each with name, section, ok, details, time
*/
public static function document(string $suite_c, array $cases, float $seconds, string $file_c = ''): string
{
$failed_n = 0;
foreach ($cases as $case) {
if (empty($case['ok'])) {
$failed_n++;
}
}
$attributes = [
'name' => $suite_c,
'tests' => (string) count($cases),
'failures' => (string) $failed_n,
'errors' => '0',
'time' => self::seconds($seconds),
];
if ($file_c !== '') {
$attributes['file'] = $file_c;
}
$xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
$xml .= '<testsuites tests="' . count($cases) . '" failures="' . $failed_n
. '" time="' . self::seconds($seconds) . "\">\n";
$xml .= ' <testsuite' . self::attributes($attributes) . ">\n";
foreach ($cases as $case) {
$xml .= self::case_element($case, $suite_c);
}
$xml .= " </testsuite>\n</testsuites>\n";
return $xml;
}
/**
* Write the report, creating the directory when it does not exist.
*
* Returns false rather than throwing: a report that cannot be written is a
* problem with the build machine, and failing the suite over it would
* report a green codebase as broken.
*/
public static function write(string $path, string $xml): bool
{
$dir = dirname($path);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
return @file_put_contents($path, $xml) !== false;
}
// -------------------------------------------------------------------------
/** @param array<string, mixed> $case */
private static function case_element(array $case, string $suite_c): string
{
$section_c = (string) ($case['section'] ?? '');
$element = ' <testcase' . self::attributes([
'name' => (string) ($case['name'] ?? ''),
'classname' => $section_c === '' ? $suite_c : $suite_c . '.' . $section_c,
'time' => self::seconds((float) ($case['time'] ?? 0.0)),
]);
if (!empty($case['ok'])) {
return $element . "/>\n";
}
$details_c = (string) ($case['details'] ?? '');
$message_c = $details_c === '' ? (string) ($case['name'] ?? '') : $details_c;
return $element . ">\n"
. ' <failure message="' . self::escape($message_c) . '" type="assertion">'
. self::escape($details_c)
. "</failure>\n </testcase>\n";
}
/** @param array<string, string> $attributes */
private static function attributes(array $attributes): string
{
$out = '';
foreach ($attributes as $name_c => $value_c) {
$out .= ' ' . $name_c . '="' . self::escape($value_c) . '"';
}
return $out;
}
/**
* XML 1.0 admits fewer characters than UTF-8 does.
*
* A failure detail can hold anything the assertion decided to print — a raw
* HTTP body, a payload from an XSS corpus, a byte from a fuzzer. Escaping
* the five special characters is not enough: a control byte such as `\x00`
* or `\x1b` is *never* legal in XML 1.0, entity-encoded or not, and one of
* them makes the whole report unparseable. So they are dropped, and the
* report survives the test that found the interesting byte.
*/
private static function escape(string $value_c): string
{
$clean_c = (string) preg_replace('/[^\x09\x0A\x0D\x20-\x{D7FF}\x{E000}-\x{FFFD}]/u', '', $value_c);
if ($clean_c === '' && $value_c !== '') {
// Not valid UTF-8: preg_replace returned null-turned-empty. Keep
// something rather than silently losing the message.
$clean_c = (string) preg_replace('/[^\x09\x0A\x0D\x20-\x7E]/', '', $value_c);
}
return htmlspecialchars($clean_c, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
private static function seconds(float $seconds): string
{
return number_format($seconds, 4, '.', '');
}
private static function slug(string $value_c): string
{
$slug_c = strtolower((string) preg_replace('/[^A-Za-z0-9]+/', '-', $value_c));
return trim($slug_c, '-') === '' ? 'suite' : trim($slug_c, '-');
}
}