Merge branch '2.x' into feature/compact-dataset-description

This commit is contained in:
Nuno Maduro
2023-02-13 23:32:02 +00:00
committed by GitHub
66 changed files with 1904 additions and 452 deletions

View File

@ -13,7 +13,7 @@ use Pest\Contracts\Bootstrapper;
final class BootExceptionHandler implements Bootstrapper
{
/**
* Boots the Exception Handler.
* Boots the "Collision" exception handler.
*/
public function boot(): void
{

View File

@ -19,7 +19,7 @@ use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator;
final class BootFiles implements Bootstrapper
{
/**
* The Pest convention.
* The structure of the tests directory.
*
* @var array<int, string>
*/
@ -32,7 +32,7 @@ final class BootFiles implements Bootstrapper
];
/**
* Boots the Subscribers.
* Boots the structure of the tests directory.
*/
public function boot(): void
{

View File

@ -20,10 +20,11 @@ final class BootOverrides implements Bootstrapper
private const FILES = [
'Runner/Filter/NameFilterIterator.php',
'Runner/TestSuiteLoader.php',
'TextUI/Output/Default/ProgressPrinter/TestSkippedSubscriber.php',
];
/**
* Boots the Subscribers.
* Boots the list of files to be overridden.
*/
public function boot(): void
{

View File

@ -16,21 +16,18 @@ use PHPUnit\Event\Subscriber;
final class BootSubscribers implements Bootstrapper
{
/**
* The Kernel subscribers.
* The list of Subscribers.
*
* @var array<int, class-string<Subscriber>>
*/
private const SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsValid::class,
Subscribers\EnsureConfigurationDefaults::class,
Subscribers\EnsureRetryRepositoryExists::class,
Subscribers\EnsureErroredTestsAreRetryable::class,
Subscribers\EnsureFailedTestsAreRetryable::class,
Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureTeamCityEnabled::class,
];
/**
* Creates a new Subscriber instance.
* Creates a new instance of the Boot Subscribers.
*/
public function __construct(
private readonly Container $container,
@ -38,7 +35,7 @@ final class BootSubscribers implements Bootstrapper
}
/**
* Boots the Subscribers.
* Boots the list of Subscribers.
*/
public function boot(): void
{

View File

@ -13,6 +13,9 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
final class BootView implements Bootstrapper
{
/**
* Creates a new instance of the Boot View.
*/
public function __construct(
private readonly OutputInterface $output
) {

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
interface Panicable
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void;
/**
* The exit code to be used.
*/
public function exitCode(): int;
}

View File

@ -12,7 +12,7 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidConsoleArgument extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
final class InvalidOption extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoDirtyTestsFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace, Panicable
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No "dirty" tests found.',
'',
]);
}
/**
* The exit code to be used.
*/
public function exitCode(): int
{
return 0;
}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class NoTestsFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct()
{
parent::__construct('No tests found.');
// ...
}
}

View File

@ -8,7 +8,6 @@ use Closure;
use Pest\Contracts\AddsAnnotations;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Plugins\Retry;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Str;
use Pest\TestSuite;
@ -129,12 +128,6 @@ final class TestCaseMethodFactory
$methodName = Str::evaluable($this->description);
$retryRepository = TestSuite::getInstance()->retryRepository;
if (Retry::$retrying && ! $retryRepository->isEmpty() && ! $retryRepository->exists(sprintf('%s::%s', $classFQN, $methodName))) {
return '';
}
$datasetsCode = '';
$annotations = ['@test'];
$attributes = [];
@ -188,7 +181,7 @@ final class TestCaseMethodFactory
return <<<EOF
public function $dataProviderName()
public static function $dataProviderName()
{
return __PestDatasets::get(self::\$__filename, "$methodName");
}

View File

@ -5,13 +5,13 @@ declare(strict_types=1);
namespace Pest;
use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\NoTestsFound;
use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Plugins\Actions\CallsAddsOutput;
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<int, string> $argv
*
* @throws Exception
* @param array<int, string> $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);
} catch (NoTestsFound) {
$output->writeln([
$this->application->run($args);
} catch (NoDirtyTestsFound) {
$this->output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
'',

View File

@ -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();
}
/**
@ -175,7 +172,7 @@ final class Converter
private function toRelativePath(string $path): string
{
// Remove cwd from the path.
return str_replace("$this->rootPath/", '', $path);
return str_replace("$this->rootPath".DIRECTORY_SEPARATOR, '', $path);
}
/**
@ -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);
}
}

View File

@ -211,7 +211,7 @@ final class TeamCityLogger
);
}
$style->writeRecap($state, $telemetry);
$style->writeRecap($state, $telemetry, $result);
}
public function output(ServiceMessage $message): void

View File

@ -302,6 +302,36 @@ final class Expectation
return $this;
}
/**
* Asserts that the value has the method $name.
*
* @return self<TValue>
*/
public function toHaveMethod(string $name, string $message = ''): self
{
$this->toBeObject();
// @phpstan-ignore-next-line
Assert::assertTrue(method_exists($this->value, $name), $message);
return $this;
}
/**
* Asserts that the value has the provided methods $names.
*
* @param iterable<array-key, string> $names
* @return self<TValue>
*/
public function toHaveMethods(iterable $names, string $message = ''): self
{
foreach ($names as $name) {
$this->toHaveMethod($name, message: $message);
}
return $this;
}
/**
* Asserts that two variables have the same value.
*

60
src/Panic.php Normal file
View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Pest;
use NunoMaduro\Collision\Writer;
use Pest\Support\Container;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use Whoops\Exception\Inspector;
final class Panic
{
/**
* Creates a new Panic instance.
*/
private function __construct(
private readonly Throwable $throwable
) {
// ...
}
/**
* Creates a new Panic instance, and exits the application.
*/
public static function with(Throwable $throwable): never
{
$panic = new self($throwable);
$panic->handle();
exit(1);
}
/**
* Handles the panic.
*/
private function handle(): void
{
/** @var OutputInterface $output */
$output = Container::getInstance()->get(OutputInterface::class);
if ($this->throwable instanceof Contracts\Panicable) {
$this->throwable->render($output);
exit($this->throwable->exitCode());
}
$writer = new Writer(null, $output);
$inspector = new Inspector($this->throwable);
$output->writeln('');
$writer->write($inspector);
$output->writeln('');
exit(1);
}
}

44
src/Plugins/Cache.php Normal file
View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Concerns\HandleArguments;
/**
* @internal
*/
final class Cache implements HandlesArguments
{
use HandleArguments;
/**
* The temporary folder.
*/
private const TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'.temp';
/**
* Handles the arguments, adding the cache directory and the cache result arguments.
*/
public function handleArguments(array $arguments): array
{
if (! $this->hasArgument('--parallel', $arguments)) {
$arguments = $this->pushArgument(
sprintf('--cache-directory=%s', realpath(self::TEMPORARY_FOLDER)),
$arguments
);
$arguments = $this->pushArgument('--cache-result', $arguments);
}
return $arguments;
}
}

View File

@ -55,6 +55,7 @@ final class Coverage implements AddsOutput, HandlesArguments
if ($original === sprintf('--%s', $option)) {
return true;
}
if (Str::startsWith($original, sprintf('--%s=', $option))) {
return true;
}

190
src/Plugins/Parallel.php Normal file
View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use ParaTest\ParaTestCommand;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Actions\CallsAddsOutput;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Contracts\HandlersWorkerArguments;
use Pest\Plugins\Parallel\Paratest\CleanConsoleOutput;
use Pest\Support\Arr;
use Pest\Support\Container;
use Pest\TestSuite;
use function Pest\version;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\OutputInterface;
final class Parallel implements HandlesArguments
{
use HandleArguments;
private const HANDLERS = [
Parallel\Handlers\Parallel::class,
Parallel\Handlers\Pest::class,
Parallel\Handlers\Laravel::class,
];
/**
* @var string[]
*/
private const UNSUPPORTED_ARGUMENTS = ['--todo', '--retry'];
/**
* Whether the given command line arguments indicate that the test suite should be run in parallel.
*/
public static function isEnabled(): bool
{
$argv = new ArgvInput();
if ($argv->hasParameterOption('--parallel')) {
return true;
}
return $argv->hasParameterOption('-p');
}
/**
* If this code is running in a worker process rather than the main process.
*/
public static function isWorker(): bool
{
$argvValue = Arr::get($_SERVER, 'PARATEST');
assert(is_string($argvValue) || is_int($argvValue) || is_null($argvValue));
return ((int) $argvValue) === 1;
}
/**
* {@inheritdoc}
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgumentsThatWouldBeFasterWithoutParallel()) {
return $this->runTestSuiteInSeries($arguments);
}
if (self::isEnabled()) {
exit($this->runTestSuiteInParallel($arguments));
}
if (self::isWorker()) {
return $this->runWorkerHandlers($arguments);
}
return $arguments;
}
/**
* Runs the test suite in parallel. This method will exit the process upon completion.
*
* @param array<int, string> $arguments
*/
private function runTestSuiteInParallel(array $arguments): int
{
if (! class_exists(ParaTestCommand::class)) {
$this->askUserToInstallParatest();
return Command::FAILURE;
}
$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);
}
/**
* Runs any handlers that have been registered to handle worker arguments, and returns the modified arguments.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function runWorkerHandlers(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
);
}
/**
* Outputs a message to the user asking them to install ParaTest as a dev dependency.
*/
private function askUserToInstallParatest(): void
{
/** @var OutputInterface $output */
$output = Container::getInstance()->get(OutputInterface::class);
$output->writeln([
'<fg=red>Pest Parallel requires ParaTest to run.</>',
'Please run <fg=yellow>composer require --dev brianium/paratest</>.',
]);
}
/**
* Builds an instance of the Paratest command.
*/
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;
}
/**
* Whether the command line arguments contain any arguments that are
* not supported or are suboptimal when running in parallel.
*/
private function hasArgumentsThatWouldBeFasterWithoutParallel(): bool
{
$arguments = new ArgvInput();
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
if ($arguments->hasParameterOption($unsupportedArgument)) {
return true;
}
}
return false;
}
/**
* Removes any parallel arguments.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function runTestSuiteInSeries(array $arguments): array
{
$arguments = $this->popArgument('--parallel', $arguments);
return $this->popArgument('-p', $arguments);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Contracts;
interface HandlersWorkerArguments
{
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
public function handleWorkerArguments(array $arguments): array;
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Handlers;
use Closure;
use Composer\InstalledVersions;
use Illuminate\Testing\ParallelRunner;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Paratest\WrapperRunner;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class Laravel implements HandlesArguments
{
use HandleArguments;
/**
* {@inheritdoc}
*/
public function handleArguments(array $arguments): array
{
return self::whenUsingLaravel($arguments, function (array $arguments): array {
$this->ensureRunnerIsResolvable();
$arguments = $this->ensureEnvironmentVariables($arguments);
return $this->ensureRunner($arguments);
});
}
/**
* Executes the given closure when running Laravel.
*
* @param array<int, string> $arguments
* @param CLosure(array<int, string>): array<int, string> $closure
* @return array<int, string>
*/
private static function whenUsingLaravel(array $arguments, Closure $closure): array
{
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
$isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class);
if ($isLaravelApplication && ! $isLaravelPackage) {
return $closure($arguments);
}
return $arguments;
}
/**
* Ensures the runner is resolvable.
*/
private function ensureRunnerIsResolvable(): void
{
ParallelRunner::resolveRunnerUsing( // @phpstan-ignore-line
fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output)
);
}
/**
* Ensures the environment variables are set.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function ensureEnvironmentVariables(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);
}
/**
* Ensure the runner is set.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function ensureRunner(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);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Handlers;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Paratest\WrapperRunner;
/**
* @internal
*/
final class Parallel implements HandlesArguments
{
use HandleArguments;
/**
* The list of arguments to remove.
*/
private const ARGS_TO_REMOVE = [
'--parallel',
'-p',
'--no-output',
'--cache-result',
];
/**
* Handles the arguments, removing the ones that are not needed, and adds the "runner" argument.
*/
public function handleArguments(array $arguments): array
{
$args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $arguments);
return $this->pushArgument('--runner='.WrapperRunner::class, $args);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Handlers;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Contracts\HandlersWorkerArguments;
final class Pest implements HandlersWorkerArguments
{
use HandleArguments;
/**
* Handles the arguments, adding the "PEST_PARALLEL" environment variable to the global $_SERVER.
*/
public function handleWorkerArguments(array $arguments): array
{
$_SERVER['PEST_PARALLEL'] = '1';
return $arguments;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Paratest;
use Symfony\Component\Console\Output\ConsoleOutput;
final class CleanConsoleOutput extends ConsoleOutput
{
/**
* {@inheritdoc}
*/
protected function doWrite(string $message, bool $newline): void
{
if ($this->isOpeningHeadline($message)) {
return;
}
parent::doWrite($message, $newline);
}
/**
* Removes the opening headline, witch is not needed.
*/
private function isOpeningHeadline(string $message): bool
{
return str_contains($message, 'by Sebastian Bergmann and contributors.');
}
}

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Paratest;
use function assert;
use function fclose;
use function feof;
use function fopen;
use function fread;
use function fseek;
use function ftell;
use function fwrite;
use ParaTest\Options;
use Pest\Plugins\Parallel\Support\CompactPrinter;
use Pest\Support\StateGenerator;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Output\Printer;
use function preg_replace;
use SebastianBergmann\Timer\Duration;
use SplFileInfo;
use function sprintf;
use function strlen;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;
/** @internal */
final class ResultPrinter
{
/**
* The "native" printer.
*/
public readonly Printer $printer;
/**
* The "compact" printer.
*/
private readonly CompactPrinter $compactPrinter;
/** @var resource|null */
private $teamcityLogFileHandle;
/** @var array<string, int> */
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 = CompactPrinter::default();
if (! $this->options->configuration->hasLogfileTeamcity()) {
return;
}
$teamcityLogFileHandle = fopen($this->options->configuration->logfileTeamcity(), 'ab+');
assert($teamcityLogFileHandle !== false);
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
}
public function start(int $numberOfTests): void
{
$this->compactPrinter->line(sprintf(
'Running %d test%s using %d process%s',
$numberOfTests,
$numberOfTests === 1 ? '' : 's',
$this->options->processes,
$this->options->processes === 1 ? '' : 'es')
);
}
/** @param array<int, SplFileInfo> $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<int, SplFileInfo> $teamcityFiles
* @param array<int, SplFileInfo> $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;
}
$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<int, SplFileInfo> $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;
}
}

View File

@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Paratest;
use function array_merge;
use function array_merge_recursive;
use function array_shift;
use function assert;
use function count;
use const DIRECTORY_SEPARATOR;
use function dirname;
use function file_get_contents;
use function max;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use ParaTest\WrapperRunner\SuiteLoader;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\ShellExitCodeCalculator;
use PHPUnit\Util\ExcludeList;
use function realpath;
use SebastianBergmann\Timer\Timer;
use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use function unlink;
use function unserialize;
use function usleep;
/**
* @internal
*/
final class WrapperRunner implements RunnerInterface
{
private const CYCLE_SLEEP = 10000;
private readonly ResultPrinter $printer;
private readonly Timer $timer;
/** @var array<int, string> */
private array $pending = [];
private int $exitCode = -1;
/** @var array<int,WrapperWorker> */
private array $workers = [];
/** @var array<int,int> */
private array $batches = [];
/** @var array<int, SplFileInfo> */
private array $testresultFiles = [];
/** @var array<int, SplFileInfo> */
private array $coverageFiles = [];
/** @var array<int, SplFileInfo> */
private array $junitFiles = [];
/** @var array<int, SplFileInfo> */
private array $teamcityFiles = [];
/** @var array<int, SplFileInfo> */
private array $testdoxFiles = [];
/** @var array<int, string> */
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->start($suiteLoader->testCount);
$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<non-empty-string> $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<int, SplFileInfo> $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<int, string>
*/
private function getTestFiles(SuiteLoader $suiteLoader): array
{
$this->debug(sprintf('Found %d test file%s', count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's'));
/** @var array<string, string> $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(" <fg=blue>{$message}</> ");
}
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Support;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\Style;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\Info;
use PHPUnit\Event\Telemetry\MemoryUsage;
use PHPUnit\Event\Telemetry\Snapshot;
use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult;
use SebastianBergmann\Timer\Duration;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use function Termwind\render;
use Termwind\Terminal;
use function Termwind\terminal;
/**
* @internal
*/
final class CompactPrinter
{
/**
* The number of processed tests.
*/
private int $processed = 0;
/**
* @var array<string, array<int, string>>
*/
private const LOOKUP_TABLE = [
'.' => ['gray', '.'],
'S' => ['yellow', 's'],
'T' => ['cyan', 't'],
'I' => ['yellow', 'i'],
'N' => ['yellow', 'i'],
'R' => ['yellow', '!'],
'W' => ['yellow', '!'],
'E' => ['red', ''],
'F' => ['red', ''],
];
/**
* Creates a new instance of the Compact Printer.
*/
public function __construct(
private readonly Terminal $terminal,
private readonly OutputInterface $output,
private readonly Style $style,
private readonly int $compactSymbolsPerLine,
) {
// ..
}
/**
* Creates a new instance of the Compact Printer.
*/
public static function default(): self
{
return new self(
terminal(),
new ConsoleOutput(decorated: true),
new Style(new ConsoleOutput(decorated: true)),
terminal()->width() - 4,
);
}
/**
* Output an empty line in the console. Useful for providing a little breathing room.
*/
public function newLine(): void
{
render('<div class="py-1"></div>');
}
/**
* Write the given message to the console, adding vertical and horizontal padding.
*/
public function line(string $message): void
{
render("<span class='mx-2 py-1 text-gray-700'>{$message}</span>");
}
/**
* 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->processed % $this->compactSymbolsPerLine;
if ($symbolsOnCurrentLine >= $this->terminal->width() - 4) {
$symbolsOnCurrentLine = 0;
}
if ($symbolsOnCurrentLine === 0) {
$this->output->writeln('');
$this->output->write(' ');
}
$this->output->write(sprintf('<fg=%s;options=bold>%s</>', $color, $icon));
$this->processed++;
}
/**
* 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);
}
}

View File

@ -13,18 +13,19 @@ final class Retry implements HandlesArguments
{
use Concerns\HandleArguments;
/**
* Whether it should show retry or not.
*/
public static bool $retrying = false;
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
self::$retrying = $this->hasArgument('--retry', $arguments);
if (! $this->hasArgument('--retry', $arguments)) {
return $arguments;
}
return $this->popArgument('--retry', $arguments);
$arguments = $this->popArgument('--retry', $arguments);
$arguments = $this->pushArgument('--order-by=defects', $arguments);
return $this->pushArgument('--stop-on-failure', $arguments);
}
}

View File

@ -33,7 +33,7 @@ final class AfterEachRepository
}
/**
* Gets a after each closure by the given filename.
* Gets an after each closure by the given filename.
*/
public function get(string $filename): Closure
{

View File

@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
/**
* @internal
*/
final class RetryRepository
{
private const TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'.temp';
/**
* Creates a new Temp Repository instance.
*/
public function __construct(private readonly string $filename)
{
// ..
}
/**
* Adds a new element.
*/
public function add(string $element): void
{
$this->save([...$this->all(), ...[$element]]);
}
/**
* Clears the existing file, if any, and re-creates it.
*/
public function boot(): void
{
@unlink(self::TEMPORARY_FOLDER.'/'.$this->filename.'.json'); // @phpstan-ignore-line
$this->save([]);
}
/**
* Checks if there is any element.
*/
public function isEmpty(): bool
{
return $this->all() === [];
}
/**
* Checks if the given element exists.
*/
public function exists(string $element): bool
{
return in_array($element, $this->all(), true);
}
/**
* Gets all elements.
*
* @return array<int, string>
*/
private function all(): array
{
$path = self::TEMPORARY_FOLDER.'/'.$this->filename.'.json';
$contents = file_exists($path) ? file_get_contents($path) : '{}';
assert(is_string($contents));
$all = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
return is_array($all) ? $all : [];
}
/**
* Save the given elements.
*
* @param array<int, string> $elements
*/
private function save(array $elements): void
{
$contents = json_encode($elements, JSON_THROW_ON_ERROR);
file_put_contents(self::TEMPORARY_FOLDER.'/'.$this->filename.'.json', $contents);
}
}

View File

@ -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]);
}
/**

View File

@ -4,19 +4,21 @@ declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Support\Container;
use PHPUnit\Event\TestRunner\Configured;
use PHPUnit\Event\TestRunner\ConfiguredSubscriber;
use PHPUnit\TextUI\Configuration\Configuration;
/**
* @internal
*/
final class EnsureConfigurationDefaults implements ConfiguredSubscriber
final class EnsureConfigurationIsAvailable implements ConfiguredSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Configured $event): void
{
// TODO...
Container::getInstance()->add(Configuration::class, $event->configuration());
}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\TestSuite;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final class EnsureErroredTestsAreRetryable implements ErroredSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Errored $event): void
{
TestSuite::getInstance()->retryRepository->add($event->test()->id());
}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\TestSuite;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class EnsureFailedTestsAreRetryable implements FailedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Failed $event): void
{
TestSuite::getInstance()->retryRepository->add($event->test()->id());
}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\TestSuite;
use PHPUnit\Event\TestRunner\Started;
use PHPUnit\Event\TestRunner\StartedSubscriber;
/**
* @internal
*/
final class EnsureRetryRepositoryExists implements StartedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Started $event): void
{
TestSuite::getInstance()->retryRepository->boot();
}
}

