From 48309931efc30309c5f60ffedd6b5e734814ad30 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Mon, 6 Feb 2023 16:43:58 +0000 Subject: [PATCH 01/27] WIP --- bin/pest-wrapper.php | 0 composer.json | 4 +- src/Plugins/Parallel.php | 74 ++++ src/Plugins/Parallel/Handlers/Laravel.php | 37 ++ src/Plugins/Parallel/Handlers/Parallel.php | 30 ++ .../Parallel/Paratest/WrapperRunner.php | 315 ++++++++++++++++++ 6 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 bin/pest-wrapper.php create mode 100644 src/Plugins/Parallel.php create mode 100644 src/Plugins/Parallel/Handlers/Laravel.php create mode 100644 src/Plugins/Parallel/Handlers/Parallel.php create mode 100644 src/Plugins/Parallel/Paratest/WrapperRunner.php diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php new file mode 100644 index 00000000..e69de29b diff --git a/composer.json b/composer.json index 5417ae05..0c4e5343 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ ] }, "require-dev": { + "brianium/paratest": "^7.0", "pestphp/pest-dev-tools": "^2.4.0", "pestphp/pest-plugin-arch": "^2.0.0", "symfony/process": "^6.2.5" @@ -89,7 +90,8 @@ "Pest\\Plugins\\Memory", "Pest\\Plugins\\Printer", "Pest\\Plugins\\Retry", - "Pest\\Plugins\\Version" + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" ] } } diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php new file mode 100644 index 00000000..bc2df874 --- /dev/null +++ b/src/Plugins/Parallel.php @@ -0,0 +1,74 @@ +argumentsContainParallelFlags($arguments)) { + $exitCode = $this->runTestSuiteInParallel($arguments); + exit($exitCode); + } + + $this->markTestSuiteAsParallelSubProcessIfRequired(); + + return $arguments; + } + + private function argumentsContainParallelFlags(array $arguments): bool + { + return $this->hasArgument('--parallel', $arguments) + || $this->hasArgument('-p', $arguments); + } + + private function runTestSuiteInParallel(array $arguments): int + { + if (! class_exists(ParaTestCommand::class)) { + $this->askUserToInstallParatest(); + + return 1; + } + + $filteredArguments = array_reduce( + $this->handlers, + fn($arguments, $handler) => (new $handler())->handle($arguments), + $arguments + ); + + $testSuite = TestSuite::getInstance(); + + return ParaTestCommand::applicationFactory($testSuite->rootPath)->run(new ArgvInput($filteredArguments)); + } + + private function markTestSuiteAsParallelSubProcessIfRequired(): void + { + if ((int) Arr::get($_SERVER, 'PARATEST') === 1) { + $_SERVER['PEST_PARALLEL'] = 1; + } + } + + private function askUserToInstallParatest(): void + { + Container::getInstance()->get(OutputInterface::class)->writeln([ + 'Parallel support requires ParaTest, which is not installed.', + 'Please run composer require --dev brianium/paratest to install ParaTest.', + ]); + } +} diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php new file mode 100644 index 00000000..07fcb062 --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -0,0 +1,37 @@ +popArgument($value, $args); + } + } + + return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $args); + } + + private static function isALaravelApplication(): bool + { + return class_exists(\Illuminate\Foundation\Application::class) + && class_exists(\Illuminate\Testing\ParallelRunner::class) + && !class_exists(\Orchestra\Testbench\TestCase::class); + } +} diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php new file mode 100644 index 00000000..0e88c3bb --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -0,0 +1,30 @@ + $this->popArgument($arg, $args), $args); + + return $this->pushArgument('--runner=' . WrapperRunner::class, $args); + } +} diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php new file mode 100644 index 00000000..55873d8c --- /dev/null +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -0,0 +1,315 @@ + */ + private array $workers = []; + /** @var array */ + private array $batches = []; + + /** @var SplFileInfo */ + private array $testresultFiles = []; + /** @var SplFileInfo */ + private array $coverageFiles = []; + /** @var SplFileInfo */ + private array $junitFiles = []; + /** @var SplFileInfo */ + private array $teamcityFiles = []; + /** @var SplFileInfo */ + private array $testdoxFiles = []; + + /** @var non-empty-string[] */ + private readonly array $parameters; + + public function __construct( + private readonly Options $options, + private readonly OutputInterface $output + ) { + $this->printer = new ResultPrinter($output, $options); + + $wrapper = realpath( + dirname(__DIR__, 5) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit-wrapper.php', + ); + assert($wrapper !== false); + $phpFinder = new PhpExecutableFinder(); + $phpBin = $phpFinder->find(false); + assert($phpBin !== false); + $parameters = [$phpBin]; + $parameters = array_merge($parameters, $phpFinder->findArguments()); + + if ($options->passthruPhp !== null) { + $parameters = array_merge($parameters, $options->passthruPhp); + } + + $parameters[] = $wrapper; + + $this->parameters = $parameters; + } + + public function run(): void + { + ExcludeList::addDirectory(dirname(__DIR__)); + TestResultFacade::init(); + EventFacade::seal(); + $suiteLoader = new SuiteLoader($this->options, $this->output); + $result = TestResultFacade::result(); + + $this->pending = $suiteLoader->files; + $this->printer->setTestCount($suiteLoader->testCount); + $this->printer->start(); + $this->startWorkers(); + $this->assignAllPendingTests(); + $this->waitForAllToFinish(); + $this->complete($result); + } + + public function getExitCode(): int + { + return $this->exitcode; + } + + private function startWorkers(): void + { + for ($token = 1; $token <= $this->options->processes; ++$token) { + $this->startWorker($token); + } + } + + private function assignAllPendingTests(): void + { + $batchSize = $this->options->maxBatchSize; + + while (count($this->pending) > 0 && count($this->workers) > 0) { + foreach ($this->workers as $token => $worker) { + if (! $worker->isRunning()) { + throw $worker->getWorkerCrashedException(); + } + + if (! $worker->isFree()) { + continue; + } + + $this->flushWorker($worker); + + if ($batchSize !== null && $batchSize !== 0 && $this->batches[$token] === $batchSize) { + $this->destroyWorker($token); + $worker = $this->startWorker($token); + } + + if ( + $this->exitcode > 0 + && ( + $this->options->configuration->stopOnFailure() + || $this->options->configuration->stopOnError() + ) + ) { + $this->pending = []; + } elseif (($pending = array_shift($this->pending)) !== null) { + $worker->assign($pending); + $this->batches[$token]++; + } + } + + usleep(self::CYCLE_SLEEP); + } + } + + private function flushWorker(WrapperWorker $worker): void + { + $this->exitcode = max($this->exitcode, $worker->getExitCode()); + $this->printer->printFeedback($worker->progressFile); + $worker->reset(); + } + + private function waitForAllToFinish(): void + { + $stopped = []; + while (count($this->workers) > 0) { + foreach ($this->workers as $index => $worker) { + if ($worker->isRunning()) { + if (! isset($stopped[$index]) && $worker->isFree()) { + $worker->stop(); + $stopped[$index] = true; + } + + continue; + } + + if (! $worker->isFree()) { + throw $worker->getWorkerCrashedException(); + } + + $this->flushWorker($worker); + unset($this->workers[$index]); + } + + usleep(self::CYCLE_SLEEP); + } + } + + private function startWorker(int $token): WrapperWorker + { + $worker = new WrapperWorker( + $this->output, + $this->options, + $this->parameters, + $token, + ); + $worker->start(); + $this->batches[$token] = 0; + + $this->testresultFiles[] = $worker->testresultFile; + if (isset($worker->junitFile)) { + $this->junitFiles[] = $worker->junitFile; + } + + if (isset($worker->coverageFile)) { + $this->coverageFiles[] = $worker->coverageFile; + } + + if (isset($worker->teamcityFile)) { + $this->teamcityFiles[] = $worker->teamcityFile; + } + + if (isset($worker->testdoxFile)) { + $this->testdoxFiles[] = $worker->testdoxFile; + } + + return $this->workers[$token] = $worker; + } + + private function destroyWorker(int $token): void + { + // Mutation Testing tells us that the following `unset()` already destroys + // the `WrapperWorker`, which destroys the Symfony's `Process`, which + // automatically calls `Process::stop` within `Process::__destruct()`. + // But we prefer to have an explicit stops. + $this->workers[$token]->stop(); + + unset($this->workers[$token]); + } + + private function complete(TestResult $testResultSum): void + { + foreach ($this->testresultFiles as $testresultFile) { + if (! $testresultFile->isFile()) { + continue; + } + + $contents = file_get_contents($testresultFile->getPathname()); + assert($contents !== false); + $testResult = unserialize($contents); + assert($testResult instanceof TestResult); + + $testResultSum = new TestResult( + $testResultSum->numberOfTests() + $testResult->numberOfTests(), + $testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(), + $testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(), + array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), + array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()), + array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()), + array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()), + array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()), + array_merge_recursive($testResultSum->testTriggeredDeprecationEvents(), $testResult->testTriggeredDeprecationEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpDeprecationEvents(), $testResult->testTriggeredPhpDeprecationEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()), + array_merge_recursive($testResultSum->testTriggeredErrorEvents(), $testResult->testTriggeredErrorEvents()), + array_merge_recursive($testResultSum->testTriggeredNoticeEvents(), $testResult->testTriggeredNoticeEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpNoticeEvents(), $testResult->testTriggeredPhpNoticeEvents()), + array_merge_recursive($testResultSum->testTriggeredWarningEvents(), $testResult->testTriggeredWarningEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpWarningEvents(), $testResult->testTriggeredPhpWarningEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()), + array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()), + array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), + ); + } + + $this->printer->printResults($testResultSum); + $this->generateCodeCoverageReports(); + $this->generateLogs(); + + $this->exitcode = (new ShellExitCodeCalculator())->calculate( + $this->options->configuration->failOnEmptyTestSuite(), + $this->options->configuration->failOnRisky(), + $this->options->configuration->failOnWarning(), + $this->options->configuration->failOnIncomplete(), + $this->options->configuration->failOnSkipped(), + $testResultSum, + ); + } + + protected function generateCodeCoverageReports(): void + { + if ($this->coverageFiles === []) { + return; + } + + CodeCoverage::init($this->options->configuration); + $coverageMerger = new CoverageMerger(CodeCoverage::instance()); + foreach ($this->coverageFiles as $coverageFile) { + $coverageMerger->addCoverageFromFile($coverageFile); + } + + CodeCoverage::generateReports( + $this->printer->printer, + $this->options->configuration, + ); + } + + private function generateLogs(): void + { + if ($this->junitFiles === []) { + return; + } + + $testSuite = (new LogMerger())->merge($this->junitFiles); + (new Writer())->write( + $testSuite, + $this->options->configuration->logfileJunit(), + ); + } +} From 2929af471525824d08541d397d69d41dea4077f4 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Mon, 6 Feb 2023 16:58:34 +0000 Subject: [PATCH 02/27] WIP --- bin/pest-wrapper.php | 91 +++++++++++++++++++ .../Parallel/Paratest/WrapperRunner.php | 80 ++++++++++++---- 2 files changed, 154 insertions(+), 17 deletions(-) diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index e69de29b..2d339284 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -0,0 +1,91 @@ + false]); + assert(is_array($phpunitArgv)); + + /** + * We need to instantiate the Pest Test suite instance + * so that Pest is able to execute correctly. + */ + $argv = new ArgvInput(); + $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); + TestSuite::getInstance( + $rootPath, + $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), + ); + + $application = new ApplicationForWrapperWorker( + $phpunitArgv, + $getopt['progress-file'], + $getopt['testresult-file'], + $getopt['teamcity-file'] ?? null, + $getopt['testdox-file'] ?? null, + isset($getopt['testdox-color']), + ); + + while (true) { + if (feof(STDIN)) { + $application->end(); + exit; + } + + $testPath = fgets(STDIN); + if ($testPath === false || $testPath === WrapperWorker::COMMAND_EXIT) { + $application->end(); + exit; + } + + // It must be a 1 byte string to ensure filesize() is equal to the number of tests executed + $exitCode = $application->runTest(trim($testPath)); + + fwrite($statusFile, (string) $exitCode); + fflush($statusFile); + } +})(); diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 55873d8c..2ce4a13b 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -12,6 +12,7 @@ use ParaTest\RunnerInterface; use ParaTest\WrapperRunner\ResultPrinter; use ParaTest\WrapperRunner\SuiteLoader; use ParaTest\WrapperRunner\WrapperWorker; +use Pest\TestSuite; use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; @@ -31,6 +32,7 @@ use function dirname; use function file_get_contents; use function max; use function realpath; +use function unlink; use function unserialize; use function usleep; @@ -50,15 +52,15 @@ final class WrapperRunner implements RunnerInterface /** @var array */ private array $batches = []; - /** @var SplFileInfo */ + /** @var list */ private array $testresultFiles = []; - /** @var SplFileInfo */ + /** @var list */ private array $coverageFiles = []; - /** @var SplFileInfo */ + /** @var list */ private array $junitFiles = []; - /** @var SplFileInfo */ + /** @var list */ private array $teamcityFiles = []; - /** @var SplFileInfo */ + /** @var list */ private array $testdoxFiles = []; /** @var non-empty-string[] */ @@ -71,7 +73,7 @@ final class WrapperRunner implements RunnerInterface $this->printer = new ResultPrinter($output, $options); $wrapper = realpath( - dirname(__DIR__, 5) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit-wrapper.php', + dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'pest-wrapper.php', ); assert($wrapper !== false); $phpFinder = new PhpExecutableFinder(); @@ -89,21 +91,23 @@ final class WrapperRunner implements RunnerInterface $this->parameters = $parameters; } - public function run(): void + public function run(): int { ExcludeList::addDirectory(dirname(__DIR__)); TestResultFacade::init(); EventFacade::seal(); $suiteLoader = new SuiteLoader($this->options, $this->output); + $this->pending = $this->getTestFiles($suiteLoader); + $result = TestResultFacade::result(); - $this->pending = $suiteLoader->files; $this->printer->setTestCount($suiteLoader->testCount); $this->printer->start(); $this->startWorkers(); $this->assignAllPendingTests(); $this->waitForAllToFinish(); - $this->complete($result); + + return $this->complete($result); } public function getExitCode(): int @@ -141,10 +145,7 @@ final class WrapperRunner implements RunnerInterface if ( $this->exitcode > 0 - && ( - $this->options->configuration->stopOnFailure() - || $this->options->configuration->stopOnError() - ) + && $this->options->configuration->stopOnFailure() ) { $this->pending = []; } elseif (($pending = array_shift($this->pending)) !== null) { @@ -160,7 +161,10 @@ final class WrapperRunner implements RunnerInterface private function flushWorker(WrapperWorker $worker): void { $this->exitcode = max($this->exitcode, $worker->getExitCode()); - $this->printer->printFeedback($worker->progressFile); + $this->printer->printFeedback( + $worker->progressFile, + $this->teamcityFiles, + ); $worker->reset(); } @@ -232,7 +236,7 @@ final class WrapperRunner implements RunnerInterface unset($this->workers[$token]); } - private function complete(TestResult $testResultSum): void + private function complete(TestResult $testResultSum): int { foreach ($this->testresultFiles as $testresultFile) { if (! $testresultFile->isFile()) { @@ -251,6 +255,7 @@ final class WrapperRunner implements RunnerInterface array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()), array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()), + array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()), array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()), array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()), array_merge_recursive($testResultSum->testTriggeredDeprecationEvents(), $testResult->testTriggeredDeprecationEvents()), @@ -268,11 +273,15 @@ final class WrapperRunner implements RunnerInterface ); } - $this->printer->printResults($testResultSum); + $this->printer->printResults( + $testResultSum, + $this->teamcityFiles, + $this->testdoxFiles, + ); $this->generateCodeCoverageReports(); $this->generateLogs(); - $this->exitcode = (new ShellExitCodeCalculator())->calculate( + $exitcode = (new ShellExitCodeCalculator())->calculate( $this->options->configuration->failOnEmptyTestSuite(), $this->options->configuration->failOnRisky(), $this->options->configuration->failOnWarning(), @@ -280,6 +289,14 @@ final class WrapperRunner implements RunnerInterface $this->options->configuration->failOnSkipped(), $testResultSum, ); + + $this->clearFiles($this->testresultFiles); + $this->clearFiles($this->coverageFiles); + $this->clearFiles($this->junitFiles); + $this->clearFiles($this->teamcityFiles); + $this->clearFiles($this->testdoxFiles); + + return $exitcode; } protected function generateCodeCoverageReports(): void @@ -312,4 +329,33 @@ final class WrapperRunner implements RunnerInterface $this->options->configuration->logfileJunit(), ); } + + /** @param list $files */ + private function clearFiles(array $files): void + { + foreach ($files as $file) { + if (! $file->isFile()) { + continue; + } + + unlink($file->getPathname()); + } + } + + private function getTestFiles(SuiteLoader $suiteLoader): array + { + /** + * TODO: Clean this up + * + * We are doing this because the SuiteLoader returns filenames incorrectly + * for Pest tests. We need to find a better way to do this. + */ + + $tests = array_filter( + $suiteLoader->files, + fn(string $filename) => ! str_ends_with($filename, "eval()'d code") + ); + + return [...$tests, ...TestSuite::getInstance()->tests->getFilenames()]; + } } From 951b54e7cd68a616e2f43013a5b338828dc3f7a6 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Mon, 6 Feb 2023 19:56:35 +0000 Subject: [PATCH 03/27] Uses `ResultPrinter` --- .../Parallel/Paratest/ResultPrinter.php | 282 ++++++++++++++++++ .../Parallel/Paratest/WrapperRunner.php | 2 +- 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/Plugins/Parallel/Paratest/ResultPrinter.php diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php new file mode 100644 index 00000000..5fd0cdb2 --- /dev/null +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -0,0 +1,282 @@ + */ + private array $tailPositions; + + public function __construct( + private readonly OutputInterface $output, + private readonly Options $options + ) { + $this->printer = new class ($this->output) implements Printer { + public function __construct( + private readonly OutputInterface $output, + ) { + } + + public function print(string $buffer): void + { + $this->output->write(OutputFormatter::escape($buffer)); + } + + public function flush(): void + { + } + }; + + if (! $this->options->configuration->hasLogfileTeamcity()) { + return; + } + + $teamcityLogFileHandle = fopen($this->options->configuration->logfileTeamcity(), 'ab+'); + assert($teamcityLogFileHandle !== false); + $this->teamcityLogFileHandle = $teamcityLogFileHandle; + } + + public function setTestCount(int $testCount): void + { + $this->totalCases = $testCount; + } + + public function start(): void + { + $this->numTestsWidth = strlen((string) $this->totalCases); + $this->maxColumn = $this->numberOfColumns + + (DIRECTORY_SEPARATOR === '\\' ? -1 : 0) // fix windows blank lines + - strlen($this->getProgress()); + + // @see \PHPUnit\TextUI\TestRunner::writeMessage() + $output = $this->output; + $write = static function (string $type, string $message) use ($output): void { + $output->write(sprintf("%-15s%s\n", $type . ':', $message)); + }; + + // @see \PHPUnit\TextUI\Application::writeRuntimeInformation() + $write('Processes', (string) $this->options->processes); + } + + /** @param list $teamcityFiles */ + public function printFeedback(SplFileInfo $progressFile, array $teamcityFiles): void + { + if ($this->options->needsTeamcity) { + $teamcityProgress = $this->tailMultiple($teamcityFiles); + + if ($this->teamcityLogFileHandle !== null) { + fwrite($this->teamcityLogFileHandle, $teamcityProgress); + } + } + + if ($this->options->configuration->outputIsTeamCity()) { + assert(isset($teamcityProgress)); + $this->output->write($teamcityProgress); + + return; + } + + if ($this->options->configuration->noProgress()) { + return; + } + + $feedbackItems = $this->tail($progressFile); + if ($feedbackItems === '') { + return; + } + + $feedbackItems = preg_replace('/ +\\d+ \\/ \\d+ \\( ?\\d+%\\)\\s*/', '', $feedbackItems); + + $actualTestCount = strlen($feedbackItems); + for ($index = 0; $index < $actualTestCount; ++$index) { + $this->printFeedbackItem($feedbackItems[$index]); + } + } + + /** + * @param list $teamcityFiles + * @param list $testdoxFiles + */ + public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles): void + { + if ($this->options->needsTeamcity) { + $teamcityProgress = $this->tailMultiple($teamcityFiles); + + if ($this->teamcityLogFileHandle !== null) { + fwrite($this->teamcityLogFileHandle, $teamcityProgress); + $resource = $this->teamcityLogFileHandle; + $this->teamcityLogFileHandle = null; + fclose($resource); + } + } + + if ($this->options->configuration->outputIsTeamCity()) { + assert(isset($teamcityProgress)); + $this->output->write($teamcityProgress); + + return; + } + + if ($this->options->configuration->outputIsTestDox()) { + $this->output->write($this->tailMultiple($testdoxFiles)); + + return; + } + + $resultPrinter = new DefaultResultPrinter( + $this->printer, + $this->options->configuration->displayDetailsOnIncompleteTests(), + $this->options->configuration->displayDetailsOnSkippedTests(), + $this->options->configuration->displayDetailsOnTestsThatTriggerDeprecations(), + $this->options->configuration->displayDetailsOnTestsThatTriggerErrors(), + $this->options->configuration->displayDetailsOnTestsThatTriggerNotices(), + $this->options->configuration->displayDetailsOnTestsThatTriggerWarnings(), + false, + ); + $summaryPrinter = new SummaryPrinter( + $this->printer, + $this->options->configuration->colors(), + ); + + $this->printer->print(PHP_EOL . (new ResourceUsageFormatter())->resourceUsageSinceStartOfRequest() . PHP_EOL . PHP_EOL); + + $resultPrinter->print($testResult); + $summaryPrinter->print($testResult); + } + + private function printFeedbackItem(string $item): void + { + $this->printFeedbackItemColor($item); + ++$this->column; + ++$this->casesProcessed; + if ($this->column !== $this->maxColumn && $this->casesProcessed < $this->totalCases) { + return; + } + + if ( + $this->casesProcessed > 0 + && $this->casesProcessed === $this->totalCases + && ($pad = $this->maxColumn - $this->column) > 0 + ) { + $this->output->write(str_repeat(' ', $pad)); + } + + $this->output->write($this->getProgress() . "\n"); + $this->column = 0; + } + + private function printFeedbackItemColor(string $item): void + { + $buffer = match ($item) { + 'E' => $this->colorizeTextBox('fg-red, bold', $item), + 'F' => $this->colorizeTextBox('bg-red, fg-white', $item), + 'I', 'N', 'D', 'R', 'W' => $this->colorizeTextBox('fg-yellow, bold', $item), + 'S' => $this->colorizeTextBox('fg-cyan, bold', $item), + default => $item, + }; + + $this->output->write($buffer); + } + + private function getProgress(): string + { + return sprintf( + ' %' . $this->numTestsWidth . 'd / %' . $this->numTestsWidth . 'd (%3s%%)', + $this->casesProcessed, + $this->totalCases, + floor(($this->totalCases > 0 ? $this->casesProcessed / $this->totalCases : 0) * 100), + ); + } + + private function colorizeTextBox(string $color, string $buffer): string + { + if (! $this->options->configuration->colors()) { + return $buffer; + } + + return Color::colorizeTextBox($color, $buffer); + } + + /** @param list $files */ + private function tailMultiple(array $files): string + { + $content = ''; + foreach ($files as $file) { + if (! $file->isFile()) { + continue; + } + + $content .= $this->tail($file); + } + + return $content; + } + + private function tail(SplFileInfo $file): string + { + $path = $file->getPathname(); + $handle = fopen($path, 'r'); + assert($handle !== false); + $fseek = fseek($handle, $this->tailPositions[$path] ?? 0); + assert($fseek === 0); + + $contents = ''; + while (! feof($handle)) { + $fread = fread($handle, 8192); + assert($fread !== false); + $contents .= $fread; + } + + $ftell = ftell($handle); + assert($ftell !== false); + $this->tailPositions[$path] = $ftell; + fclose($handle); + + return $contents; + } +} diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 2ce4a13b..4ea37787 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -9,7 +9,6 @@ use ParaTest\JUnit\LogMerger; use ParaTest\JUnit\Writer; use ParaTest\Options; use ParaTest\RunnerInterface; -use ParaTest\WrapperRunner\ResultPrinter; use ParaTest\WrapperRunner\SuiteLoader; use ParaTest\WrapperRunner\WrapperWorker; use Pest\TestSuite; @@ -75,6 +74,7 @@ final class WrapperRunner implements RunnerInterface $wrapper = realpath( dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'pest-wrapper.php', ); + assert($wrapper !== false); $phpFinder = new PhpExecutableFinder(); $phpBin = $phpFinder->find(false); From d03302db7b93a09ebda7b73461442401e96e187f Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Mon, 6 Feb 2023 23:06:13 +0000 Subject: [PATCH 04/27] WIP --- src/Plugins/Parallel/Handlers/Laravel.php | 18 ++ .../Parallel/Paratest/ResultPrinter.php | 106 +++------- .../Parallel/Paratest/WrapperRunner.php | 6 + .../Parallel/Support/CompactPrinter.php | 181 ++++++++++++++++++ 4 files changed, 231 insertions(+), 80 deletions(-) create mode 100644 src/Plugins/Parallel/Support/CompactPrinter.php diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php index 07fcb062..50f34950 100644 --- a/src/Plugins/Parallel/Handlers/Laravel.php +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -4,7 +4,12 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Handlers; +use Illuminate\Testing\ParallelRunner; +use ParaTest\Options; +use ParaTest\RunnerInterface; use Pest\Plugins\Concerns\HandleArguments; +use Pest\Plugins\Parallel\Paratest\WrapperRunner; +use Symfony\Component\Console\Output\OutputInterface; /** * @internal @@ -19,6 +24,8 @@ final class Laravel return $args; } + $this->setLaravelParallelRunner(); + foreach ($args as $value) { if (str_starts_with($value, '--runner')) { $args = $this->popArgument($value, $args); @@ -28,6 +35,17 @@ final class Laravel return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $args); } + private function setLaravelParallelRunner(): void + { + if (!method_exists(ParallelRunner::class, 'resolveRunnerUsing')) { + exit('Using parallel with Pest requires Laravel v8.55.0 or higher.'); + } + + ParallelRunner::resolveRunnerUsing(function (Options $options, OutputInterface $output): RunnerInterface { + return new WrapperRunner($options, $output); + }); + } + private static function isALaravelApplication(): bool { return class_exists(\Illuminate\Foundation\Application::class) diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php index 5fd0cdb2..752b1787 100644 --- a/src/Plugins/Parallel/Paratest/ResultPrinter.php +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -4,7 +4,10 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Paratest; +use NunoMaduro\Collision\Adapters\Phpunit\TestResult as CollisionTestResult; +use NunoMaduro\Collision\Exceptions\TestException; use ParaTest\Options; +use Pest\Plugins\Parallel\Support\CompactPrinter; use PHPUnit\Runner\TestSuiteSorter; use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TextUI\Output\Default\ResultPrinter as DefaultResultPrinter; @@ -13,11 +16,13 @@ use PHPUnit\TextUI\Output\SummaryPrinter; use PHPUnit\Util\Color; use SebastianBergmann\CodeCoverage\Driver\Selector; use SebastianBergmann\CodeCoverage\Filter; +use SebastianBergmann\Timer\Duration; use SebastianBergmann\Timer\ResourceUsageFormatter; use SplFileInfo; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Output\OutputInterface; +use Termwind\Terminal; use function assert; use function fclose; use function feof; @@ -32,6 +37,7 @@ use function sprintf; use function str_repeat; use function strlen; +use function Termwind\terminal; use const DIRECTORY_SEPARATOR; use const PHP_EOL; use const PHP_VERSION; @@ -40,6 +46,7 @@ use const PHP_VERSION; final class ResultPrinter { public readonly Printer $printer; + private readonly CompactPrinter $compactPrinter; private int $numTestsWidth = 0; private int $maxColumn = 0; @@ -72,6 +79,8 @@ final class ResultPrinter } }; + $this->compactPrinter = new CompactPrinter(); + if (! $this->options->configuration->hasLogfileTeamcity()) { return; } @@ -88,19 +97,13 @@ final class ResultPrinter public function start(): void { - $this->numTestsWidth = strlen((string) $this->totalCases); - $this->maxColumn = $this->numberOfColumns - + (DIRECTORY_SEPARATOR === '\\' ? -1 : 0) // fix windows blank lines - - strlen($this->getProgress()); - - // @see \PHPUnit\TextUI\TestRunner::writeMessage() - $output = $this->output; - $write = static function (string $type, string $message) use ($output): void { - $output->write(sprintf("%-15s%s\n", $type . ':', $message)); - }; - - // @see \PHPUnit\TextUI\Application::writeRuntimeInformation() - $write('Processes', (string) $this->options->processes); + $this->compactPrinter->line(sprintf( + 'Running %d test file%s using %d process%s', + $this->totalCases, + $this->totalCases === 1 ? '' : 's', + $this->options->processes, + $this->options->processes === 1 ? '' : 'es') + ); } /** @param list $teamcityFiles */ @@ -142,7 +145,7 @@ final class ResultPrinter * @param list $teamcityFiles * @param list $testdoxFiles */ - public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles): void + public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles, Duration $duration): void { if ($this->options->needsTeamcity) { $teamcityProgress = $this->tailMultiple($teamcityFiles); @@ -168,78 +171,21 @@ final class ResultPrinter return; } - $resultPrinter = new DefaultResultPrinter( - $this->printer, - $this->options->configuration->displayDetailsOnIncompleteTests(), - $this->options->configuration->displayDetailsOnSkippedTests(), - $this->options->configuration->displayDetailsOnTestsThatTriggerDeprecations(), - $this->options->configuration->displayDetailsOnTestsThatTriggerErrors(), - $this->options->configuration->displayDetailsOnTestsThatTriggerNotices(), - $this->options->configuration->displayDetailsOnTestsThatTriggerWarnings(), - false, - ); - $summaryPrinter = new SummaryPrinter( - $this->printer, - $this->options->configuration->colors(), - ); + $this->compactPrinter->newLine(); - $this->printer->print(PHP_EOL . (new ResourceUsageFormatter())->resourceUsageSinceStartOfRequest() . PHP_EOL . PHP_EOL); + $issues = array_map(fn($event) => CollisionTestResult::fromTestCase( + $event->test(), + CollisionTestResult::FAIL, + $event->throwable(), + ), [...$testResult->testFailedEvents(), ...$testResult->testErroredEvents()]); - $resultPrinter->print($testResult); - $summaryPrinter->print($testResult); + $this->compactPrinter->errors($issues); + $this->compactPrinter->recap($testResult, $duration); } private function printFeedbackItem(string $item): void { - $this->printFeedbackItemColor($item); - ++$this->column; - ++$this->casesProcessed; - if ($this->column !== $this->maxColumn && $this->casesProcessed < $this->totalCases) { - return; - } - - if ( - $this->casesProcessed > 0 - && $this->casesProcessed === $this->totalCases - && ($pad = $this->maxColumn - $this->column) > 0 - ) { - $this->output->write(str_repeat(' ', $pad)); - } - - $this->output->write($this->getProgress() . "\n"); - $this->column = 0; - } - - private function printFeedbackItemColor(string $item): void - { - $buffer = match ($item) { - 'E' => $this->colorizeTextBox('fg-red, bold', $item), - 'F' => $this->colorizeTextBox('bg-red, fg-white', $item), - 'I', 'N', 'D', 'R', 'W' => $this->colorizeTextBox('fg-yellow, bold', $item), - 'S' => $this->colorizeTextBox('fg-cyan, bold', $item), - default => $item, - }; - - $this->output->write($buffer); - } - - private function getProgress(): string - { - return sprintf( - ' %' . $this->numTestsWidth . 'd / %' . $this->numTestsWidth . 'd (%3s%%)', - $this->casesProcessed, - $this->totalCases, - floor(($this->totalCases > 0 ? $this->casesProcessed / $this->totalCases : 0) * 100), - ); - } - - private function colorizeTextBox(string $color, string $buffer): string - { - if (! $this->options->configuration->colors()) { - return $buffer; - } - - return Color::colorizeTextBox($color, $buffer); + $this->compactPrinter->descriptionItem($item); } /** @param list $files */ diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 4ea37787..76a77fde 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -18,6 +18,7 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TextUI\ShellExitCodeCalculator; use PHPUnit\Util\ExcludeList; +use SebastianBergmann\Timer\Timer; use SplFileInfo; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\PhpExecutableFinder; @@ -42,6 +43,7 @@ final class WrapperRunner implements RunnerInterface { private const CYCLE_SLEEP = 10000; private readonly ResultPrinter $printer; + private Timer $timer; /** @var non-empty-string[] */ private array $pending = []; @@ -70,6 +72,7 @@ final class WrapperRunner implements RunnerInterface private readonly OutputInterface $output ) { $this->printer = new ResultPrinter($output, $options); + $this->timer = new Timer(); $wrapper = realpath( dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'pest-wrapper.php', @@ -93,6 +96,8 @@ final class WrapperRunner implements RunnerInterface public function run(): int { + $this->timer->start(); + ExcludeList::addDirectory(dirname(__DIR__)); TestResultFacade::init(); EventFacade::seal(); @@ -277,6 +282,7 @@ final class WrapperRunner implements RunnerInterface $testResultSum, $this->teamcityFiles, $this->testdoxFiles, + $this->timer->stop(), ); $this->generateCodeCoverageReports(); $this->generateLogs(); diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php new file mode 100644 index 00000000..1e852e5f --- /dev/null +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -0,0 +1,181 @@ +terminal = terminal(); + $this->output = new ConsoleOutput(decorated: true); + $this->style = new Style($this->output); + + $this->compactSymbolsPerLine = $this->terminal->width() - 4; + } + + public function newLine(): void + { + render('
'); + } + + public function line(string $message): void + { + render("{$message}"); + } + + public function descriptionItem(string $item): void + { + // TODO: Support todos + + $icon = match (strtolower($item)) { + 'f', 'e' => '⨯', // FAILED + 's' => 's', // SKIPPED + 'w', 'r' => '!', // WARN, RISKY + 'i' => '…', // INCOMPLETE + '.' => '.', // PASSED + default => $item, + }; + + $color = match (strtolower($item)) { + 'f', 'e' => 'red', + 'd', 's', 'i', 'r', 'w' => 'yellow', + default => 'gray', + }; + + $symbolsOnCurrentLine = $this->compactProcessed % $this->compactSymbolsPerLine; + + if ($symbolsOnCurrentLine >= $this->terminal->width() - 4) { + $symbolsOnCurrentLine = 0; + } + + if ($symbolsOnCurrentLine === 0) { + $this->output->writeln(''); + $this->output->write(' '); + } + + $this->output->write(sprintf('%s', $color, $icon)); + + $this->compactProcessed++; + + //switch ($item) { + // case self::TODO: + // return '↓'; + // case self::RUNS: + // return '•'; + // default: + // return '✓'; + //} + } + + public function errors(array $errors): void + { + array_map(function (TestResult $testResult): void { + if (! $testResult->throwable instanceof \PHPUnit\Event\Code\Throwable) { + throw new ShouldNotHappen(); + } + + renderUsing($this->output); + render(<<<'HTML' +
+
+
+ HTML); + + $testCaseName = $testResult->testCaseName; + $description = $testResult->description; + + /** @var class-string $throwableClassName */ + $throwableClassName = $testResult->throwable->className(); + + $throwableClassName = ! in_array($throwableClassName, [ + ExpectationFailedException::class, + IncompleteTestError::class, + ], true) ? sprintf('%s', (new ReflectionClass($throwableClassName))->getShortName()) + : ''; + + $truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate'; + + renderUsing($this->output); + render(sprintf(<<<'HTML' +
+ + %s %s>%s + + + %s + +
+ HTML, $truncateClasses, $testResult->color, $testResult->type, $testCaseName, $description, $throwableClassName)); + + $this->style->writeError($testResult->throwable); + }, $errors); + } + + public function recap(\PHPUnit\TestRunner\TestResult\TestResult $testResult, Duration $duration): void + { + $testCounts = [ + 'passed' => ['green', $testResult->numberOfTestsRun()], + 'failed' => ['red', $testResult->numberOfTestFailedEvents()], + 'errored' => ['red', $testResult->numberOfTestErroredEvents()], + 'skipped' => ['yellow', $testResult->numberOfTestSkippedEvents()], + 'incomplete' => ['yellow', $testResult->numberOfTestMarkedIncompleteEvents()], + 'risky' => ['yellow', $testResult->numberOfTestsWithTestConsideredRiskyEvents()], + 'warnings' => ['yellow', $testResult->numberOfTestsWithTestTriggeredWarningEvents()], + ]; + + $tests = []; + + foreach ($testCounts as $type => [$color, $count]) { + if ($count === 0) { + continue; + } + + $tests[] = "$count $type"; + } + + $this->output->writeln(['']); + + if (! empty($tests)) { + $this->output->writeln([ + sprintf( + ' Tests: %s (%s assertions)', + implode(', ', $tests), + $testResult->numberOfAssertions() + ), + ]); + } + + $this->output->writeln([ + sprintf( + ' Duration: %ss', + number_format($duration->asSeconds(), 2, '.', '') + ), + ]); + + $this->output->writeln(''); + } +} From 7466667c082e13453ee4f270062433878b2ed855 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Mon, 6 Feb 2023 23:07:40 +0000 Subject: [PATCH 05/27] WIP --- src/Plugins/Parallel/Paratest/WrapperRunner.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 76a77fde..2644539f 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -96,8 +96,6 @@ final class WrapperRunner implements RunnerInterface public function run(): int { - $this->timer->start(); - ExcludeList::addDirectory(dirname(__DIR__)); TestResultFacade::init(); EventFacade::seal(); @@ -108,6 +106,9 @@ final class WrapperRunner implements RunnerInterface $this->printer->setTestCount($suiteLoader->testCount); $this->printer->start(); + + $this->timer->start(); + $this->startWorkers(); $this->assignAllPendingTests(); $this->waitForAllToFinish(); From 2f519261f57797c05fec1c307f04a6dc9a4a5650 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 12:49:41 +0000 Subject: [PATCH 06/27] Fixes and improvements. --- bin/pest-wrapper.php | 41 +++-- composer.json | 2 +- docker/Dockerfile | 4 + src/Logging/TeamCity/Converter.php | 83 +--------- src/Plugins/Parallel.php | 11 +- .../Parallel/Paratest/ResultPrinter.php | 36 +---- .../Parallel/Paratest/WrapperRunner.php | 38 +++-- .../Parallel/Support/CompactPrinter.php | 146 ++++++------------ src/Support/StateGenerator.php | 102 ++++++++++++ 9 files changed, 227 insertions(+), 236 deletions(-) create mode 100644 src/Support/StateGenerator.php diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index 2d339284..210e7976 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -5,10 +5,36 @@ declare(strict_types=1); use ParaTest\WrapperRunner\ApplicationForWrapperWorker; use ParaTest\WrapperRunner\WrapperWorker; use Pest\ConfigLoader; +use Pest\Kernel; +use Pest\Plugins\Actions\CallsAddsOutput; +use Pest\Plugins\Actions\CallsHandleArguments; +use Pest\Support\Container; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\Console\Output\OutputInterface; -(static function (): void { +$bootPest = (static function (): void { + $argv = new ArgvInput(); + $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); + $testSuite = TestSuite::getInstance( + $rootPath, + $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), + ); + + $output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true); + + $container = Container::getInstance(); + $container->add(TestSuite::class, $testSuite); + $container->add(OutputInterface::class, $output); + $container->add(InputInterface::class, $argv); + $container->add(Container::class, $container); + + Kernel::boot(); +}); + +(static function () use ($bootPest): void { $getopt = getopt('', [ 'status-file:', 'progress-file:', @@ -50,16 +76,9 @@ use Symfony\Component\Console\Input\ArgvInput; $phpunitArgv = unserialize($getopt['phpunit-argv'], ['allowed_classes' => false]); assert(is_array($phpunitArgv)); - /** - * We need to instantiate the Pest Test suite instance - * so that Pest is able to execute correctly. - */ - $argv = new ArgvInput(); - $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); - TestSuite::getInstance( - $rootPath, - $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), - ); + $bootPest(); + + (new CallsHandleArguments())($phpunitArgv); $application = new ApplicationForWrapperWorker( $phpunitArgv, diff --git a/composer.json b/composer.json index 0c4e5343..8c5342fe 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,7 @@ ] }, "require-dev": { - "brianium/paratest": "^7.0", + "brianium/paratest": "^7.0.4", "pestphp/pest-dev-tools": "^2.4.0", "pestphp/pest-plugin-arch": "^2.0.0", "symfony/process": "^6.2.5" diff --git a/docker/Dockerfile b/docker/Dockerfile index 61334034..ff10bcb2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,6 +12,10 @@ RUN docker-php-ext-configure intl RUN docker-php-ext-install intl RUN docker-php-ext-enable intl +RUN apk add --no-cache $PHPIZE_DEPS linux-headers +RUN pecl install xdebug +RUN docker-php-ext-enable xdebug + COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html diff --git a/src/Logging/TeamCity/Converter.php b/src/Logging/TeamCity/Converter.php index c0d91d41..0a1e592e 100644 --- a/src/Logging/TeamCity/Converter.php +++ b/src/Logging/TeamCity/Converter.php @@ -7,6 +7,7 @@ namespace Pest\Logging\TeamCity; use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\TestResult; use Pest\Exceptions\ShouldNotHappen; +use Pest\Support\StateGenerator; use Pest\Support\Str; use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\TestDox; @@ -28,12 +29,15 @@ final class Converter { private const PREFIX = 'P\\'; + private readonly StateGenerator $stateGenerator; + /** * Creates a new instance of the Converter. */ public function __construct( private readonly string $rootPath, ) { + $this->stateGenerator = new StateGenerator(); } /** @@ -183,83 +187,6 @@ final class Converter */ public function getStateFromResult(PhpUnitTestResult $result): State { - $state = new State(); - - foreach ($result->testErroredEvents() as $resultEvent) { - assert($resultEvent instanceof Errored); - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::FAIL, - $resultEvent->throwable() - )); - } - - foreach ($result->testFailedEvents() as $resultEvent) { - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::FAIL, - $resultEvent->throwable() - )); - } - - foreach ($result->testMarkedIncompleteEvents() as $resultEvent) { - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::INCOMPLETE, - $resultEvent->throwable() - )); - } - - foreach ($result->testConsideredRiskyEvents() as $riskyEvents) { - foreach ($riskyEvents as $riskyEvent) { - $state->add(TestResult::fromTestCase( - $riskyEvent->test(), - TestResult::RISKY, - Throwable::from(new IncompleteTestError($riskyEvent->message())) - )); - } - } - - foreach ($result->testSkippedEvents() as $resultEvent) { - if ($resultEvent->message() === '__TODO__') { - $state->add(TestResult::fromTestCase($resultEvent->test(), TestResult::TODO)); - - continue; - } - - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::SKIPPED, - Throwable::from(new SkippedWithMessageException($resultEvent->message())) - )); - } - - $numberOfPassedTests = $result->numberOfTests() - - $result->numberOfTestErroredEvents() - - $result->numberOfTestFailedEvents() - - $result->numberOfTestSkippedEvents() - - $result->numberOfTestsWithTestConsideredRiskyEvents() - - $result->numberOfTestMarkedIncompleteEvents(); - - for ($i = 0; $i < $numberOfPassedTests; $i++) { - $state->add(TestResult::fromTestCase( - - new TestMethod( - /** @phpstan-ignore-next-line */ - "$i", - /** @phpstan-ignore-next-line */ - '', - '', - 1, - /** @phpstan-ignore-next-line */ - TestDox::fromClassNameAndMethodName('', ''), - MetadataCollection::fromArray([]), - TestDataCollection::fromArray([]) - ), - TestResult::PASS - )); - } - - return $state; + return $this->stateGenerator->fromPhpUnitTestResult($result); } } diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index bc2df874..76e69b43 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -4,12 +4,14 @@ namespace Pest\Plugins; use ParaTest\ParaTestCommand; use Pest\Contracts\Plugins\HandlesArguments; +use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Concerns\HandleArguments; use Pest\Support\Arr; use Pest\Support\Container; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\OutputInterface; +use function Pest\version; final class Parallel implements HandlesArguments { @@ -54,7 +56,14 @@ final class Parallel implements HandlesArguments $testSuite = TestSuite::getInstance(); - return ParaTestCommand::applicationFactory($testSuite->rootPath)->run(new ArgvInput($filteredArguments)); + $command = ParaTestCommand::applicationFactory($testSuite->rootPath); + $command->setAutoExit(false); + $command->setName('Pest'); + $command->setVersion(version()); + $exitCode = $command->run(new ArgvInput($filteredArguments)); + + $exitCode = (new CallsAddsOutput())($exitCode); + exit($exitCode); } private function markTestSuiteAsParallelSubProcessIfRequired(): void diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php index 752b1787..7837ab36 100644 --- a/src/Plugins/Parallel/Paratest/ResultPrinter.php +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -5,28 +5,20 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Paratest; use NunoMaduro\Collision\Adapters\Phpunit\TestResult as CollisionTestResult; -use NunoMaduro\Collision\Exceptions\TestException; use ParaTest\Options; use Pest\Plugins\Parallel\Support\CompactPrinter; -use PHPUnit\Runner\TestSuiteSorter; +use Pest\Support\StateGenerator; +use PHPUnit\Event\Test\Errored; use PHPUnit\TestRunner\TestResult\TestResult; -use PHPUnit\TextUI\Output\Default\ResultPrinter as DefaultResultPrinter; use PHPUnit\TextUI\Output\Printer; -use PHPUnit\TextUI\Output\SummaryPrinter; -use PHPUnit\Util\Color; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; use SebastianBergmann\Timer\Duration; -use SebastianBergmann\Timer\ResourceUsageFormatter; use SplFileInfo; use Symfony\Component\Console\Formatter\OutputFormatter; use Symfony\Component\Console\Output\OutputInterface; -use Termwind\Terminal; use function assert; use function fclose; use function feof; -use function floor; use function fopen; use function fread; use function fseek; @@ -34,26 +26,16 @@ use function ftell; use function fwrite; use function preg_replace; use function sprintf; -use function str_repeat; use function strlen; -use function Termwind\terminal; -use const DIRECTORY_SEPARATOR; -use const PHP_EOL; -use const PHP_VERSION; - /** @internal */ final class ResultPrinter { public readonly Printer $printer; private readonly CompactPrinter $compactPrinter; - private int $numTestsWidth = 0; - private int $maxColumn = 0; private int $totalCases = 0; - private int $column = 0; - private int $casesProcessed = 0; - private int $numberOfColumns = 80; + /** @var resource|null */ private $teamcityLogFileHandle; /** @var array */ @@ -98,7 +80,7 @@ final class ResultPrinter public function start(): void { $this->compactPrinter->line(sprintf( - 'Running %d test file%s using %d process%s', + 'Running %d test%s using %d process%s', $this->totalCases, $this->totalCases === 1 ? '' : 's', $this->options->processes, @@ -173,14 +155,10 @@ final class ResultPrinter $this->compactPrinter->newLine(); - $issues = array_map(fn($event) => CollisionTestResult::fromTestCase( - $event->test(), - CollisionTestResult::FAIL, - $event->throwable(), - ), [...$testResult->testFailedEvents(), ...$testResult->testErroredEvents()]); + $state = (new StateGenerator())->fromPhpUnitTestResult($testResult); - $this->compactPrinter->errors($issues); - $this->compactPrinter->recap($testResult, $duration); + $this->compactPrinter->errors($state); + $this->compactPrinter->recap($state, $testResult, $duration); } private function printFeedbackItem(string $item): void diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 2644539f..1c0d87c8 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -16,6 +16,7 @@ use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Runner\CodeCoverage; use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade; use PHPUnit\TestRunner\TestResult\TestResult; +use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\ShellExitCodeCalculator; use PHPUnit\Util\ExcludeList; use SebastianBergmann\Timer\Timer; @@ -67,6 +68,8 @@ final class WrapperRunner implements RunnerInterface /** @var non-empty-string[] */ private readonly array $parameters; + private CodeCoverageFilterRegistry $codeCoverageFilterRegistry; + public function __construct( private readonly Options $options, private readonly OutputInterface $output @@ -92,6 +95,7 @@ final class WrapperRunner implements RunnerInterface $parameters[] = $wrapper; $this->parameters = $parameters; + $this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry(); } public function run(): int @@ -99,7 +103,7 @@ final class WrapperRunner implements RunnerInterface ExcludeList::addDirectory(dirname(__DIR__)); TestResultFacade::init(); EventFacade::seal(); - $suiteLoader = new SuiteLoader($this->options, $this->output); + $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry,); $this->pending = $this->getTestFiles($suiteLoader); $result = TestResultFacade::result(); @@ -116,11 +120,6 @@ final class WrapperRunner implements RunnerInterface return $this->complete($result); } - public function getExitCode(): int - { - return $this->exitcode; - } - private function startWorkers(): void { for ($token = 1; $token <= $this->options->processes; ++$token) { @@ -155,6 +154,8 @@ final class WrapperRunner implements RunnerInterface ) { $this->pending = []; } elseif (($pending = array_shift($this->pending)) !== null) { + $this->debug(sprintf('Assigning %s to worker %d', $pending, $token)); + $worker->assign($pending); $this->batches[$token]++; } @@ -312,13 +313,14 @@ final class WrapperRunner implements RunnerInterface return; } - CodeCoverage::init($this->options->configuration); - $coverageMerger = new CoverageMerger(CodeCoverage::instance()); + $coverageManager = new CodeCoverage(); + $coverageManager->init($this->options->configuration, $this->codeCoverageFilterRegistry); + $coverageMerger = new CoverageMerger($coverageManager->codeCoverage()); foreach ($this->coverageFiles as $coverageFile) { $coverageMerger->addCoverageFromFile($coverageFile); } - CodeCoverage::generateReports( + $coverageManager->generateReports( $this->printer->printer, $this->options->configuration, ); @@ -349,14 +351,13 @@ final class WrapperRunner implements RunnerInterface } } + /** + * We are doing this because the SuiteLoader returns filenames incorrectly + * for Pest tests. Ideally we should find a cleaner solution. + */ private function getTestFiles(SuiteLoader $suiteLoader): array { - /** - * TODO: Clean this up - * - * We are doing this because the SuiteLoader returns filenames incorrectly - * for Pest tests. We need to find a better way to do this. - */ + $this->debug(sprintf("Found %d test file%s", count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's')); $tests = array_filter( $suiteLoader->files, @@ -365,4 +366,11 @@ final class WrapperRunner implements RunnerInterface return [...$tests, ...TestSuite::getInstance()->tests->getFilenames()]; } + + private function debug(string $message): void + { + if ($this->options->verbose) { + $this->output->writeln(" {$message} "); + } + } } diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index 1e852e5f..e32dff72 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -4,12 +4,26 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Support; +use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter; use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\Style; use NunoMaduro\Collision\Adapters\Phpunit\TestResult; use NunoMaduro\Collision\Exceptions\ShouldNotHappen; +use Pest\Logging\TeamCity\Converter; +use Pest\Support\StateGenerator; +use PHPUnit\Event\Code\TestDox; +use PHPUnit\Event\Code\TestMethod; +use PHPUnit\Event\Event; +use PHPUnit\Event\Telemetry\HRTime; +use PHPUnit\Event\Telemetry\Info; +use PHPUnit\Event\Telemetry\MemoryUsage; +use PHPUnit\Event\Telemetry\Snapshot; +use PHPUnit\Event\Test\Passed; +use PHPUnit\Event\TestData\TestDataCollection; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\IncompleteTestError; +use PHPUnit\Metadata\MetadataCollection; +use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult; use ReflectionClass; use SebastianBergmann\Timer\Duration; use Symfony\Component\Console\Output\ConsoleOutput; @@ -49,22 +63,20 @@ final class CompactPrinter public function descriptionItem(string $item): void { - // TODO: Support todos + // TODO: Support TODOs - $icon = match (strtolower($item)) { - 'f', 'e' => '⨯', // FAILED - 's' => 's', // SKIPPED - 'w', 'r' => '!', // WARN, RISKY - 'i' => '…', // INCOMPLETE - '.' => '.', // PASSED - default => $item, - }; + $lookupTable = [ + '.' => ['gray', '.'], + 'S' => ['yellow', 's'], + 'I' => ['yellow', 'i'], + 'N' => ['yellow', 'i'], + 'R' => ['yellow', '!'], + 'W' => ['yellow', '!'], + 'E' => ['red', '⨯'], + 'F' => ['red', '⨯'], + ]; - $color = match (strtolower($item)) { - 'f', 'e' => 'red', - 'd', 's', 'i', 'r', 'w' => 'yellow', - default => 'gray', - }; + [$color, $icon] = $lookupTable[$item] ?? $lookupTable['.']; $symbolsOnCurrentLine = $this->compactProcessed % $this->compactSymbolsPerLine; @@ -80,102 +92,34 @@ final class CompactPrinter $this->output->write(sprintf('%s', $color, $icon)); $this->compactProcessed++; - - //switch ($item) { - // case self::TODO: - // return '↓'; - // case self::RUNS: - // return '•'; - // default: - // return '✓'; - //} } - public function errors(array $errors): void + public function errors(State $state): void { - array_map(function (TestResult $testResult): void { - if (! $testResult->throwable instanceof \PHPUnit\Event\Code\Throwable) { - throw new ShouldNotHappen(); - } - - renderUsing($this->output); - render(<<<'HTML' -
-
-
- HTML); - - $testCaseName = $testResult->testCaseName; - $description = $testResult->description; - - /** @var class-string $throwableClassName */ - $throwableClassName = $testResult->throwable->className(); - - $throwableClassName = ! in_array($throwableClassName, [ - ExpectationFailedException::class, - IncompleteTestError::class, - ], true) ? sprintf('%s', (new ReflectionClass($throwableClassName))->getShortName()) - : ''; - - $truncateClasses = $this->output->isVerbose() ? '' : 'flex-1 truncate'; - - renderUsing($this->output); - render(sprintf(<<<'HTML' -
- - %s %s>%s - - - %s - -
- HTML, $truncateClasses, $testResult->color, $testResult->type, $testCaseName, $description, $throwableClassName)); - - $this->style->writeError($testResult->throwable); - }, $errors); + $this->style->writeErrorsSummary($state, false); } - public function recap(\PHPUnit\TestRunner\TestResult\TestResult $testResult, Duration $duration): void + public function recap(State $state, PHPUnitTestResult $testResult, Duration $duration): void { - $testCounts = [ - 'passed' => ['green', $testResult->numberOfTestsRun()], - 'failed' => ['red', $testResult->numberOfTestFailedEvents()], - 'errored' => ['red', $testResult->numberOfTestErroredEvents()], - 'skipped' => ['yellow', $testResult->numberOfTestSkippedEvents()], - 'incomplete' => ['yellow', $testResult->numberOfTestMarkedIncompleteEvents()], - 'risky' => ['yellow', $testResult->numberOfTestsWithTestConsideredRiskyEvents()], - 'warnings' => ['yellow', $testResult->numberOfTestsWithTestTriggeredWarningEvents()], - ]; + assert($this->output instanceof ConsoleOutput); + $style = new Style($this->output); - $tests = []; + $nanoseconds = $duration->asNanoseconds() % 1000000000; + $snapshotDuration = HRTime::fromSecondsAndNanoseconds((int)$duration->asSeconds(), $nanoseconds); + $telemetryDuration = \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds((int)$duration->asSeconds(), $nanoseconds); - foreach ($testCounts as $type => [$color, $count]) { - if ($count === 0) { - continue; - } - - $tests[] = "$count $type"; - } - - $this->output->writeln(['']); - - if (! empty($tests)) { - $this->output->writeln([ - sprintf( - ' Tests: %s (%s assertions)', - implode(', ', $tests), - $testResult->numberOfAssertions() - ), - ]); - } - - $this->output->writeln([ - sprintf( - ' Duration: %ss', - number_format($duration->asSeconds(), 2, '.', '') + $telemetry = new Info( + new Snapshot( + $snapshotDuration, + MemoryUsage::fromBytes(0), + MemoryUsage::fromBytes(0), ), - ]); + $telemetryDuration, + MemoryUsage::fromBytes(0), + \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0), + MemoryUsage::fromBytes(0), + ); - $this->output->writeln(''); + $style->writeRecap($state, $telemetry, $testResult); } } diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php new file mode 100644 index 00000000..a78d499e --- /dev/null +++ b/src/Support/StateGenerator.php @@ -0,0 +1,102 @@ +testErroredEvents() as $testResultEvent) { + assert($testResultEvent instanceof Errored); + $state->add(\NunoMaduro\Collision\Adapters\Phpunit\TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::FAIL, + $testResultEvent->throwable() + )); + } + + foreach ($testResult->testFailedEvents() as $testResultEvent) { + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::FAIL, + $testResultEvent->throwable() + )); + } + + foreach ($testResult->testMarkedIncompleteEvents() as $testResultEvent) { + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::INCOMPLETE, + $testResultEvent->throwable() + )); + } + + foreach ($testResult->testConsideredRiskyEvents() as $riskyEvents) { + foreach ($riskyEvents as $riskyEvent) { + $state->add(TestResult::fromTestCase( + $riskyEvent->test(), + TestResult::RISKY, + Throwable::from(new IncompleteTestError($riskyEvent->message())) + )); + } + } + + foreach ($testResult->testSkippedEvents() as $testResultEvent) { + if ($testResultEvent->message() === '__TODO__') { + $state->add(TestResult::fromTestCase($testResultEvent->test(), TestResult::TODO)); + + continue; + } + + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::SKIPPED, + Throwable::from(new SkippedWithMessageException($testResultEvent->message())) + )); + } + + $numberOfPassedTests = $testResult->numberOfTestsRun() + - $testResult->numberOfTestErroredEvents() + - $testResult->numberOfTestFailedEvents() + - $testResult->numberOfTestSkippedEvents() + - $testResult->numberOfTestsWithTestConsideredRiskyEvents() + - $testResult->numberOfTestMarkedIncompleteEvents(); + + for ($i = 0; $i < $numberOfPassedTests; $i++) { + $state->add(TestResult::fromTestCase( + + new TestMethod( + /** @phpstan-ignore-next-line */ + "$i", + /** @phpstan-ignore-next-line */ + '', + '', + 1, + /** @phpstan-ignore-next-line */ + TestDox::fromClassNameAndMethodName('', ''), + MetadataCollection::fromArray([]), + TestDataCollection::fromArray([]) + ), + TestResult::PASS + )); + } + + return $state; + } +} From 1658176fe1486b7c924e69dc3468c46165fac04a Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Tue, 7 Feb 2023 00:34:48 +0000 Subject: [PATCH 07/27] Uses default gray --- src/Plugins/Parallel/Support/CompactPrinter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index e32dff72..a348751c 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -58,7 +58,7 @@ final class CompactPrinter public function line(string $message): void { - render("{$message}"); + render("{$message}"); } public function descriptionItem(string $item): void From f48ae48677cdd770d643c8470c19af2c4cb551f8 Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Tue, 7 Feb 2023 00:55:48 +0000 Subject: [PATCH 08/27] Fixes test name --- .../Parallel/Paratest/ResultPrinter.php | 46 +++++------ .../Parallel/Support/CompactPrinter.php | 80 ++++++++++--------- 2 files changed, 65 insertions(+), 61 deletions(-) diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php index 7837ab36..49f83ec3 100644 --- a/src/Plugins/Parallel/Paratest/ResultPrinter.php +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -4,18 +4,6 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Paratest; -use NunoMaduro\Collision\Adapters\Phpunit\TestResult as CollisionTestResult; -use ParaTest\Options; -use Pest\Plugins\Parallel\Support\CompactPrinter; -use Pest\Support\StateGenerator; -use PHPUnit\Event\Test\Errored; -use PHPUnit\TestRunner\TestResult\TestResult; -use PHPUnit\TextUI\Output\Printer; -use SebastianBergmann\Timer\Duration; -use SplFileInfo; -use Symfony\Component\Console\Formatter\OutputFormatter; -use Symfony\Component\Console\Output\OutputInterface; - use function assert; use function fclose; use function feof; @@ -24,28 +12,40 @@ use function fread; use function fseek; use function ftell; use function fwrite; +use ParaTest\Options; +use Pest\Plugins\Parallel\Support\CompactPrinter; +use Pest\Support\StateGenerator; +use PHPUnit\TestRunner\TestResult\TestResult; +use PHPUnit\TextUI\Output\Printer; use function preg_replace; +use SebastianBergmann\Timer\Duration; +use SplFileInfo; use function sprintf; use function strlen; +use Symfony\Component\Console\Formatter\OutputFormatter; +use Symfony\Component\Console\Output\OutputInterface; /** @internal */ final class ResultPrinter { public readonly Printer $printer; + private readonly CompactPrinter $compactPrinter; - private int $totalCases = 0; + private int $totalCases = 0; /** @var resource|null */ private $teamcityLogFileHandle; - /** @var array */ + + /** @var array */ private array $tailPositions; public function __construct( private readonly OutputInterface $output, private readonly Options $options ) { - $this->printer = new class ($this->output) implements Printer { + $this->printer = new class($this->output) implements Printer + { public function __construct( private readonly OutputInterface $output, ) { @@ -88,7 +88,7 @@ final class ResultPrinter ); } - /** @param list $teamcityFiles */ + /** @param array $teamcityFiles */ public function printFeedback(SplFileInfo $progressFile, array $teamcityFiles): void { if ($this->options->needsTeamcity) { @@ -115,17 +115,17 @@ final class ResultPrinter return; } - $feedbackItems = preg_replace('/ +\\d+ \\/ \\d+ \\( ?\\d+%\\)\\s*/', '', $feedbackItems); + $feedbackItems = (string) preg_replace('/ +\\d+ \\/ \\d+ \\( ?\\d+%\\)\\s*/', '', $feedbackItems); $actualTestCount = strlen($feedbackItems); - for ($index = 0; $index < $actualTestCount; ++$index) { + for ($index = 0; $index < $actualTestCount; $index++) { $this->printFeedbackItem($feedbackItems[$index]); } } /** - * @param list $teamcityFiles - * @param list $testdoxFiles + * @param array $teamcityFiles + * @param array $testdoxFiles */ public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles, Duration $duration): void { @@ -134,7 +134,7 @@ final class ResultPrinter if ($this->teamcityLogFileHandle !== null) { fwrite($this->teamcityLogFileHandle, $teamcityProgress); - $resource = $this->teamcityLogFileHandle; + $resource = $this->teamcityLogFileHandle; $this->teamcityLogFileHandle = null; fclose($resource); } @@ -166,7 +166,7 @@ final class ResultPrinter $this->compactPrinter->descriptionItem($item); } - /** @param list $files */ + /** @param array $files */ private function tailMultiple(array $files): string { $content = ''; @@ -183,7 +183,7 @@ final class ResultPrinter private function tail(SplFileInfo $file): string { - $path = $file->getPathname(); + $path = $file->getPathname(); $handle = fopen($path, 'r'); assert($handle !== false); $fseek = fseek($handle, $this->tailPositions[$path] ?? 0); diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index a348751c..d5f35f17 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -4,43 +4,46 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Support; -use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter; use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\Style; -use NunoMaduro\Collision\Adapters\Phpunit\TestResult; -use NunoMaduro\Collision\Exceptions\ShouldNotHappen; -use Pest\Logging\TeamCity\Converter; -use Pest\Support\StateGenerator; -use PHPUnit\Event\Code\TestDox; -use PHPUnit\Event\Code\TestMethod; -use PHPUnit\Event\Event; use PHPUnit\Event\Telemetry\HRTime; use PHPUnit\Event\Telemetry\Info; use PHPUnit\Event\Telemetry\MemoryUsage; use PHPUnit\Event\Telemetry\Snapshot; -use PHPUnit\Event\Test\Passed; -use PHPUnit\Event\TestData\TestDataCollection; -use PHPUnit\Framework\ExpectationFailedException; -use PHPUnit\Framework\IncompleteTestError; -use PHPUnit\Metadata\MetadataCollection; use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult; -use ReflectionClass; use SebastianBergmann\Timer\Duration; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; -use Termwind\Terminal; use function Termwind\render; -use function Termwind\renderUsing; +use Termwind\Terminal; use function Termwind\terminal; +/** + * @internal + */ final class CompactPrinter { private readonly Terminal $terminal; - private readonly ConsoleOutputInterface $output; + private readonly Style $style; private int $compactProcessed = 0; - private int $compactSymbolsPerLine = 0; + + private readonly int $compactSymbolsPerLine; + + /** + * @var array> + */ + private const LOOKUP_TABLE = [ + '.' => ['gray', '.'], + 'S' => ['yellow', 's'], + 'I' => ['yellow', 'i'], + 'N' => ['yellow', 'i'], + 'R' => ['yellow', '!'], + 'W' => ['yellow', '!'], + 'E' => ['red', '⨯'], + 'F' => ['red', '⨯'], + ]; public function __construct() { @@ -51,32 +54,28 @@ final class CompactPrinter $this->compactSymbolsPerLine = $this->terminal->width() - 4; } + /** + * Output an empty line in the console. Useful for providing a little breathing room. + */ public function newLine(): void { render('
'); } + /** + * Write the given message to the console, adding vertical and horizontal padding. + */ public function line(string $message): void { - render("{$message}"); + render("{$message}"); } + /** + * Outputs the given description item from the ProgressPrinter as a gorgeous, colored symbol. + */ public function descriptionItem(string $item): void { - // TODO: Support TODOs - - $lookupTable = [ - '.' => ['gray', '.'], - 'S' => ['yellow', 's'], - 'I' => ['yellow', 'i'], - 'N' => ['yellow', 'i'], - 'R' => ['yellow', '!'], - 'W' => ['yellow', '!'], - 'E' => ['red', '⨯'], - 'F' => ['red', '⨯'], - ]; - - [$color, $icon] = $lookupTable[$item] ?? $lookupTable['.']; + [$color, $icon] = self::LOOKUP_TABLE[$item] ?? self::LOOKUP_TABLE['.']; $symbolsOnCurrentLine = $this->compactProcessed % $this->compactSymbolsPerLine; @@ -94,19 +93,24 @@ final class CompactPrinter $this->compactProcessed++; } + /** + * Outputs all errors from the given state using Collision's beautiful error output. + */ public function errors(State $state): void { $this->style->writeErrorsSummary($state, false); } + /** + * Outputs a clean recap of the test run, including the number of tests, assertions, and failures. + */ public function recap(State $state, PHPUnitTestResult $testResult, Duration $duration): void { assert($this->output instanceof ConsoleOutput); - $style = new Style($this->output); - $nanoseconds = $duration->asNanoseconds() % 1000000000; - $snapshotDuration = HRTime::fromSecondsAndNanoseconds((int)$duration->asSeconds(), $nanoseconds); - $telemetryDuration = \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds((int)$duration->asSeconds(), $nanoseconds); + $nanoseconds = $duration->asNanoseconds() % 1_000_000_000; + $snapshotDuration = HRTime::fromSecondsAndNanoseconds((int) $duration->asSeconds(), $nanoseconds); + $telemetryDuration = \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds((int) $duration->asSeconds(), $nanoseconds); $telemetry = new Info( new Snapshot( @@ -120,6 +124,6 @@ final class CompactPrinter MemoryUsage::fromBytes(0), ); - $style->writeRecap($state, $telemetry, $testResult); + $this->style->writeRecap($state, $telemetry, $testResult); } } From 6338d762fa37fca59ee754609972938227dc3e3f Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Tue, 7 Feb 2023 01:06:31 +0000 Subject: [PATCH 09/27] wip --- bin/pest-wrapper.php | 14 +++++++------- src/Plugins/Parallel.php | 10 ++++++---- src/Plugins/Parallel/Handlers/Laravel.php | 6 ++---- src/Plugins/Parallel/Handlers/Parallel.php | 16 +++++++++------- src/Plugins/Parallel/Paratest/WrapperRunner.php | 10 +++++----- src/Plugins/Parallel/Support/CompactPrinter.php | 2 ++ 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index 210e7976..49f21966 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -45,13 +45,13 @@ $bootPest = (static function (): void { 'phpunit-argv:', ]); - require_once __DIR__ . '/../overrides/Runner/TestSuiteLoader.php'; - require_once __DIR__ . '/../overrides/Runner/Filter/NameFilterIterator.php'; + require_once __DIR__.'/../overrides/Runner/TestSuiteLoader.php'; + require_once __DIR__.'/../overrides/Runner/Filter/NameFilterIterator.php'; $composerAutoloadFiles = [ - dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'autoload.php', - dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php', - dirname(__DIR__) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php', + dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'autoload.php', + dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.php', + dirname(__DIR__).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.php', ]; foreach ($composerAutoloadFiles as $file) { @@ -69,8 +69,8 @@ $bootPest = (static function (): void { assert(isset($getopt['progress-file']) && is_string($getopt['progress-file'])); assert(isset($getopt['testresult-file']) && is_string($getopt['testresult-file'])); - assert(!isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file'])); - assert(!isset($getopt['testdox-file']) || is_string($getopt['testdox-file'])); + assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file'])); + assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file'])); assert(isset($getopt['phpunit-argv']) && is_string($getopt['phpunit-argv'])); $phpunitArgv = unserialize($getopt['phpunit-argv'], ['allowed_classes' => false]); diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 76e69b43..a7fb0ea9 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -17,7 +17,7 @@ final class Parallel implements HandlesArguments { use HandleArguments; - private array $handlers = [ + private const HANDLERS = [ \Pest\Plugins\Parallel\Handlers\Parallel::class, \Pest\Plugins\Parallel\Handlers\Laravel::class, ]; @@ -36,8 +36,10 @@ final class Parallel implements HandlesArguments private function argumentsContainParallelFlags(array $arguments): bool { - return $this->hasArgument('--parallel', $arguments) - || $this->hasArgument('-p', $arguments); + if ($this->hasArgument('--parallel', $arguments)) { + return true; + } + return $this->hasArgument('-p', $arguments); } private function runTestSuiteInParallel(array $arguments): int @@ -49,7 +51,7 @@ final class Parallel implements HandlesArguments } $filteredArguments = array_reduce( - $this->handlers, + self::HANDLERS, fn($arguments, $handler) => (new $handler())->handle($arguments), $arguments ); diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php index 50f34950..9d05a8fd 100644 --- a/src/Plugins/Parallel/Handlers/Laravel.php +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -27,7 +27,7 @@ final class Laravel $this->setLaravelParallelRunner(); foreach ($args as $value) { - if (str_starts_with($value, '--runner')) { + if (str_starts_with((string) $value, '--runner')) { $args = $this->popArgument($value, $args); } } @@ -41,9 +41,7 @@ final class Laravel exit('Using parallel with Pest requires Laravel v8.55.0 or higher.'); } - ParallelRunner::resolveRunnerUsing(function (Options $options, OutputInterface $output): RunnerInterface { - return new WrapperRunner($options, $output); - }); + ParallelRunner::resolveRunnerUsing(fn(Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)); } private static function isALaravelApplication(): bool diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php index 0e88c3bb..0ff58839 100644 --- a/src/Plugins/Parallel/Handlers/Parallel.php +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -14,16 +14,18 @@ use Symfony\Component\Console\Input\ArgvInput; final class Parallel { use HandleArguments; + /** + * @var string[] + */ + private const ARGS_TO_REMOVE = [ + '--parallel', + '-p', + '--no-output', + ]; public function handle(array $args): array { - $argsToRemove = [ - '--parallel', - '-p', - '--no-output', - ]; - - $args = array_reduce($argsToRemove, fn ($args, $arg) => $this->popArgument($arg, $args), $args); + $args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $args); return $this->pushArgument('--runner=' . WrapperRunner::class, $args); } diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 1c0d87c8..9d2dd3b2 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -44,7 +44,7 @@ final class WrapperRunner implements RunnerInterface { private const CYCLE_SLEEP = 10000; private readonly ResultPrinter $printer; - private Timer $timer; + private readonly Timer $timer; /** @var non-empty-string[] */ private array $pending = []; @@ -131,7 +131,7 @@ final class WrapperRunner implements RunnerInterface { $batchSize = $this->options->maxBatchSize; - while (count($this->pending) > 0 && count($this->workers) > 0) { + while ($this->pending !== [] && $this->workers !== []) { foreach ($this->workers as $token => $worker) { if (! $worker->isRunning()) { throw $worker->getWorkerCrashedException(); @@ -178,7 +178,7 @@ final class WrapperRunner implements RunnerInterface private function waitForAllToFinish(): void { $stopped = []; - while (count($this->workers) > 0) { + while ($this->workers !== []) { foreach ($this->workers as $index => $worker) { if ($worker->isRunning()) { if (! isset($stopped[$index]) && $worker->isFree()) { @@ -307,7 +307,7 @@ final class WrapperRunner implements RunnerInterface return $exitcode; } - protected function generateCodeCoverageReports(): void + private function generateCodeCoverageReports(): void { if ($this->coverageFiles === []) { return; @@ -361,7 +361,7 @@ final class WrapperRunner implements RunnerInterface $tests = array_filter( $suiteLoader->files, - fn(string $filename) => ! str_ends_with($filename, "eval()'d code") + fn(string $filename): bool => ! str_ends_with($filename, "eval()'d code") ); return [...$tests, ...TestSuite::getInstance()->tests->getFilenames()]; diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index d5f35f17..f6921a4a 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -25,6 +25,8 @@ final class CompactPrinter { private readonly Terminal $terminal; + private readonly ConsoleOutputInterface $output; + private readonly Style $style; private int $compactProcessed = 0; From f94ea9ba0ddc2a3f9c0e73d9a7d348b72b483a8c Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 13:04:52 +0000 Subject: [PATCH 10/27] WIP --- src/Plugins/Parallel.php | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index a7fb0ea9..48d2370b 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -6,9 +6,12 @@ use ParaTest\ParaTestCommand; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Concerns\HandleArguments; +use Pest\Plugins\Parallel\Handlers\Laravel; use Pest\Support\Arr; use Pest\Support\Container; use Pest\TestSuite; +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\OutputInterface; use function Pest\version; @@ -18,15 +21,14 @@ final class Parallel implements HandlesArguments use HandleArguments; private const HANDLERS = [ - \Pest\Plugins\Parallel\Handlers\Parallel::class, - \Pest\Plugins\Parallel\Handlers\Laravel::class, + Parallel\Handlers\Parallel::class, + Laravel::class, ]; public function handleArguments(array $arguments): array { if ($this->argumentsContainParallelFlags($arguments)) { - $exitCode = $this->runTestSuiteInParallel($arguments); - exit($exitCode); + exit($this->runTestSuiteInParallel($arguments)); } $this->markTestSuiteAsParallelSubProcessIfRequired(); @@ -47,7 +49,7 @@ final class Parallel implements HandlesArguments if (! class_exists(ParaTestCommand::class)) { $this->askUserToInstallParatest(); - return 1; + return Command::FAILURE; } $filteredArguments = array_reduce( @@ -56,16 +58,9 @@ final class Parallel implements HandlesArguments $arguments ); - $testSuite = TestSuite::getInstance(); + $exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments)); - $command = ParaTestCommand::applicationFactory($testSuite->rootPath); - $command->setAutoExit(false); - $command->setName('Pest'); - $command->setVersion(version()); - $exitCode = $command->run(new ArgvInput($filteredArguments)); - - $exitCode = (new CallsAddsOutput())($exitCode); - exit($exitCode); + return (new CallsAddsOutput())($exitCode); } private function markTestSuiteAsParallelSubProcessIfRequired(): void @@ -82,4 +77,14 @@ final class Parallel implements HandlesArguments 'Please run composer require --dev brianium/paratest to install ParaTest.', ]); } + + private function paratestCommand(): Application + { + $command = ParaTestCommand::applicationFactory(TestSuite::getInstance()->rootPath); + $command->setAutoExit(false); + $command->setName('Pest'); + $command->setVersion(version()); + + return $command; + } } From dd840f8861d7c56acd2e7b6830396bf363c16fc5 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 13:06:28 +0000 Subject: [PATCH 11/27] WIP --- src/Plugins/Parallel.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 48d2370b..f7d308ce 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -73,8 +73,8 @@ final class Parallel implements HandlesArguments private function askUserToInstallParatest(): void { Container::getInstance()->get(OutputInterface::class)->writeln([ - 'Parallel support requires ParaTest, which is not installed.', - 'Please run composer require --dev brianium/paratest to install ParaTest.', + 'Pest Parallel requires ParaTest to run.', + 'Please run composer require --dev brianium/paratest.', ]); } From a34001faf01e3d96a151b365691aa5a43976d272 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 13:48:42 +0000 Subject: [PATCH 12/27] WIP --- bin/pest-wrapper.php | 1 - src/Logging/TeamCity/Converter.php | 7 --- src/Plugins/Parallel.php | 10 ++-- src/Plugins/Parallel/Handlers/Laravel.php | 6 +- src/Plugins/Parallel/Handlers/Parallel.php | 4 +- .../Parallel/Paratest/WrapperRunner.php | 56 +++++++++++-------- src/Support/StateGenerator.php | 2 +- 7 files changed, 45 insertions(+), 41 deletions(-) diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index 49f21966..c32d9e1f 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -6,7 +6,6 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker; use ParaTest\WrapperRunner\WrapperWorker; use Pest\ConfigLoader; use Pest\Kernel; -use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Support\Container; use Pest\TestSuite; diff --git a/src/Logging/TeamCity/Converter.php b/src/Logging/TeamCity/Converter.php index 0a1e592e..1f0de446 100644 --- a/src/Logging/TeamCity/Converter.php +++ b/src/Logging/TeamCity/Converter.php @@ -5,21 +5,14 @@ declare(strict_types=1); namespace Pest\Logging\TeamCity; use NunoMaduro\Collision\Adapters\Phpunit\State; -use NunoMaduro\Collision\Adapters\Phpunit\TestResult; use Pest\Exceptions\ShouldNotHappen; use Pest\Support\StateGenerator; use Pest\Support\Str; use PHPUnit\Event\Code\Test; -use PHPUnit\Event\Code\TestDox; use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\Throwable; -use PHPUnit\Event\Test\Errored; -use PHPUnit\Event\TestData\TestDataCollection; use PHPUnit\Event\TestSuite\TestSuite; use PHPUnit\Framework\Exception as FrameworkException; -use PHPUnit\Framework\IncompleteTestError; -use PHPUnit\Framework\SkippedWithMessageException; -use PHPUnit\Metadata\MetadataCollection; use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult; /** diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index f7d308ce..d7465b62 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -1,20 +1,21 @@ hasArgument('--parallel', $arguments)) { return true; } + return $this->hasArgument('-p', $arguments); } @@ -54,7 +56,7 @@ final class Parallel implements HandlesArguments $filteredArguments = array_reduce( self::HANDLERS, - fn($arguments, $handler) => (new $handler())->handle($arguments), + fn ($arguments, $handler) => (new $handler())->handle($arguments), $arguments ); diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php index 9d05a8fd..46d20c6b 100644 --- a/src/Plugins/Parallel/Handlers/Laravel.php +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -37,17 +37,17 @@ final class Laravel private function setLaravelParallelRunner(): void { - if (!method_exists(ParallelRunner::class, 'resolveRunnerUsing')) { + if (! method_exists(ParallelRunner::class, 'resolveRunnerUsing')) { exit('Using parallel with Pest requires Laravel v8.55.0 or higher.'); } - ParallelRunner::resolveRunnerUsing(fn(Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)); + ParallelRunner::resolveRunnerUsing(fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)); } private static function isALaravelApplication(): bool { return class_exists(\Illuminate\Foundation\Application::class) && class_exists(\Illuminate\Testing\ParallelRunner::class) - && !class_exists(\Orchestra\Testbench\TestCase::class); + && ! class_exists(\Orchestra\Testbench\TestCase::class); } } diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php index 0ff58839..8a5199b0 100644 --- a/src/Plugins/Parallel/Handlers/Parallel.php +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -6,7 +6,6 @@ namespace Pest\Plugins\Parallel\Handlers; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Parallel\Paratest\WrapperRunner; -use Symfony\Component\Console\Input\ArgvInput; /** * @internal @@ -14,6 +13,7 @@ use Symfony\Component\Console\Input\ArgvInput; final class Parallel { use HandleArguments; + /** * @var string[] */ @@ -27,6 +27,6 @@ final class Parallel { $args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $args); - return $this->pushArgument('--runner=' . WrapperRunner::class, $args); + return $this->pushArgument('--runner='.WrapperRunner::class, $args); } } diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 9d2dd3b2..7424152e 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -4,6 +4,15 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Paratest; +use function array_merge; +use function array_merge_recursive; +use function array_shift; +use function assert; +use function count; +use const DIRECTORY_SEPARATOR; +use function dirname; +use function file_get_contents; +use function max; use ParaTest\Coverage\CoverageMerger; use ParaTest\JUnit\LogMerger; use ParaTest\JUnit\Writer; @@ -19,49 +28,47 @@ use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry; use PHPUnit\TextUI\ShellExitCodeCalculator; use PHPUnit\Util\ExcludeList; +use function realpath; use SebastianBergmann\Timer\Timer; use SplFileInfo; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\PhpExecutableFinder; - -use function array_merge; -use function array_merge_recursive; -use function array_shift; -use function assert; -use function count; -use function dirname; -use function file_get_contents; -use function max; -use function realpath; use function unlink; use function unserialize; use function usleep; -use const DIRECTORY_SEPARATOR; - /** @internal */ final class WrapperRunner implements RunnerInterface { private const CYCLE_SLEEP = 10000; + private readonly ResultPrinter $printer; + private readonly Timer $timer; /** @var non-empty-string[] */ private array $pending = []; - private int $exitcode = -1; + + private int $exitcode = -1; + /** @var array */ private array $workers = []; + /** @var array */ private array $batches = []; /** @var list */ private array $testresultFiles = []; + /** @var list */ private array $coverageFiles = []; + /** @var list */ private array $junitFiles = []; + /** @var list */ private array $teamcityFiles = []; + /** @var list */ private array $testdoxFiles = []; @@ -78,12 +85,12 @@ final class WrapperRunner implements RunnerInterface $this->timer = new Timer(); $wrapper = realpath( - dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'pest-wrapper.php', + dirname(__DIR__, 4).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-wrapper.php', ); assert($wrapper !== false); $phpFinder = new PhpExecutableFinder(); - $phpBin = $phpFinder->find(false); + $phpBin = $phpFinder->find(false); assert($phpBin !== false); $parameters = [$phpBin]; $parameters = array_merge($parameters, $phpFinder->findArguments()); @@ -100,13 +107,16 @@ final class WrapperRunner implements RunnerInterface public function run(): int { - ExcludeList::addDirectory(dirname(__DIR__)); + $directory = dirname(__DIR__); + assert(strlen($directory) > 0); + ExcludeList::addDirectory($directory); + TestResultFacade::init(); EventFacade::seal(); - $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry,); + $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry); $this->pending = $this->getTestFiles($suiteLoader); - $result = TestResultFacade::result(); + $result = TestResultFacade::result(); $this->printer->setTestCount($suiteLoader->testCount); $this->printer->start(); @@ -122,7 +132,7 @@ final class WrapperRunner implements RunnerInterface private function startWorkers(): void { - for ($token = 1; $token <= $this->options->processes; ++$token) { + for ($token = 1; $token <= $this->options->processes; $token++) { $this->startWorker($token); } } @@ -143,7 +153,7 @@ final class WrapperRunner implements RunnerInterface $this->flushWorker($worker); - if ($batchSize !== null && $batchSize !== 0 && $this->batches[$token] === $batchSize) { + if ($batchSize !== 0 && $this->batches[$token] === $batchSize) { $this->destroyWorker($token); $worker = $this->startWorker($token); } @@ -339,7 +349,7 @@ final class WrapperRunner implements RunnerInterface ); } - /** @param list $files */ + /** @param list $files */ private function clearFiles(array $files): void { foreach ($files as $file) { @@ -357,11 +367,11 @@ final class WrapperRunner implements RunnerInterface */ private function getTestFiles(SuiteLoader $suiteLoader): array { - $this->debug(sprintf("Found %d test file%s", count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's')); + $this->debug(sprintf('Found %d test file%s', count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's')); $tests = array_filter( $suiteLoader->files, - fn(string $filename): bool => ! str_ends_with($filename, "eval()'d code") + fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") ); return [...$tests, ...TestSuite::getInstance()->tests->getFilenames()]; diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php index a78d499e..21bd1794 100644 --- a/src/Support/StateGenerator.php +++ b/src/Support/StateGenerator.php @@ -82,7 +82,7 @@ final class StateGenerator $state->add(TestResult::fromTestCase( new TestMethod( - /** @phpstan-ignore-next-line */ + /** @phpstan-ignore-next-line */ "$i", /** @phpstan-ignore-next-line */ '', From 87ee5ef36b42965d99e856c094240c4a27ef5a11 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 17:05:41 +0000 Subject: [PATCH 13/27] Style --- src/Plugins/Parallel.php | 3 ++- .../Parallel/Paratest/CleanConsoleOutput.php | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/Plugins/Parallel/Paratest/CleanConsoleOutput.php diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index d7465b62..c3f9c52b 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -8,6 +8,7 @@ use ParaTest\ParaTestCommand; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Concerns\HandleArguments; +use Pest\Plugins\Parallel\Paratest\CleanConsoleOutput; use Pest\Support\Arr; use Pest\Support\Container; use Pest\TestSuite; @@ -60,7 +61,7 @@ final class Parallel implements HandlesArguments $arguments ); - $exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments)); + $exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput()); return (new CallsAddsOutput())($exitCode); } diff --git a/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php b/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php new file mode 100644 index 00000000..f4ffbc2f --- /dev/null +++ b/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php @@ -0,0 +1,22 @@ +isOpeningHeadline($message)) { + return; + } + + parent::doWrite($message, $newline); + } + + private function isOpeningHeadline(string $message): bool + { + return str_contains($message, 'by Sebastian Bergmann and contributors.'); + } +} From d5495a7e3ad7763f08fd0b57c1af04b89850e420 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 17:06:50 +0000 Subject: [PATCH 14/27] WIP --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index 8c5342fe..66776cef 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,9 @@ "pestphp/pest-plugin": "^2.0.0", "phpunit/phpunit": "^10.0.7" }, + "conflict": { + "brianium/paratest": "<7.0.4" + }, "version": "2.x-dev", "autoload": { "psr-4": { From 17cda168e1edcfe84ac787fdfb9507acfa1f2273 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Wed, 8 Feb 2023 17:08:22 +0000 Subject: [PATCH 15/27] WIP --- src/Plugins/Parallel/Paratest/CleanConsoleOutput.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php b/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php index f4ffbc2f..91a95023 100644 --- a/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php +++ b/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php @@ -6,6 +6,9 @@ use Symfony\Component\Console\Output\ConsoleOutput; class CleanConsoleOutput extends ConsoleOutput { + /** + * @inheritdoc + */ protected function doWrite(string $message, bool $newline): void { if ($this->isOpeningHeadline($message)) { @@ -15,6 +18,10 @@ class CleanConsoleOutput extends ConsoleOutput parent::doWrite($message, $newline); } + /** + * Determines if the given message is the descriptive message + * that Paratest outputs when it starts. + */ private function isOpeningHeadline(string $message): bool { return str_contains($message, 'by Sebastian Bergmann and contributors.'); From aff11486b22a178472b5fbeeb87d73d8385c2b25 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 10:22:49 +0000 Subject: [PATCH 16/27] Fixes --dirty integration --- src/Repositories/TestRepository.php | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index e20b67ab..d505b883 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -54,22 +54,22 @@ final class TestRepository */ public function getFilenames(): array { - $testCases = array_filter($this->testCases, static fn (TestCaseFactory $testCase): bool => $testCase->methodsUsingOnly() !== []); + $testCases = array_filter($this->testCases, static fn(TestCaseFactory $testCase): bool => $testCase->methodsUsingOnly() !== []); if ($testCases === []) { $testCases = $this->testCases; } - return array_values(array_map(static fn (TestCaseFactory $factory): string => $factory->filename, $testCases)); + return array_values(array_map(static fn(TestCaseFactory $factory): string => $factory->filename, $testCases)); } /** * Uses the given `$testCaseClass` on the given `$paths`. * - * @param array $classOrTraits - * @param array $groups - * @param array $paths - * @param array $hooks + * @param array $classOrTraits + * @param array $groups + * @param array $paths + * @param array $hooks */ public function use(array $classOrTraits, array $groups, array $paths, array $hooks): void { @@ -125,13 +125,19 @@ final class TestRepository */ public function set(TestCaseMethodFactory $method): void { - foreach ($this->testCaseMethodFilters as $filter) { - if (! $filter->accept($method)) { + foreach ($this->testCaseFilters as $filter) { + if (!$filter->accept($method->filename)) { return; } } - if (! array_key_exists($method->filename, $this->testCases)) { + foreach ($this->testCaseMethodFilters as $filter) { + if (!$filter->accept($method)) { + return; + } + } + + if (!array_key_exists($method->filename, $this->testCases)) { $this->testCases[$method->filename] = new TestCaseFactory($method->filename); } @@ -143,19 +149,17 @@ final class TestRepository */ public function makeIfNeeded(string $filename): void { - if (! array_key_exists($filename, $this->testCases)) { + if (!array_key_exists($filename, $this->testCases)) { return; } - $accepted = array_reduce( - $this->testCaseFilters, - fn (bool $carry, TestCaseFilter $filter): bool => $carry && $filter->accept($filename), - true, - ); - - if ($accepted) { - $this->make($this->testCases[$filename]); + foreach ($this->testCaseFilters as $filter) { + if (!$filter->accept($filename)) { + return; + } } + + $this->make($this->testCases[$filename]); } /** @@ -163,12 +167,12 @@ final class TestRepository */ private function make(TestCaseFactory $testCase): void { - $startsWith = static fn (string $target, string $directory): bool => Str::startsWith($target, $directory.DIRECTORY_SEPARATOR); + $startsWith = static fn(string $target, string $directory): bool => Str::startsWith($target, $directory . DIRECTORY_SEPARATOR); foreach ($this->uses as $path => $uses) { [$classOrTraits, $groups, $hooks] = $uses; - if ((! is_dir($path) && $testCase->filename === $path) || (is_dir($path) && $startsWith($testCase->filename, $path))) { + if ((!is_dir($path) && $testCase->filename === $path) || (is_dir($path) && $startsWith($testCase->filename, $path))) { foreach ($classOrTraits as $class) { /** @var string $class */ if (class_exists($class)) { From 2561d47bb538299232d828f6a72fc28b8445f7a7 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 11:12:59 +0000 Subject: [PATCH 17/27] WIP --- src/Plugins/Parallel.php | 30 ++++++++++++++----- .../Contracts/HandlesSubprocessArguments.php | 10 +++++++ src/Plugins/Parallel/Handlers/Laravel.php | 25 +++++++++++----- src/Plugins/Parallel/Handlers/Parallel.php | 7 +++-- src/Plugins/Parallel/Handlers/Pest.php | 20 +++++++++++++ src/Repositories/TestRepository.php | 14 +++++++++ 6 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php create mode 100644 src/Plugins/Parallel/Handlers/Pest.php diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index c3f9c52b..133c1a58 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -8,6 +8,7 @@ use ParaTest\ParaTestCommand; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Concerns\HandleArguments; +use Pest\Plugins\Parallel\Contracts\HandlesSubprocessArguments; use Pest\Plugins\Parallel\Paratest\CleanConsoleOutput; use Pest\Support\Arr; use Pest\Support\Container; @@ -24,6 +25,7 @@ final class Parallel implements HandlesArguments private const HANDLERS = [ Parallel\Handlers\Parallel::class, + Parallel\Handlers\Pest::class, Parallel\Handlers\Laravel::class, ]; @@ -33,7 +35,9 @@ final class Parallel implements HandlesArguments exit($this->runTestSuiteInParallel($arguments)); } - $this->markTestSuiteAsParallelSubProcessIfRequired(); + if ((int) Arr::get($_SERVER, 'PARATEST') === 1) { + return $this->runSubprocessHandlers($arguments); + } return $arguments; } @@ -55,9 +59,14 @@ final class Parallel implements HandlesArguments return Command::FAILURE; } + $handlers = array_filter( + array_map(fn ($handler) => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler) => $handler instanceof HandlesArguments, + ); + $filteredArguments = array_reduce( - self::HANDLERS, - fn ($arguments, $handler) => (new $handler())->handle($arguments), + $handlers, + fn ($arguments, HandlesArguments $handler) => $handler->handleArguments($arguments), $arguments ); @@ -66,11 +75,18 @@ final class Parallel implements HandlesArguments return (new CallsAddsOutput())($exitCode); } - private function markTestSuiteAsParallelSubProcessIfRequired(): void + private function runSubprocessHandlers(array $arguments): array { - if ((int) Arr::get($_SERVER, 'PARATEST') === 1) { - $_SERVER['PEST_PARALLEL'] = 1; - } + $handlers = array_filter( + array_map(fn ($handler) => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler) => $handler instanceof HandlesSubprocessArguments, + ); + + return array_reduce( + $handlers, + fn ($arguments, HandlesSubprocessArguments $handler) => $handler->handleSubprocessArguments($arguments), + $arguments + ); } private function askUserToInstallParatest(): void diff --git a/src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php b/src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php new file mode 100644 index 00000000..4fb08457 --- /dev/null +++ b/src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php @@ -0,0 +1,10 @@ +setLaravelParallelRunner(); - foreach ($args as $value) { + foreach ($arguments as $value) { if (str_starts_with((string) $value, '--runner')) { - $args = $this->popArgument($value, $args); + $arguments = $this->popArgument($value, $arguments); } } - return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $args); + return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $arguments); } private function setLaravelParallelRunner(): void @@ -46,8 +49,14 @@ final class Laravel private static function isALaravelApplication(): bool { - return class_exists(\Illuminate\Foundation\Application::class) - && class_exists(\Illuminate\Testing\ParallelRunner::class) + return InstalledVersions::isInstalled('laravel/framework', false) && ! class_exists(\Orchestra\Testbench\TestCase::class); } + + public function handleSubprocessArguments(array $arguments): array + { + putenv('LARAVEL_PARALLEL_TESTING=1'); + + return $arguments; + } } diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php index 8a5199b0..71f6d4d6 100644 --- a/src/Plugins/Parallel/Handlers/Parallel.php +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -4,13 +4,14 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Handlers; +use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Parallel\Paratest\WrapperRunner; /** * @internal */ -final class Parallel +final class Parallel implements HandlesArguments { use HandleArguments; @@ -23,9 +24,9 @@ final class Parallel '--no-output', ]; - public function handle(array $args): array + public function handleArguments(array $arguments): array { - $args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $args); + $args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $arguments); return $this->pushArgument('--runner='.WrapperRunner::class, $args); } diff --git a/src/Plugins/Parallel/Handlers/Pest.php b/src/Plugins/Parallel/Handlers/Pest.php new file mode 100644 index 00000000..726d687e --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Pest.php @@ -0,0 +1,20 @@ +testCaseMethodFilters[] = $filter; } + /** + * @param class-string $filter + */ + public function hasTestCaseMethodFilter(string $filter): bool + { + foreach ($this->testCaseMethodFilters as $testCaseMethodFilter) { + if ($testCaseMethodFilter instanceof $filter) { + return true; + } + } + + return false; + } + /** * Gets the test case factory from the given filename. */ From f107fdfa082ee74867bc88237cf8a6895f5fe21a Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 11:47:38 +0000 Subject: [PATCH 18/27] WIP --- src/Plugins/Parallel/Handlers/Laravel.php | 58 ++++++++++++++++++----- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php index 05bb7cf8..0e1a6b8f 100644 --- a/src/Plugins/Parallel/Handlers/Laravel.php +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -5,22 +5,32 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Handlers; use Composer\InstalledVersions; +use Illuminate\Support\Facades\App; use Illuminate\Testing\ParallelRunner; +use NunoMaduro\Collision\Adapters\Laravel\Commands\TestCommand; use ParaTest\Options; use ParaTest\RunnerInterface; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Concerns\HandleArguments; -use Pest\Plugins\Parallel\Contracts\HandlesSubprocessArguments; use Pest\Plugins\Parallel\Paratest\WrapperRunner; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\PhpProcess; /** * @internal */ -final class Laravel implements HandlesArguments, HandlesSubprocessArguments +final class Laravel implements HandlesArguments { use HandleArguments; + public function __construct( + private readonly OutputInterface $output, + private readonly InputInterface $input, + ) + { + } + public function handleArguments(array $arguments): array { if (! self::isALaravelApplication()) { @@ -29,19 +39,15 @@ final class Laravel implements HandlesArguments, HandlesSubprocessArguments $this->setLaravelParallelRunner(); - foreach ($arguments as $value) { - if (str_starts_with((string) $value, '--runner')) { - $arguments = $this->popArgument($value, $arguments); - } - } + $arguments = $this->setEnvironmentVariables($arguments); - return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $arguments); + return $this->useLaravelRunner($arguments); } private function setLaravelParallelRunner(): void { if (! method_exists(ParallelRunner::class, 'resolveRunnerUsing')) { - exit('Using parallel with Pest requires Laravel v8.55.0 or higher.'); + $this->output->writeln(' Using parallel with Pest requires Laravel v8.55.0 or higher.'); } ParallelRunner::resolveRunnerUsing(fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)); @@ -53,10 +59,38 @@ final class Laravel implements HandlesArguments, HandlesSubprocessArguments && ! class_exists(\Orchestra\Testbench\TestCase::class); } - public function handleSubprocessArguments(array $arguments): array + /** + * @param array $arguments + * @return array + */ + private function setEnvironmentVariables(array $arguments): array { - putenv('LARAVEL_PARALLEL_TESTING=1'); + $_ENV['LARAVEL_PARALLEL_TESTING'] = 1; - return $arguments; + if ($this->hasArgument('--recreate-databases', $arguments)) { + $_ENV['LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES'] = 1; + } + + if ($this->hasArgument('--drop-databases', $arguments)) { + $_ENV['LARAVEL_PARALLEL_TESTING_DROP_DATABASES'] = 1; + } + + $arguments = $this->popArgument('--recreate-databases', $arguments); + return $this->popArgument('--drop-databases', $arguments); + } + + /** + * @param array $arguments + * @return array + */ + private function useLaravelRunner(array $arguments): array + { + foreach ($arguments as $value) { + if (str_starts_with((string)$value, '--runner')) { + $arguments = $this->popArgument($value, $arguments); + } + } + + return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $arguments); } } From 2ae06a0e2dec8c9b804fa6eaf1673b1110a31a56 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 11:50:52 +0000 Subject: [PATCH 19/27] WIP --- src/Plugins/Parallel/Handlers/Laravel.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php index 0e1a6b8f..e188d386 100644 --- a/src/Plugins/Parallel/Handlers/Laravel.php +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -13,6 +13,7 @@ use ParaTest\RunnerInterface; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Parallel\Paratest\WrapperRunner; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\PhpProcess; @@ -47,7 +48,8 @@ final class Laravel implements HandlesArguments private function setLaravelParallelRunner(): void { if (! method_exists(ParallelRunner::class, 'resolveRunnerUsing')) { - $this->output->writeln(' Using parallel with Pest requires Laravel v8.55.0 or higher.'); + $this->output->writeln(' Using parallel with Pest requires Laravel v8.55.0 or higher.'); + exit(Command::FAILURE); } ParallelRunner::resolveRunnerUsing(fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)); From 8d33c9dc8937c7d7c26ca24c57bc829fcbb26035 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 12:48:26 +0000 Subject: [PATCH 20/27] WIP --- src/Plugins/Parallel/Handlers/Laravel.php | 3 --- src/Repositories/TestRepository.php | 14 -------------- 2 files changed, 17 deletions(-) diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php index e188d386..665494fa 100644 --- a/src/Plugins/Parallel/Handlers/Laravel.php +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -5,9 +5,7 @@ declare(strict_types=1); namespace Pest\Plugins\Parallel\Handlers; use Composer\InstalledVersions; -use Illuminate\Support\Facades\App; use Illuminate\Testing\ParallelRunner; -use NunoMaduro\Collision\Adapters\Laravel\Commands\TestCommand; use ParaTest\Options; use ParaTest\RunnerInterface; use Pest\Contracts\Plugins\HandlesArguments; @@ -16,7 +14,6 @@ use Pest\Plugins\Parallel\Paratest\WrapperRunner; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Process\PhpProcess; /** * @internal diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index c3b9e699..d505b883 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -112,20 +112,6 @@ final class TestRepository $this->testCaseMethodFilters[] = $filter; } - /** - * @param class-string $filter - */ - public function hasTestCaseMethodFilter(string $filter): bool - { - foreach ($this->testCaseMethodFilters as $testCaseMethodFilter) { - if ($testCaseMethodFilter instanceof $filter) { - return true; - } - } - - return false; - } - /** * Gets the test case factory from the given filename. */ From c319a8e84cf6c76f2b5c5d8d5e22c73cf3f11609 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 13:20:34 +0000 Subject: [PATCH 21/27] WIP --- src/Plugins/Parallel/Paratest/WrapperRunner.php | 6 ++++-- .../SubFolder/SubFolder/UsesPerSubDirectory.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 7424152e..13eb1737 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -369,12 +369,14 @@ final class WrapperRunner implements RunnerInterface { $this->debug(sprintf('Found %d test file%s', count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's')); - $tests = array_filter( + $phpunitTests = array_filter( $suiteLoader->files, fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") ); - return [...$tests, ...TestSuite::getInstance()->tests->getFilenames()]; + $pestTests = TestSuite::getInstance()->tests->getFilenames(); + + return [...$phpunitTests, ...$pestTests]; } private function debug(string $message): void diff --git a/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php b/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php index 01bb8456..3136a08b 100644 --- a/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php +++ b/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php @@ -2,4 +2,4 @@ test('closure was bound to CustomTestCase', function () { $this->assertCustomInSubFolderTrue(); -}); +})->only(); From 757a98230e11446889bde0fdf9cb6a3de5e5131e Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 13:20:46 +0000 Subject: [PATCH 22/27] WIP --- .../SubFolder/SubFolder/UsesPerSubDirectory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php b/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php index 3136a08b..01bb8456 100644 --- a/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php +++ b/tests/PHPUnit/CustomTestCaseInSubFolders/SubFolder/SubFolder/UsesPerSubDirectory.php @@ -2,4 +2,4 @@ test('closure was bound to CustomTestCase', function () { $this->assertCustomInSubFolderTrue(); -})->only(); +}); From b7ec3c59b85302cab5d3c72949439c3f9f6c116c Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 14:48:42 +0000 Subject: [PATCH 23/27] WIP --- bin/pest-wrapper.php | 2 +- src/Plugins/Parallel.php | 7 ++++++- src/Plugins/Parallel/Handlers/Pest.php | 19 ++++++++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index c32d9e1f..2dbe4be5 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -77,7 +77,7 @@ $bootPest = (static function (): void { $bootPest(); - (new CallsHandleArguments())($phpunitArgv); + $phpunitArgv = (new CallsHandleArguments())($phpunitArgv); $application = new ApplicationForWrapperWorker( $phpunitArgv, diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 133c1a58..d4e39d73 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -29,13 +29,18 @@ final class Parallel implements HandlesArguments Parallel\Handlers\Laravel::class, ]; + public static function isInParallelProcess(): bool + { + return (int) Arr::get($_SERVER, 'PARATEST') === 1; + } + public function handleArguments(array $arguments): array { if ($this->argumentsContainParallelFlags($arguments)) { exit($this->runTestSuiteInParallel($arguments)); } - if ((int) Arr::get($_SERVER, 'PARATEST') === 1) { + if (self::isInParallelProcess()) { return $this->runSubprocessHandlers($arguments); } diff --git a/src/Plugins/Parallel/Handlers/Pest.php b/src/Plugins/Parallel/Handlers/Pest.php index 726d687e..75c12557 100644 --- a/src/Plugins/Parallel/Handlers/Pest.php +++ b/src/Plugins/Parallel/Handlers/Pest.php @@ -2,19 +2,32 @@ namespace Pest\Plugins\Parallel\Handlers; +use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Concerns\HandleArguments; use Pest\Plugins\Parallel\Contracts\HandlesSubprocessArguments; -use Pest\TestCaseMethodFilters\TodoTestCaseFilter; -use Pest\TestSuite; +use Pest\Plugins\Retry; -final class Pest implements HandlesSubprocessArguments +final class Pest implements HandlesArguments, HandlesSubprocessArguments { use HandleArguments; + public function handleArguments(array $arguments): array + { + if (Retry::$retrying) { + $_ENV['PEST_RETRY'] = '1'; + } + + return $arguments; + } + public function handleSubprocessArguments(array $arguments): array { $_SERVER['PEST_PARALLEL'] = '1'; + if (isset($_SERVER['PEST_RETRY'])) { + Retry::$retrying = true; + } + return $arguments; } } From 7fe7a01d43074d334b5f083870d7a03b77b8f88c Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 14:49:56 +0000 Subject: [PATCH 24/27] WIP --- tests/Hooks/BeforeAllTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Hooks/BeforeAllTest.php b/tests/Hooks/BeforeAllTest.php index 12524554..c318e1e2 100644 --- a/tests/Hooks/BeforeAllTest.php +++ b/tests/Hooks/BeforeAllTest.php @@ -1,11 +1,12 @@ calls baseline. This is because // two other tests are executed before this one due to filename ordering. $args = $_SERVER['argv'] ?? []; -$single = (isset($args[1]) && Str::endsWith(__FILE__, $args[1])) || ($_SERVER['PEST_PARALLEL'] ?? false); +$single = (isset($args[1]) && Str::endsWith(__FILE__, $args[1])) || Parallel::isInParallelProcess(); $offset = $single ? 0 : 2; uses()->beforeAll(function () use ($offset) { From 504fd0470531541787f4b2f85da7372194989da8 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 15:49:30 +0000 Subject: [PATCH 25/27] WIP --- bin/pest-wrapper.php | 7 +++++++ src/Plugins/Parallel.php | 2 ++ 2 files changed, 9 insertions(+) diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index 2dbe4be5..9e014558 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -8,6 +8,7 @@ use Pest\ConfigLoader; use Pest\Kernel; use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Support\Container; +use Pest\TestCaseMethodFilters\TodoTestCaseFilter; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; @@ -16,12 +17,18 @@ use Symfony\Component\Console\Output\OutputInterface; $bootPest = (static function (): void { $argv = new ArgvInput(); + $originalArgv = new ArgvInput(json_decode($_SERVER['PEST_PARALLEL_ARGV'])); + $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); $testSuite = TestSuite::getInstance( $rootPath, $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), ); + if ($originalArgv->hasParameterOption('--todo')) { + $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); + } + $output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true); $container = Container::getInstance(); diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index d4e39d73..7a959afe 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -64,6 +64,8 @@ final class Parallel implements HandlesArguments return Command::FAILURE; } + $_ENV['PEST_PARALLEL_ARGV'] = json_encode($_SERVER['argv']); + $handlers = array_filter( array_map(fn ($handler) => Container::getInstance()->get($handler), self::HANDLERS), fn ($handler) => $handler instanceof HandlesArguments, From e1406554fcb51ff9153ce84b8489d27f331eec40 Mon Sep 17 00:00:00 2001 From: Luke Downing Date: Fri, 10 Feb 2023 15:58:10 +0000 Subject: [PATCH 26/27] WIP --- bin/pest-wrapper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/pest-wrapper.php b/bin/pest-wrapper.php index 9e014558..7972b206 100644 --- a/bin/pest-wrapper.php +++ b/bin/pest-wrapper.php @@ -17,7 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface; $bootPest = (static function (): void { $argv = new ArgvInput(); - $originalArgv = new ArgvInput(json_decode($_SERVER['PEST_PARALLEL_ARGV'])); + $parentProcessArgv = new ArgvInput(json_decode($_SERVER['PEST_PARALLEL_ARGV'])); $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); $testSuite = TestSuite::getInstance( @@ -25,7 +25,7 @@ $bootPest = (static function (): void { $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), ); - if ($originalArgv->hasParameterOption('--todo')) { + if ($parentProcessArgv->hasParameterOption('--todo')) { $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); } From 8eaf4859ff30ca71fa6b4784648dbb098666791a Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Sat, 11 Feb 2023 16:07:30 +0000 Subject: [PATCH 27/27] chore: different refactors --- bin/pest | 17 ++--- bin/{pest-wrapper.php => worker.php} | 28 +++---- composer.json | 2 +- phpstan.neon | 3 +- src/Kernel.php | 34 ++++++--- src/Plugins/Parallel.php | 49 +++++++++---- .../Contracts/HandlersWorkerArguments.php | 14 ++++ .../Contracts/HandlesSubprocessArguments.php | 10 --- src/Plugins/Parallel/Handlers/Laravel.php | 32 +++----- src/Plugins/Parallel/Handlers/Pest.php | 8 +- .../Parallel/Paratest/CleanConsoleOutput.php | 6 +- .../Parallel/Paratest/WrapperRunner.php | 73 +++++++++++-------- src/Repositories/TestRepository.php | 26 +++---- src/Subscribers/EnsureTeamCityEnabled.php | 2 +- src/Support/Container.php | 6 +- src/Support/StateGenerator.php | 15 ++-- tests/.snapshots/Failure.php.inc | 24 ------ tests/.snapshots/success.txt | 5 +- tests/Visual/Parallel.php | 18 +++++ 19 files changed, 207 insertions(+), 165 deletions(-) rename bin/{pest-wrapper.php => worker.php} (79%) create mode 100644 src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php delete mode 100644 src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php create mode 100644 tests/Visual/Parallel.php diff --git a/bin/pest b/bin/pest index bae7b4eb..e99c09bb 100755 --- a/bin/pest +++ b/bin/pest @@ -17,6 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface; $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; $args = $_SERVER['argv']; + $dirty = false; $todo = false; @@ -68,11 +69,11 @@ use Symfony\Component\Console\Output\OutputInterface; // Get $rootPath based on $autoloadPath $rootPath = dirname($autoloadPath, 2); - $argv = new ArgvInput(); + $input = new ArgvInput(); $testSuite = TestSuite::getInstance( $rootPath, - $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), + $input->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), ); if ($dirty) { @@ -83,19 +84,13 @@ use Symfony\Component\Console\Output\OutputInterface; $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); } - $isDecorated = $argv->getParameterOption('--colors', 'always') !== 'never'; + $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never'; $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); - $container = Container::getInstance(); - $container->add(TestSuite::class, $testSuite); - $container->add(OutputInterface::class, $output); - $container->add(InputInterface::class, $argv); - $container->add(Container::class, $container); + $kernel = Kernel::boot($testSuite, $input, $output); - $kernel = Kernel::boot(); - - $result = $kernel->handle($output, $args); + $result = $kernel->handle($args); $kernel->shutdown(); diff --git a/bin/pest-wrapper.php b/bin/worker.php similarity index 79% rename from bin/pest-wrapper.php rename to bin/worker.php index 7972b206..ca6e84c6 100644 --- a/bin/pest-wrapper.php +++ b/bin/worker.php @@ -7,37 +7,31 @@ use ParaTest\WrapperRunner\WrapperWorker; use Pest\ConfigLoader; use Pest\Kernel; use Pest\Plugins\Actions\CallsHandleArguments; -use Pest\Support\Container; use Pest\TestCaseMethodFilters\TodoTestCaseFilter; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; $bootPest = (static function (): void { - $argv = new ArgvInput(); - $parentProcessArgv = new ArgvInput(json_decode($_SERVER['PEST_PARALLEL_ARGV'])); + $workerArgv = new ArgvInput(); + $masterArgv = new ArgvInput(json_decode($_SERVER['PEST_PARALLEL_ARGV'])); $rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2); - $testSuite = TestSuite::getInstance( - $rootPath, - $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), - ); + $testSuite = TestSuite::getInstance($rootPath, $workerArgv->getParameterOption( + '--test-directory', + (new ConfigLoader($rootPath))->getTestsDirectory() + )); - if ($parentProcessArgv->hasParameterOption('--todo')) { + if ($masterArgv->hasParameterOption('--todo')) { $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); } + $input = new ArgvInput(); + $output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true); - $container = Container::getInstance(); - $container->add(TestSuite::class, $testSuite); - $container->add(OutputInterface::class, $output); - $container->add(InputInterface::class, $argv); - $container->add(Container::class, $container); - - Kernel::boot(); + Kernel::boot($testSuite, $input, $output); }); (static function () use ($bootPest): void { @@ -104,10 +98,10 @@ $bootPest = (static function (): void { $testPath = fgets(STDIN); if ($testPath === false || $testPath === WrapperWorker::COMMAND_EXIT) { $application->end(); + exit; } - // It must be a 1 byte string to ensure filesize() is equal to the number of tests executed $exitCode = $application->runTest(trim($testPath)); fwrite($statusFile, (string) $exitCode); diff --git a/composer.json b/composer.json index 66776cef..d4598946 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ ] }, "require-dev": { - "brianium/paratest": "^7.0.4", + "brianium/paratest": "^7.0.5", "pestphp/pest-dev-tools": "^2.4.0", "pestphp/pest-plugin-arch": "^2.0.0", "symfony/process": "^6.2.5" diff --git a/phpstan.neon b/phpstan.neon index cb50f844..a75d68cc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,9 +12,10 @@ parameters: reportUnmatchedIgnoredErrors: true ignoreErrors: + - "#Language construct isset\\(\\) should not be used.#" + - "#is not allowed to extend#" - "#with a nullable type declaration#" - "#type mixed is not subtype of native#" - - "#is not allowed to extend#" - "# with null as default value#" - "#has parameter \\$closure with default value.#" - "#has parameter \\$description with default value.#" diff --git a/src/Kernel.php b/src/Kernel.php index 39c0fd66..3914418e 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -11,7 +11,7 @@ use Pest\Plugins\Actions\CallsBoot; use Pest\Plugins\Actions\CallsShutdown; use Pest\Support\Container; use PHPUnit\TextUI\Application; -use PHPUnit\TextUI\Exception; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; /** @@ -36,7 +36,8 @@ final class Kernel * Creates a new Kernel instance. */ public function __construct( - private readonly Application $application + private readonly Application $application, + private readonly OutputInterface $output, ) { register_shutdown_function(function (): void { if (error_get_last() !== null) { @@ -50,8 +51,16 @@ final class Kernel /** * Boots the Kernel. */ - public static function boot(): self + public static function boot(TestSuite $testSuite, InputInterface $input, OutputInterface $output): self { + $container = Container::getInstance(); + + $container + ->add(TestSuite::class, $testSuite) + ->add(InputInterface::class, $input) + ->add(OutputInterface::class, $output) + ->add(Container::class, $container); + foreach (self::BOOTSTRAPPERS as $bootstrapper) { $bootstrapper = Container::getInstance()->get($bootstrapper); assert($bootstrapper instanceof Bootstrapper); @@ -61,24 +70,25 @@ final class Kernel (new CallsBoot())->__invoke(); - return new self(new Application()); + return new self( + new Application(), + $output, + ); } /** - * Handles the given argv. + * Runs the application, and returns the exit code. * - * @param array $argv - * - * @throws Exception + * @param array $args */ - public function handle(OutputInterface $output, array $argv): int + public function handle(array $args): int { - $argv = (new Plugins\Actions\CallsHandleArguments())->__invoke($argv); + $args = (new Plugins\Actions\CallsHandleArguments())->__invoke($args); try { - $this->application->run($argv); + $this->application->run($args); } catch (NoTestsFound) { - $output->writeln([ + $this->output->writeln([ '', ' INFO No tests found.', '', diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 7a959afe..07a48587 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Pest\Plugins; +use JsonException; use ParaTest\ParaTestCommand; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Concerns\HandleArguments; -use Pest\Plugins\Parallel\Contracts\HandlesSubprocessArguments; +use Pest\Plugins\Parallel\Contracts\HandlersWorkerArguments; use Pest\Plugins\Parallel\Paratest\CleanConsoleOutput; use Pest\Support\Arr; use Pest\Support\Container; @@ -31,7 +32,11 @@ final class Parallel implements HandlesArguments public static function isInParallelProcess(): bool { - return (int) Arr::get($_SERVER, 'PARATEST') === 1; + $argvValue = Arr::get($_SERVER, 'PARATEST'); + + assert(is_string($argvValue) || is_int($argvValue) || is_null($argvValue)); + + return ((int) $argvValue) === 1; } public function handleArguments(array $arguments): array @@ -41,12 +46,15 @@ final class Parallel implements HandlesArguments } if (self::isInParallelProcess()) { - return $this->runSubprocessHandlers($arguments); + return $this->runWorkersHandlers($arguments); } return $arguments; } + /** + * @param array $arguments + */ private function argumentsContainParallelFlags(array $arguments): bool { if ($this->hasArgument('--parallel', $arguments)) { @@ -56,6 +64,11 @@ final class Parallel implements HandlesArguments return $this->hasArgument('-p', $arguments); } + /** + * @param array $arguments + * + * @throws JsonException + */ private function runTestSuiteInParallel(array $arguments): int { if (! class_exists(ParaTestCommand::class)) { @@ -64,16 +77,16 @@ final class Parallel implements HandlesArguments return Command::FAILURE; } - $_ENV['PEST_PARALLEL_ARGV'] = json_encode($_SERVER['argv']); + $_ENV['PEST_PARALLEL_ARGV'] = json_encode($_SERVER['argv'], JSON_THROW_ON_ERROR); $handlers = array_filter( - array_map(fn ($handler) => Container::getInstance()->get($handler), self::HANDLERS), - fn ($handler) => $handler instanceof HandlesArguments, + array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler): bool => $handler instanceof HandlesArguments, ); $filteredArguments = array_reduce( $handlers, - fn ($arguments, HandlesArguments $handler) => $handler->handleArguments($arguments), + fn ($arguments, HandlesArguments $handler): array => $handler->handleArguments($arguments), $arguments ); @@ -82,23 +95,30 @@ final class Parallel implements HandlesArguments return (new CallsAddsOutput())($exitCode); } - private function runSubprocessHandlers(array $arguments): array + /** + * @param array $arguments + * @return array + */ + private function runWorkersHandlers(array $arguments): array { $handlers = array_filter( - array_map(fn ($handler) => Container::getInstance()->get($handler), self::HANDLERS), - fn ($handler) => $handler instanceof HandlesSubprocessArguments, + array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler): bool => $handler instanceof HandlersWorkerArguments, ); return array_reduce( $handlers, - fn ($arguments, HandlesSubprocessArguments $handler) => $handler->handleSubprocessArguments($arguments), + fn ($arguments, HandlersWorkerArguments $handler): array => $handler->handleWorkerArguments($arguments), $arguments ); } private function askUserToInstallParatest(): void { - Container::getInstance()->get(OutputInterface::class)->writeln([ + /** @var OutputInterface $output */ + $output = Container::getInstance()->get(OutputInterface::class); + + $output->writeln([ 'Pest Parallel requires ParaTest to run.', 'Please run composer require --dev brianium/paratest.', ]); @@ -106,7 +126,10 @@ final class Parallel implements HandlesArguments private function paratestCommand(): Application { - $command = ParaTestCommand::applicationFactory(TestSuite::getInstance()->rootPath); + /** @var non-empty-string $rootPath */ + $rootPath = TestSuite::getInstance()->rootPath; + + $command = ParaTestCommand::applicationFactory($rootPath); $command->setAutoExit(false); $command->setName('Pest'); $command->setVersion(version()); diff --git a/src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php b/src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php new file mode 100644 index 00000000..f953ecb7 --- /dev/null +++ b/src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php @@ -0,0 +1,14 @@ + $arguments + * @return array + */ + public function handleWorkerArguments(array $arguments): array; +} diff --git a/src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php b/src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php deleted file mode 100644 index 4fb08457..00000000 --- a/src/Plugins/Parallel/Contracts/HandlesSubprocessArguments.php +++ /dev/null @@ -1,10 +0,0 @@ -output->writeln(' Using parallel with Pest requires Laravel v8.55.0 or higher.'); - exit(Command::FAILURE); - } - - ParallelRunner::resolveRunnerUsing(fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)); + ParallelRunner::resolveRunnerUsing( // @phpstan-ignore-line + fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output) + ); } private static function isALaravelApplication(): bool { - return InstalledVersions::isInstalled('laravel/framework', false) - && ! class_exists(\Orchestra\Testbench\TestCase::class); + if (! InstalledVersions::isInstalled('laravel/framework', false)) { + return false; + } + + return ! class_exists(\Orchestra\Testbench\TestCase::class); } /** - * @param array $arguments + * @param array $arguments * @return array */ private function setEnvironmentVariables(array $arguments): array @@ -75,17 +66,18 @@ final class Laravel implements HandlesArguments } $arguments = $this->popArgument('--recreate-databases', $arguments); + return $this->popArgument('--drop-databases', $arguments); } /** - * @param array $arguments + * @param array $arguments * @return array */ private function useLaravelRunner(array $arguments): array { foreach ($arguments as $value) { - if (str_starts_with((string)$value, '--runner')) { + if (str_starts_with($value, '--runner')) { $arguments = $this->popArgument($value, $arguments); } } diff --git a/src/Plugins/Parallel/Handlers/Pest.php b/src/Plugins/Parallel/Handlers/Pest.php index 75c12557..71b24647 100644 --- a/src/Plugins/Parallel/Handlers/Pest.php +++ b/src/Plugins/Parallel/Handlers/Pest.php @@ -1,13 +1,15 @@ */ private array $pending = []; - private int $exitcode = -1; + private int $exitCode = -1; - /** @var array */ + /** @var array */ private array $workers = []; /** @var array */ private array $batches = []; - /** @var list */ + /** @var array */ private array $testresultFiles = []; - /** @var list */ + /** @var array */ private array $coverageFiles = []; - /** @var list */ + /** @var array */ private array $junitFiles = []; - /** @var list */ + /** @var array */ private array $teamcityFiles = []; - /** @var list */ + /** @var array */ private array $testdoxFiles = []; - /** @var non-empty-string[] */ + /** @var array */ private readonly array $parameters; - private CodeCoverageFilterRegistry $codeCoverageFilterRegistry; + private readonly CodeCoverageFilterRegistry $codeCoverageFilterRegistry; public function __construct( private readonly Options $options, @@ -84,11 +86,11 @@ final class WrapperRunner implements RunnerInterface $this->printer = new ResultPrinter($output, $options); $this->timer = new Timer(); - $wrapper = realpath( - dirname(__DIR__, 4).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-wrapper.php', + $worker = realpath( + dirname(__DIR__, 4).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'worker.php', ); - assert($wrapper !== false); + assert($worker !== false); $phpFinder = new PhpExecutableFinder(); $phpBin = $phpFinder->find(false); assert($phpBin !== false); @@ -99,7 +101,7 @@ final class WrapperRunner implements RunnerInterface $parameters = array_merge($parameters, $options->passthruPhp); } - $parameters[] = $wrapper; + $parameters[] = $worker; $this->parameters = $parameters; $this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry(); @@ -113,6 +115,7 @@ final class WrapperRunner implements RunnerInterface TestResultFacade::init(); EventFacade::seal(); + $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry); $this->pending = $this->getTestFiles($suiteLoader); @@ -159,7 +162,7 @@ final class WrapperRunner implements RunnerInterface } if ( - $this->exitcode > 0 + $this->exitCode > 0 && $this->options->configuration->stopOnFailure() ) { $this->pending = []; @@ -177,7 +180,7 @@ final class WrapperRunner implements RunnerInterface private function flushWorker(WrapperWorker $worker): void { - $this->exitcode = max($this->exitcode, $worker->getExitCode()); + $this->exitCode = max($this->exitCode, $worker->getExitCode()); $this->printer->printFeedback( $worker->progressFile, $this->teamcityFiles, @@ -191,7 +194,7 @@ final class WrapperRunner implements RunnerInterface while ($this->workers !== []) { foreach ($this->workers as $index => $worker) { if ($worker->isRunning()) { - if (! isset($stopped[$index]) && $worker->isFree()) { + if (! array_key_exists($index, $stopped) && $worker->isFree()) { $worker->stop(); $stopped[$index] = true; } @@ -213,16 +216,22 @@ final class WrapperRunner implements RunnerInterface private function startWorker(int $token): WrapperWorker { + /** @var array $parameters */ + $parameters = $this->parameters; + $worker = new WrapperWorker( $this->output, $this->options, - $this->parameters, + $parameters, $token, ); + $worker->start(); + $this->batches[$token] = 0; $this->testresultFiles[] = $worker->testresultFile; + if (isset($worker->junitFile)) { $this->junitFiles[] = $worker->junitFile; } @@ -349,7 +358,7 @@ final class WrapperRunner implements RunnerInterface ); } - /** @param list $files */ + /** @param array $files */ private function clearFiles(array $files): void { foreach ($files as $file) { @@ -362,23 +371,29 @@ final class WrapperRunner implements RunnerInterface } /** - * We are doing this because the SuiteLoader returns filenames incorrectly - * for Pest tests. Ideally we should find a cleaner solution. + * Returns the test files to be executed. + * + * @return array */ private function getTestFiles(SuiteLoader $suiteLoader): array { $this->debug(sprintf('Found %d test file%s', count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's')); - $phpunitTests = array_filter( - $suiteLoader->files, - fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") - ); + /** @var array $files */ + $files = $suiteLoader->files; - $pestTests = TestSuite::getInstance()->tests->getFilenames(); - - return [...$phpunitTests, ...$pestTests]; + return [ + ...array_values(array_filter( + $files, + fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") + )), + ...TestSuite::getInstance()->tests->getFilenames(), + ]; } + /** + * Prints a debug message. + */ private function debug(string $message): void { if ($this->options->verbose) { diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index d505b883..d698fbd8 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -54,22 +54,22 @@ final class TestRepository */ public function getFilenames(): array { - $testCases = array_filter($this->testCases, static fn(TestCaseFactory $testCase): bool => $testCase->methodsUsingOnly() !== []); + $testCases = array_filter($this->testCases, static fn (TestCaseFactory $testCase): bool => $testCase->methodsUsingOnly() !== []); if ($testCases === []) { $testCases = $this->testCases; } - return array_values(array_map(static fn(TestCaseFactory $factory): string => $factory->filename, $testCases)); + return array_values(array_map(static fn (TestCaseFactory $factory): string => $factory->filename, $testCases)); } /** * Uses the given `$testCaseClass` on the given `$paths`. * - * @param array $classOrTraits - * @param array $groups - * @param array $paths - * @param array $hooks + * @param array $classOrTraits + * @param array $groups + * @param array $paths + * @param array $hooks */ public function use(array $classOrTraits, array $groups, array $paths, array $hooks): void { @@ -126,18 +126,18 @@ final class TestRepository public function set(TestCaseMethodFactory $method): void { foreach ($this->testCaseFilters as $filter) { - if (!$filter->accept($method->filename)) { + if (! $filter->accept($method->filename)) { return; } } foreach ($this->testCaseMethodFilters as $filter) { - if (!$filter->accept($method)) { + if (! $filter->accept($method)) { return; } } - if (!array_key_exists($method->filename, $this->testCases)) { + if (! array_key_exists($method->filename, $this->testCases)) { $this->testCases[$method->filename] = new TestCaseFactory($method->filename); } @@ -149,12 +149,12 @@ final class TestRepository */ public function makeIfNeeded(string $filename): void { - if (!array_key_exists($filename, $this->testCases)) { + if (! array_key_exists($filename, $this->testCases)) { return; } foreach ($this->testCaseFilters as $filter) { - if (!$filter->accept($filename)) { + if (! $filter->accept($filename)) { return; } } @@ -167,12 +167,12 @@ final class TestRepository */ private function make(TestCaseFactory $testCase): void { - $startsWith = static fn(string $target, string $directory): bool => Str::startsWith($target, $directory . DIRECTORY_SEPARATOR); + $startsWith = static fn (string $target, string $directory): bool => Str::startsWith($target, $directory.DIRECTORY_SEPARATOR); foreach ($this->uses as $path => $uses) { [$classOrTraits, $groups, $hooks] = $uses; - if ((!is_dir($path) && $testCase->filename === $path) || (is_dir($path) && $startsWith($testCase->filename, $path))) { + if ((! is_dir($path) && $testCase->filename === $path) || (is_dir($path) && $startsWith($testCase->filename, $path))) { foreach ($classOrTraits as $class) { /** @var string $class */ if (class_exists($class)) { diff --git a/src/Subscribers/EnsureTeamCityEnabled.php b/src/Subscribers/EnsureTeamCityEnabled.php index 5da91a70..bd6b1bf2 100644 --- a/src/Subscribers/EnsureTeamCityEnabled.php +++ b/src/Subscribers/EnsureTeamCityEnabled.php @@ -21,8 +21,8 @@ final class EnsureTeamCityEnabled implements ConfiguredSubscriber * Creates a new Configured Subscriber instance. */ public function __construct( - private readonly OutputInterface $output, private readonly InputInterface $input, + private readonly OutputInterface $output, private readonly TestSuite $testSuite, ) { } diff --git a/src/Support/Container.php b/src/Support/Container.php index 3afa9818..af15d003 100644 --- a/src/Support/Container.php +++ b/src/Support/Container.php @@ -47,10 +47,14 @@ final class Container /** * Adds the given instance to the container. + * + * @return $this */ - public function add(string $id, object|string $instance): void + public function add(string $id, object|string $instance): self { $this->instances[$id] = $instance; + + return $this; } /** diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php index 21bd1794..7d06b7b4 100644 --- a/src/Support/StateGenerator.php +++ b/src/Support/StateGenerator.php @@ -23,12 +23,15 @@ final class StateGenerator $state = new State(); foreach ($testResult->testErroredEvents() as $testResultEvent) { - assert($testResultEvent instanceof Errored); - $state->add(\NunoMaduro\Collision\Adapters\Phpunit\TestResult::fromTestCase( - $testResultEvent->test(), - TestResult::FAIL, - $testResultEvent->throwable() - )); + if ($testResultEvent instanceof Errored) { + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::FAIL, + $testResultEvent->throwable() + )); + } else { + $state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent)); + } } foreach ($testResult->testFailedEvents() as $testResultEvent) { diff --git a/tests/.snapshots/Failure.php.inc b/tests/.snapshots/Failure.php.inc index 6e04c2ba..e69de29b 100644 --- a/tests/.snapshots/Failure.php.inc +++ b/tests/.snapshots/Failure.php.inc @@ -1,24 +0,0 @@ -##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='file://tests/.tests/Failure.php' flowId='1234'] -##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234'] -##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at src/Mixins/Expectation.php:342|nat src/Support/ExpectationPipeline.php:75|nat src/Support/ExpectationPipeline.php:79|nat src/Expectation.php:300|nat tests/.tests/Failure.php:6|nat src/Factories/TestCaseMethodFactory.php:105|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:79' type='comparisonFailure' actual='true' expected='false' flowId='1234'] -##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234'] -##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234'] -##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234'] -##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234'] -##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234'] -##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234'] -##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234'] -##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234'] -##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18|nat src/Factories/TestCaseMethodFactory.php:105|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:79' flowId='1234'] -##teamcity[testFinished name='it can fail' duration='100000' flowId='1234'] -##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234'] -##teamcity[testIgnored name='it is not done yet' message='This test was ignored.' details='' flowId='1234'] -##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234'] -##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234'] -##teamcity[testIgnored name='build this one.' message='This test was ignored.' details='' flowId='1234'] -##teamcity[testFinished name='build this one.' duration='100000' flowId='1234'] -##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234'] - - Tests: 2 failed, 1 risky, 2 todos, 1 skipped (2 assertions) - Duration: 1.00s - diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index cce6c952..6ab35fd6 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -896,6 +896,9 @@ PASS Tests\Visual\Help ✓ visual snapshot of help command output + PASS Tests\Visual\Parallel + ✓ parallel + PASS Tests\Visual\SingleTestOrDirectory ✓ allows to run a single test ✓ allows to run a directory @@ -914,4 +917,4 @@ PASS Tests\Visual\Version ✓ visual snapshot of help command output - Tests: 4 incomplete, 4 todos, 18 skipped, 633 passed (1552 assertions) \ No newline at end of file + Tests: 4 incomplete, 4 todos, 18 skipped, 634 passed (1554 assertions) \ No newline at end of file diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php new file mode 100644 index 00000000..5b622870 --- /dev/null +++ b/tests/Visual/Parallel.php @@ -0,0 +1,18 @@ + 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], + ); + + $process->run(); + + return preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $process->getOutput()); +}; + +test('parallel', function () use ($run) { + expect($run())->toContain('Running 650 tests using 3 processes') + ->toContain('Tests: 4 incomplete, 4 todos, 15 skipped, 627 passed (1546 assertions)'); +});