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/worker.php b/bin/worker.php new file mode 100644 index 00000000..ca6e84c6 --- /dev/null +++ b/bin/worker.php @@ -0,0 +1,110 @@ +getParameterOption( + '--test-directory', + (new ConfigLoader($rootPath))->getTestsDirectory() + )); + + if ($masterArgv->hasParameterOption('--todo')) { + $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); + } + + $input = new ArgvInput(); + + $output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true); + + Kernel::boot($testSuite, $input, $output); +}); + +(static function () use ($bootPest): void { + $getopt = getopt('', [ + 'status-file:', + 'progress-file:', + 'testresult-file:', + 'teamcity-file:', + 'testdox-file:', + 'testdox-color', + 'phpunit-argv:', + ]); + + 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', + ]; + + foreach ($composerAutoloadFiles as $file) { + if (file_exists($file)) { + require_once $file; + define('PHPUNIT_COMPOSER_INSTALL', $file); + + break; + } + } + + assert(isset($getopt['status-file']) && is_string($getopt['status-file'])); + $statusFile = fopen($getopt['status-file'], 'wb'); + assert(is_resource($statusFile)); + + 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['phpunit-argv']) && is_string($getopt['phpunit-argv'])); + $phpunitArgv = unserialize($getopt['phpunit-argv'], ['allowed_classes' => false]); + assert(is_array($phpunitArgv)); + + $bootPest(); + + $phpunitArgv = (new CallsHandleArguments())($phpunitArgv); + + $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; + } + + $exitCode = $application->runTest(trim($testPath)); + + fwrite($statusFile, (string) $exitCode); + fflush($statusFile); + } +})(); diff --git a/composer.json b/composer.json index 5417ae05..d4598946 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": { @@ -43,6 +46,7 @@ ] }, "require-dev": { + "brianium/paratest": "^7.0.5", "pestphp/pest-dev-tools": "^2.4.0", "pestphp/pest-plugin-arch": "^2.0.0", "symfony/process": "^6.2.5" @@ -89,7 +93,8 @@ "Pest\\Plugins\\Memory", "Pest\\Plugins\\Printer", "Pest\\Plugins\\Retry", - "Pest\\Plugins\\Version" + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" ] } } 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/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/Logging/TeamCity/Converter.php b/src/Logging/TeamCity/Converter.php index c0d91d41..1f0de446 100644 --- a/src/Logging/TeamCity/Converter.php +++ b/src/Logging/TeamCity/Converter.php @@ -5,20 +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; /** @@ -28,12 +22,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 +180,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 new file mode 100644 index 00000000..07a48587 --- /dev/null +++ b/src/Plugins/Parallel.php @@ -0,0 +1,139 @@ +argumentsContainParallelFlags($arguments)) { + exit($this->runTestSuiteInParallel($arguments)); + } + + if (self::isInParallelProcess()) { + return $this->runWorkersHandlers($arguments); + } + + return $arguments; + } + + /** + * @param array $arguments + */ + private function argumentsContainParallelFlags(array $arguments): bool + { + if ($this->hasArgument('--parallel', $arguments)) { + return true; + } + + return $this->hasArgument('-p', $arguments); + } + + /** + * @param array $arguments + * + * @throws JsonException + */ + private function runTestSuiteInParallel(array $arguments): int + { + if (! class_exists(ParaTestCommand::class)) { + $this->askUserToInstallParatest(); + + return Command::FAILURE; + } + + $_ENV['PEST_PARALLEL_ARGV'] = json_encode($_SERVER['argv'], JSON_THROW_ON_ERROR); + + $handlers = array_filter( + 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): array => $handler->handleArguments($arguments), + $arguments + ); + + $exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput()); + + return (new CallsAddsOutput())($exitCode); + } + + /** + * @param array $arguments + * @return array + */ + private function runWorkersHandlers(array $arguments): array + { + $handlers = array_filter( + array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler): bool => $handler instanceof HandlersWorkerArguments, + ); + + return array_reduce( + $handlers, + fn ($arguments, HandlersWorkerArguments $handler): array => $handler->handleWorkerArguments($arguments), + $arguments + ); + } + + private function askUserToInstallParatest(): void + { + /** @var OutputInterface $output */ + $output = Container::getInstance()->get(OutputInterface::class); + + $output->writeln([ + 'Pest Parallel requires ParaTest to run.', + 'Please run composer require --dev brianium/paratest.', + ]); + } + + private function paratestCommand(): Application + { + /** @var non-empty-string $rootPath */ + $rootPath = TestSuite::getInstance()->rootPath; + + $command = ParaTestCommand::applicationFactory($rootPath); + $command->setAutoExit(false); + $command->setName('Pest'); + $command->setVersion(version()); + + return $command; + } +} 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/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php new file mode 100644 index 00000000..838f1480 --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -0,0 +1,87 @@ +setLaravelParallelRunner(); + + $arguments = $this->setEnvironmentVariables($arguments); + + return $this->useLaravelRunner($arguments); + } + + private function setLaravelParallelRunner(): void + { + ParallelRunner::resolveRunnerUsing( // @phpstan-ignore-line + fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output) + ); + } + + private static function isALaravelApplication(): bool + { + if (! InstalledVersions::isInstalled('laravel/framework', false)) { + return false; + } + + return ! class_exists(\Orchestra\Testbench\TestCase::class); + } + + /** + * @param array $arguments + * @return array + */ + private function setEnvironmentVariables(array $arguments): array + { + $_ENV['LARAVEL_PARALLEL_TESTING'] = 1; + + 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($value, '--runner')) { + $arguments = $this->popArgument($value, $arguments); + } + } + + return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $arguments); + } +} diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php new file mode 100644 index 00000000..71f6d4d6 --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -0,0 +1,33 @@ + $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..71b24647 --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Pest.php @@ -0,0 +1,35 @@ +isOpeningHeadline($message)) { + return; + } + + 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.'); + } +} diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php new file mode 100644 index 00000000..49f83ec3 --- /dev/null +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -0,0 +1,206 @@ + */ + 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 + { + } + }; + + $this->compactPrinter = new CompactPrinter(); + + 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->compactPrinter->line(sprintf( + 'Running %d test%s using %d process%s', + $this->totalCases, + $this->totalCases === 1 ? '' : 's', + $this->options->processes, + $this->options->processes === 1 ? '' : 'es') + ); + } + + /** @param array $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 = (string) preg_replace('/ +\\d+ \\/ \\d+ \\( ?\\d+%\\)\\s*/', '', $feedbackItems); + + $actualTestCount = strlen($feedbackItems); + for ($index = 0; $index < $actualTestCount; $index++) { + $this->printFeedbackItem($feedbackItems[$index]); + } + } + + /** + * @param array $teamcityFiles + * @param array $testdoxFiles + */ + public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles, Duration $duration): 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; + } + + $this->compactPrinter->newLine(); + + $state = (new StateGenerator())->fromPhpUnitTestResult($testResult); + + $this->compactPrinter->errors($state); + $this->compactPrinter->recap($state, $testResult, $duration); + } + + private function printFeedbackItem(string $item): void + { + $this->compactPrinter->descriptionItem($item); + } + + /** @param array $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 new file mode 100644 index 00000000..8d100f98 --- /dev/null +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -0,0 +1,403 @@ + */ + 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::seal(); + + $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry); + $this->pending = $this->getTestFiles($suiteLoader); + + $result = TestResultFacade::result(); + + $this->printer->setTestCount($suiteLoader->testCount); + $this->printer->start(); + + $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()), + ); + } + + $this->printer->printResults( + $testResultSum, + $this->teamcityFiles, + $this->testdoxFiles, + $this->timer->stop(), + ); + $this->generateCodeCoverageReports(); + $this->generateLogs(); + + $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, + ); + + $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(); + $coverageManager->init($this->options->configuration, $this->codeCoverageFilterRegistry); + $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} "); + } + } +} diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php new file mode 100644 index 00000000..f6921a4a --- /dev/null +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -0,0 +1,131 @@ +> + */ + 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() + { + $this->terminal = terminal(); + $this->output = new ConsoleOutput(decorated: true); + $this->style = new Style($this->output); + + $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}"); + } + + /** + * Outputs the given description item from the ProgressPrinter as a gorgeous, colored symbol. + */ + public function descriptionItem(string $item): void + { + [$color, $icon] = self::LOOKUP_TABLE[$item] ?? self::LOOKUP_TABLE['.']; + + $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++; + } + + /** + * 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); + + $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( + $snapshotDuration, + MemoryUsage::fromBytes(0), + MemoryUsage::fromBytes(0), + ), + $telemetryDuration, + MemoryUsage::fromBytes(0), + \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0), + MemoryUsage::fromBytes(0), + ); + + $this->style->writeRecap($state, $telemetry, $testResult); + } +} diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index e20b67ab..d698fbd8 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -125,6 +125,12 @@ final class TestRepository */ public function set(TestCaseMethodFactory $method): void { + foreach ($this->testCaseFilters as $filter) { + if (! $filter->accept($method->filename)) { + return; + } + } + foreach ($this->testCaseMethodFilters as $filter) { if (! $filter->accept($method)) { return; @@ -147,15 +153,13 @@ final class TestRepository 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]); } /** 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 new file mode 100644 index 00000000..7d06b7b4 --- /dev/null +++ b/src/Support/StateGenerator.php @@ -0,0 +1,105 @@ +testErroredEvents() as $testResultEvent) { + 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) { + $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; + } +} 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/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) { 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)'); +});