Merge branch '4.x' into 3.x

This commit is contained in:
nuno maduro
2025-07-26 03:56:09 +01:00
committed by GitHub
84 changed files with 1502 additions and 437 deletions

View File

@ -17,7 +17,7 @@ final class BootExcludeList implements Bootstrapper
*
* @var array<int, non-empty-string>
*/
private const EXCLUDE_LIST = [
private const array EXCLUDE_LIST = [
'bin',
'overrides',
'resources',

View File

@ -24,7 +24,7 @@ final class BootFiles implements Bootstrapper
*
* @var array<int, string>
*/
private const STRUCTURE = [
private const array STRUCTURE = [
'Expectations',
'Expectations.php',
'Helpers',

View File

@ -15,17 +15,18 @@ final class BootOverrides implements Bootstrapper
/**
* The list of files to be overridden.
*
* @var array<string, string>
* @var array<int, string>
*/
public const FILES = [
'53c246e5f416a39817ac81124cdd64ea8403038d01d7a202e1ffa486fbdf3fa7' => 'Runner/Filter/NameFilterIterator.php',
'77ffb7647b583bd82e37962c6fbdc4b04d3344d8a2c1ed103e625ed1ff7cb5c2' => 'Runner/ResultCache/DefaultResultCache.php',
'd0e81317889ad88c707db4b08a94cadee4c9010d05ff0a759f04e71af5efed89' => 'Runner/TestSuiteLoader.php',
'3bb609b0d3bf6dee8df8d6cd62a3c8ece823c4bb941eaaae39e3cb267171b9d2' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'8abdad6413329c6fe0d7d44a8b9926e390af32c0b3123f3720bb9c5bbc6fbb7e' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'b4250fc3ffad5954624cb5e682fd940b874e8d3422fa1ee298bd7225e1aa5fc2' => 'TextUI/TestSuiteFilterProcessor.php',
'8cfcb4999af79463eca51a42058e502ea4ddc776cba5677bf2f8eb6093e21a5c' => 'Event/Value/ThrowableBuilder.php',
'86cd9bcaa53cdd59c5b13e58f30064a015c549501e7629d93b96893d4dee1eb1' => 'Logging/JUnit/JunitXmlLogger.php',
public const array FILES = [
'Runner/Filter/NameFilterIterator.php',
'Runner/ResultCache/DefaultResultCache.php',
'Runner/TestSuiteLoader.php',
'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php',
'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php',
'TextUI/TestSuiteFilterProcessor.php',
'Event/Value/ThrowableBuilder.php',
'Logging/JUnit/JunitXmlLogger.php',
'Report/PHP.php',
];
/**

View File

@ -20,7 +20,7 @@ final readonly class BootSubscribers implements Bootstrapper
*
* @var array<int, class-string<Subscriber>>
*/
private const SUBSCRIBERS = [
private const array SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class,

View File

@ -6,10 +6,12 @@ namespace Pest\Concerns;
use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Preset;
use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\TestCase;
@ -101,27 +103,6 @@ trait Testable
*/
private array $__snapshotChanges = [];
/**
* Creates a new Test Case instance.
*/
public function __construct(string $name)
{
parent::__construct($name);
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure();
}
}
/**
* Resets the test case static properties.
*/
@ -214,7 +195,11 @@ trait Testable
$beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll);
}
call_user_func(Closure::bind($beforeAll, null, self::class));
try {
call_user_func(Closure::bind($beforeAll, null, self::class));
} catch (Throwable $e) {
Panic::with($e);
}
}
/**
@ -242,8 +227,6 @@ trait Testable
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$method->setUp($this);
$description = $method->description;
if ($this->dataName()) {
$description = str_contains((string) $description, ':dataset')
@ -285,6 +268,33 @@ trait Testable
$this->__callClosure($beforeEach, $arguments);
}
/**
* Initialize test case properties from TestSuite.
*/
public function __initializeTestCase(): void
{
// Return if the test case has already been initialized
if (isset($this->__test)) {
return;
}
$name = $this->name();
$test = TestSuite::getInstance()->tests->get(self::$__filename);
if ($test->hasMethod($name)) {
$method = $test->getMethod($name);
$this->__description = self::$__latestDescription = $method->description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
$this->__describing = $method->describing;
$this->__test = $method->getClosure();
$method->setUp($this);
}
}
/**
* Gets executed after the Test Case.
*/
@ -434,15 +444,7 @@ trait Testable
return;
}
if (count($this->__snapshotChanges) === 1) {
$this->markTestIncomplete($this->__snapshotChanges[0]);
return;
}
$messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges));
$this->markTestIncomplete($messages);
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
}
/**
@ -466,7 +468,7 @@ trait Testable
*/
public static function getLatestPrintableTestCaseMethodName(): string
{
return self::$__latestDescription;
return self::$__latestDescription ?? '';
}
/**
@ -481,4 +483,12 @@ trait Testable
'notes' => self::$__latestNotes,
];
}
/**
* Opens a shell for the test case.
*/
public function shell(): void
{
Shell::open();
}
}

View File

