Compare commits

...

2 Commits

Author SHA1 Message Date
nuno maduro c6073511a2 chore: fixes test printer 2026-07-19 13:59:31 +01:00
nuno maduro b5e1b83bb4 fix: cache results on parallel 2026-07-19 12:29:33 +01:00
8 changed files with 212 additions and 42 deletions
@@ -0,0 +1,71 @@
<?php
/*
* The MIT License (MIT)
*
* Copyright (c) 2011 Brian Scaturro
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/**
* @internal
*
* This file is overridden so the "T" progress character — emitted by Pest for
* "todo" tests — is routed to the progress file next to the regular characters,
* instead of being treated as unexpected output.
*/
final readonly class ProgressPrinterOutput implements Printer
{
public function __construct(
private Printer $progressPrinter,
private Printer $outputPrinter,
) {}
public function print(string $buffer): void
{
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
if (
$buffer === "\n"
|| preg_match('/^ +$/', $buffer) === 1
|| preg_match('/^ \d+ \/ \d+ \(...%\)$/', $buffer) === 1
) {
return;
}
match ($buffer) {
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', 'T', '.' => $this->progressPrinter->print($buffer),
default => $this->outputPrinter->print($buffer),
};
}
public function flush(): void
{
$this->progressPrinter->flush();
$this->outputPrinter->flush();
}
}
@@ -43,7 +43,7 @@ declare(strict_types=1);
* file that was distributed with this source code.
*/
namespace Pest\Logging\TeamCity\Subscriber;
namespace PHPUnit\TextUI\Output\Default\ProgressPrinter;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
@@ -51,16 +51,20 @@ use ReflectionClass;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*
* This file is overridden so PHPUnit's progress output emits a "T" before the
* regular "S" for "todo" tests — Pest's parallel result printer consumes the
* "T" and swallows the "S" that follows it.
*/
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
final readonly class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{
public function notify(Skipped $event): void
{
if (str_contains($event->message(), '__TODO__')) {
if ($event->message() === '__TODO__') {
$this->printTodoItem();
}
$this->logger()->testSkipped($event);
$this->printer()->testSkipped();
}
/**
@@ -69,7 +73,7 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
private function printTodoItem(): void
{
$mirror = new ReflectionClass($this->printer());
$printerMirror = $mirror->getMethod('printProgress');
$printerMirror->invoke($this->printer(), 'T');
$printProgress = $mirror->getMethod('printProgress');
$printProgress->invoke($this->printer(), 'T');
}
}
+1
View File
@@ -18,6 +18,7 @@ final class BootOverrides implements Bootstrapper
* @var array<int, string>
*/
public const array FILES = [
'ParaTest/WrapperRunner/ProgressPrinterOutput.php',
'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php',
+21 -22
View File
@@ -15,11 +15,6 @@ use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
use PHPUnit\Framework\Exception as FrameworkException;
@@ -254,25 +249,29 @@ final readonly class Converter
...$result->testMarkedIncompleteEvents(),
];
$numberOfNotPassedTests = count(
array_unique(
array_map(
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName();
}
$notPassedTests = [];
return $this->getTestCaseLocation($event->test());
},
$events
)
)
);
foreach ($events as $event) {
if ($event instanceof AfterLastTestMethodErrored) {
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
continue;
}
if ($event instanceof AfterLastTestMethodFailed) {
// PHPUnit's collector does not count these towards `numberOfTestsRun`...
continue;
}
if ($event instanceof BeforeFirstTestMethodErrored || $event instanceof BeforeFirstTestMethodFailed) {
$notPassedTests[] = $event->testClassName();
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
continue;
}
$notPassedTests[] = $this->getTestCaseLocation($event->test());
}
$numberOfPassedTests = $result->numberOfTestsRun()
- count(array_unique($notPassedTests))
- $result->numberOfTestSkippedByTestSuiteSkippedEvents();
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
}
@@ -14,6 +14,10 @@ final class TestSkippedSubscriber extends Subscriber implements SkippedSubscribe
{
public function notify(Skipped $event): void
{
if ($event->message() === '__TODO__') {
return; // "todo" tests are reported in the summary, not as ignored tests...
}
$this->logger()->testSkipped($event);
}
}
@@ -120,10 +120,6 @@ final class ResultPrinter
$unexpectedOutput = $this->tail($outputFile);
if ($unexpectedOutput !== '') {
if (preg_match('/^T+$/', $unexpectedOutput) > 0) {
return;
}
$this->output->write($unexpectedOutput);
}
@@ -11,6 +11,7 @@ use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use ParaTest\WrapperRunner\MissingResultsException;
use ParaTest\WrapperRunner\SuiteLoader;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Result;
@@ -42,6 +43,8 @@ use function assert;
use function count;
use function dirname;
use function file_get_contents;
use function filesize;
use function is_file;
use function max;
use function realpath;
use function str_starts_with;
@@ -54,14 +57,14 @@ use function usleep;
*/
final class WrapperRunner implements RunnerInterface
{
/**
* The time to sleep between cycles.
*/
/**
* The merged test result from the parallel run.
*/
public static ?TestResult $result = null;
/**
* The time to sleep between cycles.
*/
private const int CYCLE_SLEEP = 10000;
/**
@@ -88,6 +91,18 @@ final class WrapperRunner implements RunnerInterface
/** @var array<int,int> */
private array $batches = [];
/** @var array<non-empty-string,true> */
private array $requiredTestResultFiles = [];
/** @var array<non-empty-string,true> */
private array $requiredCoverageFiles = [];
/** @var list<SplFileInfo> */
private array $statusFiles = [];
/** @var list<SplFileInfo> */
private array $progressFiles = [];
/** @var list<SplFileInfo> */
private array $unexpectedOutputFiles = [];
@@ -217,7 +232,7 @@ final class WrapperRunner implements RunnerInterface
if (
$this->exitcode > 0
&& $this->options->configuration->stopOnFailure()
&& $this->options->configuration->stopOnFailureThreshold() > 0
) {
$this->pending = [];
} elseif (($pending = array_shift($this->pending)) !== null) {
@@ -232,6 +247,18 @@ final class WrapperRunner implements RunnerInterface
private function flushWorker(WrapperWorker $worker): void
{
if ($worker->hasExecutedTests()) {
$testResultFile = $worker->testResultFile->getPathname();
if ($testResultFile !== '') {
$this->requiredTestResultFiles[$testResultFile] = true;
}
if (isset($worker->coverageFile) && $worker->coverageFile->getPathname() !== '') {
$this->requiredCoverageFiles[$worker->coverageFile->getPathname()] = true;
}
}
$this->exitcode = max($this->exitcode, $worker->getExitCode());
$this->printer->printFeedback(
$worker->progressFile,
@@ -279,10 +306,15 @@ final class WrapperRunner implements RunnerInterface
$worker->start();
$this->batches[$token] = 0;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->statusFiles[] = $worker->statusFile;
$this->progressFiles[] = $worker->progressFile;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testResultFiles[] = $worker->testResultFile;
if (isset($worker->resultCacheFile)) {
$this->resultCacheFiles[] = $worker->resultCacheFile;
}
if (isset($worker->junitFile)) {
$this->junitFiles[] = $worker->junitFile;
}
@@ -315,6 +347,20 @@ final class WrapperRunner implements RunnerInterface
private function complete(TestResult $testResultSum): int
{
$missingTestResultFiles = [];
foreach ($this->requiredTestResultFiles as $filePath => $true) {
if (is_file($filePath)) {
continue;
}
$missingTestResultFiles[] = $filePath;
}
if ($missingTestResultFiles !== []) {
throw MissingResultsException::create($missingTestResultFiles, 'test_result');
}
foreach ($this->testResultFiles as $testResultFile) {
if (! $testResultFile->isFile()) {
continue;
@@ -398,7 +444,7 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->testRunnerTriggeredNoticeEvents(),
array_values(array_filter(
$testResultSum->testRunnerTriggeredWarningEvents(),
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found in class')
)),
$testResultSum->testRunnerTriggeredIssueDeprecationEvents(),
$testResultSum->testRunnerTriggeredIssueErrorEvents(),
@@ -442,8 +488,11 @@ final class WrapperRunner implements RunnerInterface
$exitcode = Result::exitCode($this->options->configuration, $testResultSum);
$this->clearFiles($this->statusFiles);
$this->clearFiles($this->progressFiles);
$this->clearFiles($this->unexpectedOutputFiles);
$this->clearFiles($this->testResultFiles);
$this->clearFiles($this->resultCacheFiles);
$this->clearFiles($this->coverageFiles);
$this->clearFiles($this->junitFiles);
$this->clearFiles($this->teamcityFiles);
@@ -458,6 +507,20 @@ final class WrapperRunner implements RunnerInterface
return;
}
$missingCoverageFiles = [];
foreach ($this->requiredCoverageFiles as $filePath => $true) {
if (is_file($filePath) && filesize($filePath) !== 0) {
continue;
}
$missingCoverageFiles[] = $filePath;
}
if ($missingCoverageFiles !== []) {
throw MissingResultsException::create($missingCoverageFiles, 'coverage');
}
$coverageManager = new CodeCoverage;
$coverageManager->init(
$this->options->configuration,
+36 -4
View File
@@ -10,6 +10,10 @@ use NunoMaduro\Collision\Exceptions\TestOutcome;
use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
@@ -35,8 +39,7 @@ final class StateGenerator
$testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
$this->addClassLevelEvent($state, $testResultEvent);
}
}
@@ -48,8 +51,7 @@ final class StateGenerator
$testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
$this->addClassLevelEvent($state, $testResultEvent);
}
}
@@ -184,6 +186,36 @@ final class StateGenerator
return $state;
}
/**
* Adds the given class-level "hook" failure to the state. Collision's
* `fromBeforeFirstTestMethodErrored` only accepts `BeforeFirstTestMethodErrored`
* events, so the remaining class-level events get a synthesized test method.
*/
private function addClassLevelEvent(State $state, AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed $event): void
{
if ($event instanceof BeforeFirstTestMethodErrored) {
$state->add(TestResult::fromBeforeFirstTestMethodErrored($event));
return;
}
$methodName = $event instanceof BeforeFirstTestMethodFailed ? 'beforeAll' : 'afterAll';
$state->add(TestResult::fromPestParallelTestCase(
new TestMethod(
$event->testClassName(), // @phpstan-ignore-line
$methodName,
'', // @phpstan-ignore-line
1,
TestDoxBuilder::fromClassNameAndMethodName($event->testClassName(), $methodName), // @phpstan-ignore-line
MetadataCollection::fromArray([]),
TestDataCollection::fromArray([])
),
TestResult::FAIL,
$event->throwable()
));
}
/**
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
*/