View File

@ -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,
) {
}

View File

@ -54,7 +54,9 @@ final class Backtrace
foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) {
assert(array_key_exists(self::FILE, $trace));
if (Str::endsWith($trace['file'], 'Bootstrappers/BootFiles.php') || Str::endsWith($trace[self::FILE], 'overrides/Runner/TestSuiteLoader.php')) {
$traceFile = str_replace(DIRECTORY_SEPARATOR, '/', $trace[self::FILE]);
if (Str::endsWith($traceFile, 'Bootstrappers/BootFiles.php') || Str::endsWith($traceFile, 'overrides/Runner/TestSuiteLoader.php')) {
break;
}

View File

@ -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;
}
/**

View File

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use PHPUnit\Util\Exception;
use PHPUnit\Util\Filesystem;
abstract class Printer implements \PHPUnit\Util\Printer
{
/** @var resource|bool */
private $stream;
private readonly bool $isPhpStream;
private bool $isOpen;
private function __construct(string $out)
{
if (str_starts_with($out, 'socket://')) {
$tmp = explode(':', str_replace('socket://', '', $out));
if (count($tmp) !== 2) {
throw new Exception(sprintf('"%s" does not match "socket://hostname:port" format', $out));
}
$this->stream = fsockopen($tmp[0], (int) $tmp[1]);
$this->isOpen = true;
return;
}
$this->isPhpStream = str_starts_with($out, 'php://');
if (! $this->isPhpStream && ! Filesystem::createDirectory(dirname($out))) {
throw new Exception(sprintf('Directory "%s" was not created', dirname($out)));
}
$this->stream = fopen($out, 'wb');
$this->isOpen = true;
}
final public function print(string $buffer): void
{
assert($this->isOpen);
assert($this->stream !== false);
// @phpstan-ignore-next-line
fwrite($this->stream, $buffer);
}
final public function flush(): void
{
if ($this->isOpen && $this->isPhpStream && $this->stream !== false) {
// @phpstan-ignore-next-line
fclose($this->stream);
$this->isOpen = false;
}
}
}

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
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\Framework\IncompleteTestError;
use PHPUnit\Framework\SkippedWithMessageException;
use PHPUnit\Metadata\MetadataCollection;
use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult;
final class StateGenerator
{
public function fromPhpUnitTestResult(PHPUnitTestResult $testResult): State
{
$state = new State();
foreach ($testResult->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;
}
}

View File

@ -6,7 +6,8 @@ namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter;
use Pest\Exceptions\MissingDependency;
use Pest\Exceptions\NoTestsFound;
use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Panic;
use Pest\TestSuite;
use Symfony\Component\Process\Process;
@ -66,7 +67,7 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter
$dirtyFiles = array_values($dirtyFiles);
if ($dirtyFiles === []) {
throw new NoTestsFound();
Panic::with(new NoDirtyTestsFound());
}
$this->changedFiles = $dirtyFiles;

View File

@ -9,7 +9,6 @@ use Pest\Repositories\AfterAllRepository;
use Pest\Repositories\AfterEachRepository;
use Pest\Repositories\BeforeAllRepository;
use Pest\Repositories\BeforeEachRepository;
use Pest\Repositories\RetryRepository;
use Pest\Repositories\TestRepository;
use PHPUnit\Framework\TestCase;
@ -48,11 +47,6 @@ final class TestSuite
*/
public AfterAllRepository $afterAll;
/**
* Holds the retry repository.
*/
public RetryRepository $retryRepository;
/**
* Holds the root path.
*/
@ -75,7 +69,6 @@ final class TestSuite
$this->tests = new TestRepository();
$this->afterEach = new AfterEachRepository();
$this->afterAll = new AfterAllRepository();
$this->retryRepository = new RetryRepository('retry');
$this->rootPath = (string) realpath($rootPath);
}