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(), + ); + } +}