Initial working draft

This commit is contained in:
luke
2021-08-05 13:13:53 +01:00
parent d1a9e0bbe3
commit 0a3991c314
6 changed files with 537 additions and 2 deletions

View File

@ -2,8 +2,10 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
use NunoMaduro\Collision\Provider; use NunoMaduro\Collision\Provider;
use ParaTest\Console\Commands\ParaTestCommand;
use Pest\Actions\ValidatesEnvironment; use Pest\Actions\ValidatesEnvironment;
use Pest\Console\Command; use Pest\Console\Command;
use Pest\Console\Paratest\Runner;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
@ -32,6 +34,11 @@ use Symfony\Component\Console\Output\OutputInterface;
$argv = new ArgvInput(); $argv = new ArgvInput();
$testSuite = TestSuite::getInstance($rootPath, $argv->getParameterOption('--test-directory', 'tests')); $testSuite = TestSuite::getInstance($rootPath, $argv->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'; $isDecorated = $argv->getParameterOption('--colors', 'always') !== 'never';
$output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); $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'])); exit($container->get(Command::class)->run($_SERVER['argv']));
})(); })();

View File

@ -40,6 +40,7 @@
] ]
}, },
"require-dev": { "require-dev": {
"brianium/paratest": "dev-pest-support",
"illuminate/console": "^8.47.0", "illuminate/console": "^8.47.0",
"illuminate/support": "^8.47.0", "illuminate/support": "^8.47.0",
"laravel/dusk": "^6.15.0", "laravel/dusk": "^6.15.0",
@ -84,5 +85,11 @@
"Pest\\Laravel\\PestServiceProvider" "Pest\\Laravel\\PestServiceProvider"
] ]
} }
} },
"repositories": [
{
"type": "vcs",
"url": "../paratest"
}
]
} }

View File

@ -0,0 +1,31 @@
<?php
namespace Pest\Console\Paratest;
use ParaTest\Runners\PHPUnit\ExecutableTest;
class ExecutablePestTest extends ExecutableTest
{
/**
* The number of tests in this file.
*
* @var int $testCount
*/
private $testCount;
public function __construct(string $path, int $testCount, bool $needsCoverage, bool $needsTeamcity, string $tmpDir)
{
parent::__construct($path, $needsCoverage, $needsTeamcity, $tmpDir);
$this->testCount = $testCount;
}
public function getTestCount(): int
{
return $this->testCount;
}
protected function prepareOptions(array $options): array
{
return $options;
}
}

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Pest\Console\Paratest;
use ParaTest\Runners\PHPUnit\ExecutableTest;
use ParaTest\Runners\PHPUnit\Options;
use ParaTest\Runners\PHPUnit\WorkerCrashedException;
use RuntimeException;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Throwable;
use function array_merge;
use function strlen;
use const DIRECTORY_SEPARATOR;
/**
* @internal
*/
final class PestRunnerWorker
{
/** @var ExecutableTest */
private $executableTest;
/** @var Process */
private $process;
public function __construct(ExecutableTest $executableTest, Options $options, int $token)
{
$this->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
);
}
}

View File

@ -0,0 +1,361 @@
<?php
namespace Pest\Console\Paratest;
use Exception;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\Coverage\CoverageReporter;
use ParaTest\Logging\JUnit\Writer;
use ParaTest\Logging\LogInterpreter;
use ParaTest\Runners\PHPUnit\BaseRunner;
use ParaTest\Runners\PHPUnit\EmptyLogFileException;
use ParaTest\Runners\PHPUnit\ExecutableTest;
use ParaTest\Runners\PHPUnit\FullSuite;
use ParaTest\Runners\PHPUnit\Options;
use ParaTest\Runners\PHPUnit\ResultPrinter;
use ParaTest\Runners\PHPUnit\RunnerInterface;
use ParaTest\Runners\PHPUnit\SuiteLoader;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\TextUI\TestRunner;
use SebastianBergmann\Timer\Timer;
use Symfony\Component\Console\Output\OutputInterface;
final class Runner implements RunnerInterface
{
protected const CYCLE_SLEEP = 10000;
/** @var Options */
protected $options;
/** @var ResultPrinter */
protected $printer;
/**
* A collection of ExecutableTest objects that have processes
* currently running.
*
* @var PestRunnerWorker[]
*/
private $running = [];
/**
* A collection of pending ExecutableTest objects that have
* yet to run.
*
* @var ExecutableTest[]
*/
protected $pending = [];
/**
* A tallied exit code that returns the highest exit
* code returned out of the entire collection of tests.
*
* @var int
*/
protected $exitcode = -1;
/** @var OutputInterface */
protected $output;
/** @var LogInterpreter */
private $interpreter;
/**
* CoverageMerger to hold track of the accumulated coverage.
*
* @var CoverageMerger|null
*/
private $coverage = null;
public function __construct(Options $options, OutputInterface $output)
{
$this->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);
}
}
}

View File

@ -29,7 +29,7 @@ final class TestRepository
/** /**
* @var array<string, TestCaseFactory> * @var array<string, TestCaseFactory>
*/ */
private $state = []; public $state = [];
/** /**
* @var array<string, array<int, array<int, string|Closure>>> * @var array<string, array<int, array<int, string|Closure>>>