Merge branch '4.x' into patch-1

This commit is contained in:
nuno maduro
2026-04-10 16:58:26 +01:00
committed by GitHub
79 changed files with 915 additions and 255 deletions

View File

@ -53,7 +53,7 @@ abstract class AbstractPreset // @pest-arch-ignore-line
/**
* Runs the given callback for each namespace.
*
* @param callable(Expectation<string|null>): ArchExpectation ...$callbacks
* @param callable(Expectation<string>): ArchExpectation ...$callbacks
*/
final public function eachUserNamespace(callable ...$callbacks): void
{

View File

@ -69,6 +69,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('Request');
$this->expectations[] = expect('App\Http\Requests')
->classes()
->toExtend('Illuminate\Foundation\Http\FormRequest');
$this->expectations[] = expect('App\Http\Requests')
@ -118,6 +119,7 @@ final class Laravel extends AbstractPreset
->toHaveMethod('handle');
$this->expectations[] = expect('App\Notifications')
->classes()
->toExtend('Illuminate\Notifications\Notification');
$this->expectations[] = expect('App')
@ -128,6 +130,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('ServiceProvider');
$this->expectations[] = expect('App\Providers')
->classes()
->toExtend('Illuminate\Support\ServiceProvider');
$this->expectations[] = expect('App\Providers')
@ -150,7 +153,7 @@ final class Laravel extends AbstractPreset
->toHaveSuffix('Controller');
$this->expectations[] = expect('App\Http')
->toOnlyBeUsedIn('App\Http');
->toOnlyBeUsedIn(['App\Http', 'App\Providers']);
$this->expectations[] = expect('App\Http\Controllers')
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
@ -173,5 +176,9 @@ final class Laravel extends AbstractPreset
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute')
->toHaveMethod('resolve');
$this->expectations[] = expect('App\Rules')
->classes()
->toImplement('Illuminate\Contracts\Validation\ValidationRule');
}
}

View File

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

View File