@ -102,6 +102,14 @@ final readonly class Configuration
return Configuration\Project::getInstance();
}
/**
* Gets the browser configuration.
*/
public function browser(): Browser\Configuration
{
return new Browser\Configuration;
}
/**
* Proxies calls to the uses method.
*

View File

@ -16,7 +16,7 @@ final readonly class Help
*
* @var array<int, string>
*/
private const HELP_MESSAGES = [
private const array HELP_MESSAGES = [
'<comment>Pest Options:</comment>',
' <info>--init</info> Initialise a standard Pest configuration',
' <info>--coverage</info> Enable coverage and output to standard output',

View File

@ -22,10 +22,14 @@ final readonly class Thanks
*
* @var array<string, string>
*/
private const FUNDING_MESSAGES = [
private const array FUNDING_MESSAGES = [
'Star' => 'https://github.com/pestphp/pest',
'News' => 'https://twitter.com/pestphp',
'Videos' => 'https://youtube.com/@nunomaduro',
'YouTube' => 'https://youtube.com/@nunomaduro',
'TikTok' => 'https://tiktok.com/@nunomaduro',
'Twitch' => 'https://twitch.tv/enunomaduro',
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
'Instagram' => 'https://instagram.com/enunomaduro',
'X' => 'https://x.com/enunomaduro',
'Sponsor' => 'https://github.com/sponsors/nunomaduro',
];

View File

@ -535,7 +535,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isFinal(),
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isFinal(),
'to be final',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -548,7 +548,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
'to be readonly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -561,7 +561,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isTrait(),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isTrait(),
'to be trait',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -582,7 +582,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isAbstract(),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isAbstract(),
'to be abstract',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -599,7 +599,7 @@ final class Expectation
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => $object->reflectionClass->hasMethod($method))) === count($methods),
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod($method))) === count($methods),
sprintf("to have method '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -670,7 +670,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum(),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isEnum(),
'to be enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -712,7 +712,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isInterface(),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isInterface(),
'to be interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -733,7 +733,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && ($class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class)),
sprintf("to extend '%s'", $class),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -773,6 +773,10 @@ final class Expectation
$this,
function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (isset($object->reflectionClass) === false) {
return false;
}
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
@ -792,7 +796,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() === [],
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getInterfaceNames() === [],
'to implement nothing',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -809,7 +813,8 @@ final class Expectation
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => count($interfaces) === count($object->reflectionClass->getInterfaceNames())
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& (count($interfaces) === count($object->reflectionClass->getInterfaceNames()))
&& array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [],
"to only implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
@ -823,7 +828,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => str_starts_with($object->reflectionClass->getShortName(), $prefix),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_starts_with($object->reflectionClass->getShortName(), $prefix),
"to have prefix '{$prefix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -836,7 +841,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => str_ends_with($object->reflectionClass->getName(), $suffix),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_ends_with($object->reflectionClass->getName(), $suffix),
"to have suffix '{$suffix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -855,7 +860,7 @@ final class Expectation
$this,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (! $object->reflectionClass->implementsInterface($interface)) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
@ -928,7 +933,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->hasMethod('__invoke'),
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod('__invoke'),
'to be invokable',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
@ -1037,7 +1042,7 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) !== [],
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [],
"to have attribute '{$attribute}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -1066,7 +1071,8 @@ final class Expectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum()
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum()
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum',

View File

