From 0a3991c314d22837c0220f6ebbde436b0a1c0962 Mon Sep 17 00:00:00 2001 From: luke Date: Thu, 5 Aug 2021 13:13:53 +0100 Subject: [PATCH] Initial working draft --- bin/pest | 14 + composer.json | 9 +- src/Console/Paratest/ExecutablePestTest.php | 31 ++ src/Console/Paratest/PestRunnerWorker.php | 122 +++++++ src/Console/Paratest/Runner.php | 361 ++++++++++++++++++++ src/Repositories/TestRepository.php | 2 +- 6 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 src/Console/Paratest/ExecutablePestTest.php create mode 100644 src/Console/Paratest/PestRunnerWorker.php create mode 100644 src/Console/Paratest/Runner.php diff --git a/bin/pest b/bin/pest index 649c6540..39302e87 100755 --- a/bin/pest +++ b/bin/pest @@ -2,8 +2,10 @@ getParameterOption('--test-directory', 'tests')); + $shouldExecuteInParallel = $argv->hasParameterOption('--parallel'); + // Let's remove the parallel option now we've retrieved its value + if (($parallelKey = array_search('--parallel', $_SERVER['argv'])) !== false) { + unset($_SERVER['argv'][$parallelKey]); + } $isDecorated = $argv->getParameterOption('--colors', 'always') !== 'never'; $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); @@ -51,5 +58,12 @@ use Symfony\Component\Console\Output\OutputInterface; } } + if ($shouldExecuteInParallel) { + $_SERVER['argv'][] = '--runner'; + $_SERVER['argv'][] = Runner::class; + + exit(ParaTestCommand::applicationFactory(getcwd())->run(new ArgvInput())); + } + exit($container->get(Command::class)->run($_SERVER['argv'])); })(); diff --git a/composer.json b/composer.json index 9a3960f9..95e9848b 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ ] }, "require-dev": { + "brianium/paratest": "dev-pest-support", "illuminate/console": "^8.47.0", "illuminate/support": "^8.47.0", "laravel/dusk": "^6.15.0", @@ -84,5 +85,11 @@ "Pest\\Laravel\\PestServiceProvider" ] } - } + }, + "repositories": [ + { + "type": "vcs", + "url": "../paratest" + } + ] } diff --git a/src/Console/Paratest/ExecutablePestTest.php b/src/Console/Paratest/ExecutablePestTest.php new file mode 100644 index 00000000..8a9d16e2 --- /dev/null +++ b/src/Console/Paratest/ExecutablePestTest.php @@ -0,0 +1,31 @@ +testCount = $testCount; + } + + public function getTestCount(): int + { + return $this->testCount; + } + + protected function prepareOptions(array $options): array + { + return $options; + } +} diff --git a/src/Console/Paratest/PestRunnerWorker.php b/src/Console/Paratest/PestRunnerWorker.php new file mode 100644 index 00000000..5e5ed0a5 --- /dev/null +++ b/src/Console/Paratest/PestRunnerWorker.php @@ -0,0 +1,122 @@ +executableTest = $executableTest; + + $phpFinder = new PhpExecutableFinder(); + $args = [$phpFinder->find(false)]; + $args = array_merge($args, $phpFinder->findArguments()); + + if (($passthruPhp = $options->passthruPhp()) !== null) { + $args = array_merge($args, $passthruPhp); + } + + $args = array_merge( + $args, + $this->executableTest->commandArguments( + '/Users/luke/Packages/pest/bin/pest', + $options->filtered(), + $options->passthru() + ) + ); + + $this->process = new Process($args, $options->cwd(), $options->fillEnvWithTokens($token)); + + $cmd = $this->process->getCommandLine(); + $this->assertValidCommandLineLength($cmd); + $this->executableTest->setLastCommand($cmd); + } + + public function getExecutableTest(): ExecutableTest + { + return $this->executableTest; + } + + /** + * Executes the test by creating a separate process. + */ + public function run(): void + { + $this->process->start(); + } + + /** + * Check if the process has terminated. + */ + public function isRunning(): bool + { + return $this->process->isRunning(); + } + + /** + * Stop the process and return it's + * exit code. + */ + public function stop(): ?int + { + return $this->process->stop(); + } + + /** + * Assert that command line length is valid. + * + * In some situations process command line can became too long when combining different test + * cases in single --filter arguments so it's better to show error regarding that to user + * and propose him to decrease max batch size. + * + * @param string $cmd Command line + * + * @throws RuntimeException on too long command line. + * + * @codeCoverageIgnore + */ + private function assertValidCommandLineLength(string $cmd): void + { + if (DIRECTORY_SEPARATOR !== '\\') { + return; + } + + // symfony's process wrapper + $cmd = 'cmd /V:ON /E:ON /C "(' . $cmd . ')'; + if (strlen($cmd) > 32767) { + throw new RuntimeException('Command line is too long, try to decrease max batch size'); + } + } + + public function getWorkerCrashedException(?Throwable $previousException = null): WorkerCrashedException + { + return WorkerCrashedException::fromProcess( + $this->process, + $this->process->getCommandLine(), + $previousException + ); + } +} diff --git a/src/Console/Paratest/Runner.php b/src/Console/Paratest/Runner.php new file mode 100644 index 00000000..aa9266c7 --- /dev/null +++ b/src/Console/Paratest/Runner.php @@ -0,0 +1,361 @@ +options = $options; + $this->output = $output; + $this->interpreter = new LogInterpreter(); + $this->printer = new ResultPrinter($this->interpreter, $output, $options); + + if (! $this->options->hasCoverage()) { + return; + } + + $this->coverage = new CoverageMerger($this->options->coverageTestLimit()); + } + + final public function run(): void + { + $this->load(new SuiteLoader($this->options, $this->output)); + $this->printer->start(); + + $this->doRun(); + + $this->complete(); + } + + /** + * Builds the collection of pending ExecutableTest objects + * to run. If functional mode is enabled $this->pending will + * contain a collection of TestMethod objects instead of Suite + * objects. + */ + private function load(SuiteLoader $loader): void + { + $this->beforeLoadChecks(); + $loader->load(); + $this->pending = $this->options->functional() + ? $loader->getTestMethods() + : $loader->getSuites(); + + $this->sortPending(); + + foreach ($this->pending as $pending) { + $this->printer->addTest($pending); + } + } + + private function sortPending(): void + { + if ($this->options->orderBy() === Options::ORDER_RANDOM) { + mt_srand($this->options->randomOrderSeed()); + shuffle($this->pending); + } + + if ($this->options->orderBy() !== Options::ORDER_REVERSE) { + return; + } + + $this->pending = array_reverse($this->pending); + } + + /** + * Finalizes the run process. This method + * prints all results, rewinds the log interpreter, + * logs any results to JUnit, and cleans up temporary + * files. + */ + private function complete(): void + { + $this->printer->printResults(); + $this->log(); + $this->logCoverage(); + $readers = $this->interpreter->getReaders(); + foreach ($readers as $reader) { + $reader->removeLog(); + } + } + + /** + * Returns the highest exit code encountered + * throughout the course of test execution. + */ + final public function getExitCode(): int + { + return $this->exitcode; + } + + /** + * Write output to JUnit format if requested. + */ + final protected function log(): void + { + if (($logJunit = $this->options->logJunit()) === null) { + return; + } + + $name = $this->options->path() ?? ''; + + $writer = new Writer($this->interpreter, $name); + $writer->write($logJunit); + } + + /** + * Write coverage to file if requested. + */ + final protected function logCoverage(): void + { + if (! $this->hasCoverage()) { + return; + } + + $coverageMerger = $this->getCoverage(); + assert($coverageMerger !== null); + $codeCoverage = $coverageMerger->getCodeCoverageObject(); + assert($codeCoverage !== null); + $codeCoverageConfiguration = null; + if (($configuration = $this->options->configuration()) !== null) { + $codeCoverageConfiguration = $configuration->codeCoverage(); + } + + $reporter = new CoverageReporter($codeCoverage, $codeCoverageConfiguration); + + $this->output->write('Generating code coverage report ... '); + + $timer = new Timer(); + $timer->start(); + + if (($coverageClover = $this->options->coverageClover()) !== null) { + $reporter->clover($coverageClover); + } + + if (($coverageCobertura = $this->options->coverageCobertura()) !== null) { + $reporter->cobertura($coverageCobertura); + } + + if (($coverageCrap4j = $this->options->coverageCrap4j()) !== null) { + $reporter->crap4j($coverageCrap4j); + } + + if (($coverageHtml = $this->options->coverageHtml()) !== null) { + $reporter->html($coverageHtml); + } + + if (($coverageText = $this->options->coverageText()) !== null) { + if ($coverageText === '') { + $this->output->write($reporter->text()); + } else { + file_put_contents($coverageText, $reporter->text()); + } + } + + if (($coverageXml = $this->options->coverageXml()) !== null) { + $reporter->xml($coverageXml); + } + + if (($coveragePhp = $this->options->coveragePhp()) !== null) { + $reporter->php($coveragePhp); + } + + $this->output->writeln( + sprintf('done [%s]', $timer->stop()->asString()) + ); + } + + final protected function hasCoverage(): bool + { + return $this->options->hasCoverage(); + } + + final protected function getCoverage(): ?CoverageMerger + { + return $this->coverage; + } + + protected function doRun(): void + { + $this->loadPestSuite(); + + $availableTokens = range(1, $this->options->processes()); + while (count($this->running) > 0 || count($this->pending) > 0) { + $this->fillRunQueue($availableTokens); + usleep(self::CYCLE_SLEEP); + + $availableTokens = []; + foreach ($this->running as $token => $test) { + if ($this->testIsStillRunning($test)) { + continue; + } + + unset($this->running[$token]); + $availableTokens[] = $token; + } + } + } + + private function fillRunQueue(array $availableTokens) + { + while ( + count($this->pending) > 0 + && count($this->running) < $this->options->processes() + && ($token = array_shift($availableTokens)) !== null + ) { + $executableTest = array_shift($this->pending); + + $this->running[$token] = new PestRunnerWorker($executableTest, $this->options, $token); + $this->running[$token]->run(); + + if ($this->options->verbosity() < Options::VERBOSITY_VERY_VERBOSE) { + continue; + } + + $cmd = $this->running[$token]; + $this->output->write("\nExecuting test via: {$cmd->getExecutableTest()->getLastCommand()}\n"); + } + } + + /** + * Returns whether or not a test has finished being + * executed. If it has, this method also halts a test process - optionally + * throwing an exception if a fatal error has occurred - + * prints feedback, and updates the overall exit code. + * + * @throws Exception + */ + private function testIsStillRunning(PestRunnerWorker $worker) + { + if ($worker->isRunning()) { + return true; + } + + $this->exitcode = max($this->exitcode, (int) $worker->stop()); + if ($this->options->stopOnFailure() && $this->exitcode > 0) { + $this->pending = []; + } + + if ( + $this->exitcode > 0 + && $this->exitcode !== TestRunner::FAILURE_EXIT + && $this->exitcode !== TestRunner::EXCEPTION_EXIT + ) { + throw $worker->getWorkerCrashedException(); + } + + $executableTest = $worker->getExecutableTest(); + try { + $this->printer->printFeedback($executableTest); + } catch (EmptyLogFileException $emptyLogFileException) { + throw $worker->getWorkerCrashedException($emptyLogFileException); + } + + if ($this->hasCoverage()) { + $coverageMerger = $this->getCoverage(); + assert($coverageMerger !== null); + $coverageMerger->addCoverageFromFile($executableTest->getCoverageFileName()); + } + + return false; + } + + protected function beforeLoadChecks(): void + { + } + + private function loadPestSuite(): void + { + $pestTestSuite = TestSuite::getInstance(); + + $files = array_values(array_map(function(TestCaseFactory $factory): string { + return $factory->filename; + }, $pestTestSuite->tests->state)); + + $occurrences = array_count_values($files); + + $tests = array_values(array_map(function(int $occurrences, string $file) { + return new ExecutablePestTest( + $file, + $occurrences, + $this->options->hasCoverage(), + $this->options->hasLogTeamcity(), + $this->options->tmpDir(), + ); + }, $occurrences, array_keys($occurrences))); + + $this->pending = $tests; + + // We need to reset the printer because we don't want to output + + foreach ($this->pending as $pending) { + $this->printer->addTest($pending); + } + } +} diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index 47684548..3a39b0a7 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -29,7 +29,7 @@ final class TestRepository /** * @var array */ - private $state = []; + public $state = []; /** * @var array>>