@ -129,7 +129,7 @@ trait Testable
*/
public function __addBeforeAll(?Closure $hook): void
{
if (! $hook instanceof \Closure) {
if (! $hook instanceof Closure) {
return;
}
@ -143,7 +143,7 @@ trait Testable
*/
public function __addAfterAll(?Closure $hook): void
{
if (! $hook instanceof \Closure) {
if (! $hook instanceof Closure) {
return;
}
@ -173,7 +173,7 @@ trait Testable
*/
private function __addHook(string $property, ?Closure $hook): void
{
if (! $hook instanceof \Closure) {
if (! $hook instanceof Closure) {
return;
}
@ -350,7 +350,8 @@ trait Testable
}
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest));
$testParameterTypesByName = Reflection::getFunctionArguments($underlyingTest);
$testParameterTypes = array_values($testParameterTypesByName);
if (count($arguments) !== 1) {
foreach ($arguments as $argumentIndex => $argumentValue) {
@ -358,7 +359,11 @@ trait Testable
continue;
}
if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) {
$parameterType = is_string($argumentIndex)
? $testParameterTypesByName[$argumentIndex]
: $testParameterTypes[$argumentIndex];
if (in_array($parameterType, [Closure::class, 'callable', 'mixed'])) {
continue;
}
@ -384,7 +389,7 @@ trait Testable
return [$boundDatasetResult];
}
return array_values($boundDatasetResult);
return $boundDatasetResult;
}
/**

View File

@ -18,6 +18,7 @@ use Pest\Arch\Expectations\ToOnlyUse;
use Pest\Arch\Expectations\ToUse;
use Pest\Arch\Expectations\ToUseNothing;
use Pest\Arch\PendingArchExpectation;
use Pest\Arch\Support\Composer;
use Pest\Arch\Support\FileLineFinder;
use Pest\Concerns\Extendable;
use Pest\Concerns\Pipeable;
@ -136,7 +137,7 @@ final class Expectation
/**
* Dump the expectation value when the result of the condition is truthy.
*
* @param (\Closure(TValue): bool)|bool $condition
* @param (Closure(TValue): bool)|bool $condition
* @return self<TValue>
*/
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
@ -153,7 +154,7 @@ final class Expectation
/**
* Dump the expectation value when the result of the condition is falsy.
*
* @param (\Closure(TValue): bool)|bool $condition
* @param (Closure(TValue): bool)|bool $condition
* @return self<TValue>
*/
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
@ -669,6 +670,41 @@ final class Expectation
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
}
/**
* Asserts that the given expectation target is cased correctly.
*/
public function toBeCasedCorrectly(): ArchExpectation
{
return Targeted::make(
$this,
function (ObjectDescription $object): bool {
if (! isset($object->reflectionClass)) {
return false;
}
$realPath = realpath($object->path);
if ($realPath === false) {
return false;
}
foreach (Composer::userNamespacesWithDirectories() as $directory => $namespace) {
if (str_starts_with($realPath, $directory)) {
$relativePath = substr($realPath, strlen($directory) + 1);
$relativePath = explode('.', $relativePath)[0];
$classFromPath = $namespace.'\\'.str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
return $classFromPath === $object->reflectionClass->getName();
}
}
return false;
},
'to be cased correctly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target is enum.
*/
@ -783,7 +819,22 @@ final class Expectation
return false;
}
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
$currentClass = $object->reflectionClass;
$usedTraits = [];
do {
$classTraits = $currentClass->getTraits();
foreach ($classTraits as $traitReflection) {
$usedTraits[$traitReflection->getName()] = $traitReflection->getName();
$nestedTraits = $traitReflection->getTraits();
foreach ($nestedTraits as $nestedTrait) {
$usedTraits[$nestedTrait->getName()] = $nestedTrait->getName();
}
}
} while ($currentClass = $currentClass->getParentClass());
if (! array_key_exists($trait, $usedTraits)) {
return false;
}
}

View File

@ -17,6 +17,7 @@ use Pest\Factories\Concerns\HigherOrderable;
use Pest\Support\Reflection;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@ -58,6 +59,11 @@ final class TestCaseFactory
Concerns\Expectable::class,
];
/**
* The namespace for the test case, overrides the path-based namespace when set.
*/
public ?string $namespace = null;
/**
* Creates a new Factory instance.
*/
@ -110,8 +116,8 @@ final class TestCaseFactory
$relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath);
// Remove escaped quote sequences (maintain namespace)
$relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath);
// Limit to A-Z, a-z, 0-9, '_', '-'.
$relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath);
// Limit to Unicode letters and numbers.
$relativePath = (string) preg_replace('/[^\p{L}\p{N}\\\\]/u', '', $relativePath);
$classFQN = 'P\\'.$relativePath;
@ -126,7 +132,7 @@ final class TestCaseFactory
$partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN);
$namespace = $this->namespace ?? implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class);
if (trim($className) === '') {
@ -135,7 +141,7 @@ final class TestCaseFactory
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
TestDox::class,
[$this->filename],
),
...$this->attributes,

View File

