Merge branch '2.x' into dirty_integration

This commit is contained in:
Nuno Maduro
2023-01-10 21:34:18 +00:00
committed by GitHub
65 changed files with 823 additions and 266 deletions

View File

@ -5,16 +5,17 @@ declare(strict_types=1);
namespace Pest\Bootstrappers;
use NunoMaduro\Collision;
use Pest\Contracts\Bootstrapper;
/**
* @internal
*/
final class BootExceptionHandler
final class BootExceptionHandler implements Bootstrapper
{
/**
* Boots the Exception Handler.
*/
public function __invoke(): void
public function boot(): void
{
$handler = new Collision\Provider();

View File

@ -4,16 +4,19 @@ declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Support\DatasetInfo;
use Pest\Support\Str;
use function Pest\testDirectory;
use Pest\TestSuite;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator;
/**
* @internal
*/
final class BootFiles
final class BootFiles implements Bootstrapper
{
/**
* The Pest convention.
@ -21,8 +24,6 @@ final class BootFiles
* @var array<int, string>
*/
private const STRUCTURE = [
'Datasets',
'Datasets.php',
'Expectations',
'Expectations.php',
'Helpers',
@ -33,7 +34,7 @@ final class BootFiles
/**
* Boots the Subscribers.
*/
public function __invoke(): void
public function boot(): void
{
$rootPath = TestSuite::getInstance()->rootPath;
$testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory();
@ -56,6 +57,8 @@ final class BootFiles
$this->load($filename);
}
}
$this->bootDatasets($testsPath);
}
/**
@ -73,4 +76,15 @@ final class BootFiles
include_once $filename;
}
private function bootDatasets(string $testsPath): void
{
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');
foreach ($files as $file) {
if (DatasetInfo::isADatasetsFile($file) || DatasetInfo::isInsideADatasetsDirectory($file)) {
$this->load($file);
}
}
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\ShouldNotHappen;
/**
* @internal
*/
final class BootOverrides implements Bootstrapper
{
/**
* The list of files to be overridden.
*
* @var array<int, string>
*/
private const FILES = [
'Runner/Filter/NameFilterIterator.php',
'Runner/TestSuiteLoader.php',
];
/**
* Boots the Subscribers.
*/
public function boot(): void
{
foreach (self::FILES as $file) {
$file = __DIR__."/../../overrides/$file";
if (! file_exists($file)) {
throw ShouldNotHappen::fromMessage(sprintf('File [%s] does not exist.', $file));
}
require_once $file;
}
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Subscribers;
use PHPUnit\Event;
use PHPUnit\Event\Subscriber;
@ -11,7 +12,7 @@ use PHPUnit\Event\Subscriber;
/**
* @internal
*/
final class BootSubscribers
final class BootSubscribers implements Bootstrapper
{
/**
* The Kernel subscribers.
@ -22,13 +23,14 @@ final class BootSubscribers
Subscribers\EnsureConfigurationIsValid::class,
Subscribers\EnsureConfigurationDefaults::class,
Subscribers\EnsureRetryRepositoryExists::class,
Subscribers\EnsureFailedTestsAreStoredForRetry::class,
Subscribers\EnsureErroredTestsAreRetryable::class,
Subscribers\EnsureFailedTestsAreRetryable::class,
];
/**
* Boots the Subscribers.
*/
public function __invoke(): void
public function boot(): void
{
foreach (self::SUBSCRIBERS as $subscriber) {
Event\Facade::registerSubscriber(

View File

@ -4,13 +4,14 @@ declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class BootView
final class BootView implements Bootstrapper
{
public function __construct(
private readonly OutputInterface $output
@ -21,7 +22,7 @@ final class BootView
/**
* Boots the view renderer.
*/
public function __invoke(): void
public function boot(): void
{
View::renderUsing($this->output);
}

View File

@ -58,6 +58,6 @@ trait Pipeable
*/
private function pipes(string $name, object $context, string $scope): array
{
return array_map(fn (Closure $pipe) => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
}
}

View File

@ -13,7 +13,6 @@ trait Retrievable
* @template TRetrievableValue
*
* Safely retrieve the value at the given key from an object or array.
*
* @template TRetrievableValue
*
* @param array<string, TRetrievableValue>|object $value

View File

@ -259,7 +259,7 @@ trait Testable
*/
private function __callClosure(Closure $closure, array $arguments): mixed
{
return ExceptionTrace::ensure(fn () => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments));
return ExceptionTrace::ensure(fn (): mixed => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments));
}
/**

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Console;
use Pest\Bootstrappers\BootView;
use Pest\Support\View;
use Symfony\Component\Console\Helper\SymfonyQuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
@ -39,6 +40,9 @@ final class Thanks
*/
public function __invoke(): void
{
$bootstrapper = new BootView($this->output);
$bootstrapper->boot();
$wantsToSupport = (new SymfonyQuestionHelper())->ask(
new ArrayInput([]),
$this->output,

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Bootstrapper
{
/**
* Boots the bootstrapper.
*/
public function boot(): void;
}

View File

@ -12,13 +12,13 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
final class DatasetAlreadyExists extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $name)
public function __construct(string $name, string $scope)
{
parent::__construct(sprintf('A dataset with the name `%s` already exist.', $name));
parent::__construct(sprintf('A dataset with the name `%s` already exist in scope [%s].', $name, $scope));
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use LogicException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidExpectation extends LogicException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* @param array<int, string> $methods
*
* @throws self
*/
public static function fromMethods(array $methods): never
{
throw new self(sprintf('Expectation [%s] is not valid.', implode('->', $methods)));
}
}

View File

@ -12,11 +12,9 @@ use InvalidArgumentException;
final class InvalidExpectationValue extends InvalidArgumentException
{
/**
* @return never
*
* @throws self
*/
public static function expected(string $type): void
public static function expected(string $type): never
{
throw new self(sprintf('Invalid expectation value type. Expected [%s].', $type));
}

View File

@ -6,10 +6,18 @@ namespace Pest;
use BadMethodCallException;
use Closure;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Arch\Expectations\ToBeUsedOn;
use Pest\Arch\Expectations\ToBeUsedOnNothing;
use Pest\Arch\Expectations\ToOnlyBeUsedOn;
use Pest\Arch\Expectations\ToOnlyUse;
use Pest\Arch\Expectations\ToUse;
use Pest\Arch\Expectations\ToUseNothing;
use Pest\Concerns\Extendable;
use Pest\Concerns\Pipeable;
use Pest\Concerns\Retrievable;
use Pest\Exceptions\ExpectationNotFound;
use Pest\Exceptions\InvalidExpectation;
use Pest\Exceptions\InvalidExpectationValue;
use Pest\Expectations\EachExpectation;
use Pest\Expectations\HigherOrderExpectation;
@ -24,7 +32,7 @@ use PHPUnit\Framework\ExpectationFailedException;
*
* @template TValue
*
* @property Expectation $not Creates the opposite expectation.
* @property OppositeExpectation $not Creates the opposite expectation.
* @property EachExpectation $each Creates an expectation on each element on the traversable value.
*
* @mixin Mixins\Expectation<TValue>
@ -70,10 +78,12 @@ final class Expectation
InvalidExpectationValue::expected('string');
}
/** @var array<int|string, mixed>|bool $value */
$value = json_decode($this->value, true, 512);
$this->toBeJson();
return $this->toBeJson()->and($value);
/** @var array<int|string, mixed>|bool $value */
$value = json_decode($this->value, true, 512, JSON_THROW_ON_ERROR);
return $this->and($value);
}
/**
@ -348,4 +358,65 @@ final class Expectation
{
return new Any();
}
/**
* Asserts that the given expectation target use the given dependencies.
*
* @param array<int, string>|string $targets
*/
public function toUse(array|string $targets): ArchExpectation
{
return ToUse::make($this, $targets);
}
/**
* Asserts that the given expectation target "only" use on the given dependencies.
*
* @param array<int, string>|string $targets
*/
public function toOnlyUse(array|string $targets): ArchExpectation
{
return ToOnlyUse::make($this, $targets);
}
/**
* Asserts that the given expectation target does not use any dependencies.
*/
public function toUseNothing(): ArchExpectation
{
return ToUseNothing::make($this);
}
public function toBeUsed(): never
{
throw InvalidExpectation::fromMethods(['toBeUsed']);
}
/**
* Asserts that the given expectation dependency is used by the given targets.
*
* @param array<int, string>|string $targets
*/
public function toBeUsedOn(array|string $targets): ArchExpectation
{
return ToBeUsedOn::make($this, $targets);
}
/**
* Asserts that the given expectation dependency is "only" used by the given targets.
*
* @param array<int, string>|string $targets
*/
public function toOnlyBeUsedOn(array|string $targets): ArchExpectation
{
return ToOnlyBeUsedOn::make($this, $targets);
}
/**
* Asserts that the given expectation dependency is not used.
*/
public function toBeUsedOnNothing(): ArchExpectation
{
return ToBeUsedOnNothing::make($this);
}
}

View File

@ -4,6 +4,13 @@ declare(strict_types=1);
namespace Pest\Expectations;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Arch\Expectations\ToBeUsedOn;
use Pest\Arch\Expectations\ToBeUsedOnNothing;
use Pest\Arch\Expectations\ToUse;
use Pest\Arch\GroupArchExpectation;
use Pest\Arch\SingleArchExpectation;
use Pest\Exceptions\InvalidExpectation;
use Pest\Expectation;
use Pest\Support\Arr;
use PHPUnit\Framework\ExpectationFailedException;
@ -52,6 +59,64 @@ final class OppositeExpectation
return $this->original;
}
/**
* Asserts that the given expectation target does not use any of the given dependencies.
*
* @param array<int, string>|string $targets
*/
public function toUse(array|string $targets): ArchExpectation
{
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($this->original, $target)->opposite(
fn () => $this->throwExpectationFailedException('toUse', $target),
), is_string($targets) ? [$targets] : $targets));
}
/**
* @param array<int, string>|string $targets
*/
public function toOnlyUse(array|string $targets): never
{
throw InvalidExpectation::fromMethods(['not', 'toOnlyUse']);
}
public function toUseNothing(): never
{
throw InvalidExpectation::fromMethods(['not', 'toUseNothing']);
}
/**
* Asserts that the given expectation dependency is not used.
*/
public function toBeUsed(): ArchExpectation
{
return ToBeUsedOnNothing::make($this->original);
}
/**
* Asserts that the given expectation dependency is not used by any of the given targets.
*
* @param array<int, string>|string $targets
*/
public function toBeUsedOn(array|string $targets): ArchExpectation
{
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedOn::make($this->original, $target)->opposite(
fn () => $this->throwExpectationFailedException('toBeUsedOn', $target),
), is_string($targets) ? [$targets] : $targets));
}
public function toOnlyBeUsedOn(): never
{
throw InvalidExpectation::fromMethods(['not', 'toOnlyBeUsedOn']);
}
/**
* Asserts that the given expectation dependency is not used.
*/
public function toBeUsedOnNothing(): never
{
throw InvalidExpectation::fromMethods(['not', 'toBeUsedOnNothing']);
}
/**
* Handle dynamic method calls into the original expectation.
*
@ -89,11 +154,12 @@ final class OppositeExpectation
/**
* Creates a new expectation failed exception with a nice readable message.
*
* @param array<int, mixed> $arguments
* @return never
* @param array<int, mixed>|string $arguments
*/
private function throwExpectationFailedException(string $name, array $arguments = []): void
public function throwExpectationFailedException(string $name, array|string $arguments = []): never
{
$arguments = is_array($arguments) ? $arguments : [$arguments];
$exporter = new Exporter();
$toString = fn ($argument): string => $exporter->shortenedExport($argument);

View File

@ -13,10 +13,8 @@ abstract class Attribute
{
/**
* Determine if the attribute should be placed above the class instead of above the method.
*
* @var bool
*/
public const ABOVE_CLASS = false;
public static bool $above = false;
/**
* @param array<int, string> $attributes

View File

@ -15,10 +15,8 @@ final class Covers extends Attribute
{
/**
* Determine if the attribute should be placed above the classe instead of above the method.
*
* @var bool
*/
public const ABOVE_CLASS = true;
public static bool $above = true;
/**
* Adds attributes regarding the "covers" feature.

View File

@ -85,7 +85,7 @@ final class TestCaseFactory
$methods = array_values(array_filter(
$this->methods,
fn ($method) => $methodsUsingOnly === [] || in_array($method, $methodsUsingOnly, true)
fn ($method): bool => $methodsUsingOnly === [] || in_array($method, $methodsUsingOnly, true)
));
if ($methods !== []) {
@ -165,21 +165,21 @@ final class TestCaseFactory
$classFQN .= $className;
}
$classAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute) => $attribute::ABOVE_CLASS);
$methodAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute) => ! $attribute::ABOVE_CLASS);
$classAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute): bool => $attribute::$above);
$methodAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute): bool => ! $attribute::$above);
$classAttributes = [];
foreach ($classAvailableAttributes as $attribute) {
$classAttributes = array_reduce(
$methods,
fn (array $carry, TestCaseMethodFactory $methodFactory) => (new $attribute())->__invoke($methodFactory, $carry),
fn (array $carry, TestCaseMethodFactory $methodFactory): array => (new $attribute())->__invoke($methodFactory, $carry),
$classAttributes
);
}
$methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory) => $methodFactory->buildForEvaluation(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(
$classFQN,
self::ANNOTATIONS,
$methodAvailableAttributes
@ -188,7 +188,7 @@ final class TestCaseFactory
));
$classAttributesCode = implode('', array_map(
static fn (string $attribute) => sprintf("\n%s", $attribute),
static fn (string $attribute): string => sprintf("\n%s", $attribute),
array_unique($classAttributes),
));
@ -209,7 +209,7 @@ final class TestCaseFactory
}
PHP;
eval($classCode);
eval($classCode); // @phpstan-ignore-line
} catch (ParseError $caught) {
throw new RuntimeException(sprintf(
"Unable to create test case for test file at %s. \n %s",

View File

@ -97,7 +97,7 @@ final class TestCaseMethodFactory
$testCase->chains->chain($this);
$method->chains->chain($this);
return \Pest\Support\Closure::bind($closure, $this, $this::class)(...func_get_args());
return \Pest\Support\Closure::bind($closure, $this, self::class)(...func_get_args());
};
}
@ -123,7 +123,9 @@ final class TestCaseMethodFactory
$methodName = Str::evaluable($this->description);
if (Retry::$retrying && ! TestSuite::getInstance()->retryTempRepository->exists(sprintf('%s::%s', $classFQN, $methodName))) {
$retryRepository = TestSuite::getInstance()->retryRepository;
if (Retry::$retrying && ! $retryRepository->isEmpty() && ! $retryRepository->exists(sprintf('%s::%s', $classFQN, $methodName))) {
return '';
}
@ -147,11 +149,11 @@ final class TestCaseMethodFactory
}
$annotations = implode('', array_map(
static fn ($annotation) => sprintf("\n * %s", $annotation), $annotations,
static fn ($annotation): string => sprintf("\n * %s", $annotation), $annotations,
));
$attributes = implode('', array_map(
static fn ($attribute) => sprintf("\n %s", $attribute), $attributes,
static fn ($attribute): string => sprintf("\n %s", $attribute), $attributes,
));
return <<<PHP

View File

@ -9,6 +9,7 @@ use Pest\PendingCalls\TestCall;
use Pest\PendingCalls\UsesCall;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Backtrace;
use Pest\Support\DatasetInfo;
use Pest\Support\HigherOrderTapProxy;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
@ -60,7 +61,8 @@ if (! function_exists('dataset')) {
*/
function dataset(string $name, Closure|iterable $dataset): void
{
DatasetsRepository::set($name, $dataset);
$scope = DatasetInfo::scope(Backtrace::datasetsFile());
DatasetsRepository::set($name, $dataset, $scope);
}
}
@ -85,9 +87,9 @@ if (! function_exists('test')) {
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @return TestCall|TestCase|mixed
* @return HigherOrderTapProxy<TestCall|TestCase>|TestCall
*/
function test(string $description = null, Closure $closure = null)
function test(string $description = null, Closure $closure = null): HigherOrderTapProxy|TestCall
{
if ($description === null && TestSuite::getInstance()->test !== null) {
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
@ -128,10 +130,11 @@ if (! function_exists('todo')) {
*/
function todo(string $description): TestCall
{
/* @phpstan-ignore-next-line */
return test($description, fn () => self::markTestSkipped(
'__TODO__',
));
$test = test($description);
assert($test instanceof TestCall);
return $test->skip('__TODO__');
}
}

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest;
use Pest\Contracts\Bootstrapper;
use Pest\Plugins\Actions\CallsAddsOutput;
use Pest\Plugins\Actions\CallsBoot;
use Pest\Plugins\Actions\CallsShutdown;
@ -22,6 +23,7 @@ final class Kernel
* @var array<int, class-string>
*/
private const BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Bootstrappers\BootExceptionHandler::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
@ -49,7 +51,10 @@ final class Kernel
public static function boot(): self
{
foreach (self::BOOTSTRAPPERS as $bootstrapper) {
Container::getInstance()->get($bootstrapper)->__invoke();
$bootstrapper = Container::getInstance()->get($bootstrapper);
assert($bootstrapper instanceof Bootstrapper);
$bootstrapper->boot();
}
(new CallsBoot())->__invoke();

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Pest\Logging;
use Pest\Support\Printer;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
final class JUnit extends Printer
{
// @todo
}

View File

@ -154,7 +154,7 @@ final class TestCall
$condition = is_callable($condition)
? $condition
: fn () => $condition;
: fn (): bool => $condition;
$message = is_string($conditionOrMessage)
? $conditionOrMessage
@ -170,6 +170,16 @@ final class TestCall
return $this;
}
/**
* Sets the test as "todo".
*/
public function todo(): self
{
$this->skip('__TODO__');
return $this;
}
/**
* Sets the covered classes or methods.
*/

View File

@ -23,7 +23,7 @@ final class Plugin
public static function uses(string ...$traits): void
{
self::$callables[] = function () use ($traits): void {
uses(...$traits)->in(TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.testDirectory());
uses(...$traits)->in(TestSuite::getInstance()->rootPath);
};
}
}

View File

@ -46,7 +46,7 @@ final class Memory implements AddsOutput, HandlesArguments
{
if ($this->enabled) {
$this->output->writeln(sprintf(
' <fg=gray;options=bold>Memory:</> <fg=default>%s MB</>',
' <fg=gray>Memory:</> <fg=default>%s MB</>',
round(memory_get_usage(true) / 1000 ** 2, 3)
));
}

View File

@ -5,7 +5,8 @@ declare(strict_types=1);
namespace Pest\Repositories;
use Closure;
use Pest\Exceptions\DatasetAlreadyExist;
use Generator;
use Pest\Exceptions\DatasetAlreadyExists;
use Pest\Exceptions\DatasetDoesNotExist;
use Pest\Exceptions\ShouldNotHappen;
use SebastianBergmann\Exporter\Exporter;
@ -17,6 +18,8 @@ use Traversable;
*/
final class DatasetsRepository
{
private const SEPARATOR = '>>';
/**
* Holds the datasets.
*
@ -36,13 +39,15 @@ final class DatasetsRepository
*
* @param Closure|iterable<int|string, mixed> $data
*/
public static function set(string $name, Closure|iterable $data): void
public static function set(string $name, Closure|iterable $data, string $scope): void
{
if (array_key_exists($name, self::$datasets)) {
throw new DatasetAlreadyExist($name);
$datasetKey = "$scope".self::SEPARATOR."$name";
if (array_key_exists("$datasetKey", self::$datasets)) {
throw new DatasetAlreadyExists($name, $scope);
}
self::$datasets[$name] = $data;
self::$datasets[$datasetKey] = $data;
}
/**
@ -52,12 +57,12 @@ final class DatasetsRepository
*/
public static function with(string $filename, string $description, array $with): void
{
self::$withs[$filename.'>>>'.$description] = $with;
self::$withs["$filename".self::SEPARATOR."$description"] = $with;
}
public static function has(string $filename, string $description): bool
{
return array_key_exists($filename.'>>>'.$description, self::$withs);
return array_key_exists($filename.self::SEPARATOR.$description, self::$withs);
}
/**
@ -67,9 +72,9 @@ final class DatasetsRepository
*/
public static function get(string $filename, string $description)
{
$dataset = self::$withs[$filename.'>>>'.$description];
$dataset = self::$withs[$filename.self::SEPARATOR.$description];
$dataset = self::resolve($description, $dataset);
$dataset = self::resolve($dataset, $filename);
if ($dataset === null) {
throw ShouldNotHappen::fromMessage('Dataset [%s] not resolvable.');
@ -84,14 +89,13 @@ final class DatasetsRepository
* @param array<Closure|iterable<int|string, mixed>|string> $dataset
* @return array<string, mixed>|null
*/
public static function resolve(string $description, array $dataset): array|null
public static function resolve(array $dataset, string $currentTestFile): array|null
{
/* @phpstan-ignore-next-line */
if (empty($dataset)) {
if ($dataset === []) {
return null;
}
$dataset = self::processDatasets($dataset);
$dataset = self::processDatasets($dataset, $currentTestFile);
$datasetCombinations = self::getDatasetsCombinations($dataset);
@ -136,7 +140,7 @@ final class DatasetsRepository
* @param array<Closure|iterable<int|string, mixed>|string> $datasets
* @return array<array<mixed>>
*/
private static function processDatasets(array $datasets): array
private static function processDatasets(array $datasets, string $currentTestFile): array
{
$processedDatasets = [];
@ -144,11 +148,7 @@ final class DatasetsRepository
$processedDataset = [];
if (is_string($data)) {
if (! array_key_exists($data, self::$datasets)) {
throw new DatasetDoesNotExist($data);
}
$datasets[$index] = self::$datasets[$data];
$datasets[$index] = self::getScopedDataset($data, $currentTestFile);
}
if (is_callable($datasets[$index])) {
@ -156,14 +156,16 @@ final class DatasetsRepository
}
if ($datasets[$index] instanceof Traversable) {
$datasets[$index] = iterator_to_array($datasets[$index], false);
$preserveKeysForArrayIterator = $datasets[$index] instanceof Generator
&& is_string($datasets[$index]->key());
$datasets[$index] = iterator_to_array($datasets[$index], $preserveKeysForArrayIterator);
}
// @phpstan-ignore-next-line
foreach ($datasets[$index] as $key => $values) {
$values = is_array($values) ? $values : [$values];
$processedDataset[] = [
'label' => self::getDatasetDescription($key, $values), // @phpstan-ignore-line
'label' => self::getDatasetDescription($key, $values),
'values' => $values,
];
}
@ -174,6 +176,33 @@ final class DatasetsRepository
return $processedDatasets;
}
/**
* @return Closure|iterable<int|string, mixed>
*/
private static function getScopedDataset(string $name, string $currentTestFile): Closure|iterable
{
$matchingDatasets = array_filter(self::$datasets, function (string $key) use ($name, $currentTestFile): bool {
[$datasetScope, $datasetName] = explode(self::SEPARATOR, $key);
if ($name !== $datasetName) {
return false;
}
return str_starts_with($currentTestFile, $datasetScope);
}, ARRAY_FILTER_USE_KEY);
$closestScopeDatasetKey = array_reduce(
array_keys($matchingDatasets),
fn ($keyA, $keyB) => $keyA !== null && strlen((string) $keyA) > strlen($keyB) ? $keyA : $keyB
);
if ($closestScopeDatasetKey === null) {
throw new DatasetDoesNotExist($name);
}
return $matchingDatasets[$closestScopeDatasetKey];
}
/**
* @param array<array<mixed>> $combinations
* @return array<array<array<mixed>>>

View File

@ -7,9 +7,15 @@ namespace Pest\Repositories;
/**
* @internal
*/
final class TempRepository
final class RetryRepository
{
private const FOLDER = __DIR__.'/../../.temp';
private const TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'.temp';
/**
* Creates a new Temp Repository instance.
@ -32,11 +38,19 @@ final class TempRepository
*/
public function boot(): void
{
@unlink(self::FOLDER.'/'.$this->filename.'.json'); // @phpstan-ignore-line
@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.
*/
@ -52,7 +66,9 @@ final class TempRepository
*/
private function all(): array
{
$contents = file_get_contents(self::FOLDER.'/'.$this->filename.'.json');
$path = self::TEMPORARY_FOLDER.'/'.$this->filename.'.json';
$contents = file_exists($path) ? file_get_contents($path) : '{}';
assert(is_string($contents));
@ -70,6 +86,6 @@ final class TempRepository
{
$contents = json_encode($elements, JSON_THROW_ON_ERROR);
file_put_contents(self::FOLDER.'/'.$this->filename.'.json', $contents);
file_put_contents(self::TEMPORARY_FOLDER.'/'.$this->filename.'.json', $contents);
}
}

View File

@ -50,7 +50,7 @@ final class TestRepository
*/
public function getFilenames(): array
{
$testCases = array_filter($this->testCases, static fn(TestCaseFactory $testCase) => $testCase->methodsUsingOnly() !== []);
$testCases = array_filter($this->testCases, static fn (TestCaseFactory $testCase): bool => $testCase->methodsUsingOnly() !== []);
if ($testCases === []) {
$testCases = $this->testCases;

View File

@ -0,0 +1,23 @@
<?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

@ -11,13 +11,13 @@ use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class EnsureFailedTestsAreStoredForRetry implements FailedSubscriber
final class EnsureFailedTestsAreRetryable implements FailedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Failed $event): void
{
TestSuite::getInstance()->retryTempRepository->add($event->test()->id());
TestSuite::getInstance()->retryRepository->add($event->test()->id());
}
}

View File

@ -18,6 +18,6 @@ final class EnsureRetryRepositoryExists implements StartedSubscriber
*/
public function notify(Started $event): void
{
TestSuite::getInstance()->retryTempRepository->boot();
TestSuite::getInstance()->retryRepository->boot();
}
}

View File

@ -44,6 +44,30 @@ final class Backtrace
return $current[self::FILE];
}
/**
* Returns the current datasets file.
*/
public static function datasetsFile(): string
{
$current = null;
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')) {
break;
}
$current = $trace;
}
if ($current === null) {
throw ShouldNotHappen::fromMessage('Dataset file not found.');
}
return $current[self::FILE];
}
/**
* Returns the filename that called the current function/method.
*/

View File

@ -22,8 +22,8 @@ final class ChainableClosure
throw ShouldNotHappen::fromMessage('$this not bound to chainable closure.');
}
\Pest\Support\Closure::bind($closure, $this, $this::class)(...func_get_args());
\Pest\Support\Closure::bind($next, $this, $this::class)(...func_get_args());
\Pest\Support\Closure::bind($closure, $this, self::class)(...func_get_args());
\Pest\Support\Closure::bind($next, $this, self::class)(...func_get_args());
};
}

View File

@ -16,7 +16,7 @@ final class Container
private static ?Container $instance = null;
/**
* @var array<string, mixed>
* @var array<string, object|string>
*/
private array $instances = [];
@ -25,37 +25,30 @@ final class Container
*/
public static function getInstance(): self
{
if (static::$instance === null) {
static::$instance = new self();
if (self::$instance === null) {
self::$instance = new self();
}
return static::$instance;
return self::$instance;
}
/**
* Gets a dependency from the container.
*
* @template TObject of object
*
* @param class-string<TObject> $id
* @return TObject
*/
public function get(string $id): mixed
public function get(string $id): object|string
{
if (! array_key_exists($id, $this->instances)) {
/** @var class-string $id */
$this->instances[$id] = $this->build($id);
}
/** @var TObject $concrete */
$concrete = $this->instances[$id];
return $concrete;
return $this->instances[$id];
}
/**
* Adds the given instance to the container.
*/
public function add(string $id, mixed $instance): void
public function add(string $id, object|string $instance): void
{
$this->instances[$id] = $instance;
}
@ -68,7 +61,7 @@ final class Container
* @param class-string<TObject> $id
* @return TObject
*/
private function build(string $id): mixed
private function build(string $id): object
{
$reflectionClass = new ReflectionClass($id);
@ -77,7 +70,7 @@ final class Container
if ($constructor !== null) {
$params = array_map(
function (ReflectionParameter $param) use ($id) {
function (ReflectionParameter $param) use ($id): object|string {
$candidate = Reflection::getParameterClassName($param);
if ($candidate === null) {
@ -90,7 +83,6 @@ final class Container
}
}
// @phpstan-ignore-next-line
return $this->get($candidate);
},
$constructor->getParameters()

View File

@ -10,9 +10,9 @@ use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Terminal;
use function Termwind\render;
use function Termwind\renderUsing;
use function Termwind\terminal;
/**
* @internal
@ -42,15 +42,15 @@ final class Coverage
return false;
}
if ($runtime->hasXdebug()) {
if (version_compare((string) phpversion('xdebug'), '3.1', '>=')) {
if (! in_array('coverage', xdebug_info('mode'), true)) {
return false;
}
}
if (! $runtime->hasXdebug()) {
return true;
}
return true;
if (! version_compare((string) phpversion('xdebug'), '3.1', '>=')) {
return true;
}
return in_array('coverage', xdebug_info('mode'), true);
}
/**
@ -83,10 +83,6 @@ final class Coverage
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalWidth = (new Terminal())->getWidth();
$dottedLineLength = $totalWidth - 6;
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
/** @var Directory<File|Directory> $report */
@ -103,36 +99,23 @@ final class Coverage
$dirname,
$basename,
]);
$rawName = $dirname === '.' ? $basename : implode(DIRECTORY_SEPARATOR, [
$dirname,
$basename,
]);
$linesExecutedTakenSize = 0;
if ($file->percentageOfExecutedLines()->asString() != '0.00%') {
$linesExecutedTakenSize = strlen($uncoveredLines = trim(implode(', ', self::getMissingCoverage($file)))) + 1;
$name .= sprintf(' <fg=red>%s</>', $uncoveredLines);
}
$percentage = $file->numberOfExecutableLines() === 0
? '100.0'
: number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', '');
$takenSize = strlen($rawName.$percentage) + 2 + $linesExecutedTakenSize; // adding 3 space and percent sign
$color = $percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow');
$percentage = sprintf(
'<fg=%s>%s</>',
$percentage === '100.0' ? 'green' : ($percentage === '0.0' ? 'red' : 'yellow'),
$percentage
);
$truncateAt = max(1, terminal()->width() - 12);
$output->writeln(sprintf(
' %s <fg=gray>%s</> %s <fg=gray>%%</>',
$name,
str_repeat('.', max($dottedLineLength - $takenSize, 1)),
$percentage
));
renderUsing($output);
render(<<<HTML
<div class="flex mx-2">
<span class="truncate-{$truncateAt}">{$name}</span>
<span class="flex-1 content-repeat-[.] text-gray mx-1"></span>
<span class="text-{$color}">{$percentage}%</span>
</div>
HTML);
}
$totalCoverageAsString = $totalCoverage->asFloat() === 0.0

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* @internal
*/
final class DatasetInfo
{
public const DATASETS_DIR_NAME = 'Datasets';
public const DATASETS_FILE_NAME = 'Datasets.php';
public static function isInsideADatasetsDirectory(string $file): bool
{
return basename(dirname($file)) === self::DATASETS_DIR_NAME;
}
public static function isADatasetsFile(string $file): bool
{
return basename($file) === self::DATASETS_FILE_NAME;
}
public static function scope(string $file): string
{
if (self::isInsideADatasetsDirectory($file)) {
return dirname($file, 2);
}
if (self::isADatasetsFile($file)) {
return dirname($file);
}
return $file;
}
}

View File

@ -10,6 +10,10 @@ use Throwable;
/**
* @internal
*
* @template TProxy
*
* @mixin TProxy
*/
final class HigherOrderTapProxy
{

View File

@ -12,6 +12,7 @@ use ReflectionException;
use ReflectionFunction;
use ReflectionNamedType;
use ReflectionParameter;
use ReflectionProperty;
use ReflectionUnionType;
/**
@ -95,7 +96,7 @@ final class Reflection
$reflectionProperty = null;
while ($reflectionProperty === null) {
while (! $reflectionProperty instanceof ReflectionProperty) {
try {
/* @var ReflectionProperty $reflectionProperty */
$reflectionProperty = $reflectionClass->getProperty($property);
@ -127,7 +128,7 @@ final class Reflection
$reflectionProperty = null;
while ($reflectionProperty === null) {
while (! $reflectionProperty instanceof ReflectionProperty) {
try {
/* @var ReflectionProperty $reflectionProperty */
$reflectionProperty = $reflectionClass->getProperty($property);

View File

@ -56,7 +56,7 @@ final class Str
{
$code = str_replace(' ', '_', $code);
return (string) preg_replace('/[^A-Z_a-z0-9\\\\]/', '', $code);
return (string) preg_replace('/[^A-Z_a-z0-9]/', '_', $code);
}
/**

View File

@ -9,7 +9,7 @@ use Pest\Repositories\AfterAllRepository;
use Pest\Repositories\AfterEachRepository;
use Pest\Repositories\BeforeAllRepository;
use Pest\Repositories\BeforeEachRepository;
use Pest\Repositories\TempRepository;
use Pest\Repositories\RetryRepository;
use Pest\Repositories\TestRepository;
use PHPUnit\Framework\TestCase;
@ -44,9 +44,9 @@ final class TestSuite
public AfterAllRepository $afterAll;
/**
* Holds the retry temp repository.
* Holds the retry repository.
*/
public TempRepository $retryTempRepository;
public RetryRepository $retryRepository;
/**
* Holds the root path.
@ -71,7 +71,7 @@ final class TestSuite
$this->beforeEach = new BeforeEachRepository();
$this->afterEach = new AfterEachRepository();
$this->afterAll = new AfterAllRepository();
$this->retryTempRepository = new TempRepository('retry');
$this->retryRepository = new RetryRepository('retry');
$this->rootPath = (string) realpath($rootPath);
}
@ -95,7 +95,7 @@ final class TestSuite
return self::$instance;
}
if (self::$instance === null) {
if (! self::$instance instanceof self) {
throw new InvalidPestCommand();
}