@ -74,7 +74,10 @@ final readonly class OppositeExpectation
*/
public function toUse(array|string $targets): ArchExpectation
{
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($this->original, $target)->opposite(
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($original, $target)->opposite(
fn () => $this->throwExpectationFailedException('toUse', $target),
), is_string($targets) ? [$targets] : $targets));
}
@ -84,8 +87,11 @@ final readonly class OppositeExpectation
*/
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) !== $permissions,
sprintf('permissions not to be [%s]', $permissions),
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
@ -105,8 +111,11 @@ final readonly class OppositeExpectation
*/
public function toHaveMethodsDocumented(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| array_filter(
Reflection::getMethodsFromReflectionClass($object->reflectionClass),
@ -124,8 +133,11 @@ final readonly class OppositeExpectation
*/
public function toHavePropertiesDocumented(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| array_filter(
Reflection::getPropertiesFromReflectionClass($object->reflectionClass),
@ -144,8 +156,11 @@ final readonly class OppositeExpectation
*/
public function toUseStrictTypes(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => ! (bool) preg_match('/^<\?php\s+declare\(.*?strict_types\s?=\s?1.*?\);/', (string) file_get_contents($object->path)),
'not to use strict types',
FileLineFinder::where(fn (string $line): bool => str_contains($line, '<?php')),
@ -157,8 +172,11 @@ final readonly class OppositeExpectation
*/
public function toUseStrictEquality(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => ! str_contains((string) file_get_contents($object->path), ' === ') && ! str_contains((string) file_get_contents($object->path), ' !== '),
'to use strict equality',
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' === ') || str_contains($line, ' !== ')),
@ -170,9 +188,12 @@ final readonly class OppositeExpectation
*/
public function toBeFinal(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isFinal(),
$original,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isFinal()),
'not to be final',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -183,9 +204,12 @@ final readonly class OppositeExpectation
*/
public function toBeReadonly(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
$original,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isReadOnly()) && assert(true), // @phpstan-ignore-line
'not to be readonly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -196,9 +220,12 @@ final readonly class OppositeExpectation
*/
public function toBeTrait(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isTrait(),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isTrait(),
'not to be trait',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -217,9 +244,12 @@ final readonly class OppositeExpectation
*/
public function toBeAbstract(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isAbstract(),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isAbstract(),
'not to be abstract',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -234,11 +264,14 @@ final readonly class OppositeExpectation
{
$methods = is_array($method) ? $method : [$method];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => array_filter(
$methods,
fn (string $method): bool => $object->reflectionClass->hasMethod($method),
fn (string $method): bool => isset($object->reflectionClass) === false || $object->reflectionClass->hasMethod($method),
) === [],
'to not have methods: '.implode(', ', $methods),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
@ -266,8 +299,11 @@ final readonly class OppositeExpectation
$state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC)
@ -309,8 +345,11 @@ final readonly class OppositeExpectation
$state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED)
@ -352,8 +391,11 @@ final readonly class OppositeExpectation
$state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE)
@ -389,9 +431,12 @@ final readonly class OppositeExpectation
*/
public function toBeEnum(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum(),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isEnum(),
'not to be enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -410,8 +455,11 @@ final readonly class OppositeExpectation
*/
public function toBeClass(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
fn (ObjectDescription $object): bool => ! class_exists($object->name),
'not to be class',
FileLineFinder::where(fn (string $line): bool => true),
@ -431,9 +479,12 @@ final readonly class OppositeExpectation
*/
public function toBeInterface(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isInterface(),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isInterface(),
'not to be interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -452,9 +503,12 @@ final readonly class OppositeExpectation
*/
public function toExtend(string $class): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isSubclassOf($class),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isSubclassOf($class),
sprintf("not to extend '%s'", $class),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -465,9 +519,12 @@ final readonly class OppositeExpectation
*/
public function toExtendNothing(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() !== false,
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getParentClass() !== false,
'to extend a class',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -490,11 +547,14 @@ final readonly class OppositeExpectation
{
$traits = is_array($traits) ? $traits : [$traits];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
@ -515,11 +575,14 @@ final readonly class OppositeExpectation
{
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
$original,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if ($object->reflectionClass->implementsInterface($interface)) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
@ -536,9 +599,12 @@ final readonly class OppositeExpectation
*/
public function toImplementNothing(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() !== [],
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getInterfaceNames() !== [],
'to implement an interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -557,9 +623,12 @@ final readonly class OppositeExpectation
*/
public function toHavePrefix(string $prefix): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_starts_with($object->reflectionClass->getShortName(), $prefix),
"not to have prefix '{$prefix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -570,9 +639,12 @@ final readonly class OppositeExpectation
*/
public function toHaveSuffix(string $suffix): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! str_ends_with($object->reflectionClass->getName(), $suffix),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_ends_with($object->reflectionClass->getName(), $suffix),
"not to have suffix '{$suffix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -599,7 +671,10 @@ final readonly class OppositeExpectation
*/
public function toBeUsed(): ArchExpectation
{
return ToBeUsedInNothing::make($this->original);
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return ToBeUsedInNothing::make($original);
}
/**
@ -609,7 +684,10 @@ final readonly class OppositeExpectation
*/
public function toBeUsedIn(array|string $targets): ArchExpectation
{
return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($this->original, $target)->opposite(
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($original, $target)->opposite(
fn () => $this->throwExpectationFailedException('toBeUsedIn', $target),
), is_string($targets) ? [$targets] : $targets));
}
@ -632,9 +710,12 @@ final readonly class OppositeExpectation
*/
public function toBeInvokable(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->hasMethod('__invoke'),
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->hasMethod('__invoke'),
'to not be invokable',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
@ -645,9 +726,12 @@ final readonly class OppositeExpectation
*/
public function toHaveAttribute(string $attribute): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) === [],
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getAttributes($attribute) === [],
"to not have attribute '{$attribute}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
@ -737,9 +821,13 @@ final readonly class OppositeExpectation
*/
private function toBeBackedEnum(string $backingType): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$this->original,
fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum()
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $object->reflectionClass->isEnum()
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
'not to be '.$backingType.' backed enum',

View File

@ -155,7 +155,7 @@ final class TestCaseMethodFactory
assert($testCase instanceof TestCaseFactory);
$method = $this;
return function (...$arguments) use ($testCase, $method, $closure): mixed { // @phpstan-ignore-line
return function (...$arguments) use ($testCase, $method, $closure): mixed {
/* @var TestCase $this */
$testCase->proxies->proxy($this);
$method->proxies->proxy($this);

View File

@ -2,11 +2,14 @@
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;
use Pest\Expectation;
use Pest\Installers\PluginBrowser;
use Pest\Mutate\Contracts\MutationTestRunner;
use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\PendingCalls\AfterEachCall;
@ -278,3 +281,51 @@ if (! function_exists('mutates')) {
}
}
}
if (! function_exists('fixture')) {
/**
* Returns the absolute path to a fixture file.
*/
function fixture(string $file): string
{
$file = implode(DIRECTORY_SEPARATOR, [
TestSuite::getInstance()->rootPath,
TestSuite::getInstance()->testPath,
'Fixtures',
str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file),
]);
$fileRealPath = realpath($file);
if ($fileRealPath === false) {
throw new InvalidArgumentException(
'The fixture file ['.$file.'] does not exist.',
);
}
return $fileRealPath;
}
}
if (! function_exists('visit')) {
/**
* Browse to the given URL.
*
* @template TUrl of array<int, string>|string
*
* @param TUrl $url
* @param array<string, mixed> $options
* @return (TUrl is array<int, string> ? ArrayablePendingAwaitablePage : PendingAwaitablePage)
*/
function visit(array|string $url, array $options = []): ArrayablePendingAwaitablePage|PendingAwaitablePage
{
if (! class_exists(\Pest\Browser\Configuration::class)) {
PluginBrowser::install();
exit(0);
}
// @phpstan-ignore-next-line
return test()->visit($url, $options);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Installers;
use Pest\Support\View;
final readonly class PluginBrowser
{
public static function install(): void
{
View::render('installers/plugin-browser');
}
}

View File

@ -34,7 +34,7 @@ final readonly class Kernel
*
* @var array<int, class-string>
*/
private const BOOTSTRAPPERS = [
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,

View File

@ -31,7 +31,7 @@ final readonly class Converter
/**
* The prefix for the test suite name.
*/
private const PREFIX = 'P\\';
private const string PREFIX = 'P\\';
/**
* The state generator.

View File

@ -183,7 +183,6 @@ final class Expectation
{
foreach ($needles as $needle) {
if (is_string($this->value)) {
// @phpstan-ignore-next-line
Assert::assertStringContainsString((string) $needle, $this->value);
} else {
if (! is_iterable($this->value)) {
@ -1159,4 +1158,21 @@ final class Expectation
return $this;
}
/**
* Asserts that the value can be converted to a slug
*
* @return self<TValue>
*/
public function toBeSlug(string $message = ''): self
{
if ($message === '') {
$message = "Failed asserting that {$this->value} can be converted to a slug.";
}
$slug = Str::slugify((string) $this->value);
Assert::assertNotEmpty($slug, $message);
return $this;
}
}

View File

@ -46,7 +46,7 @@ final readonly class Panic
{
try {
$output = Container::getInstance()->get(OutputInterface::class);
} catch (Throwable) { // @phpstan-ignore-line
} catch (Throwable) {
$output = new ConsoleOutput;
}

View File

@ -78,7 +78,7 @@ final class DescribeCall
$this->currentBeforeEachCall->describing[] = $this->description;
}
$this->currentBeforeEachCall->{$name}(...$arguments); // @phpstan-ignore-line
$this->currentBeforeEachCall->{$name}(...$arguments);
return $this;
}

View File

@ -12,6 +12,7 @@ use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Mutate\Repositories\ConfigurationRepository;
use Pest\PendingCalls\Concerns\Describable;
use Pest\Plugins\Environment;
use Pest\Plugins\Only;
use Pest\Support\Backtrace;
use Pest\Support\Container;
@ -178,10 +179,9 @@ final class TestCall // @phpstan-ignore-line
}
/**
* Runs the current test multiple times with
* each item of the given `iterable`.
* Runs the current test multiple times with each item of the given `iterable`.
*
* @param array<\Closure|iterable<int|string, mixed>|string> $data
* @param Closure|iterable<array-key, mixed>|string $data
*/
public function with(Closure|iterable|string ...$data): self
{
@ -224,7 +224,7 @@ final class TestCall // @phpstan-ignore-line
*/
public function only(): self
{
Only::enable($this, ...func_get_args()); // @phpstan-ignore-line
Only::enable($this, ...func_get_args());
return $this;
}
@ -315,6 +315,61 @@ final class TestCall // @phpstan-ignore-line
: $this;
}
/**
* Weather the current test is running on a CI environment.
*/
private function runningOnCI(): bool
{
foreach ([
'CI',
'GITHUB_ACTIONS',
'GITLAB_CI',
'CIRCLECI',
'TRAVIS',
'APPVEYOR',
'BITBUCKET_BUILD_NUMBER',
'BUILDKITE',
'TEAMCITY_VERSION',
'JENKINS_URL',
'SYSTEM_COLLECTIONURI',
'CI_NAME',
'TASKCLUSTER_ROOT_URL',
'DRONE',
'WERCKER',
'NEVERCODE',
'SEMAPHORE',
'NETLIFY',
'NOW_BUILDER',
] as $env) {
if (getenv($env) !== false) {
return true;
}
}
return Environment::name() === Environment::CI;
}
/**
* Skips the current test when running on a CI environments.
*/
public function skipOnCI(): self
{
if ($this->runningOnCI()) {
return $this->skip('This test is skipped on [CI].');
}
return $this;
}
public function skipLocally(): self
{
if ($this->runningOnCI() === false) {
return $this->skip('This test is skipped [locally].');
}
return $this;
}
/**
* Skips the current test unless the given test is running on Windows.
*/
@ -616,6 +671,30 @@ final class TestCall // @phpstan-ignore-line
return $this;
}
/**
* Adds one or more references to the tested method or class. This helps
* to link test cases to the source code for easier navigation.
*
* @param array<class-string|string>|class-string ...$classes
*/
public function references(string|array ...$classes): self
{
assert($classes !== []);
return $this;
}
/**
* Adds one or more references to the tested method or class. This helps
* to link test cases to the source code for easier navigation.
*
* @param array<class-string|string>|class-string ...$classes
*/
public function see(string|array ...$classes): self
{
return $this->references(...$classes);
}
/**
* Informs the test runner that no expectations happen in this test,
* and its purpose is simply to check whether the given code can

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '3.7.4';
return '4.0.0-alpha.6';
}
function testDirectory(string $file = ''): string

View File

@ -21,7 +21,7 @@ final class Cache implements HandlesArguments
/**
* The temporary folder.
*/
private const TEMPORARY_FOLDER = __DIR__
private const string TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR

View File

@ -21,7 +21,7 @@ final class Configuration implements HandlesArguments, Terminable
/**
* The base PHPUnit file.
*/
public const BASE_PHPUNIT_FILE = __DIR__
public const string BASE_PHPUNIT_FILE = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
@ -34,7 +34,7 @@ final class Configuration implements HandlesArguments, Terminable
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgument('--configuration', $arguments) || $this->hasCustomConfigurationFile()) {
if ($this->hasArgument('--configuration', $arguments) || $this->hasArgument('-c', $arguments) || $this->hasCustomConfigurationFile()) {
return $arguments;
}

View File

@ -17,20 +17,11 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
final class Coverage implements AddsOutput, HandlesArguments
{
/**
* @var string
*/
private const COVERAGE_OPTION = 'coverage';
private const string COVERAGE_OPTION = 'coverage';
/**
* @var string
*/
private const MIN_OPTION = 'min';
private const string MIN_OPTION = 'min';
/**
* @var string
*/
private const EXACTLY_OPTION = 'exactly';
private const string EXACTLY_OPTION = 'exactly';
/**
* Whether it should show the coverage or not.

View File

@ -14,12 +14,12 @@ final class Environment implements HandlesArguments
/**
* The continuous integration environment.
*/
public const CI = 'ci';
public const string CI = 'ci';
/**
* The local environment.
*/
public const LOCAL = 'local';
public const string LOCAL = 'local';
/**
* The current environment.

View File

@ -20,12 +20,12 @@ final readonly class Init implements HandlesArguments
/**
* The option the triggers the init job.
*/
private const INIT_OPTION = '--init';
private const string INIT_OPTION = '--init';
/**
* The files that will be created.
*/
private const STUBS = [
private const array STUBS = [
'phpunit.xml.stub' => 'phpunit.xml',
'Pest.php.stub' => 'tests/Pest.php',
'TestCase.php.stub' => 'tests/TestCase.php',
@ -119,6 +119,6 @@ final readonly class Init implements HandlesArguments
*/
private function isLaravelInstalled(): bool
{
return InstalledVersions::isInstalled('laravel/laravel');
return InstalledVersions::isInstalled('laravel/framework');
}
}

View File

@ -5,7 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\Terminable;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseMethodFactory;
use Pest\PendingCalls\TestCall;
use PHPUnit\Framework\Attributes\Group;
/**
* @internal
@ -15,7 +18,7 @@ final class Only implements Terminable
/**
* The temporary folder.
*/
private const TEMPORARY_FOLDER = __DIR__
private const string TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
@ -23,28 +26,19 @@ final class Only implements Terminable
.DIRECTORY_SEPARATOR
.'.temp';
/**
* {@inheritDoc}
*/
public function terminate(): void
{
if (Parallel::isWorker()) {
return;
}
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
/**
* Creates the lock file.
*/
public static function enable(TestCall $testCall, string $group = '__pest_only'): void
public static function enable(TestCall|TestCaseMethodFactory $testCall, string $group = '__pest_only'): void
{
$testCall->group($group);
if ($testCall instanceof TestCall) {
$testCall->group($group);
} else {
$testCall->attributes[] = new Attribute(
Group::class,
[$group],
);
}
if (Environment::name() === Environment::CI || Parallel::isWorker()) {
return;
@ -88,4 +82,20 @@ final class Only implements Terminable
return file_get_contents($lockFile) ?: '__pest_only'; // @phpstan-ignore-line
}
/**
* {@inheritDoc}
*/
public function terminate(): void
{
if (Parallel::isWorker()) {
return;
}
$lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock';
if (file_exists($lockFile)) {
unlink($lockFile);
}
}
}

View File

@ -23,9 +23,9 @@ final class Parallel implements HandlesArguments
{
use HandleArguments;
private const GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
private const string GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_';
private const HANDLERS = [
private const array HANDLERS = [
Parallel\Handlers\Parallel::class,
Parallel\Handlers\Pest::class,
Parallel\Handlers\Laravel::class,
@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments
/**
* @var string[]
*/
private const UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request'];
private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request'];
/**
* Whether the given command line arguments indicate that the test suite should be run in parallel.

View File

@ -18,7 +18,7 @@ final class Parallel implements HandlesArguments
/**
* The list of arguments to remove.
*/
private const ARGS_TO_REMOVE = [
private const array ARGS_TO_REMOVE = [
'--parallel',
'-p',
'--no-output',

View File

@ -11,6 +11,7 @@ final class CleanConsoleOutput extends ConsoleOutput
/**
* {@inheritdoc}
*/
#[\Override]
protected function doWrite(string $message, bool $newline): void // @pest-arch-ignore-line
{
if ($this->isOpeningHeadline($message)) {

View File

@ -59,10 +59,10 @@ final class ResultPrinter
private readonly OutputInterface $output,
private readonly Options $options
) {
$this->printer = new class($this->output) implements Printer
$this->printer = new readonly class($this->output) implements Printer
{
public function __construct(
private readonly OutputInterface $output,
private OutputInterface $output,
) {}
public function print(string $buffer): void

View File

@ -17,6 +17,7 @@ use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Result;
use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\TestRunner\WarningTriggered;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\ResultCache\DefaultResultCache;
@ -50,7 +51,7 @@ final class WrapperRunner implements RunnerInterface
/**
* The time to sleep between cycles.
*/
private const CYCLE_SLEEP = 10000;
private const int CYCLE_SLEEP = 10000;
/**
* The result printer.
@ -313,27 +314,42 @@ final class WrapperRunner implements RunnerInterface
$testResult = unserialize($contents);
assert($testResult instanceof TestResult);
/** @var list<AfterLastTestMethodFailed> $failedEvents */
$failedEvents = array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents());
$testResultSum = new TestResult(
(int) $testResultSum->hasTests() + (int) $testResult->hasTests(),
$testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
$testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(),
array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()),
array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()),
$failedEvents,
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->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredNoticeEvents(), $testResult->testRunnerTriggeredNoticeEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->notices(), $testResult->notices()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->warnings(), $testResult->warnings()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()),
// @phpstan-ignore-next-line
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
);
@ -351,8 +367,10 @@ final class WrapperRunner implements RunnerInterface
$testResultSum->testMarkedIncompleteEvents(),
$testResultSum->testTriggeredPhpunitDeprecationEvents(),
$testResultSum->testTriggeredPhpunitErrorEvents(),
$testResultSum->testTriggeredPhpunitNoticeEvents(),
$testResultSum->testTriggeredPhpunitWarningEvents(),
$testResultSum->testRunnerTriggeredDeprecationEvents(),
$testResultSum->testRunnerTriggeredNoticeEvents(),
array_values(array_filter(
$testResultSum->testRunnerTriggeredWarningEvents(),
fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found')

View File

@ -34,7 +34,7 @@ final class CompactPrinter
/**
* @var array<string, array<int, string>>
*/
private const LOOKUP_TABLE = [
private const array LOOKUP_TABLE = [
'.' => ['gray', '.'],
'S' => ['yellow', 's'],
'T' => ['cyan', 't'],
@ -131,14 +131,14 @@ final class CompactPrinter
$status['collected'],
$status['threshold'],
$status['roots'],
null,
null,
null,
null,
null,
null,
null,
null,
0.00,
0.00,
0.00,
0.00,
false,
false,
false,
0,
);
$telemetry = new Info(

177
src/Plugins/Shard.php Normal file
View File

@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Exceptions\InvalidOption;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Shard implements AddsOutput, HandlesArguments
{
use Concerns\HandleArguments;
private const string SHARD_OPTION = 'shard';
/**
* The shard index and total number of shards.
*
* @var array{
* index: int,
* total: int,
* testsRan: int,
* testsCount: int
* }|null
*/
private static ?array $shard = null;
/**
* Creates a new Plugin instance.
*/
public function __construct(
private readonly OutputInterface $output,
) {
//
}
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
if (! $this->hasArgument('--shard', $arguments)) {
return $arguments;
}
// @phpstan-ignore-next-line
$input = new ArgvInput($arguments);
['index' => $index, 'total' => $total] = self::getShard($input);
$arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument(
"$index/$total",
$arguments,
)));
/** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments);
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
self::$shard = [
'index' => $index,
'total' => $total,
'testsRan' => count($testsToRun),
'testsCount' => count($tests),
];
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
}
/**
* Returns all tests that the test suite would run.
*
* @param list<string> $arguments
* @return list<string>
*/
private function allTests(array $arguments): array
{
$output = (new Process([
'php',
...$this->removeParallelArguments($arguments),
'--list-tests',
]))->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
return array_values(array_unique($matches[1]));
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function removeParallelArguments(array $arguments): array
{
return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
}
/**
* Builds the filter argument for the given tests to run.
*/
private function buildFilterArgument(mixed $testsToRun): string
{
return addslashes(implode('|', $testsToRun));
}
/**
* Adds output after the Test Suite execution.
*/
public function addOutput(int $exitCode): int
{
if (self::$shard === null) {
return $exitCode;
}
[
'index' => $index,
'total' => $total,
'testsRan' => $testsRan,
'testsCount' => $testsCount,
] = self::$shard;
$this->output->writeln(sprintf(
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
$index,
$total,
$testsRan,
$testsRan === 1 ? '' : 's',
$testsCount,
));
return $exitCode;
}
/**
* Returns the shard information.
*
* @return array{index: int, total: int}
*/
public static function getShard(InputInterface $input): array
{
if ($input->hasParameterOption('--'.self::SHARD_OPTION)) {
$shard = $input->getParameterOption('--'.self::SHARD_OPTION);
} else {
$shard = null;
}
if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) {
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
}
[$index, $total] = explode('/', $shard);
if (! is_numeric($index) || ! is_numeric($total)) {
throw new InvalidOption('The [--shard] option must be in the format "index/total".');
}
if ($index <= 0 || $total <= 0 || $index > $total) {
throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.');
}
$index = (int) $index;
$total = (int) $total;
return [
'index' => $index,
'total' => $total,
];
}
}

View File

@ -16,7 +16,7 @@ final class Verbose implements HandlesArguments
/**
* The list of verbosity levels.
*/
private const VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
private const array VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q'];
/**
* {@inheritDoc}

View File

@ -19,7 +19,7 @@ use function sprintf;
*/
final class DatasetsRepository
{
private const SEPARATOR = '>>';
private const string SEPARATOR = '>>';
/**
* Holds the datasets.
@ -71,7 +71,7 @@ final class DatasetsRepository
*
* @throws ShouldNotHappen
*/
public static function get(string $filename, string $description): Closure|array
public static function get(string $filename, string $description): Closure|array // @phpstan-ignore-line
{
$dataset = self::$withs[$filename.self::SEPARATOR.$description];
@ -110,7 +110,6 @@ final class DatasetsRepository
foreach ($datasetCombination as $datasetCombinationElement) {
$partialDescriptions[] = $datasetCombinationElement['label'];
// @phpstan-ignore-next-line
$values = array_merge($values, $datasetCombinationElement['values']);
}
@ -221,7 +220,6 @@ final class DatasetsRepository
$result = $tmp;
}
// @phpstan-ignore-next-line
return $result;
}

View File

@ -19,9 +19,9 @@ final class SnapshotRepository
* Creates a snapshot repository instance.
*/
public function __construct(
readonly private string $rootPath,
readonly private string $testsPath,
readonly private string $snapshotsPath,
private readonly string $rootPath,
private readonly string $testsPath,
private readonly string $snapshotsPath,
) {}
/**

View File

@ -4,20 +4,16 @@ declare(strict_types=1);
namespace Pest;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\ShellExitCodeCalculator;
/**
* @internal
*/
final class Result
{
private const SUCCESS_EXIT = 0;
private const FAILURE_EXIT = 1;
private const EXCEPTION_EXIT = 2;
private const int SUCCESS_EXIT = 0;
/**
* If the exit code is different from 0.
@ -40,44 +36,8 @@ final class Result
*/
public static function exitCode(Configuration $configuration, TestResult $result): int
{
if ($result->wasSuccessfulIgnoringPhpunitWarnings()) {
if ($configuration->failOnWarning()) {
$warnings = $result->numberOfTestsWithTestTriggeredPhpunitWarningEvents()
+ count($result->warnings())
+ count($result->phpWarnings());
$shell = new ShellExitCodeCalculator;
if ($warnings > 0) {
return self::FAILURE_EXIT;
}
}
if (! $result->hasTestTriggeredPhpunitWarningEvents()) {
return self::SUCCESS_EXIT;
}
}
if ($configuration->failOnEmptyTestSuite() && ResultReflection::numberOfTests($result) === 0) {
return self::FAILURE_EXIT;
}
if ($result->wasSuccessfulIgnoringPhpunitWarnings()) {
if ($configuration->failOnRisky() && $result->hasTestConsideredRiskyEvents()) {
$returnCode = self::FAILURE_EXIT;
}
if ($configuration->failOnIncomplete() && $result->hasTestMarkedIncompleteEvents()) {
$returnCode = self::FAILURE_EXIT;
}
if ($configuration->failOnSkipped() && $result->hasTestSkippedEvents()) {
$returnCode = self::FAILURE_EXIT;
}
}
if ($result->hasTestErroredEvents()) {
return self::EXCEPTION_EXIT;
}
return self::FAILURE_EXIT;
return $shell->calculate($configuration, $result);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Pest\Runner\Filter;
use Pest\Contracts\HasPrintableTestCaseName;
use PHPUnit\Framework\Test;
use RecursiveFilterIterator;
use RecursiveIterator;
/**
* @internal
*/
final class EnsureTestCaseIsInitiatedFilter extends RecursiveFilterIterator
{
/**
* @param RecursiveIterator<int, Test> $iterator
*/
public function __construct(RecursiveIterator $iterator)
{
parent::__construct($iterator);
}
/**
* {@inheritdoc}
*/
public function accept(): bool
{
$test = $this->getInnerIterator()->current();
if ($test instanceof HasPrintableTestCaseName) {
/** @phpstan-ignore-next-line */
$test->__initializeTestCase();
}
return true;
}
}

View File

@ -35,7 +35,7 @@ final class EnsureIgnorableTestCasesAreIgnored implements StartedSubscriber
/** @var array<int, WarningTriggered> $testRunnerTriggeredWarningEvents */
$testRunnerTriggeredWarningEvents = $property->getValue($collector);
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => $event->message() !== 'No tests found in class "Pest\TestCases\IgnorableTestCase".'));
$testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => str_contains($event->message(), 'No tests found in class') === false));
$property->setValue($collector, $testRunnerTriggeredWarningEvents);
}

View File

@ -11,12 +11,9 @@ use Pest\Exceptions\ShouldNotHappen;
*/
final class Backtrace
{
/**
* @var string
*/
private const FILE = 'file';
private const string FILE = 'file';
private const BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS;
private const int BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS;
/**
* Returns the current test file.

View File

@ -15,7 +15,6 @@ final class Closure
/**
* Binds the given closure to the given "this".
*
*
* @throws ShouldNotHappen
*/
public static function bind(?BaseClosure $closure, ?object $newThis, object|string|null $newScope = 'static'): BaseClosure
@ -24,6 +23,7 @@ final class Closure
throw ShouldNotHappen::fromMessage('Could not bind null closure.');
}
// @phpstan-ignore-next-line
$closure = BaseClosure::bind($closure, $newThis, $newScope);
if (! $closure instanceof \Closure) {

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Support;
use Pest\Exceptions\ShouldNotHappen;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\Environment\Runtime;
@ -88,10 +87,20 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
/** @var CodeCoverage $codeCoverage */
$codeCoverage = require $reportPath;
$handle = fopen($reportPath, 'r');
$code = '';
while (is_resource($handle) && ! feof($handle)) {
$code .= fread($handle, 8192);
}
if (is_resource($handle)) {
fclose($handle);
}
unlink($reportPath);
$codeCoverage = eval(substr($code, 5));
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
/** @var Directory<File|Directory> $report */

View File

@ -11,9 +11,9 @@ use function Pest\testDirectory;
*/
final class DatasetInfo
{
public const DATASETS_DIR_NAME = 'Datasets';
public const string DATASETS_DIR_NAME = 'Datasets';
public const DATASETS_FILE_NAME = 'Datasets.php';
public const string DATASETS_FILE_NAME = 'Datasets.php';
public static function isInsideADatasetsDirectory(string $file): bool
{

View File

@ -13,7 +13,7 @@ use Throwable;
*/
final class ExceptionTrace
{
private const UNDEFINED_METHOD = 'Call to undefined method P\\';
private const string UNDEFINED_METHOD = 'Call to undefined method P\\';
/**
* Ensures the given closure reports the good execution context.

View File

@ -15,7 +15,7 @@ final readonly class Exporter
/**
* The maximum number of items in an array to export.
*/
private const MAX_ARRAY_ITEMS = 3;
private const int MAX_ARRAY_ITEMS = 3;
/**
* Creates a new Exporter instance.
@ -66,6 +66,7 @@ final readonly class Exporter
$result[] = $context->contains($data[$key]) !== false
? '*RECURSION*'
// @phpstan-ignore-next-line
: sprintf('[%s]', $this->shortenedRecursiveExport($data[$key], $context));
}

View File

@ -13,7 +13,7 @@ use Throwable;
*/
final class HigherOrderMessage
{
public const UNDEFINED_METHOD = 'Method %s does not exist';
public const string UNDEFINED_METHOD = 'Method %s does not exist';
/**
* An optional condition that will determine if the message will be executed.
@ -50,14 +50,13 @@ final class HigherOrderMessage
}
if ($this->hasHigherOrderCallable()) {
/* @phpstan-ignore-next-line */
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
}
try {
return is_array($this->arguments)
? Reflection::call($target, $this->name, $this->arguments)
: $target->{$this->name}; /* @phpstan-ignore-line */
: $target->{$this->name};
} catch (Throwable $throwable) {
Reflection::setPropertyValue($throwable, 'file', $this->filename);
Reflection::setPropertyValue($throwable, 'line', $this->line);
@ -65,7 +64,6 @@ final class HigherOrderMessage
if ($throwable->getMessage() === $this->getUndefinedMethodMessage($target, $this->name)) {
/** @var ReflectionClass<TValue> $reflection */
$reflection = new ReflectionClass($target);
/* @phpstan-ignore-next-line */
$reflection = $reflection->getParentClass() ?: $reflection;
Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $this->name));
}
@ -96,10 +94,6 @@ final class HigherOrderMessage
private function getUndefinedMethodMessage(object $target, string $methodName): string
{
if (\PHP_MAJOR_VERSION >= 8) {
return sprintf(self::UNDEFINED_METHOD, sprintf('%s::%s()', $target::class, $methodName));
}
return sprintf(self::UNDEFINED_METHOD, $methodName);
return sprintf(self::UNDEFINED_METHOD, sprintf('%s::%s()', $target::class, $methodName));
}
}

View File

@ -40,7 +40,6 @@ final class HigherOrderMessageCollection
public function chain(object $target): void
{
foreach ($this->messages as $message) {
// @phpstan-ignore-next-line
$target = $message->call($target) ?? $target;
}
}

View File

@ -26,7 +26,7 @@ final class HigherOrderTapProxy
*/
public function __set(string $property, mixed $value): void
{
$this->target->{$property} = $value; // @phpstan-ignore-line
$this->target->{$property} = $value;
}
/**
@ -37,7 +37,7 @@ final class HigherOrderTapProxy
public function __get(string $property)
{
if (property_exists($this->target, $property)) {
return $this->target->{$property}; // @phpstan-ignore-line
return $this->target->{$property};
}
$className = (new ReflectionClass($this->target))->getName();

101
src/Support/Shell.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Illuminate\Support\Env;
use Laravel\Tinker\ClassAliasAutoloader;
use Pest\TestSuite;
use Psy\Configuration;
use Psy\Shell as PsyShell;
use Psy\VersionUpdater\Checker;
/**
* @internal
*/
final class Shell
{
/**
* Creates a new interactive shell.
*/
public static function open(): void
{
$config = new Configuration;
$config->setUpdateCheck(Checker::NEVER);
$config->getPresenter()->addCasters(self::casters());
$shell = new PsyShell($config);
$loader = self::tinkered($shell);
try {
$shell->run();
} finally {
$loader?->unregister(); // @phpstan-ignore-line
}
}
/**
* Returns the casters for the Psy Shell.
*
* @return array<string, callable>
*/
private static function casters(): array
{
$casters = [
'Illuminate\Support\Collection' => 'Laravel\Tinker\TinkerCaster::castCollection',
'Illuminate\Support\HtmlString' => 'Laravel\Tinker\TinkerCaster::castHtmlString',
'Illuminate\Support\Stringable' => 'Laravel\Tinker\TinkerCaster::castStringable',
];
if (class_exists('Illuminate\Database\Eloquent\Model')) {
$casters['Illuminate\Database\Eloquent\Model'] = 'Laravel\Tinker\TinkerCaster::castModel';
}
if (class_exists('Illuminate\Process\ProcessResult')) {
$casters['Illuminate\Process\ProcessResult'] = 'Laravel\Tinker\TinkerCaster::castProcessResult';
}
if (class_exists('Illuminate\Foundation\Application')) {
$casters['Illuminate\Foundation\Application'] = 'Laravel\Tinker\TinkerCaster::castApplication';
}
if (function_exists('app') === false) {
return $casters; // @phpstan-ignore-line
}
$config = app()->make('config');
return array_merge($casters, (array) $config->get('tinker.casters', []));
}
/**
* Tinkers the current shell, if the Tinker package is available.
*/
private static function tinkered(PsyShell $shell): ?object
{
if (function_exists('app') === false
|| ! class_exists(Env::class)
|| ! class_exists(ClassAliasAutoloader::class)
) {
return null;
}
$path = Env::get('COMPOSER_VENDOR_DIR', app()->basePath().DIRECTORY_SEPARATOR.'vendor');
$path .= '/composer/autoload_classmap.php';
if (! file_exists($path)) {
$path = TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'composer'.DIRECTORY_SEPARATOR.'autoload_classmap.php';
}
$config = app()->make('config');
return ClassAliasAutoloader::register(
$shell, $path, $config->get('tinker.alias', []), $config->get('tinker.dont_alias', [])
);
}
}

View File

@ -13,12 +13,9 @@ final class Str
* Pool of alpha-numeric characters for generating (unsafe) random strings
* from.
*/
private const POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const string POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
/**
* @var string
*/
private const PREFIX = '__pest_evaluable_';
private const string PREFIX = '__pest_evaluable_';
/**
* Create a (unsecure & non-cryptographically safe) random alpha-numeric
@ -120,4 +117,14 @@ final class Str
{
return (bool) filter_var($value, FILTER_VALIDATE_URL);
}
/**
* Converts the given `$target` to a URL-friendly "slug".
*/
public static function slugify(string $target): string
{
$target = preg_replace('/[^a-zA-Z0-9]+/', '-', $target);
return strtolower(trim((string) $target, '-'));
}
}