@ -9,10 +9,14 @@ use Pest\Evaluators\Attributes;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Description;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Depends;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
/**
@ -32,7 +36,7 @@ final class TestCaseMethodFactory
/**
* The test's describing, if any.
*
* @var array<int, \Pest\Support\Description>
* @var array<int, Description>
*/
public array $describing = [];
@ -192,11 +196,11 @@ final class TestCaseMethodFactory
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\Test::class,
Test::class,
[],
),
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
TestDox::class,
[str_replace('*/', '{@*}', $this->description)],
),
...$this->attributes,
@ -206,7 +210,7 @@ final class TestCaseMethodFactory
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
$this->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Depends::class,
Depends::class,
[$depend],
);
}

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
use Pest\Browser\Api\ArrayablePendingAwaitablePage;
use Pest\Browser\Api\PendingAwaitablePage;
use Pest\Concerns\Expectable;
use Pest\Configuration;
use Pest\Exceptions\AfterAllWithinDescribe;
use Pest\Exceptions\BeforeAllWithinDescribe;
@ -62,8 +61,6 @@ if (! function_exists('beforeEach')) {
* Runs the given closure before each test in the current file.
*
* @param-closure-this TestCall $closure
*
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
*/
function beforeEach(?Closure $closure = null): BeforeEachCall
{
@ -92,8 +89,6 @@ if (! function_exists('describe')) {
* Adds the given closure as a group of tests. The first argument
* is the group description; the second argument is a closure
* that contains the group tests.
*
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
*/
function describe(string $description, Closure $tests): DescribeCall
{
@ -136,11 +131,11 @@ if (! function_exists('test')) {
*
* @param-closure-this TestCall $closure
*
* @return Expectable|TestCall|TestCase|mixed
* @return ($description is string ? TestCall : HigherOrderTapProxy|TestCall)
*/
function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall
{
if ($description === null && TestSuite::getInstance()->test instanceof \PHPUnit\Framework\TestCase) {
if ($description === null && TestSuite::getInstance()->test instanceof TestCase) {
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
}
@ -157,33 +152,22 @@ if (! function_exists('it')) {
* a closure that contains the test expectations.
*
* @param-closure-this TestCall $closure
*
* @return Expectable|TestCall|TestCase|mixed
*/
function it(string $description, ?Closure $closure = null): TestCall
{
$description = sprintf('it %s', $description);
/** @var TestCall $test */
$test = test($description, $closure);
return $test;
return test($description, $closure);
}
}
if (! function_exists('todo')) {
/**
* Creates a new test that is marked as "todo".
*
* @return Expectable|TestCall|TestCase|mixed
*/
function todo(string $description): TestCall
{
$test = test($description);
assert($test instanceof TestCall);
return $test->todo();
return test($description)->todo();
}
}
@ -192,8 +176,6 @@ if (! function_exists('afterEach')) {
* Runs the given closure after each test in the current file.
*
* @param-closure-this TestCall $closure
*
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|TestCall|mixed
*/
function afterEach(?Closure $closure = null): AfterEachCall
{
@ -236,7 +218,7 @@ if (! function_exists('covers')) {
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
@ -263,7 +245,7 @@ if (! function_exists('mutates')) {
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
@ -320,7 +302,7 @@ if (! function_exists('visit')) {
*/
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
{
if (! class_exists(\Pest\Browser\Configuration::class)) {
if (! class_exists(Pest\Browser\Configuration::class)) {
PluginBrowser::install();
exit(0);

View File

@ -151,7 +151,7 @@ final readonly class Converter
{
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$firstTest = $this->getFirstTest($testSuite);
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
if ($firstTest instanceof TestMethod) {
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
}
}
@ -179,7 +179,7 @@ final readonly class Converter
public function getTestSuiteLocation(TestSuite $testSuite): ?string
{
$firstTest = $this->getFirstTest($testSuite);
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
if (! $firstTest instanceof TestMethod) {
return null;
}
$path = $firstTest->testDox()->prettifiedClassName();

View File

@ -200,7 +200,7 @@ final class TeamCityLogger
public function testFinished(Finished $event): void
{
if (! $this->time instanceof \PHPUnit\Event\Telemetry\HRTime) {
if (! $this->time instanceof HRTime) {
throw ShouldNotHappen::fromMessage('Start time has not been set.');
}

View File

@ -9,6 +9,7 @@ use Closure;
use Countable;
use DateTimeInterface;
use Error;
use Illuminate\Testing\TestResponse;
use InvalidArgumentException;
use JsonSerializable;
use Pest\Exceptions\InvalidExpectationValue;
@ -842,7 +843,7 @@ final class Expectation
is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(),
is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(),
is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(),
$this->value instanceof \Illuminate\Testing\TestResponse => $this->value->getContent(), // @phpstan-ignore-line
$this->value instanceof TestResponse => $this->value->getContent(), // @phpstan-ignore-line
is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
@ -983,7 +984,7 @@ final class Expectation
*/
private function export(mixed $value): string
{
if (! $this->exporter instanceof \Pest\Support\Exporter) {
if (! $this->exporter instanceof Exporter) {
$this->exporter = Exporter::default();
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Pest\PHPStan;
use Pest\Expectations\HigherOrderExpectation;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\ExpressionTypeResolverExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
/**
* Prevents native declared properties of HigherOrderExpectation (like $original,
* $expectation, $opposite, $shouldReset) from being incorrectly resolved as
* higher-order value property accesses by downstream ExpressionTypeResolverExtensions.
*
* This extension must be registered BEFORE the peststan HigherOrderExpectationTypeExtension.
*
* @internal
*/
final readonly class HigherOrderExpectationTypeExtension implements ExpressionTypeResolverExtension
{
public function __construct(
private ReflectionProvider $reflectionProvider,
) {}
public function getType(Expr $expr, Scope $scope): ?Type
{
if (! $expr instanceof PropertyFetch || ! $expr->name instanceof Identifier) {
return null;
}
$varType = $scope->getType($expr->var);
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
return null;
}
if (! $this->reflectionProvider->hasClass(HigherOrderExpectation::class)) {
return null;
}
$propertyName = $expr->name->name;
$classReflection = $this->reflectionProvider->getClass(HigherOrderExpectation::class);
if (! $classReflection->hasNativeProperty($propertyName)) {
return null;
}
return $varType->getProperty($propertyName, $scope)->getReadableType();
}
}

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Pest\PendingCalls\Concerns;
use Pest\Support\Description;
/**
* @internal
*/
@ -12,14 +14,14 @@ trait Describable
/**
* Note: this is property is not used; however, it gets added automatically by rector php.
*
* @var array<int, \Pest\Support\Description>
* @var array<int, Description>
*/
public array $__describing;
/**
* The describing of the test case.
*
* @var array<int, \Pest\Support\Description>
* @var array<int, Description>
*/
public array $describing = [];
}

View File

@ -73,7 +73,7 @@ final class DescribeCall
{
$filename = Backtrace::file();
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) {
if (! $this->currentBeforeEachCall instanceof BeforeEachCall) {
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
$this->currentBeforeEachCall->describing[] = $this->description;

View File

@ -22,6 +22,10 @@ use Pest\Support\NullClosure;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\CoversTrait;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestCase;
/**
@ -211,7 +215,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($groups as $group) {
$this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Group::class,
Group::class,
[$group],
);
}
@ -604,7 +608,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($classes as $class) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversClass::class,
CoversClass::class,
[$class],
);
}
@ -627,7 +631,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($traits as $trait) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversTrait::class,
CoversTrait::class,
[$trait],
);
}
@ -650,7 +654,7 @@ final class TestCall // @phpstan-ignore-line
{
foreach ($functions as $function) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversFunction::class,
CoversFunction::class,
[$function],
);
}

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.3.2';
return '4.4.4';
}
function testDirectory(string $file = ''): string

View File

@ -56,4 +56,31 @@ trait HandleArguments
return array_values(array_flip($arguments));
}
/**
* Pops the given argument and its value from the arguments, returning the value.
*
* @param array<int, string> $arguments
*/
public function popArgumentValue(string $argument, array &$arguments): ?string
{
foreach ($arguments as $key => $value) {
if (str_contains($value, "$argument=")) {
unset($arguments[$key]);
$arguments = array_values($arguments);
return substr($value, strlen($argument) + 1);
}
if ($value === $argument && isset($arguments[$key + 1])) {
$result = $arguments[$key + 1];
unset($arguments[$key], $arguments[$key + 1]);
$arguments = array_values($arguments);
return $result;
}
}
return null;
}
}

View File

@ -23,6 +23,8 @@ final class Coverage implements AddsOutput, HandlesArguments
private const string EXACTLY_OPTION = 'exactly';
private const string ONLY_COVERED_OPTION = 'only-covered';
/**
* Whether it should show the coverage or not.
*/
@ -43,6 +45,11 @@ final class Coverage implements AddsOutput, HandlesArguments
*/
public ?float $coverageExactly = null;
/**
* Whether it should show only covered files.
*/
public bool $showOnlyCovered = false;
/**
* Creates a new Plugin instance.
*/
@ -57,7 +64,7 @@ final class Coverage implements AddsOutput, HandlesArguments
public function handleArguments(array $originals): array
{
$arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool {
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION] as $option) {
foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) {
if ($original === sprintf('--%s', $option)) {
return true;
}
@ -80,6 +87,7 @@ final class Coverage implements AddsOutput, HandlesArguments
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);
$inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED);
$inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED);
$inputs[] = new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE);
$input = new ArgvInput($arguments, new InputDefinition($inputs));
if ((bool) $input->getOption(self::COVERAGE_OPTION)) {
@ -120,6 +128,10 @@ final class Coverage implements AddsOutput, HandlesArguments
$this->coverageExactly = (float) $exactlyOption;
}
if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) {
$this->showOnlyCovered = true;
}
if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) {
$this->compact = true;
}
@ -144,7 +156,7 @@ final class Coverage implements AddsOutput, HandlesArguments
exit(1);
}
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact);
$coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered);
$exitCode = (int) ($coverage < $this->coverageMin);
if ($exitCode === 0 && $this->coverageExactly !== null) {

View File

@ -107,6 +107,13 @@ final readonly class Help implements HandlesArguments
'desc' => 'Initialise a standard Pest configuration',
]], ...$content['Configuration']];
$content['AI'] = [
[
'arg' => '--ai',
'desc' => 'Run a code snippet as a fully scaffolded test for AI verification',
],
];
$content['Execution'] = [...[
[
'arg' => '--parallel',
@ -160,6 +167,12 @@ final readonly class Help implements HandlesArguments
], [
'arg' => '--coverage --min',
'desc' => 'Set the minimum required coverage percentage, and fail if not met',
], [
'arg' => '--coverage --exactly',
'desc' => 'Set the exact required coverage percentage, and fail if not met',
], [
'arg' => '--coverage --only-covered',
'desc' => 'Hide files with 0% coverage from the code coverage report',
], ...$content['Code Coverage']];
$content['Mutation Testing'] = [[

View File

@ -127,7 +127,9 @@ final class Parallel implements HandlesArguments
$arguments
);
$exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput);
$filteredArguments = $this->processTeamcityArguments($filteredArguments);
$exitCode = $this->paratestCommand()->run(new ArgvInput(array_values($filteredArguments)), new CleanConsoleOutput);
return CallsAddsOutput::execute($exitCode);
}
@ -197,4 +199,18 @@ final class Parallel implements HandlesArguments
return $this->popArgument('-p', $arguments);
}
/**
* @param string[] $arguments
* @return string[]
*/
public function processTeamcityArguments(array $arguments): array
{
$argv = new ArgvInput;
if ($argv->hasParameterOption('--teamcity')) {
$arguments[] = '--teamcity';
}
return $arguments;
}
}

View File

@ -7,6 +7,7 @@ namespace Pest\Plugins\Parallel\Handlers;
use Closure;
use Composer\InstalledVersions;
use Illuminate\Testing\ParallelRunner;
use Orchestra\Testbench\TestCase;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use Pest\Contracts\Plugins\HandlesArguments;
@ -39,13 +40,13 @@ final class Laravel implements HandlesArguments
* Executes the given closure when running Laravel.
*
* @param array<int, string> $arguments
* @param CLosure(array<int, string>): array<int, string> $closure
* @param Closure(array<int, string>): array<int, string> $closure
* @return array<int, string>
*/
private function whenUsingLaravel(array $arguments, Closure $closure): array
{
$isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false);
$isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class);
$isLaravelPackage = class_exists(TestCase::class);
if ($isLaravelApplication && ! $isLaravelPackage) {
return $closure($arguments);

View File

@ -92,14 +92,13 @@ final class ResultPrinter
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
}
/** @param list<SplFileInfo> $teamcityFiles */
public function printFeedback(
SplFileInfo $progressFile,
SplFileInfo $outputFile,
array $teamcityFiles
?SplFileInfo $teamcityFile,
): void {
if ($this->options->needsTeamcity) {
$teamcityProgress = $this->tailMultiple($teamcityFiles);
if ($this->options->needsTeamcity && $teamcityFile instanceof SplFileInfo) {
$teamcityProgress = $this->tailMultiple([$teamcityFile]);
if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
@ -171,8 +170,18 @@ final class ResultPrinter
$state = (new StateGenerator)->fromPhpUnitTestResult($this->passedTests, $testResult);
$this->compactPrinter->errors($state);
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
if ($testResult->numberOfTestsRun() === 0 && $state->testSuiteTestsCount() === 0) {
$this->output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
'',
]);
}
if (! isset($_SERVER['PEST_PARALLEL_NO_OUTPUT'])) {
$this->compactPrinter->errors($state);
$this->compactPrinter->recap($state, $testResult, $duration, $this->options);
}
}
private function printFeedbackItem(string $item): void

View File

@ -39,6 +39,7 @@ use function dirname;
use function file_get_contents;
use function max;
use function realpath;
use function str_starts_with;
use function unlink;
use function unserialize;
use function usleep;
@ -51,6 +52,11 @@ final class WrapperRunner implements RunnerInterface
/**
* The time to sleep between cycles.
*/
/**
* The merged test result from the parallel run.
*/
public static ?TestResult $result = null;
private const int CYCLE_SLEEP = 10000;
/**
@ -131,6 +137,7 @@ final class WrapperRunner implements RunnerInterface
$parameters = $this->handleLaravelHerd($parameters);
$parameters[] = $wrapper;
$parameters[] = '--test-directory='.TestSuite::getInstance()->testPath;
$this->parameters = $parameters;
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry;
@ -225,7 +232,7 @@ final class WrapperRunner implements RunnerInterface
$this->printer->printFeedback(
$worker->progressFile,
$worker->unexpectedOutputFile,
$this->teamcityFiles,
$worker->teamcityFile ?? null,
);
$worker->reset();
}
@ -385,6 +392,8 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->numberOfIssuesIgnoredByBaseline(),
);
self::$result = $testResultSum;
if ($this->options->configuration->cacheResult()) {
$resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile());
foreach ($this->resultCacheFiles as $resultCacheFile) {
@ -483,15 +492,61 @@ final class WrapperRunner implements RunnerInterface
*/
private function getTestFiles(SuiteLoader $suiteLoader): array
{
/** @var array<string, non-empty-string> $files */
$files = [
...array_values(array_filter(
$suiteLoader->tests,
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
)),
...TestSuite::getInstance()->tests->getFilenames(),
];
/** @var array<string, null> $files */
$files = [];
return $files; // @phpstan-ignore-line
foreach (array_filter(
$suiteLoader->tests,
fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code")
) as $filename) {
$resolved = realpath($filename) ?: $filename;
$files[$resolved] = null;
}
foreach (TestSuite::getInstance()->tests->getFilenames() as $filename) {
if ($this->shouldIncludeBootstrappedTestFile($filename)) {
$resolved = realpath($filename)
?: realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename)
?: $filename;
$files[$resolved] = null;
}
}
return array_keys($files); // @phpstan-ignore-line
}
private function shouldIncludeBootstrappedTestFile(string $filename): bool
{
if (! $this->options->configuration->hasCliArguments()) {
return true;
}
$resolvedFilename = realpath($filename);
if ($resolvedFilename === false) {
$resolvedFilename = realpath($this->options->cwd.DIRECTORY_SEPARATOR.$filename);
}
if ($resolvedFilename === false) {
return false;
}
foreach ($this->options->configuration->cliArguments() as $path) {
$resolvedPath = realpath($path);
if ($resolvedPath === false) {
continue;
}
if ($resolvedFilename === $resolvedPath) {
return true;
}
if (is_dir($resolvedPath) && str_starts_with($resolvedFilename, $resolvedPath.DIRECTORY_SEPARATOR)) {
return true;
}
}
return false;
}
}

View File

@ -113,6 +113,16 @@ final class TestRepository
$this->testCaseMethodFilters[] = $filter;
}
/**
* Gets the class and traits configured for the given directory path.
*
* @return array<int, string>
*/
public function getUsesForPath(string $path): array
{
return $this->uses[$path][0] ?? [];
}
/**
* Gets the test case factory from the given filename.
*/

View File

@ -19,14 +19,14 @@ final class Closure
*/
public static function bind(?BaseClosure $closure, ?object $newThis, object|string|null $newScope = 'static'): BaseClosure
{
if (! $closure instanceof \Closure) {
if (! $closure instanceof BaseClosure) {
throw ShouldNotHappen::fromMessage('Could not bind null closure.');
}
// @phpstan-ignore-next-line
$closure = BaseClosure::bind($closure, $newThis, $newScope);
if (! $closure instanceof \Closure) {
if (! $closure instanceof BaseClosure) {
throw ShouldNotHappen::fromMessage('Could not bind closure.');
}

View File

@ -28,7 +28,7 @@ final class Container
*/
public static function getInstance(): self
{
if (! self::$instance instanceof \Pest\Support\Container) {
if (! self::$instance instanceof Container) {
self::$instance = new self;
}

View File

@ -74,7 +74,7 @@ final class Coverage
* Reports the code coverage report to the
* console and returns the result in float.
*/
public static function report(OutputInterface $output, bool $compact = false): float
public static function report(OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float
{
if (! file_exists($reportPath = self::getPath())) {
if (self::usingXdebug()) {
@ -109,6 +109,10 @@ final class Coverage
$basename,
]);
if ($showOnlyCovered && $file->percentageOfExecutedLines()->asFloat() === 0.0) {
continue;
}
$percentage = $file->numberOfExecutableLines() === 0
? '100.0'
: number_format($file->percentageOfExecutedLines()->asFloat(), 1, '.', '');

View File

@ -17,7 +17,7 @@ final class DatasetInfo
public static function isInsideADatasetsDirectory(string $file): bool
{
return basename(dirname($file)) === self::DATASETS_DIR_NAME;
return in_array(self::DATASETS_DIR_NAME, self::directorySegmentsInsideTestsDirectory($file), true);
}
public static function isADatasetsFile(string $file): bool
@ -32,7 +32,23 @@ final class DatasetInfo
}
if (self::isInsideADatasetsDirectory($file)) {
return dirname($file, 2);
$scope = [];
foreach (self::directorySegmentsInsideTestsDirectory($file) as $segment) {
if ($segment === self::DATASETS_DIR_NAME) {
break;
}
$scope[] = $segment;
}
$testsDirectoryPath = self::testsDirectoryPath($file);
if ($scope === []) {
return $testsDirectoryPath;
}
return $testsDirectoryPath.DIRECTORY_SEPARATOR.implode(DIRECTORY_SEPARATOR, $scope);
}
if (self::isADatasetsFile($file)) {
@ -41,4 +57,45 @@ final class DatasetInfo
return $file;
}
/**
* @return list<string>
*/
private static function directorySegmentsInsideTestsDirectory(string $file): array
{
$directory = dirname(self::pathInsideTestsDirectory($file));
if ($directory === '.' || $directory === DIRECTORY_SEPARATOR) {
return [];
}
return array_values(array_filter(
explode(DIRECTORY_SEPARATOR, trim($directory, DIRECTORY_SEPARATOR)),
static fn (string $segment): bool => $segment !== '',
));
}
private static function pathInsideTestsDirectory(string $file): string
{
$testsDirectory = DIRECTORY_SEPARATOR.trim(testDirectory(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$position = strrpos($file, $testsDirectory);
if ($position === false) {
return $file;
}
return substr($file, $position + strlen($testsDirectory));
}
private static function testsDirectoryPath(string $file): string
{
$testsDirectory = DIRECTORY_SEPARATOR.trim(testDirectory(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$position = strrpos($file, $testsDirectory);
if ($position === false) {
return dirname($file);
}
return substr($file, 0, $position + strlen($testsDirectory) - 1);
}
}

View File

@ -26,6 +26,7 @@ final class ExceptionTrace
return $closure();
} catch (Throwable $throwable) {
if (Str::startsWith($message = $throwable->getMessage(), self::UNDEFINED_METHOD)) {
// @phpstan-ignore-next-line
$class = preg_match('/^Call to undefined method ([^:]+)::/', $message, $matches) === false ? null : $matches[1];
$message = str_replace(self::UNDEFINED_METHOD, 'Call to undefined method ', $message);

View File

@ -8,6 +8,7 @@ use Closure;
use InvalidArgumentException;
use Pest\Exceptions\ShouldNotHappen;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
@ -66,7 +67,7 @@ final class Reflection
{
$test = TestSuite::getInstance()->test;
if (! $test instanceof \PHPUnit\Framework\TestCase) {
if (! $test instanceof TestCase) {
return self::bindCallable($callable);
}
@ -221,7 +222,7 @@ final class Reflection
{
$getProperties = fn (ReflectionClass $reflectionClass): array => array_filter(
array_map(
fn (ReflectionProperty $property): \ReflectionProperty => $property,
fn (ReflectionProperty $property): ReflectionProperty => $property,
$reflectionClass->getProperties(),
), fn (ReflectionProperty $property): bool => $property->getDeclaringClass()->getName() === $reflectionClass->getName(),
);
@ -256,7 +257,7 @@ final class Reflection
{
$getMethods = fn (ReflectionClass $reflectionClass): array => array_filter(
array_map(
fn (ReflectionMethod $method): \ReflectionMethod => $method,
fn (ReflectionMethod $method): ReflectionMethod => $method,
$reflectionClass->getMethods($filter),
), fn (ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() === $reflectionClass->getName(),
);

View File

@ -11,6 +11,10 @@ use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered;
use PHPUnit\Event\Test\PhpunitWarningTriggered;
use PHPUnit\Event\TestData\TestDataCollection;
use PHPUnit\Framework\SkippedWithMessageException;
use PHPUnit\Metadata\MetadataCollection;
@ -43,6 +47,8 @@ final class StateGenerator
));
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);
foreach ($testResult->testMarkedIncompleteEvents() as $testResultEvent) {
$state->add(TestResult::fromPestParallelTestCase(
$testResultEvent->test(),
@ -99,6 +105,8 @@ final class StateGenerator
}
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitDeprecationEvents(), TestResult::DEPRECATED);
foreach ($testResult->notices() as $testResultEvent) {
foreach ($testResultEvent->triggeringTests() as $triggeringTest) {
['test' => $test] = $triggeringTest;
@ -123,6 +131,8 @@ final class StateGenerator
}
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitNoticeEvents(), TestResult::NOTICE);
foreach ($testResult->warnings() as $testResultEvent) {
foreach ($testResultEvent->triggeringTests() as $triggeringTest) {
['test' => $test] = $triggeringTest;
@ -135,6 +145,8 @@ final class StateGenerator
}
}
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitWarningEvents(), TestResult::WARN);
foreach ($testResult->phpWarnings() as $testResultEvent) {
foreach ($testResultEvent->triggeringTests() as $triggeringTest) {
['test' => $test] = $triggeringTest;
@ -165,4 +177,24 @@ final class StateGenerator
return $state;
}
/**
* @param array<string, list<PhpunitDeprecationTriggered|PhpunitErrorTriggered|PhpunitNoticeTriggered|PhpunitWarningTriggered>> $testResultEvents
*/
private function addTriggeredPhpunitEvents(State $state, array $testResultEvents, string $type): void
{
foreach ($testResultEvents as $events) {
foreach ($events as $event) {
if (! $event->test()->isTestMethod()) {
continue;
}
$state->add(TestResult::fromPestParallelTestCase(
$event->test(),
$type,
ThrowableBuilder::from(new TestOutcome($event->message()))
));
}
}
}
}