Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 181
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
PhpUnitConsoleOutputProcessor
0.00% covered (danger)
0.00%
0 / 181
0.00% covered (danger)
0.00%
0 / 22
6806
0.00% covered (danger)
0.00%
0 / 1
 writeOutputToLogFile
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 processInput
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
342
 handleSummaryTotals
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 handleDotChartLine
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 handleTestSummarySection
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 handleTestSummary
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 processPossibleErrorTotalsLine
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 collectAndDumpFailureSummary
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 getPhpVersion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getPhpUnitVersion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 wereTestsExecuted
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 close
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasFailures
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getFailureDetails
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getSlowTests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 prettyPrintErrors
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 prettyPrintFailures
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getAssertionCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getErrorCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFailureCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTestCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSkippedCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types = 1 );
4
5namespace MediaWiki\Composer\PhpUnitSplitter;
6
7use Composer\IO\IOInterface;
8
9/**
10 * @license GPL-2.0-or-later
11 */
12class PhpUnitConsoleOutputProcessor {
13
14    private const STATE_EXPECT_PHP_VERSION = 0;
15    private const STATE_EXPECT_PHPUNIT_VERSION = 1;
16    private const STATE_EXPECT_DOT_CHART = 2;
17    private const STATE_EXPECT_TEST_SUMMARY = 3;
18    private const STATE_EXPECT_ERROR_SUMMARY = 4;
19    private const STATE_EXPECT_FAILURE_SUMMARY = 5;
20    private const STATE_EXPECT_ERROR_TOTALS = 6;
21    private const STATE_EXPECT_SLOW_TESTS = 7;
22    private const STATE_EOF = 8;
23    private const STATE_CLOSED = 9;
24
25    private int $state = self::STATE_EXPECT_PHP_VERSION;
26    private array $failures = [];
27    private array $errors = [];
28    private array $slowTests = [];
29    private ?string $phpVersion = null;
30    private ?string $phpUnitVersion = null;
31    private ?string $dotChart = null;
32    private bool $noTestsExecuted = false;
33    private ?PhpUnitFailure $currentFailure = null;
34    private int $testCount = 0;
35    private int $assertionCount = 0;
36    private int $errorCount = 0;
37    private int $failureCount = 0;
38    private int $skippedCount = 0;
39
40    public static function writeOutputToLogFile(
41        string $logFilename, ?string $consoleOutput
42    ) {
43        if ( !$consoleOutput ) {
44            return;
45        }
46        file_put_contents( $logFilename, $consoleOutput );
47    }
48
49    /**
50     * @throws PhpUnitConsoleOutputProcessingException
51     */
52    public function processInput( string $data ): void {
53        $array = preg_split( "/\r\n|\n|\r/", $data );
54        foreach ( $array as $inputLine ) {
55            $matches = [];
56            switch ( $this->state ) {
57                case self::STATE_EXPECT_PHP_VERSION:
58                    if ( preg_match( "/^Using PHP (.*)$/", $inputLine, $matches ) ) {
59                        $this->phpVersion = $matches[1];
60                        $this->state = self::STATE_EXPECT_PHPUNIT_VERSION;
61                    }
62                    break;
63
64                case self::STATE_EXPECT_PHPUNIT_VERSION:
65                    if ( preg_match( "/^PHPUnit (.*) by .*$/", $inputLine, $matches ) ) {
66                        $this->phpUnitVersion = $matches[1];
67                        $this->state = self::STATE_EXPECT_DOT_CHART;
68                    }
69                    break;
70
71                case self::STATE_EXPECT_DOT_CHART:
72                    $this->handleDotChartLine( $inputLine );
73                    break;
74
75                case self::STATE_EXPECT_TEST_SUMMARY:
76                    $this->handleTestSummary( $inputLine );
77                    break;
78
79                case self::STATE_EXPECT_ERROR_SUMMARY:
80                    $this->handleSummaryTotals( $inputLine, false );
81                    break;
82
83                case self::STATE_EXPECT_FAILURE_SUMMARY:
84                    $this->handleSummaryTotals( $inputLine, true );
85                    break;
86
87                case self::STATE_EXPECT_ERROR_TOTALS:
88                    $this->processPossibleErrorTotalsLine( $inputLine );
89                    break;
90
91                case self::STATE_EXPECT_SLOW_TESTS:
92                    if ( preg_match( "/^ \d+\. (\d+)ms to run (.*)$/", $inputLine, $matches ) ) {
93                        $this->slowTests[] = new PhpUnitSlowTest( intval( $matches[1] ), $matches[2] );
94                    }
95                    break;
96
97                case self::STATE_EOF:
98                    if ( $inputLine ) {
99                        throw new PhpUnitConsoleOutputProcessingException(
100                            "Unexpected input in `EOF` state: '" . $inputLine . "'"
101                        );
102                    }
103                    break;
104
105                default:
106                    throw new PhpUnitConsoleOutputProcessingException(
107                        "Unexpected processing state " . $this->state
108                    );
109            }
110        }
111        if ( $this->currentFailure && !$this->currentFailure->empty() ) {
112            $this->failures[] = $this->currentFailure;
113        }
114    }
115
116    /**
117     * @throws PhpUnitConsoleOutputProcessingException
118     */
119    private function handleSummaryTotals( string $inputLine, bool $errorSectionComplete ) {
120        if ( preg_match( "/^.*ERRORS!.*$/", $inputLine ) ||
121            preg_match( "/^.*FAILURES!.*$/", $inputLine ) ) {
122            $this->state = self::STATE_EXPECT_ERROR_TOTALS;
123            return;
124        }
125        if ( !$errorSectionComplete && (
126            preg_match( "/^There were (\d+) failures:$/", $inputLine, $matches ) ||
127            $inputLine === "There was 1 failure:" )
128        ) {
129            if ( $this->currentFailure && !$this->currentFailure->empty() ) {
130                $this->errors[] = $this->currentFailure;
131            }
132            $this->state = self::STATE_EXPECT_FAILURE_SUMMARY;
133            $this->currentFailure = new PhpUnitFailure();
134            return;
135        }
136        if ( $this->processPossibleErrorTotalsLine( $inputLine ) ) {
137            return;
138        }
139        if ( !$this->currentFailure->processLine( $inputLine ) ) {
140            if ( !$errorSectionComplete ) {
141                $this->errors[] = $this->currentFailure;
142            } else {
143                $this->failures[] = $this->currentFailure;
144            }
145            $this->currentFailure = new PhpUnitFailure();
146            $this->currentFailure->processLine( $inputLine );
147        }
148    }
149
150    private function handleDotChartLine( string $inputLine ) {
151        if ( $this->dotChart === null ) {
152            $this->dotChart = "";
153        }
154        $this->dotChart .= $inputLine . PHP_EOL;
155        if ( preg_match( "/^.* \(100%\)$/", $inputLine ) ) {
156            $this->state = self::STATE_EXPECT_TEST_SUMMARY;
157            return;
158        }
159        if ( preg_match( "/^.*No tests executed!.*$/", $inputLine ) ) {
160            $this->noTestsExecuted = true;
161            $this->state = self::STATE_EOF;
162        }
163    }
164
165    private function handleTestSummarySection( string $inputLine, string $keyword, int $nextState ): bool {
166        if ( preg_match( "/^There were (\d+) " . $keyword . "s:$/", $inputLine ) ||
167            $inputLine === "There was 1 " . $keyword . ":" ) {
168            $this->state = $nextState;
169            $this->currentFailure = new PhpUnitFailure();
170            return true;
171        }
172        return false;
173    }
174
175    private function handleTestSummary( string $inputLine ) {
176        if ( $this->handleTestSummarySection(
177            $inputLine,
178            "error",
179            self::STATE_EXPECT_ERROR_SUMMARY
180        ) ) {
181            return;
182        }
183        if ( $this->handleTestSummarySection(
184            $inputLine,
185            "failure",
186            self::STATE_EXPECT_FAILURE_SUMMARY
187        ) ) {
188            return;
189        }
190        $this->processPossibleErrorTotalsLine( $inputLine );
191    }
192
193    private function processPossibleErrorTotalsLine( string $inputLine ): bool {
194        $matches = [];
195        if ( preg_match( "/^.*Tests: (\d+).*$/", $inputLine, $matches ) ) {
196            $this->testCount = intval( $matches[1] );
197            if ( preg_match( "/^.*Assertions: (\d+).*$/", $inputLine, $matches ) ) {
198                $this->assertionCount = intval( $matches[1] );
199            }
200            if ( preg_match( "/^.*Failures: (\d+).*$/", $inputLine, $matches ) ) {
201                $this->failureCount = intval( $matches[1] );
202            }
203            if ( preg_match( "/^.*Errors: (\d+).*$/", $inputLine, $matches ) ) {
204                $this->errorCount = intval( $matches[1] );
205            }
206            if ( preg_match( "/^.*Skipped: (\d+).*$/", $inputLine, $matches ) ) {
207                $this->skippedCount = intval( $matches[1] );
208            }
209            $this->state = self::STATE_EXPECT_SLOW_TESTS;
210            return true;
211        }
212        if ( preg_match( "/^.*OK \((\d+) tests?, (\d+) assertions?\).*$/", $inputLine, $matches ) ) {
213            $this->testCount = intval( $matches[1] );
214            $this->assertionCount = intval( $matches[2] );
215            $this->state = self::STATE_EXPECT_SLOW_TESTS;
216        }
217        return false;
218    }
219
220    /**
221     * @throws PhpUnitConsoleOutputProcessingException
222     */
223    public static function collectAndDumpFailureSummary( string $filePattern, int $groupCount, IOInterface $io ): bool {
224        $failuresFound = false;
225        $slowTests = [];
226        for ( $i = 0; $i < $groupCount; $i++ ) {
227            $filename = sprintf( $filePattern, $i );
228            if ( file_exists( $filename ) ) {
229                $summary = new PhpUnitConsoleOutputProcessor();
230                $summary->processInput( file_get_contents( $filename ) );
231                $summary->close();
232                $slowTests = array_values( array_merge( $slowTests, $summary->getSlowTests() ) );
233                $failureDetails = $summary->getFailureDetails();
234                if ( $failureDetails ) {
235                    $io->write( "Report from `split_group" . $i . "`:" . PHP_EOL );
236                    $io->write( $failureDetails );
237                    $failuresFound = true;
238                }
239            }
240        }
241        if ( count( $slowTests ) > 0 ) {
242            $io->write( PHP_EOL . "You should really speed up these slow tests (>100ms)..." . PHP_EOL );
243            usort( $slowTests, static fn ( $t1, $t2 ) => $t2->getDuration() - $t1->getDuration() );
244            for ( $i = 0; $i < min( 10, count( $slowTests ) ); $i++ ) {
245                $test = $slowTests[$i];
246                $io->write( " " . ( $i + 1 ) . ". " . $test->getDuration() . "ms to run " . $test->getTest() );
247            }
248        }
249        return $failuresFound;
250    }
251
252    /**
253     * @throws PhpUnitConsoleOutputProcessingException
254     */
255    public function getPhpVersion(): string {
256        if ( $this->state !== self::STATE_CLOSED ) {
257            throw new PhpUnitConsoleOutputProcessingException( "Still processing. Call `close()` first" );
258        }
259        if ( !$this->phpVersion ) {
260            throw new PhpUnitConsoleOutputProcessingException( "No php version string detceted" );
261        }
262        return $this->phpVersion;
263    }
264
265    /**
266     * @throws PhpUnitConsoleOutputProcessingException
267     */
268    public function getPhpUnitVersion(): string {
269        if ( $this->state !== self::STATE_CLOSED ) {
270            throw new PhpUnitConsoleOutputProcessingException( "Still processing. Call `close()` first" );
271        }
272        if ( !$this->phpUnitVersion ) {
273            throw new PhpUnitConsoleOutputProcessingException( "No phpunit version string detceted" );
274        }
275        return $this->phpUnitVersion;
276    }
277
278    /**
279     * @throws PhpUnitConsoleOutputProcessingException
280     */
281    public function wereTestsExecuted(): bool {
282        if ( $this->state !== self::STATE_CLOSED ) {
283            throw new PhpUnitConsoleOutputProcessingException( "Still processing. Call `close()` first" );
284        }
285        return !$this->noTestsExecuted;
286    }
287
288    public function close(): void {
289        $this->state = self::STATE_CLOSED;
290    }
291
292    /**
293     * @throws PhpUnitConsoleOutputProcessingException
294     */
295    public function hasFailures(): bool {
296        if ( $this->state !== self::STATE_CLOSED ) {
297            throw new PhpUnitConsoleOutputProcessingException( "Still processing. Call `close()` first" );
298        }
299        return count( $this->failures ) + count( $this->errors ) > 0;
300    }
301
302    public function getFailureDetails(): string {
303        $errorDetails = $this->prettyPrintErrors();
304        $failureDetails = $this->prettyPrintFailures();
305        $joiner = "";
306        if ( $errorDetails && $failureDetails ) {
307            $joiner = PHP_EOL . "--" . PHP_EOL . PHP_EOL;
308        }
309        return $errorDetails . $joiner . $failureDetails;
310    }
311
312    public function getSlowTests(): array {
313        return $this->slowTests;
314    }
315
316    private function prettyPrintErrors(): string {
317        $errorCount = count( $this->errors );
318        if ( $errorCount === 0 ) {
319            return "";
320        }
321        $result = "There " . ( $errorCount > 1 ? "were " : "was " ) . $errorCount
322            . " error" . ( $errorCount > 1 ? "s" : "" ) . ":" . PHP_EOL . PHP_EOL;
323        return $result . implode(
324            PHP_EOL,
325            array_map( static fn ( $err ) => $err->getFailureDetails(), array_merge( $this->errors ) )
326        );
327    }
328
329    private function prettyPrintFailures(): string {
330        $failureCount = count( $this->failures );
331        if ( $failureCount === 0 ) {
332            return "";
333        }
334        $result = "There " . ( $failureCount > 1 ? "were " : "was " ) . $failureCount
335            . " failure" . ( $failureCount > 1 ? "s" : "" ) . ":" . PHP_EOL . PHP_EOL;
336        return $result . implode(
337            PHP_EOL,
338            array_map( static fn ( $err ) => $err->getFailureDetails(), array_merge( $this->failures ) )
339        );
340    }
341
342    public function getAssertionCount(): int {
343        return $this->assertionCount;
344    }
345
346    public function getErrorCount(): int {
347        return $this->errorCount;
348    }
349
350    public function getFailureCount(): int {
351        return $this->failureCount;
352    }
353
354    public function getTestCount(): int {
355        return $this->testCount;
356    }
357
358    public function getSkippedCount(): int {
359        return $this->skippedCount;
360    }
361}