*/ private array $pending = []; private int $exitCode = -1; /** @var array */ private array $workers = []; /** @var array */ private array $batches = []; /** @var array */ private array $testresultFiles = []; /** @var array */ private array $coverageFiles = []; /** @var array */ private array $junitFiles = []; /** @var array */ private array $teamcityFiles = []; /** @var array */ private array $testdoxFiles = []; /** @var array */ private readonly array $parameters; private readonly CodeCoverageFilterRegistry $codeCoverageFilterRegistry; public function __construct( private readonly Options $options, private readonly OutputInterface $output ) { $this->printer = new ResultPrinter($output, $options); $this->timer = new Timer(); $worker = realpath( dirname(__DIR__, 4).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'worker.php', ); assert($worker !== 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[] = $worker; $this->parameters = $parameters; $this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry(); } public function run(): int { $directory = dirname(__DIR__); assert(strlen($directory) > 0); ExcludeList::addDirectory($directory); TestResultFacade::init(); EventFacade::instance()->seal(); $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry); $this->pending = $this->getTestFiles($suiteLoader); $result = TestResultFacade::result(); $this->timer->start(); $this->startWorkers(); $this->assignAllPendingTests(); $this->waitForAllToFinish(); return $this->complete($result); } private function startWorkers(): void { for ($token = 1; $token <= $this->options->processes; $token++) { $this->startWorker($token); } } private function assignAllPendingTests(): void { $batchSize = $this->options->maxBatchSize; while ($this->pending !== [] && $this->workers !== []) { foreach ($this->workers as $token => $worker) { if (! $worker->isRunning()) { throw $worker->getWorkerCrashedException(); } if (! $worker->isFree()) { continue; } $this->flushWorker($worker); if ($batchSize !== 0 && $this->batches[$token] === $batchSize) { $this->destroyWorker($token); $worker = $this->startWorker($token); } if ( $this->exitCode > 0 && $this->options->configuration->stopOnFailure() ) { $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]++; } } usleep(self::CYCLE_SLEEP); } } private function flushWorker(WrapperWorker $worker): void { $this->exitCode = max($this->exitCode, $worker->getExitCode()); $this->printer->printFeedback( $worker->progressFile, $this->teamcityFiles, ); $worker->reset(); } private function waitForAllToFinish(): void { $stopped = []; while ($this->workers !== []) { foreach ($this->workers as $index => $worker) { if ($worker->isRunning()) { if (! array_key_exists($index, $stopped) && $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 { /** @var array $parameters */ $parameters = $this->parameters; $worker = new WrapperWorker( $this->output, $this->options, $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): int { 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->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()), 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()), ); } $testResultSum = new TestResult( $testResultSum->numberOfTests(), $testResultSum->numberOfTestsRun(), $testResultSum->numberOfAssertions(), $testResultSum->testErroredEvents(), $testResultSum->testFailedEvents(), $testResultSum->testConsideredRiskyEvents(), $testResultSum->testSuiteSkippedEvents(), $testResultSum->testSkippedEvents(), $testResultSum->testMarkedIncompleteEvents(), $testResultSum->testTriggeredDeprecationEvents(), $testResultSum->testTriggeredPhpDeprecationEvents(), $testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResultSum->testTriggeredErrorEvents(), $testResultSum->testTriggeredNoticeEvents(), $testResultSum->testTriggeredPhpNoticeEvents(), $testResultSum->testTriggeredWarningEvents(), $testResultSum->testTriggeredPhpWarningEvents(), $testResultSum->testTriggeredPhpunitErrorEvents(), $testResultSum->testTriggeredPhpunitWarningEvents(), $testResultSum->testRunnerTriggeredDeprecationEvents(), array_values(array_filter($testResultSum->testRunnerTriggeredWarningEvents(), fn ($event): bool => ! str_contains($event->message(), 'No tests found'))), ); $this->printer->printResults( $testResultSum, $this->teamcityFiles, $this->testdoxFiles, $this->timer->stop(), ); $this->generateCodeCoverageReports(); $this->generateLogs(); $exitCode = Result::exitCode($this->options->configuration, $testResultSum); $this->clearFiles($this->testresultFiles); $this->clearFiles($this->coverageFiles); $this->clearFiles($this->junitFiles); $this->clearFiles($this->teamcityFiles); $this->clearFiles($this->testdoxFiles); return $exitCode; } private function generateCodeCoverageReports(): void { if ($this->coverageFiles === []) { return; } $coverageManager = new CodeCoverage(); // @phpstan-ignore-next-line is_bool(true) && $coverageManager->init($this->options->configuration, $this->codeCoverageFilterRegistry, true); $coverageMerger = new CoverageMerger($coverageManager->codeCoverage()); foreach ($this->coverageFiles as $coverageFile) { $coverageMerger->addCoverageFromFile($coverageFile); } $coverageManager->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(), ); } /** @param array $files */ private function clearFiles(array $files): void { foreach ($files as $file) { if (! $file->isFile()) { continue; } unlink($file->getPathname()); } } /** * 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')); /** @var array $files */ $files = $suiteLoader->files; 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) { $this->output->writeln(" {$message} "); } } }