🆙 Add cms i using 🆙

This commit is contained in:
Remco
2025-11-25 22:42:56 +01:00
parent 94704e0925
commit d44196149e
35591 changed files with 3601123 additions and 0 deletions
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
abstract class AbstractPreset // @pest-arch-ignore-line
{
/**
* The expectations.
*
* @var array<int, Expectation<mixed>|ArchExpectation>
*/
protected array $expectations = [];
/**
* Creates a new preset instance.
*
* @param array<int, string> $userNamespaces
*/
public function __construct(
private readonly array $userNamespaces,
) {
//
}
/**
* Executes the arch preset.
*
* @internal
*/
abstract public function execute(): void;
/**
* Ignores the given "targets" or "dependencies".
*
* @param array<int, string>|string $targetsOrDependencies
*/
final public function ignoring(array|string $targetsOrDependencies): void
{
$this->expectations = array_map(
fn (ArchExpectation|Expectation $expectation): Expectation|ArchExpectation => $expectation instanceof ArchExpectation ? $expectation->ignoring($targetsOrDependencies) : $expectation,
$this->expectations,
);
}
/**
* Runs the given callback for each namespace.
*
* @param callable(Expectation<string|null>): ArchExpectation ...$callbacks
*/
final public function eachUserNamespace(callable ...$callbacks): void
{
foreach ($this->userNamespaces as $namespace) {
foreach ($callbacks as $callback) {
$this->expectations[] = $callback(expect($namespace));
}
}
}
/**
* Flushes the expectations.
*/
final public function flush(): void
{
$this->expectations = [];
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Closure;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Custom extends AbstractPreset
{
/**
* Creates a new preset instance.
*
* @param array<int, string> $userNamespaces
* @param Closure(array<int, string>): array<Expectation<mixed>|ArchExpectation> $execute
*/
public function __construct(
private readonly array $userNamespaces,
private readonly string $name,
private readonly Closure $execute,
) {
parent::__construct($userNamespaces);
}
/**
* Returns the name of the preset.
*/
public function name(): string
{
return $this->name;
}
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations = ($this->execute)($this->userNamespaces);
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Throwable;
/**
* @internal
*/
final class Laravel extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations[] = expect('App\Traits')
->toBeTraits();
$this->expectations[] = expect('App\Concerns')
->toBeTraits();
$this->expectations[] = expect('App')
->not->toBeEnums()
->ignoring('App\Enums');
$this->expectations[] = expect('App\Enums')
->toBeEnums()
->ignoring('App\Enums\Concerns');
$this->expectations[] = expect('App\Features')
->toBeClasses()
->ignoring('App\Features\Concerns');
$this->expectations[] = expect('App\Features')
->toHaveMethod('resolve')
->ignoring('App\Features\Concerns');
$this->expectations[] = expect('App\Exceptions')
->classes()
->toImplement('Throwable')
->ignoring('App\Exceptions\Handler');
$this->expectations[] = expect('App')
->not->toImplement(Throwable::class)
->ignoring('App\Exceptions');
$this->expectations[] = expect('App\Http\Middleware')
->classes()
->toHaveMethod('handle');
$this->expectations[] = expect('App\Models')
->classes()
->toExtend('Illuminate\Database\Eloquent\Model')
->ignoring('App\Models\Scopes');
$this->expectations[] = expect('App\Models')
->classes()
->not->toHaveSuffix('Model');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Database\Eloquent\Model')
->ignoring('App\Models');
$this->expectations[] = expect('App\Http\Requests')
->classes()
->toHaveSuffix('Request');
$this->expectations[] = expect('App\Http\Requests')
->toExtend('Illuminate\Foundation\Http\FormRequest');
$this->expectations[] = expect('App\Http\Requests')
->toHaveMethod('rules');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Foundation\Http\FormRequest')
->ignoring('App\Http\Requests');
$this->expectations[] = expect('App\Console\Commands')
->classes()
->toHaveSuffix('Command');
$this->expectations[] = expect('App\Console\Commands')
->classes()
->toExtend('Illuminate\Console\Command');
$this->expectations[] = expect('App\Console\Commands')
->classes()
->toHaveMethod('handle');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Console\Command')
->ignoring('App\Console\Commands');
$this->expectations[] = expect('App\Mail')
->classes()
->toExtend('Illuminate\Mail\Mailable');
$this->expectations[] = expect('App\Mail')
->classes()
->toImplement('Illuminate\Contracts\Queue\ShouldQueue');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Mail\Mailable')
->ignoring('App\Mail');
$this->expectations[] = expect('App\Jobs')
->classes()
->toImplement('Illuminate\Contracts\Queue\ShouldQueue');
$this->expectations[] = expect('App\Jobs')
->classes()
->toHaveMethod('handle');
$this->expectations[] = expect('App\Listeners')
->toHaveMethod('handle');
$this->expectations[] = expect('App\Notifications')
->toExtend('Illuminate\Notifications\Notification');
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Notifications\Notification')
->ignoring('App\Notifications');
$this->expectations[] = expect('App\Providers')
->toHaveSuffix('ServiceProvider');
$this->expectations[] = expect('App\Providers')
->toExtend('Illuminate\Support\ServiceProvider');
$this->expectations[] = expect('App\Providers')
->not->toBeUsed();
$this->expectations[] = expect('App')
->not->toExtend('Illuminate\Support\ServiceProvider')
->ignoring('App\Providers');
$this->expectations[] = expect('App')
->not->toHaveSuffix('ServiceProvider')
->ignoring('App\Providers');
$this->expectations[] = expect('App')
->not->toHaveSuffix('Controller')
->ignoring('App\Http\Controllers');
$this->expectations[] = expect('App\Http\Controllers')
->classes()
->toHaveSuffix('Controller');
$this->expectations[] = expect('App\Http')
->toOnlyBeUsedIn('App\Http');
$this->expectations[] = expect('App\Http\Controllers')
->not->toHavePublicMethodsBesides(['__construct', '__invoke', 'index', 'show', 'create', 'store', 'edit', 'update', 'destroy', 'middleware']);
$this->expectations[] = expect([
'dd',
'ddd',
'dump',
'env',
'exit',
'ray',
])->not->toBeUsed();
$this->expectations[] = expect('App\Policies')
->classes()
->toHaveSuffix('Policy');
$this->expectations[] = expect('App\Attributes')
->classes()
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute')
->toHaveMethod('resolve');
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Php extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations[] = expect([
'debug_zval_dump',
'debug_backtrace',
'debug_print_backtrace',
'dump',
'ray',
'ds',
'die',
'goto',
'global',
'var_dump',
'phpinfo',
'echo',
'ereg',
'eregi',
'mysql_connect',
'mysql_pconnect',
'mysql_query',
'mysql_select_db',
'mysql_fetch_array',
'mysql_fetch_assoc',
'mysql_fetch_object',
'mysql_fetch_row',
'mysql_num_rows',
'mysql_affected_rows',
'mysql_free_result',
'mysql_insert_id',
'mysql_error',
'mysql_real_escape_string',
'print',
'print_r',
'var_export',
'xdebug_break',
'xdebug_call_class',
'xdebug_call_file',
'xdebug_call_int',
'xdebug_call_line',
'xdebug_code_coverage_started',
'xdebug_connect_to_client',
'xdebug_debug_zval',
'xdebug_debug_zval_stdout',
'xdebug_dump_superglobals',
'xdebug_get_code_coverage',
'xdebug_get_collected_errors',
'xdebug_get_function_count',
'xdebug_get_function_stack',
'xdebug_get_gc_run_count',
'xdebug_get_gc_total_collected_roots',
'xdebug_get_gcstats_filename',
'xdebug_get_headers',
'xdebug_get_monitored_functions',
'xdebug_get_profiler_filename',
'xdebug_get_stack_depth',
'xdebug_get_tracefile_name',
'xdebug_info',
'xdebug_is_debugger_active',
'xdebug_memory_usage',
'xdebug_notify',
'xdebug_peak_memory_usage',
'xdebug_print_function_stack',
'xdebug_set_filter',
'xdebug_start_code_coverage',
'xdebug_start_error_collection',
'xdebug_start_function_monitor',
'xdebug_start_gcstats',
'xdebug_start_trace',
'xdebug_stop_code_coverage',
'xdebug_stop_error_collection',
'xdebug_stop_function_monitor',
'xdebug_stop_gcstats',
'xdebug_stop_trace',
'xdebug_time_index',
'xdebug_var_dump',
'trap',
])->not->toBeUsed();
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->not->toHaveSuspiciousCharacters(),
);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Relaxed extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->not->toUseStrictTypes(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeFinal(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHavePrivateMethods(),
);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
/**
* @internal
*/
final class Security extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->expectations[] = expect([
'md5',
'sha1',
'uniqid',
'rand',
'mt_rand',
'tempnam',
'str_shuffle',
'shuffle',
'array_rand',
'eval',
'exec',
'shell_exec',
'system',
'passthru',
'create_function',
'unserialize',
'extract',
'mb_parse_str',
'dl',
'assert',
])->not->toBeUsed();
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Pest\ArchPresets;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Expectation;
/**
* @internal
*/
final class Strict extends AbstractPreset
{
/**
* Executes the arch preset.
*/
public function execute(): void
{
$this->eachUserNamespace(
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toHaveProtectedMethods(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->not->toBeAbstract(),
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictTypes(),
fn (Expectation $namespace): ArchExpectation => $namespace->toUseStrictEquality(),
fn (Expectation $namespace): ArchExpectation => $namespace->classes()->toBeFinal(),
);
$this->expectations[] = expect([
'sleep',
'usleep',
])->not->toBeUsed();
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use PHPUnit\Util\ExcludeList;
/**
* @internal
*/
final class BootExcludeList implements Bootstrapper
{
/**
* The directories to exclude.
*
* @var array<int, non-empty-string>
*/
private const array EXCLUDE_LIST = [
'bin',
'overrides',
'resources',
'src',
'stubs',
];
/**
* Boots the "exclude list" for PHPUnit to ignore Pest files.
*/
public function boot(): void
{
$baseDirectory = dirname(__DIR__, 2);
foreach (self::EXCLUDE_LIST as $directory) {
ExcludeList::addDirectory($baseDirectory.DIRECTORY_SEPARATOR.$directory);
}
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\FatalException;
use Pest\Support\DatasetInfo;
use Pest\Support\Str;
use Pest\TestSuite;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator;
use function Pest\testDirectory;
/**
* @internal
*/
final class BootFiles implements Bootstrapper
{
/**
* The structure of the tests directory.
*
* @var array<int, string>
*/
private const array STRUCTURE = [
'Expectations',
'Expectations.php',
'Helpers',
'Helpers.php',
'Pest.php',
];
/**
* Boots the structure of the tests directory.
*/
public function boot(): void
{
$rootPath = TestSuite::getInstance()->rootPath;
$testsPath = $rootPath.DIRECTORY_SEPARATOR.testDirectory();
if (! is_dir($testsPath)) {
throw new FatalException(sprintf('The test directory [%s] does not exist.', $testsPath));
}
foreach (self::STRUCTURE as $filename) {
$filename = sprintf('%s%s%s', $testsPath, DIRECTORY_SEPARATOR, $filename);
if (! file_exists($filename)) {
continue;
}
if (is_dir($filename)) {
$directory = new RecursiveDirectoryIterator($filename);
$iterator = new RecursiveIteratorIterator($directory);
/** @var \DirectoryIterator $file */
foreach ($iterator as $file) {
$this->load($file->__toString());
}
} else {
$this->load($filename);
}
}
$this->bootDatasets($testsPath);
}
/**
* Loads, if possible, the given file.
*/
private function load(string $filename): void
{
if (! Str::endsWith($filename, '.php')) {
return;
}
if (! file_exists($filename)) {
return;
}
include_once $filename;
}
private function bootDatasets(string $testsPath): void
{
assert($testsPath !== '');
$files = (new PhpUnitFileIterator)->getFilesAsArray($testsPath, '.php');
foreach ($files as $file) {
if (DatasetInfo::isADatasetsFile($file) || DatasetInfo::isInsideADatasetsDirectory($file)) {
$this->load($file);
}
}
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\KernelDump;
use Pest\Support\Container;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final readonly class BootKernelDump implements Bootstrapper
{
/**
* Creates a new Boot Kernel Dump instance.
*/
public function __construct(
private OutputInterface $output,
) {
// ...
}
/**
* Boots the kernel dump.
*/
public function boot(): void
{
Container::getInstance()->add(KernelDump::class, $kernelDump = new KernelDump(
$this->output,
));
$kernelDump->enable();
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\ShouldNotHappen;
/**
* @internal
*/
final class BootOverrides implements Bootstrapper
{
/**
* The list of files to be overridden.
*
* @var array<int, string>
*/
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',
];
/**
* Boots the list of files to be overridden.
*/
public function boot(): void
{
foreach (self::FILES as $file) {
$file = __DIR__."/../../overrides/$file";
if (! file_exists($file)) {
throw ShouldNotHappen::fromMessage(sprintf('File [%s] does not exist.', $file));
}
require_once $file;
}
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Subscribers;
use Pest\Support\Container;
use PHPUnit\Event;
use PHPUnit\Event\Subscriber;
/**
* @internal
*/
final readonly class BootSubscribers implements Bootstrapper
{
/**
* The list of Subscribers.
*
* @var array<int, class-string<Subscriber>>
*/
private const array SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class,
];
/**
* Creates a new instance of the Boot Subscribers.
*/
public function __construct(
private Container $container,
) {}
/**
* Boots the list of Subscribers.
*/
public function boot(): void
{
foreach (self::SUBSCRIBERS as $subscriber) {
$instance = $this->container->get($subscriber);
assert($instance instanceof Subscriber);
Event\Facade::instance()->registerSubscriber($instance);
}
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final readonly class BootView implements Bootstrapper
{
/**
* Creates a new instance of the Boot View.
*/
public function __construct(
private OutputInterface $output
) {
// ..
}
/**
* Boots the view renderer.
*/
public function boot(): void
{
View::renderUsing($this->output);
}
}
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Pest\Collision;
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
use Pest\Configuration\Project;
use Symfony\Component\Console\Output\OutputInterface;
use function Termwind\render;
use function Termwind\renderUsing;
/**
* @internal
*/
final class Events
{
/**
* Sets the output.
*/
private static ?OutputInterface $output = null;
/**
* Sets the output.
*/
public static function setOutput(OutputInterface $output): void
{
self::$output = $output;
}
/**
* Fires before the test method description is printed.
*/
public static function beforeTestMethodDescription(TestResult $result, string $description): string
{
if (($context = $result->context) === []) {
return $description;
}
renderUsing(self::$output);
[
'assignees' => $assignees,
'issues' => $issues,
'prs' => $prs,
] = $context;
if (($link = Project::getInstance()->issues) !== '') {
$issuesDescription = array_map(fn (int $issue): string => sprintf('<a href="%s">#%s</a>', sprintf($link, $issue), $issue), $issues);
}
if (($link = Project::getInstance()->prs) !== '') {
$prsDescription = array_map(fn (int $pr): string => sprintf('<a href="%s">#%s</a>', sprintf($link, $pr), $pr), $prs);
}
if (($link = Project::getInstance()->assignees) !== '' && count($assignees) > 0) {
$assigneesDescription = array_map(fn (string $assignee): string => sprintf(
'<a href="%s">@%s</a>',
sprintf($link, $assignee),
$assignee,
), $assignees);
}
if (count($assignees) > 0 || count($issues) > 0 || count($prs) > 0) {
$description .= ' '.implode(', ', array_merge(
$issuesDescription ?? [],
$prsDescription ?? [],
isset($assigneesDescription) ? ['['.implode(', ', $assigneesDescription).']'] : [],
));
}
return $description;
}
/**
* Fires after the test method description is printed.
*/
public static function afterTestMethodDescription(TestResult $result): void
{
if (($context = $result->context) === []) {
return;
}
renderUsing(self::$output);
[
'notes' => $notes,
] = $context;
foreach ($notes as $note) {
render(sprintf(<<<'HTML'
<div class="ml-2">
<span class="text-gray"> // %s</span>
</div>
HTML, $note,
));
}
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns;
use Pest\Expectation;
/**
* @internal
*/
trait Expectable
{
/**
* @template TValue
*
* Creates a new Expectation.
*
* @param TValue $value
* @return Expectation<TValue>
*/
public function expect(mixed $value): Expectation
{
return new Expectation($value);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns;
use Closure;
/**
* @internal
*/
trait Extendable
{
/**
* The list of extends.
*
* @var array<string, Closure>
*/
private static array $extends = [];
/**
* Register a new extend.
*/
public function extend(string $name, Closure $extend): void
{
static::$extends[$name] = $extend;
}
/**
* Checks if given extend name is registered.
*/
public static function hasExtend(string $name): bool
{
return array_key_exists($name, static::$extends);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns\Logging;
/**
* @internal
*/
trait WritesToConsole
{
/**
* Writes the given success message to the console.
*/
private function writeSuccess(string $message): void
{
$this->writePestTestOutput($message, 'fg-green, bold', '✓');
}
/**
* Writes the given error message to the console.
*/
private function writeError(string $message): void
{
$this->writePestTestOutput($message, 'fg-red, bold', '');
}
/**
* Writes the given warning message to the console.
*/
private function writeWarning(string $message): void
{
$this->writePestTestOutput($message, 'fg-yellow, bold', '-');
}
/**
* Writes the give message to the console.
*/
private function writePestTestOutput(string $message, string $color, string $symbol): void
{
$this->writeWithColor($color, "$symbol ", false);
$this->write($message);
$this->writeNewLine();
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns;
use Closure;
/**
* @internal
*/
trait Pipeable
{
/**
* The list of pipes.
*
* @var array<string, array<Closure(Closure, mixed ...$arguments): void>>
*/
private static array $pipes = [];
/**
* The list of interceptors.
*
* @var array<string, array<Closure(Closure, mixed ...$arguments): void>>
*/
private static array $interceptors = [];
/**
* Register a pipe to be applied before an expectation is checked.
*/
public function pipe(string $name, Closure $pipe): void
{
self::$pipes[$name][] = $pipe;
}
/**
* Register an interceptor that should replace an existing expectation.
*
* @param string|Closure(mixed $value, mixed ...$arguments):bool $filter
*/
public function intercept(string $name, string|Closure $filter, Closure $handler): void
{
if (is_string($filter)) {
$filter = fn ($value): bool => $value instanceof $filter;
}
self::$interceptors[$name][] = $handler;
$this->pipe($name, function ($next, ...$arguments) use ($handler, $filter): void {
/* @phpstan-ignore-next-line */
if ($filter($this->value, ...$arguments)) {
// @phpstan-ignore-next-line
$handler->bindTo($this, $this::class)(...$arguments);
return;
}
$next();
});
}
/**
* Get the list of pipes by the given name.
*
* @return array<int, Closure>
*/
private function pipes(string $name, object $context, string $scope): array
{
return array_map(fn (Closure $pipe): \Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Pest\Concerns;
/**
* @internal
*/
trait Retrievable
{
/**
* @template TRetrievableValue
*
* Safely retrieve the value at the given key from an object or array.
* @template TRetrievableValue
*
* @param array<string, TRetrievableValue>|object $value
* @param TRetrievableValue|null $default
* @return TRetrievableValue|null
*/
private function retrieve(string $key, mixed $value, mixed $default = null): mixed
{
if (is_array($value)) {
return $value[$key] ?? $default;
}
// @phpstan-ignore-next-line
return $value->$key ?? $default;
}
}
@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
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;
use ReflectionException;
use ReflectionFunction;
use ReflectionParameter;
use Throwable;
/**
* @internal
*
* @mixin TestCase
*/
trait Testable
{
/**
* The test's description.
*/
private string $__description;
/**
* The test's latest description.
*/
private static string $__latestDescription;
/**
* The test's assignees.
*/
private static array $__latestAssignees = [];
/**
* The test's notes.
*/
private static array $__latestNotes = [];
/**
* The test's issues.
*
* @var array<int, int>
*/
private static array $__latestIssues = [];
/**
* The test's PRs.
*
* @var array<int, int>
*/
private static array $__latestPrs = [];
/**
* The test's describing, if any.
*
* @var array<int, string>
*/
public array $__describing = [];
/**
* Whether the test has ran or not.
*/
public bool $__ran = false;
/**
* The test's test closure.
*/
private Closure $__test;
/**
* The test's before each closure.
*/
private ?Closure $__beforeEach = null;
/**
* The test's after each closure.
*/
private ?Closure $__afterEach = null;
/**
* The test's before all closure.
*/
private static ?Closure $__beforeAll = null;
/**
* The test's after all closure.
*/
private static ?Closure $__afterAll = null;
/**
* The list of snapshot changes, if any.
*/
private array $__snapshotChanges = [];
/**
* Resets the test case static properties.
*/
public static function flush(): void
{
self::$__beforeAll = null;
self::$__afterAll = null;
}
/**
* Adds a new "note" to the Test Case.
*/
public function note(array|string $note): self
{
$note = is_array($note) ? $note : [$note];
self::$__latestNotes = array_merge(self::$__latestNotes, $note);
return $this;
}
/**
* Adds a new "setUpBeforeClass" to the Test Case.
*/
public function __addBeforeAll(?Closure $hook): void
{
if (! $hook instanceof \Closure) {
return;
}
self::$__beforeAll = (self::$__beforeAll instanceof Closure)
? ChainableClosure::boundStatically(self::$__beforeAll, $hook)
: $hook;
}
/**
* Adds a new "tearDownAfterClass" to the Test Case.
*/
public function __addAfterAll(?Closure $hook): void
{
if (! $hook instanceof \Closure) {
return;
}
self::$__afterAll = (self::$__afterAll instanceof Closure)
? ChainableClosure::boundStatically(self::$__afterAll, $hook)
: $hook;
}
/**
* Adds a new "setUp" to the Test Case.
*/
public function __addBeforeEach(?Closure $hook): void
{
$this->__addHook('__beforeEach', $hook);
}
/**
* Adds a new "tearDown" to the Test Case.
*/
public function __addAfterEach(?Closure $hook): void
{
$this->__addHook('__afterEach', $hook);
}
/**
* Adds a new "hook" to the Test Case.
*/
private function __addHook(string $property, ?Closure $hook): void
{
if (! $hook instanceof \Closure) {
return;
}
$this->{$property} = ($this->{$property} instanceof Closure)
? ChainableClosure::bound($this->{$property}, $hook)
: $hook;
}
/**
* This method is called before the first test of this Test Case is run.
*/
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
$beforeAll = TestSuite::getInstance()->beforeAll->get(self::$__filename);
if (self::$__beforeAll instanceof Closure) {
$beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll);
}
try {
call_user_func(Closure::bind($beforeAll, null, self::class));
} catch (Throwable $e) {
Panic::with($e);
}
}
/**
* This method is called after the last test of this Test Case is run.
*/
public static function tearDownAfterClass(): void
{
$afterAll = TestSuite::getInstance()->afterAll->get(self::$__filename);
if (self::$__afterAll instanceof Closure) {
$afterAll = ChainableClosure::boundStatically(self::$__afterAll, $afterAll);
}
call_user_func(Closure::bind($afterAll, null, self::class));
parent::tearDownAfterClass();
}
/**
* Gets executed before the Test Case.
*/
protected function setUp(...$arguments): void
{
TestSuite::getInstance()->test = $this;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description;
if ($this->dataName()) {
$description = str_contains((string) $description, ':dataset')
? str_replace(':dataset', str_replace('dataset ', '', $this->dataName()), (string) $description)
: $description.' with '.$this->dataName();
}
$description = htmlspecialchars(html_entity_decode((string) $description), ENT_NOQUOTES);
if ($method->repetitions > 1) {
$matches = [];
preg_match('/\((.*?)\)/', $description, $matches);
if (count($matches) > 1) {
if (str_contains($description, 'with '.$matches[0].' /')) {
$description = str_replace('with '.$matches[0].' /', '', $description);
} else {
$description = str_replace('with '.$matches[0], '', $description);
}
}
$description .= ' @ repetition '.($matches[1].' of '.$method->repetitions);
}
$this->__description = self::$__latestDescription = $description;
self::$__latestAssignees = $method->assignees;
self::$__latestNotes = $method->notes;
self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs;
parent::setUp();
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) {
$beforeEach = ChainableClosure::bound($this->__beforeEach, $beforeEach);
}
$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.
*/
protected function tearDown(...$arguments): void
{
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) {
$afterEach = ChainableClosure::bound($this->__afterEach, $afterEach);
}
try {
$this->__callClosure($afterEach, func_get_args());
} finally {
parent::tearDown();
TestSuite::getInstance()->test = null;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$method->tearDown($this);
}
}
/**
* Executes the Test Case current test.
*
* @throws Throwable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
$arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
return $this->__callClosure($closure, $arguments);
}
/**
* Resolve the passed arguments. Any Closures will be bound to the testcase and resolved.
*
* @throws Throwable
*/
private function __resolveTestArguments(array $arguments): array
{
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
if ($method->repetitions > 1) {
// If the test is repeated, the first argument is the iteration number
// we need to move it to the end of the arguments list
// so that the datasets are the first n arguments
// and the iteration number is the last argument
$firstArgument = array_shift($arguments);
$arguments[] = $firstArgument;
}
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testParameterTypes = array_values(Reflection::getFunctionArguments($underlyingTest));
if (count($arguments) !== 1) {
foreach ($arguments as $argumentIndex => $argumentValue) {
if (! $argumentValue instanceof Closure) {
continue;
}
if (in_array($testParameterTypes[$argumentIndex], [Closure::class, 'callable', 'mixed'])) {
continue;
}
$arguments[$argumentIndex] = $this->__callClosure($argumentValue, []);
}
return $arguments;
}
if (! isset($arguments[0]) || ! $arguments[0] instanceof Closure) {
return $arguments;
}
if (isset($testParameterTypes[0]) && in_array($testParameterTypes[0], [Closure::class, 'callable'])) {
return $arguments;
}
$boundDatasetResult = $this->__callClosure($arguments[0], []);
if (count($testParameterTypes) === 1) {
return [$boundDatasetResult];
}
if (! is_array($boundDatasetResult)) {
return [$boundDatasetResult];
}
return array_values($boundDatasetResult);
}
/**
* Ensures dataset items count matches underlying test case required parameters
*
* @throws ReflectionException
* @throws DatasetArgumentsMismatch
*/
private function __ensureDatasetArgumentNameAndNumberMatches(array $arguments): void
{
if ($arguments === []) {
return;
}
$underlyingTest = Reflection::getFunctionVariable($this->__test, 'closure');
$testReflection = new ReflectionFunction($underlyingTest);
$requiredParametersCount = $testReflection->getNumberOfRequiredParameters();
$suppliedParametersCount = count($arguments);
$datasetParameterNames = array_keys($arguments);
$testParameterNames = array_map(
fn (ReflectionParameter $reflectionParameter): string => $reflectionParameter->getName(),
array_filter($testReflection->getParameters(), fn (ReflectionParameter $reflectionParameter): bool => ! $reflectionParameter->isOptional()),
);
if (array_diff($testParameterNames, $datasetParameterNames) === []) {
return;
}
if (isset($testParameterNames[0]) && $suppliedParametersCount >= $requiredParametersCount) {
return;
}
throw new DatasetArgumentsMismatch($requiredParametersCount, $suppliedParametersCount);
}
/**
* @throws Throwable
*/
private function __callClosure(Closure $closure, array $arguments): mixed
{
return ExceptionTrace::ensure(fn (): mixed => call_user_func_array(Closure::bind($closure, $this, $this::class), $arguments));
}
/**
* Uses the given preset on the test.
*/
public function preset(): Preset
{
return new Preset;
}
#[PostCondition]
protected function __MarkTestIncompleteIfSnapshotHaveChanged(): void
{
if (count($this->__snapshotChanges) === 0) {
return;
}
$this->markTestIncomplete(implode('. ', $this->__snapshotChanges));
}
/**
* The printable test case name.
*/
public static function getPrintableTestCaseName(): string
{
return preg_replace('/P\\\/', '', self::class, 1);
}
/**
* The printable test case method name.
*/
public function getPrintableTestCaseMethodName(): string
{
return $this->__description;
}
/**
* The latest printable test case method name.
*/
public static function getLatestPrintableTestCaseMethodName(): string
{
return self::$__latestDescription ?? '';
}
/**
* The printable test case method context.
*/
public static function getPrintableContext(): array
{
return [
'assignees' => self::$__latestAssignees,
'issues' => self::$__latestIssues,
'prs' => self::$__latestPrs,
'notes' => self::$__latestNotes,
];
}
/**
* Opens a shell for the test case.
*/
public function shell(): void
{
Shell::open();
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Pest;
use Pest\PendingCalls\UsesCall;
/**
* @internal
*
* @mixin UsesCall
*/
final readonly class Configuration
{
/**
* The filename of the configuration.
*/
private string $filename;
/**
* Creates a new configuration instance.
*/
public function __construct(
string $filename,
) {
$this->filename = str_ends_with($filename, DIRECTORY_SEPARATOR.'Pest.php') ? dirname($filename) : $filename;
}
/**
* Use the given classes and traits in the given targets.
*/
public function in(string ...$targets): UsesCall
{
return (new UsesCall($this->filename, []))->in(...$targets);
}
/**
* Depending on where is called, it will extend the given classes and traits globally or locally.
*/
public function extend(string ...$classAndTraits): UsesCall
{
return new UsesCall(
$this->filename,
array_values($classAndTraits)
);
}
/**
* Depending on where is called, it will extend the given classes and traits globally or locally.
*/
public function extends(string ...$classAndTraits): UsesCall
{
return $this->extend(...$classAndTraits);
}
/**
* Depending on where is called, it will add the given groups globally or locally.
*/
public function group(string ...$groups): UsesCall
{
return (new UsesCall($this->filename, []))->group(...$groups);
}
/**
* Depending on where is called, it will extend the given classes and traits globally or locally.
*/
public function use(string ...$classAndTraits): UsesCall
{
return $this->extend(...$classAndTraits);
}
/**
* Depending on where is called, it will extend the given classes and traits globally or locally.
*/
public function uses(string ...$classAndTraits): UsesCall
{
return $this->extends(...$classAndTraits);
}
/**
* Gets the printer configuration.
*/
public function printer(): Configuration\Printer
{
return new Configuration\Printer;
}
/**
* Gets the presets configuration.
*/
public function presets(): Configuration\Presets
{
return new Configuration\Presets;
}
/**
* Gets the project configuration.
*/
public function project(): Configuration\Project
{
return Configuration\Project::getInstance();
}
/**
* Gets the browser configuration.
*/
public function browser(): Browser\Configuration
{
return new Browser\Configuration;
}
/**
* Proxies calls to the uses method.
*
* @param array<array-key, mixed> $arguments
*/
public function __call(string $name, array $arguments): mixed
{
return $this->uses()->$name(...$arguments); // @phpstan-ignore-line
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Configuration;
use Closure;
use Pest\Preset;
final class Presets
{
/**
* Creates a custom preset instance, and adds it to the list of presets.
*/
public function custom(string $name, Closure $execute): void
{
Preset::custom($name, $execute);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Pest\Configuration;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
/**
* @internal
*/
final readonly class Printer
{
/**
* Sets the theme to compact.
*/
public function compact(): self
{
DefaultPrinter::compact(true);
return $this;
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Pest\Configuration;
/**
* @internal
*/
final class Project
{
/**
* The assignees link.
*
* @internal
*/
public string $assignees = '';
/**
* The issues link.
*
* @internal
*/
public string $issues = '';
/**
* The PRs link.
*
* @internal
*/
public string $prs = '';
/**
* The singleton instance.
*/
private static ?self $instance = null;
/**
* Creates a new instance of the project.
*/
public static function getInstance(): self
{
return self::$instance ??= new self;
}
/**
* Sets the test project to GitHub.
*/
public function github(string $project): self
{
$this->issues = "https://github.com/{$project}/issues/%s";
$this->prs = "https://github.com/{$project}/pull/%s";
$this->assignees = 'https://github.com/%s';
return $this;
}
/**
* Sets the test project to GitLab.
*/
public function gitlab(string $project): self
{
$this->issues = "https://gitlab.com/{$project}/issues/%s";
$this->prs = "https://gitlab.com/{$project}/merge_requests/%s";
$this->assignees = 'https://gitlab.com/%s';
return $this;
}
/**
* Sets the test project to Bitbucket.
*/
public function bitbucket(string $project): self
{
$this->issues = "https://bitbucket.org/{$project}/issues/%s";
$this->prs = "https://bitbucket.org/{$project}/pull-requests/%s";
$this->assignees = 'https://bitbucket.org/%s';
return $this;
}
/**
* Sets the test project to Jira.
*/
public function jira(string $namespace, string $project): self
{
$this->issues = "https://{$namespace}.atlassian.net/browse/{$project}-%s";
$this->assignees = "https://{$namespace}.atlassian.net/secure/ViewProfile.jspa?name=%s";
return $this;
}
/**
* Sets the test project to custom.
*/
public function custom(string $issues, string $prs, string $assignees): self
{
$this->issues = $issues;
$this->prs = $prs;
$this->assignees = $assignees;
return $this;
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Pest\Console;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final readonly class Help
{
/**
* The Command messages.
*
* @var array<int, string>
*/
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',
' <info>--min=<fg=cyan><N></></info> Set the minimum required coverage percentage (<N>), and fail if not met',
' <info>--group=<fg=cyan><name></></info> Only runs tests from the specified group(s)',
];
/**
* Creates a new Console Command instance.
*/
public function __construct(private OutputInterface $output)
{
// ..
}
/**
* Executes the Console Command.
*/
public function __invoke(): void
{
foreach (self::HELP_MESSAGES as $message) {
$this->output->writeln($message);
}
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Pest\Console;
use Pest\Bootstrappers\BootView;
use Pest\Support\View;
use Symfony\Component\Console\Helper\SymfonyQuestionHelper;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
/**
* @internal
*/
final readonly class Thanks
{
/**
* The support options.
*
* @var array<string, string>
*/
private const array FUNDING_MESSAGES = [
'Star' => 'https://github.com/pestphp/pest',
'YouTube' => 'https://youtube.com/@nunomaduro',
'TikTok' => 'https://tiktok.com/@enunomaduro',
'Twitch' => 'https://twitch.tv/nunomaduro',
'LinkedIn' => 'https://linkedin.com/in/nunomaduro',
'Instagram' => 'https://instagram.com/enunomaduro',
'X' => 'https://x.com/enunomaduro',
'Sponsor' => 'https://github.com/sponsors/nunomaduro',
];
/**
* Creates a new Console Command instance.
*/
public function __construct(
private InputInterface $input,
private OutputInterface $output
) {
// ..
}
/**
* Executes the Console Command.
*/
public function __invoke(): void
{
$bootstrapper = new BootView($this->output);
$bootstrapper->boot();
$wantsToSupport = false;
if (getenv('PEST_NO_SUPPORT') !== 'true' && $this->input->isInteractive()) {
$wantsToSupport = (new SymfonyQuestionHelper)->ask(
new ArrayInput([]),
$this->output,
new ConfirmationQuestion(
' <options=bold>Wanna show Pest some love by starring it on GitHub?</>',
false,
)
);
View::render('components.new-line');
foreach (self::FUNDING_MESSAGES as $message => $link) {
View::render('components.two-column-detail', [
'left' => $message,
'right' => $link,
]);
}
View::render('components.new-line');
}
if ($wantsToSupport === true) {
if (PHP_OS_FAMILY === 'Darwin') {
exec('open https://github.com/pestphp/pest');
}
if (PHP_OS_FAMILY === 'Windows') {
exec('start https://github.com/pestphp/pest');
}
if (PHP_OS_FAMILY === 'Linux') {
exec('xdg-open https://github.com/pestphp/pest');
}
}
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface ArchPreset {}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Bootstrapper
{
/**
* Boots the bootstrapper.
*/
public function boot(): void;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use NunoMaduro\Collision\Contracts\Adapters\Phpunit\HasPrintableTestCaseName as BaseHasPrintableTestCaseName;
/**
* @internal
*/
interface HasPrintableTestCaseName extends BaseHasPrintableTestCaseName
{
// ..
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
interface Panicable
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void;
/**
* The exit code to be used.
*/
public function exitCode(): int;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* @internal
*/
interface AddsOutput
{
/**
* Adds output after the Test Suite execution.
*/
public function addOutput(int $exitCode): int;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* @internal
*/
interface Bootable
{
/**
* Boots the plugin.
*/
public function boot(): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* @internal
*/
interface HandlesArguments
{
/**
* Adds arguments before the Test Suite execution.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
public function handleArguments(array $arguments): array;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* @internal
*/
interface HandlesOriginalArguments
{
/**
* Adds original arguments before the Test Suite execution.
*
* @param array<int, string> $arguments
*/
public function handleOriginalArguments(array $arguments): void;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* @internal
*/
interface Terminable
{
/**
* Terminates the plugin.
*/
public function terminate(): void;
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
interface TestCaseFilter
{
/**
* Whether the test case is accepted.
*/
public function accept(string $testCaseFilename): bool;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use Pest\Factories\TestCaseMethodFactory;
interface TestCaseMethodFilter
{
/**
* Whether the test case method is accepted.
*/
public function accept(TestCaseMethodFactory $factory): bool;
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Pest\Evaluators;
use Pest\Factories\Attribute;
/**
* @internal
*/
final class Attributes
{
/**
* Evaluates the given attributes and returns the code.
*
* @param iterable<int, Attribute> $attributes
*/
public static function code(iterable $attributes): string
{
return implode(PHP_EOL, array_map(function (Attribute $attribute): string {
$name = $attribute->name;
if ($attribute->arguments === []) {
return " #[\\{$name}]";
}
$arguments = array_map(fn (string $argument): string => var_export($argument, true), iterator_to_array($attribute->arguments));
return sprintf(' #[\\%s(%s)]', $name, implode(', ', $arguments));
}, iterator_to_array($attributes)));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterAll already exists in the filename `%s`.', $filename));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The afterAll method can not be used within describe functions. Filename `%s`.', $filename));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class AfterBeforeTestFunction extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct('After method cannot be used with before the [test|it] functions in the filename `['.$filename.']`.');
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class BeforeAllAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeAll already exists in the filename `%s`.', $filename));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class BeforeAllWithinDescribe extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The beforeAll method can not be used within describe functions. Filename `%s`.', $filename));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetAlreadyExists extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $name, string $scope)
{
parent::__construct(sprintf('A dataset with the name `%s` already exists in scope [%s].', $name, $scope));
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
final class DatasetArgumentsMismatch extends Exception
{
public function __construct(int $requiredCount, int $suppliedCount)
{
if ($requiredCount <= $suppliedCount) {
parent::__construct('Test argument names and dataset keys do not match');
} else {
parent::__construct(sprintf('Test expects %d arguments but dataset only provides %d', $requiredCount, $suppliedCount));
}
}
//
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetDoesNotExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $name)
{
parent::__construct(sprintf("A dataset with the name `%s` does not exist. You can create it using `dataset('%s', ['a', 'b']);`.", $name, $name));
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use BadFunctionCallException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class DatasetMissing extends BadFunctionCallException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*
* @param array<string, string> $arguments
*/
public function __construct(string $file, string $name, array $arguments)
{
parent::__construct(sprintf(
'A test with the description [%s] has [%d] argument(s) ([%s]) and no dataset(s) provided in [%s]',
$name,
count($arguments),
implode(', ', array_map(static fn (string $arg, string $type): string => sprintf('%s $%s', $type, $arg), array_keys($arguments), $arguments)),
$file,
));
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
/**
* @internal
*/
final class ExpectationNotFound extends Exception
{
/**
* Creates a new ExpectationNotFound instance from the given name.
*/
public static function fromName(string $name): ExpectationNotFound
{
return new self("Expectation [$name] does not exist.");
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use RuntimeException;
/**
* @internal
*/
final class FatalException extends RuntimeException implements RenderlessTrace
{
//
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class FileOrFolderNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $filename)
{
parent::__construct(sprintf('The file or folder with the name `%s` could not be found.', $filename));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException as BaseInvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidArgumentException extends BaseInvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $message)
{
parent::__construct($message, 1);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use LogicException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidExpectation extends LogicException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* @param array<int, string> $methods
*
* @throws self
*/
public static function fromMethods(array $methods): never
{
throw new self(sprintf('Expectation [%s] is not valid.', implode('->', $methods)));
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
/**
* @internal
*/
final class InvalidExpectationValue extends InvalidArgumentException
{
/**
* @throws self
*/
public static function expected(string $type): never
{
throw new self(sprintf('Invalid expectation value type. Expected [%s].', $type));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidOption extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $message)
{
parent::__construct($message, 1);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class InvalidPestCommand extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct()
{
parent::__construct('Please run [./vendor/bin/pest] instead.');
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class MissingDependency extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $feature, string $dependency)
{
parent::__construct(sprintf('The feature "%s" requires "%s".', $feature, $dependency));
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoDirtyTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No "dirty" tests found.',
'',
]);
}
/**
* The exit code to be used.
*/
public function exitCode(): int
{
return 0;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use Exception;
use RuntimeException;
/**
* @internal
*/
final class ShouldNotHappen extends RuntimeException
{
/**
* Creates a new Exception instance.
*/
public function __construct(Exception $exception)
{
$message = $exception->getMessage();
parent::__construct(sprintf(<<<'EOF'
This should not happen - please create an new issue here: https://github.com/pestphp/pest/issues
Issue: %s
PHP version: %s
Operating system: %s
EOF
, $message, phpversion(), PHP_OS), 1, $exception);
}
/**
* Creates a new instance of should not happen without a specific exception.
*/
public static function fromMessage(string $message): ShouldNotHappen
{
return new ShouldNotHappen(new Exception($message));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestAlreadyExist extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $fileName, string $description)
{
parent::__construct(sprintf('A test with the description `%s` already exists in the filename `%s`.', $description, $fileName));
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestCaseAlreadyInUse extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $inUse, string $newOne, string $folder)
{
parent::__construct(sprintf(
'Test case [%s] can not be used. The folder [%s] already uses the test case [%s].',
$newOne,
$folder,
$inUse,
));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestCaseClassOrTraitNotFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $testCaseClass)
{
parent::__construct(sprintf('The class `%s` was not found.', $testCaseClass));
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Factories\TestCaseMethodFactory;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestClosureMustNotBeStatic extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(TestCaseMethodFactory $method)
{
parent::__construct(
sprintf(
'Test closure must not be static. Please remove the [static] keyword from the [%s] method in [%s].',
$method->description,
$method->filename
)
);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class TestDescriptionMissing extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct(string $fileName)
{
parent::__construct(sprintf('Test description is missing in the filename `%s`.', $fileName));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Pest\Expectations;
use Pest\Expectation;
use function expect;
/**
* @internal
*
* @template TValue
*
* @mixin Expectation<TValue>
*/
final class EachExpectation
{
/**
* Indicates if the expectation is the opposite.
*/
private bool $opposite = false;
/**
* Creates an expectation on each item of the iterable "value".
*
* @param Expectation<TValue> $original
*/
public function __construct(private readonly Expectation $original) {}
/**
* Creates a new expectation.
*
* @template TAndValue
*
* @param TAndValue $value
* @return Expectation<TAndValue>
*/
public function and(mixed $value): Expectation
{
return $this->original->and($value);
}
/**
* Creates the opposite expectation for the value.
*
* @return self<TValue>
*/
public function not(): self
{
$this->opposite = true;
return $this;
}
/**
* Dynamically calls methods on the class with the given arguments on each item.
*
* @param array<int|string, mixed> $arguments
* @return self<TValue>
*/
public function __call(string $name, array $arguments): self
{
foreach ($this->original->value as $item) {
/* @phpstan-ignore-next-line */
$this->opposite ? expect($item)->not()->$name(...$arguments) : expect($item)->$name(...$arguments);
}
$this->opposite = false;
return $this;
}
/**
* Dynamically calls methods on the class without any arguments on each item.
*
* @return self<TValue>
*/
public function __get(string $name): self
{
/* @phpstan-ignore-next-line */
return $this->$name();
}
}
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Pest\Expectations;
use Closure;
use Pest\Concerns\Retrievable;
use Pest\Expectation;
/**
* @internal
*
* @template TOriginalValue
* @template TValue
*
* @mixin Expectation<TOriginalValue>
*/
final class HigherOrderExpectation
{
use Retrievable;
/**
* @var Expectation<TValue>|EachExpectation<TValue>
*/
private Expectation|EachExpectation $expectation;
/**
* Indicates if the expectation is the opposite.
*/
private bool $opposite = false;
/**
* Indicates if the expectation should reset the value.
*/
private bool $shouldReset = false;
/**
* Creates a new higher order expectation.
*
* @param Expectation<TOriginalValue> $original
* @param TValue $value
*/
public function __construct(private readonly Expectation $original, mixed $value)
{
$this->expectation = $this->expect($value);
}
/**
* Creates the opposite expectation for the value.
*
* @return self<TOriginalValue, TValue>
*/
public function not(): self
{
$this->opposite = ! $this->opposite;
return $this;
}
/**
* Creates a new Expectation.
*
* @template TExpectValue
*
* @param TExpectValue $value
* @return Expectation<TExpectValue>
*/
public function expect(mixed $value): Expectation
{
return new Expectation($value);
}
/**
* Creates a new expectation.
*
* @template TExpectValue
*
* @param TExpectValue $value
* @return Expectation<TExpectValue>
*/
public function and(mixed $value): Expectation
{
return $this->expect($value);
}
/**
* Scope an expectation callback to the current value in
* the HigherOrderExpectation chain.
*
* @param Closure(Expectation<TValue>): void $expectation
* @return HigherOrderExpectation<TOriginalValue, TOriginalValue>
*/
public function scoped(Closure $expectation): self
{
$expectation->__invoke($this->expectation);
return new self($this->original, $this->original->value);
}
/**
* Creates a new expectation with the decoded JSON value.
*
* @return self<TOriginalValue, array<string|int, mixed>|bool>
*/
public function json(): self
{
return new self($this->original, $this->expectation->json()->value);
}
/**
* Dynamically calls methods on the class with the given arguments.
*
* @param array<int, mixed> $arguments
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
*/
public function __call(string $name, array $arguments): self
{
if (! $this->expectationHasMethod($name)) {
/* @phpstan-ignore-next-line */
return new self($this->original, $this->getValue()->$name(...$arguments));
}
return $this->performAssertion($name, $arguments);
}
/**
* Accesses properties in the value or in the expectation.
*
* @return self<TOriginalValue, mixed>|self<TOriginalValue, TValue>
*/
public function __get(string $name): self
{
if ($name === 'not') {
return $this->not();
}
if (! $this->expectationHasMethod($name)) {
/** @var array<string, mixed>|object $value */
$value = $this->getValue();
return new self($this->original, $this->retrieve($name, $value));
}
return $this->performAssertion($name, []);
}
/**
* Determines if the original expectation has the given method name.
*/
private function expectationHasMethod(string $name): bool
{
if (method_exists($this->original, $name)) {
return true;
}
if ($this->original::hasMethod($name)) {
return true;
}
return $this->original::hasExtend($name);
}
/**
* Retrieve the applicable value based on the current reset condition.
*
* @return TOriginalValue|TValue
*/
private function getValue(): mixed
{
return $this->shouldReset ? $this->original->value : $this->expectation->value;
}
/**
* Performs the given assertion with the current expectation.
*
* @param array<int, mixed> $arguments
* @return self<TOriginalValue, TValue>
*/
private function performAssertion(string $name, array $arguments): self
{
/* @phpstan-ignore-next-line */
$this->expectation = ($this->opposite ? $this->expectation->not() : $this->expectation)->{$name}(...$arguments);
$this->opposite = false;
$this->shouldReset = true;
return $this;
}
}
@@ -0,0 +1,888 @@
<?php
declare(strict_types=1);
namespace Pest\Expectations;
use Attribute;
use Pest\Arch\Contracts\ArchExpectation;
use Pest\Arch\Expectations\Targeted;
use Pest\Arch\Expectations\ToBeUsedIn;
use Pest\Arch\Expectations\ToBeUsedInNothing;
use Pest\Arch\Expectations\ToUse;
use Pest\Arch\GroupArchExpectation;
use Pest\Arch\PendingArchExpectation;
use Pest\Arch\SingleArchExpectation;
use Pest\Arch\Support\FileLineFinder;
use Pest\Exceptions\InvalidExpectation;
use Pest\Expectation;
use Pest\Support\Arr;
use Pest\Support\Exporter;
use Pest\Support\Reflection;
use PHPUnit\Architecture\Elements\ObjectDescription;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExpectationFailedException;
use ReflectionMethod;
use ReflectionProperty;
use Spoofchecker;
use stdClass;
/**
* @internal
*
* @template TValue
*
* @mixin Expectation<TValue>
*/
final readonly class OppositeExpectation
{
/**
* Creates a new opposite expectation.
*
* @param Expectation<TValue> $original
*/
public function __construct(private Expectation $original) {}
/**
* Asserts that the value array not has the provided $keys.
*
* @param array<int, int|string|array<int-string, mixed>> $keys
* @return Expectation<TValue>
*/
public function toHaveKeys(array $keys): Expectation
{
foreach ($keys as $k => $key) {
try {
if (is_array($key)) {
$this->toHaveKeys(array_keys(Arr::dot($key, $k.'.')));
} else {
$this->original->toHaveKey($key);
}
} catch (ExpectationFailedException) {
continue;
}
$this->throwExpectationFailedException('toHaveKey', [$key]);
}
return $this->original;
}
/**
* Asserts that the given expectation target does not use any of the given dependencies.
*
* @param array<int, string>|string $targets
*/
public function toUse(array|string $targets): ArchExpectation
{
/** @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));
}
/**
* Asserts that the given expectation target does not have the given permissions
*/
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Not supported.
*/
public function toHaveLineCountLessThan(): ArchExpectation
{
throw InvalidExpectation::fromMethods(['not', 'toHaveLineCountLessThan']);
}
/**
* Not supported.
*/
public function toHaveMethodsDocumented(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| array_filter(
Reflection::getMethodsFromReflectionClass($object->reflectionClass),
fn (ReflectionMethod $method): bool => (enum_exists($object->name) === false || in_array($method->name, ['from', 'tryFrom', 'cases'], true) === false)
&& realpath($method->getFileName() ?: '/') === realpath($object->path) // @phpstan-ignore-line
&& $method->getDocComment() !== false,
) === [],
'to have methods without documentation / annotations',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
}
/**
* Not supported.
*/
public function toHavePropertiesDocumented(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| array_filter(
Reflection::getPropertiesFromReflectionClass($object->reflectionClass),
fn (ReflectionProperty $property): bool => (enum_exists($object->name) === false || in_array($property->name, ['value', 'name'], true) === false)
&& realpath($property->getDeclaringClass()->getFileName() ?: '/') === realpath($object->path) // @phpstan-ignore-line
&& $property->isPromoted() === false
&& $property->getDocComment() !== false,
) === [],
'to have properties without documentation / annotations',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
}
/**
* Asserts that the given expectation target does not use the "declare(strict_types=1)" declaration.
*/
public function toUseStrictTypes(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target does not use the strict equality operator.
*/
public function toUseStrictEquality(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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, ' !== ')),
);
}
/**
* Asserts that the given expectation target is not final.
*/
public function toBeFinal(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target is not readonly.
*/
public function toBeReadonly(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target is not trait.
*/
public function toBeTrait(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation targets are not traits.
*/
public function toBeTraits(): ArchExpectation
{
return $this->toBeTrait();
}
/**
* Asserts that the given expectation target is not abstract.
*/
public function toBeAbstract(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target does not have a specific method.
*
* @param array<int, string>|string $method
*/
public function toHaveMethod(array|string $method): ArchExpectation
{
$methods = is_array($method) ? $method : [$method];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => array_filter(
$methods,
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')),
);
}
/**
* Asserts that the given expectation target does not have suspicious characters.
*/
public function toHaveSuspiciousCharacters(): ArchExpectation
{
$checker = new Spoofchecker;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => ! $checker->isSuspicious((string) file_get_contents($object->path)),
'to not include suspicious characters',
FileLineFinder::where(fn (string $line): bool => $checker->isSuspicious($line)),
);
}
/**
* Asserts that the given expectation target does not have the given methods.
*
* @param array<int, string> $methods
*/
public function toHaveMethods(array $methods): ArchExpectation
{
return $this->toHaveMethod($methods);
}
/**
* Asserts that the given expectation target not to have the public methods besides the given methods.
*
* @param array<int, string>|string $methods
*/
public function toHavePublicMethodsBesides(array|string $methods): ArchExpectation
{
$methods = is_array($methods) ? $methods : [$methods];
$state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC)
: [];
foreach ($reflectionMethods as $reflectionMethod) {
if (! in_array($reflectionMethod->name, $methods, true)) {
$state->contains = 'public function '.$reflectionMethod->name;
return false;
}
}
return true;
},
$methods === []
? 'not to have public methods'
: sprintf("not to have public methods besides '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
);
}
/**
* Asserts that the given expectation target not to have the public methods.
*/
public function toHavePublicMethods(): ArchExpectation
{
return $this->toHavePublicMethodsBesides([]);
}
/**
* Asserts that the given expectation target not to have the protected methods besides the given methods.
*
* @param array<int, string>|string $methods
*/
public function toHaveProtectedMethodsBesides(array|string $methods): ArchExpectation
{
$methods = is_array($methods) ? $methods : [$methods];
$state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED)
: [];
foreach ($reflectionMethods as $reflectionMethod) {
if (! in_array($reflectionMethod->name, $methods, true)) {
$state->contains = 'protected function '.$reflectionMethod->name;
return false;
}
}
return true;
},
$methods === []
? 'not to have protected methods'
: sprintf("not to have protected methods besides '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
);
}
/**
* Asserts that the given expectation target not to have the protected methods.
*/
public function toHaveProtectedMethods(): ArchExpectation
{
return $this->toHaveProtectedMethodsBesides([]);
}
/**
* Asserts that the given expectation target not to have the private methods besides the given methods.
*
* @param array<int, string>|string $methods
*/
public function toHavePrivateMethodsBesides(array|string $methods): ArchExpectation
{
$methods = is_array($methods) ? $methods : [$methods];
$state = new stdClass;
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
function (ObjectDescription $object) use ($methods, &$state): bool {
$reflectionMethods = isset($object->reflectionClass)
? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE)
: [];
foreach ($reflectionMethods as $reflectionMethod) {
if (! in_array($reflectionMethod->name, $methods, true)) {
$state->contains = 'private function '.$reflectionMethod->name;
return false;
}
}
return true;
},
$methods === []
? 'not to have private methods'
: sprintf("not to have private methods besides '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, (string) $state->contains)),
);
}
/**
* Asserts that the given expectation target not to have the private methods.
*/
public function toHavePrivateMethods(): ArchExpectation
{
return $this->toHavePrivateMethodsBesides([]);
}
/**
* Asserts that the given expectation target is not enum.
*/
public function toBeEnum(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation targets are not enums.
*/
public function toBeEnums(): ArchExpectation
{
return $this->toBeEnum();
}
/**
* Asserts that the given expectation targets is not class.
*/
public function toBeClass(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
fn (ObjectDescription $object): bool => ! class_exists($object->name),
'not to be class',
FileLineFinder::where(fn (string $line): bool => true),
);
}
/**
* Asserts that the given expectation targets are not classes.
*/
public function toBeClasses(): ArchExpectation
{
return $this->toBeClass();
}
/**
* Asserts that the given expectation target is not interface.
*/
public function toBeInterface(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation targets are not interfaces.
*/
public function toBeInterfaces(): ArchExpectation
{
return $this->toBeInterface();
}
/**
* Asserts that the given expectation target to be not subclass of the given class.
*/
public function toExtend(string $class): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target to be not have any parent class.
*/
public function toExtendNothing(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target not to use the given trait.
*/
public function toUseTrait(string $trait): ArchExpectation
{
return $this->toUseTraits($trait);
}
/**
* Asserts that the given expectation target not to use the given traits.
*
* @param array<int, string>|string $traits
*/
public function toUseTraits(array|string $traits): ArchExpectation
{
$traits = is_array($traits) ? $traits : [$traits];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
"not to use traits '".implode("', '", $traits)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target not to implement the given interfaces.
*
* @param array<int, string>|string $interfaces
*/
public function toImplement(array|string $interfaces): ArchExpectation
{
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$original,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"not to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to not implement any interfaces.
*/
public function toImplementNothing(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Not supported.
*/
public function toOnlyImplement(): void
{
throw InvalidExpectation::fromMethods(['not', 'toOnlyImplement']);
}
/**
* Asserts that the given expectation target to not have the given prefix.
*/
public function toHavePrefix(string $prefix): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Asserts that the given expectation target to not have the given suffix.
*/
public function toHaveSuffix(string $suffix): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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')),
);
}
/**
* Not supported.
*/
public function toOnlyUse(): void
{
throw InvalidExpectation::fromMethods(['not', 'toOnlyUse']);
}
/**
* Not supported.
*/
public function toUseNothing(): void
{
throw InvalidExpectation::fromMethods(['not', 'toUseNothing']);
}
/**
* Asserts that the given expectation dependency is not used.
*/
public function toBeUsed(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return ToBeUsedInNothing::make($original);
}
/**
* Asserts that the given expectation dependency is not used by any of the given targets.
*
* @param array<int, string>|string $targets
*/
public function toBeUsedIn(array|string $targets): ArchExpectation
{
/** @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));
}
public function toOnlyBeUsedIn(): void
{
throw InvalidExpectation::fromMethods(['not', 'toOnlyBeUsedIn']);
}
/**
* Asserts that the given expectation dependency is not used.
*/
public function toBeUsedInNothing(): void
{
throw InvalidExpectation::fromMethods(['not', 'toBeUsedInNothing']);
}
/**
* Asserts that the given expectation dependency is not an invokable class.
*/
public function toBeInvokable(): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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'))
);
}
/**
* Asserts that the given expectation target not to have the given attribute.
*/
public function toHaveAttribute(string $attribute): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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'))
);
}
/**
* Handle dynamic method calls into the original expectation.
*
* @param array<int, mixed> $arguments
* @return Expectation<TValue>|Expectation<mixed>|never
*/
public function __call(string $name, array $arguments): Expectation
{
try {
if (! is_object($this->original->value) && method_exists(PendingArchExpectation::class, $name)) {
throw InvalidExpectation::fromMethods(['not', $name]);
}
/* @phpstan-ignore-next-line */
$this->original->{$name}(...$arguments);
} catch (ExpectationFailedException|AssertionFailedError) {
return $this->original;
}
$this->throwExpectationFailedException($name, $arguments);
}
/**
* Handle dynamic properties gets into the original expectation.
*
* @return Expectation<TValue>|Expectation<mixed>|never
*/
public function __get(string $name): Expectation
{
try {
if (! is_object($this->original->value) && method_exists(PendingArchExpectation::class, $name)) {
throw InvalidExpectation::fromMethods(['not', $name]);
}
$this->original->{$name}; // @phpstan-ignore-line
} catch (ExpectationFailedException) {
return $this->original;
}
$this->throwExpectationFailedException($name);
}
/**
* Creates a new expectation failed exception with a nice readable message.
*
* @param array<int, mixed>|string $arguments
*/
public function throwExpectationFailedException(string $name, array|string $arguments = []): never
{
$arguments = is_array($arguments) ? $arguments : [$arguments];
$exporter = Exporter::default();
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.',
$toString($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
));
}
/**
* Asserts that the given expectation target does not have a constructor method.
*/
public function toHaveConstructor(): ArchExpectation
{
return $this->toHaveMethod('__construct');
}
/**
* Asserts that the given expectation target does not have a destructor method.
*/
public function toHaveDestructor(): ArchExpectation
{
return $this->toHaveMethod('__destruct');
}
/**
* Asserts that the given expectation target is not a backed enum of given type.
*/
private function toBeBackedEnum(string $backingType): ArchExpectation
{
/** @var Expectation<array<int, string>|string> $original */
$original = $this->original;
return Targeted::make(
$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',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation targets are not string backed enums.
*/
public function toBeStringBackedEnums(): ArchExpectation
{
return $this->toBeStringBackedEnum();
}
/**
* Asserts that the given expectation targets are not int backed enums.
*/
public function toBeIntBackedEnums(): ArchExpectation
{
return $this->toBeIntBackedEnum();
}
/**
* Asserts that the given expectation target is not a string backed enum.
*/
public function toBeStringBackedEnum(): ArchExpectation
{
return $this->toBeBackedEnum('string');
}
/**
* Asserts that the given expectation target is not an int backed enum.
*/
public function toBeIntBackedEnum(): ArchExpectation
{
return $this->toBeBackedEnum('int');
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Factories;
/**
* @internal
*/
final class Attribute
{
/**
* @param iterable<int, string> $arguments
*/
public function __construct(public string $name, public iterable $arguments)
{
//
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Concerns;
use Pest\Support\HigherOrderMessageCollection;
trait HigherOrderable
{
/**
* The higher order messages that are chainable.
*/
public HigherOrderMessageCollection $chains;
/**
* The higher order messages that are "factory" proxyable.
*/
public HigherOrderMessageCollection $factoryProxies;
/**
* The higher order messages that are proxyable.
*/
public HigherOrderMessageCollection $proxies;
/**
* Boot the higher order properties.
*/
private function bootHigherOrderable(): void
{
$this->chains = new HigherOrderMessageCollection;
$this->factoryProxies = new HigherOrderMessageCollection;
$this->proxies = new HigherOrderMessageCollection;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversClass
{
public function __construct(public string $class) {}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Pest\Factories\Covers;
/**
* @internal
*/
final class CoversFunction
{
public function __construct(public string $function) {}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Pest\Factories;
use ParseError;
use Pest\Concerns;
use Pest\Contracts\HasPrintableTestCaseName;
use Pest\Evaluators\Attributes;
use Pest\Exceptions\DatasetMissing;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestClosureMustNotBeStatic;
use Pest\Exceptions\TestDescriptionMissing;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Support\Reflection;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* @internal
*/
final class TestCaseFactory
{
use HigherOrderable;
/**
* The list of attributes.
*
* @var array<int, Attribute>
*/
public array $attributes = [];
/**
* The FQN of the Test Case class.
*
* @var class-string
*/
public string $class = TestCase::class;
/**
* The list of class methods.
*
* @var array<string, TestCaseMethodFactory>
*/
public array $methods = [];
/**
* The list of class traits.
*
* @var array <int, class-string>
*/
public array $traits = [
Concerns\Testable::class,
Concerns\Expectable::class,
];
/**
* Creates a new Factory instance.
*/
public function __construct(
public string $filename
) {
$this->bootHigherOrderable();
}
public function make(): void
{
$methods = $this->methods;
if ($methods !== []) {
$this->evaluate($this->filename, $methods);
}
}
/**
* Creates a Test Case class using a runtime evaluate.
*
* @param array<string, TestCaseMethodFactory> $methods
*/
public function evaluate(string $filename, array $methods): void
{
if ('\\' === DIRECTORY_SEPARATOR) {
// In case Windows, strtolower drive name, like in UsesCall.
$filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename);
}
$filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename)));
$rootPath = TestSuite::getInstance()->rootPath;
$relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename);
$relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR);
$basename = basename($relativePath, '.php');
$dotPos = strpos($basename, '.');
if ($dotPos !== false) {
$basename = substr($basename, 0, $dotPos);
}
$relativePath = dirname(ucfirst($relativePath)).DIRECTORY_SEPARATOR.$basename;
$relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
// Strip out any %-encoded octets.
$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);
$classFQN = 'P\\'.$relativePath;
if (class_exists($classFQN)) {
return;
}
$hasPrintableTestCaseClassFQN = sprintf('\%s', HasPrintableTestCaseName::class);
$traitsCode = sprintf('use %s;', implode(', ', array_map(
static fn (string $trait): string => sprintf('\%s', $trait), $this->traits))
);
$partsFQN = explode('\\', $classFQN);
$className = array_pop($partsFQN);
$namespace = implode('\\', $partsFQN);
$baseClass = sprintf('\%s', $this->class);
if (trim($className) === '') {
$className = 'InvalidTestName'.Str::random();
}
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
[$this->filename],
),
...$this->attributes,
];
$attributesCode = Attributes::code($this->attributes);
$methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
$methods
));
try {
$classCode = <<<PHP
namespace $namespace;
use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite;
$attributesCode
#[\AllowDynamicProperties]
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode
private static \$__filename = '$filename';
$methodsCode
}
PHP;
eval($classCode);
} catch (ParseError $caught) {
throw new RuntimeException(sprintf(
"Unable to create test case for test file at %s. \n %s",
$filename,
$classCode
), 1, $caught);
}
}
/**
* Adds the given Method to the Test Case.
*/
public function addMethod(TestCaseMethodFactory $method): void
{
if ($method->description === null) {
throw new TestDescriptionMissing($method->filename);
}
if (array_key_exists($method->description, $this->methods)) {
throw new TestAlreadyExist($method->filename, $method->description);
}
if (
$method->closure instanceof \Closure &&
(new \ReflectionFunction($method->closure))->isStatic()
) {
throw new TestClosureMustNotBeStatic($method);
}
if (! $method->receivesArguments()) {
if (! $method->closure instanceof \Closure) {
throw ShouldNotHappen::fromMessage('The test closure may not be empty.');
}
$arguments = Reflection::getFunctionArguments($method->closure);
if ($arguments !== []) {
throw new DatasetMissing($method->filename, $method->description, $arguments);
}
}
$this->methods[$method->description] = $method;
}
/**
* Checks if a test case has a method.
*/
public function hasMethod(string $methodName): bool
{
foreach ($this->methods as $method) {
if ($method->description === null) {
throw ShouldNotHappen::fromMessage('The test description may not be empty.');
}
if ($methodName === Str::evaluable($method->description)) {
return true;
}
}
return false;
}
/**
* Gets a Method by the given name.
*/
public function getMethod(string $methodName): TestCaseMethodFactory
{
foreach ($this->methods as $method) {
if ($method->description === null) {
throw ShouldNotHappen::fromMessage('The test description may not be empty.');
}
if ($methodName === Str::evaluable($method->description)) {
return $method;
}
}
throw ShouldNotHappen::fromMessage(sprintf('Method %s not found.', $methodName));
}
}
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Pest\Factories;
use Closure;
use Pest\Evaluators\Attributes;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class TestCaseMethodFactory
{
use HigherOrderable;
/**
* The list of attributes.
*
* @var array<int, Attribute>
*/
public array $attributes = [];
/**
* The test's describing, if any.
*
* @var array<int, \Pest\Support\Description>
*/
public array $describing = [];
/**
* The test's description, if any.
*/
public ?string $description = null;
/**
* The test's number of repetitions.
*/
public int $repetitions = 1;
/**
* Determines if the test is a "todo".
*/
public bool $todo = false;
/**
* The associated issue numbers.
*
* @var array<int, int>
*/
public array $issues = [];
/**
* The test assignees.
*
* @var array<int, string>
*/
public array $assignees = [];
/**
* The associated PRs numbers.
*
* @var array<int, int>
*/
public array $prs = [];
/**
* The test's notes.
*
* @var array<int, string>
*/
public array $notes = [];
/**
* The test's datasets.
*
* @var array<Closure|iterable<int|string, mixed>|string>
*/
public array $datasets = [];
/**
* The test's dependencies.
*
* @var array<int, string>
*/
public array $depends = [];
/**
* The test's groups.
*
* @var array<int, string>
*/
public array $groups = [];
/**
* @see This property is not actually used in the codebase, it's only here to make Rector happy.
*/
public bool $__ran = false;
/**
* Creates a new test case method factory instance.
*/
public function __construct(
public string $filename,
public ?Closure $closure,
) {
$this->closure ??= function (): void {
(Assert::getCount() > 0 || $this->doesNotPerformAssertions()) ?: self::markTestIncomplete(); // @phpstan-ignore-line
};
$this->bootHigherOrderable();
}
/**
* Sets the test's hooks, and runs any proxy to the test case.
*/
public function setUp(TestCase $concrete): void
{
$concrete::flush(); // @phpstan-ignore-line
if ($this->description === null) {
throw ShouldNotHappen::fromMessage('Description can not be empty.');
}
$testCase = TestSuite::getInstance()->tests->get($this->filename);
assert($testCase instanceof TestCaseFactory);
$testCase->factoryProxies->proxy($concrete);
$this->factoryProxies->proxy($concrete);
}
/**
* Flushes the test case.
*/
public function tearDown(TestCase $concrete): void
{
$concrete::flush(); // @phpstan-ignore-line
}
/**
* Creates the test's closure.
*/
public function getClosure(): Closure
{
$closure = $this->closure;
$testCase = TestSuite::getInstance()->tests->get($this->filename);
assert($testCase instanceof TestCaseFactory);
$method = $this;
return function (...$arguments) use ($testCase, $method, $closure): mixed {
/* @var TestCase $this */
$testCase->proxies->proxy($this);
$method->proxies->proxy($this);
$testCase->chains->chain($this);
$method->chains->chain($this);
$this->__ran = true;
return \Pest\Support\Closure::bind($closure, $this, self::class)(...$arguments);
};
}
/**
* Determine if the test case will receive argument input from Pest, or not.
*/
public function receivesArguments(): bool
{
return $this->datasets !== [] || $this->depends !== [] || $this->repetitions > 1;
}
/**
* Creates a PHPUnit method as a string ready for evaluation.
*/
public function buildForEvaluation(): string
{
if ($this->description === null) {
throw ShouldNotHappen::fromMessage('The test description may not be empty.');
}
$methodName = Str::evaluable($this->description);
$datasetsCode = '';
$this->attributes = [
new Attribute(
\PHPUnit\Framework\Attributes\Test::class,
[],
),
new Attribute(
\PHPUnit\Framework\Attributes\TestDox::class,
[str_replace('*/', '{@*}', $this->description)],
),
...$this->attributes,
];
foreach ($this->depends as $depend) {
$depend = Str::evaluable($this->describing === [] ? $depend : Str::describe($this->describing, $depend));
$this->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Depends::class,
[$depend],
);
}
if ($this->datasets !== [] || $this->repetitions > 1) {
$dataProviderName = $methodName.'_dataset';
$this->attributes[] = new Attribute(
DataProvider::class,
[$dataProviderName],
);
$datasetsCode = $this->buildDatasetForEvaluation($methodName, $dataProviderName);
}
$attributesCode = Attributes::code($this->attributes);
return <<<PHP
$attributesCode
public function $methodName(...\$arguments)
{
return \$this->__runTest(
\$this->__test,
...\$arguments,
);
}
$datasetsCode
PHP;
}
/**
* Creates a PHPUnit Data Provider as a string ready for evaluation.
*/
private function buildDatasetForEvaluation(string $methodName, string $dataProviderName): string
{
$datasets = $this->datasets;
if ($this->repetitions > 1) {
$datasets = [range(1, $this->repetitions), ...$datasets];
}
DatasetsRepository::with($this->filename, $methodName, $datasets);
return <<<EOF
public static function $dataProviderName()
{
return __PestDatasets::get(self::\$__filename, "$methodName");
}
EOF;
}
}
@@ -0,0 +1,332 @@
<?php
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;
use Pest\PendingCalls\BeforeEachCall;
use Pest\PendingCalls\DescribeCall;
use Pest\PendingCalls\TestCall;
use Pest\PendingCalls\UsesCall;
use Pest\Repositories\DatasetsRepository;
use Pest\Support\Backtrace;
use Pest\Support\Container;
use Pest\Support\DatasetInfo;
use Pest\Support\Description;
use Pest\Support\HigherOrderTapProxy;
use Pest\TestSuite;
use PHPUnit\Framework\TestCase;
if (! function_exists('expect')) {
/**
* Creates a new expectation.
*
* @template TValue
*
* @param TValue|null $value
* @return Expectation<TValue|null>
*/
function expect(mixed $value = null): Expectation
{
return new Expectation($value);
}
}
if (! function_exists('beforeAll')) {
/**
* Runs the given closure before all tests in the current file.
*/
function beforeAll(Closure $closure): void
{
if (DescribeCall::describing() !== []) {
$filename = Backtrace::file();
throw new BeforeAllWithinDescribe($filename);
}
TestSuite::getInstance()->beforeAll->set($closure);
}
}
if (! function_exists('beforeEach')) {
/**
* Runs the given closure before each test in the current file.
*
* @param-closure-this TestCase $closure
*
* @return HigherOrderTapProxy<Expectable|TestCall|TestCase>|Expectable|TestCall|TestCase|mixed
*/
function beforeEach(?Closure $closure = null): BeforeEachCall
{
$filename = Backtrace::file();
return new BeforeEachCall(TestSuite::getInstance(), $filename, $closure);
}
}
if (! function_exists('dataset')) {
/**
* Registers the given dataset.
*
* @param Closure|iterable<int|string, mixed> $dataset
*/
function dataset(string $name, Closure|iterable $dataset): void
{
$scope = DatasetInfo::scope(Backtrace::datasetsFile());
DatasetsRepository::set($name, $dataset, $scope);
}
}
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
{
$filename = Backtrace::testFile();
return new DescribeCall(TestSuite::getInstance(), $filename, new Description($description), $tests);
}
}
if (! function_exists('uses')) {
/**
* The uses function binds the given
* arguments to test closures.
*
* @param class-string ...$classAndTraits
*/
function uses(string ...$classAndTraits): UsesCall
{
$filename = Backtrace::file();
return new UsesCall($filename, array_values($classAndTraits));
}
}
if (! function_exists('pest')) {
/**
* Creates a new Pest configuration instance.
*/
function pest(): Configuration
{
return new Configuration(Backtrace::file());
}
}
if (! function_exists('test')) {
/**
* Adds the given closure as a test. The first argument
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @param-closure-this TestCase $closure
*
* @return Expectable|TestCall|TestCase|mixed
*/
function test(?string $description = null, ?Closure $closure = null): HigherOrderTapProxy|TestCall
{
if ($description === null && TestSuite::getInstance()->test instanceof \PHPUnit\Framework\TestCase) {
return new HigherOrderTapProxy(TestSuite::getInstance()->test);
}
$filename = Backtrace::testFile();
return new TestCall(TestSuite::getInstance(), $filename, $description, $closure);
}
}
if (! function_exists('it')) {
/**
* Adds the given closure as a test. The first argument
* is the test description; the second argument is
* a closure that contains the test expectations.
*
* @param-closure-this TestCase $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;
}
}
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();
}
}
if (! function_exists('afterEach')) {
/**
* Runs the given closure after each test in the current file.
*
* @param-closure-this TestCase $closure
*
* @return Expectable|HigherOrderTapProxy<Expectable|TestCall|TestCase>|TestCall|mixed
*/
function afterEach(?Closure $closure = null): AfterEachCall
{
$filename = Backtrace::file();
return new AfterEachCall(TestSuite::getInstance(), $filename, $closure);
}
}
if (! function_exists('afterAll')) {
/**
* Runs the given closure after all tests in the current file.
*/
function afterAll(Closure $closure): void
{
if (DescribeCall::describing() !== []) {
$filename = Backtrace::file();
throw new AfterAllWithinDescribe($filename);
}
TestSuite::getInstance()->afterAll->set($closure);
}
}
if (! function_exists('covers')) {
/**
* Specifies which classes, or functions, a test case covers.
*
* @param array<int, string>|string $classesOrFunctions
*/
function covers(array|string ...$classesOrFunctions): void
{
$filename = Backtrace::file();
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
$beforeEachCall->covers(...$classesOrFunctions);
$beforeEachCall->group('__pest_mutate_only');
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if ($runner->isEnabled() && ! $everything && ! is_array($classes) && ! is_array($paths)) {
$beforeEachCall->only('__pest_mutate_only');
}
}
}
if (! function_exists('mutates')) {
/**
* Specifies which classes, enums, or traits a test case mutates.
*
* @param array<int, string>|string $targets
*/
function mutates(array|string ...$targets): void
{
$filename = Backtrace::file();
$beforeEachCall = (new BeforeEachCall(TestSuite::getInstance(), $filename));
$beforeEachCall->group('__pest_mutate_only');
/** @var MutationTestRunner $runner */
$runner = Container::getInstance()->get(MutationTestRunner::class);
/** @var \Pest\Mutate\Repositories\ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$everything = $configurationRepository->cliConfiguration->toArray()['everything'] ?? false;
$classes = $configurationRepository->cliConfiguration->toArray()['classes'] ?? false;
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if ($runner->isEnabled() && ! $everything && ! is_array($classes) && ! is_array($paths)) {
$beforeEachCall->only('__pest_mutate_only');
}
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if (! is_array($paths)) {
$configurationRepository->globalConfiguration('default')->class(...$targets); // @phpstan-ignore-line
}
}
}
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);
}
}
@@ -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');
}
}
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace Pest;
use NunoMaduro\Collision\Writer;
use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\FatalException;
use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Plugins\Actions\CallsAddsOutput;
use Pest\Plugins\Actions\CallsBoot;
use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Plugins\Actions\CallsHandleOriginalArguments;
use Pest\Plugins\Actions\CallsTerminable;
use Pest\Support\Container;
use Pest\Support\Reflection;
use Pest\Support\View;
use PHPUnit\TestRunner\TestResult\Facade;
use PHPUnit\TextUI\Application;
use PHPUnit\TextUI\Configuration\Registry;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use Whoops\Exception\Inspector;
/**
* @internal
*/
final readonly class Kernel
{
/**
* The Kernel bootstrappers.
*
* @var array<int, class-string>
*/
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class,
Bootstrappers\BootKernelDump::class,
Bootstrappers\BootExcludeList::class,
];
/**
* Creates a new Kernel instance.
*/
public function __construct(
private Application $application,
private OutputInterface $output,
) {
//
}
/**
* Boots the Kernel.
*/
public static function boot(TestSuite $testSuite, InputInterface $input, OutputInterface $output): self
{
$container = Container::getInstance();
$container
->add(TestSuite::class, $testSuite)
->add(InputInterface::class, $input)
->add(OutputInterface::class, $output)
->add(Container::class, $container);
$kernel = new self(
new Application,
$output,
);
register_shutdown_function($kernel->shutdown(...));
foreach (self::BOOTSTRAPPERS as $bootstrapper) {
$bootstrapper = Container::getInstance()->get($bootstrapper);
assert($bootstrapper instanceof Bootstrapper);
$bootstrapper->boot();
}
CallsBoot::execute();
Container::getInstance()->add(self::class, $kernel);
return $kernel;
}
/**
* Runs the application, and returns the exit code.
*
* @param array<int, string> $originalArguments
* @param array<int, string> $arguments
*/
public function handle(array $originalArguments, array $arguments): int
{
CallsHandleOriginalArguments::execute($originalArguments);
$arguments = CallsHandleArguments::execute($arguments);
try {
$this->application->run($arguments);
} catch (NoDirtyTestsFound) {
$this->output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
'',
]);
}
$configuration = Registry::get();
$result = Facade::result();
return CallsAddsOutput::execute(
Result::exitCode($configuration, $result),
);
}
/**
* Terminate the Kernel.
*/
public function terminate(): void
{
$preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump);
$preBufferOutput->terminate();
CallsTerminable::execute();
}
/**
* Shutdowns unexpectedly the Kernel.
*/
public function shutdown(): void
{
$this->terminate();
if (is_array($error = error_get_last())) {
if (! in_array($error['type'], [E_ERROR, E_CORE_ERROR], true)) {
return;
}
$message = $error['message'];
$file = $error['file'];
$line = $error['line'];
try {
$writer = new Writer(null, $this->output);
$throwable = new FatalException($message);
Reflection::setPropertyValue($throwable, 'line', $line);
Reflection::setPropertyValue($throwable, 'file', $file);
$inspector = new Inspector($throwable);
$writer->write($inspector);
} catch (Throwable) { // @phpstan-ignore-line
View::render('components.badge', [
'type' => 'ERROR',
'content' => sprintf('%s in %s:%d', $message, $file, $line),
]);
}
exit(1);
}
}
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Pest;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface;
final class KernelDump
{
/**
* The output buffer, if any.
*/
private string $buffer = '';
/**
* Creates a new Kernel Dump instance.
*/
public function __construct(
private readonly OutputInterface $output,
) {
// ...
}
/**
* Enable the output buffering.
*/
public function enable(): void
{
ob_start(function (string $message): string {
$this->buffer .= $message;
return '';
});
}
/**
* Disable the output buffering.
*/
public function disable(): void
{
@ob_clean();
if ($this->buffer !== '') {
$this->flush();
}
}
/**
* Terminate the output buffering.
*/
public function terminate(): void
{
$this->disable();
}
/**
* Flushes the buffer.
*/
private function flush(): void
{
View::renderUsing($this->output);
if ($this->isOpeningHeadline($this->buffer)) {
$this->buffer = implode(PHP_EOL, array_slice(explode(PHP_EOL, $this->buffer), 2));
}
$type = 'INFO';
if ($this->isInternalError($this->buffer)) {
$type = 'ERROR';
$this->buffer = str_replace(
sprintf('An error occurred inside PHPUnit.%s%sMessage: ', PHP_EOL, PHP_EOL), '', $this->buffer,
);
}
$this->buffer = trim($this->buffer);
$this->buffer = rtrim($this->buffer, '.').'.';
$lines = explode(PHP_EOL, $this->buffer);
$lines = array_reverse($lines);
$firstLine = array_pop($lines);
$lines = array_reverse($lines);
View::render('components.badge', [
'type' => $type,
'content' => $firstLine,
]);
$this->output->writeln($lines);
$this->buffer = '';
}
/**
* Checks if the given output contains an opening headline.
*/
private function isOpeningHeadline(string $output): bool
{
return str_contains($output, 'by Sebastian Bergmann and contributors.');
}
/**
* Checks if the given output contains an opening headline.
*/
private function isInternalError(string $output): bool
{
return str_contains($output, 'An error occurred inside PHPUnit.')
|| str_contains($output, 'Fatal error');
}
}
@@ -0,0 +1,275 @@
<?php
declare(strict_types=1);
namespace Pest\Logging;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Support\StateGenerator;
use Pest\Support\Str;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Event\TestSuite\TestSuiteForTestMethodWithDataProvider;
use PHPUnit\Framework\Exception as FrameworkException;
use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult;
/**
* @internal
*/
final readonly class Converter
{
/**
* The prefix for the test suite name.
*/
private const string PREFIX = 'P\\';
/**
* The state generator.
*/
private StateGenerator $stateGenerator;
/**
* Creates a new instance of the Converter.
*/
public function __construct(
private string $rootPath,
) {
$this->stateGenerator = new StateGenerator;
}
/**
* Gets the test case method name.
*/
public function getTestCaseMethodName(Test $test): string
{
if (! $test instanceof TestMethod) {
throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
}
return $test->testDox()->prettifiedMethodName();
}
/**
* Gets the test case location.
*/
public function getTestCaseLocation(Test $test): string
{
if (! $test instanceof TestMethod) {
throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
}
$path = $test->testDox()->prettifiedClassName();
$relativePath = $this->toRelativePath($path);
// TODO: Get the description without the dataset.
$description = $test->testDox()->prettifiedMethodName();
return "$relativePath::$description";
}
/**
* Gets the exception message.
*/
public function getExceptionMessage(Throwable $throwable): string
{
if (is_a($throwable->className(), FrameworkException::class, true)) {
return $throwable->message();
}
$buffer = $throwable->className();
$throwableMessage = $throwable->message();
if ($throwableMessage !== '') {
$buffer .= ": $throwableMessage";
}
return $buffer;
}
/**
* Gets the exception details.
*/
public function getExceptionDetails(Throwable $throwable): string
{
$buffer = $this->getStackTrace($throwable);
while ($throwable->hasPrevious()) {
$throwable = $throwable->previous();
$buffer .= sprintf(
"\nCaused by\n%s\n%s",
$throwable->description(),
$this->getStackTrace($throwable)
);
}
return $buffer;
}
/**
* Gets the stack trace.
*/
public function getStackTrace(Throwable $throwable): string
{
$stackTrace = $throwable->stackTrace();
// Split stacktrace per frame.
$frames = explode("\n", $stackTrace);
// Remove empty lines
$frames = array_filter($frames);
// clean the paths of each frame.
$frames = array_map(
$this->toRelativePath(...),
$frames
);
// Format stacktrace as `at <path>`
$frames = array_map(
fn (string $frame): string => "at $frame",
$frames
);
return implode("\n", $frames);
}
/**
* Gets the test suite name.
*/
public function getTestSuiteName(TestSuite $testSuite): string
{
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$firstTest = $this->getFirstTest($testSuite);
if ($firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
return $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
}
}
$name = $testSuite->name();
if (! str_starts_with($name, self::PREFIX)) {
return $name;
}
return Str::after($name, self::PREFIX);
}
/**
* Gets the trimmed test class name.
*/
public function getTrimmedTestClassName(TestMethod $test): string
{
return Str::after($test->className(), self::PREFIX);
}
/**
* Gets the test suite location.
*/
public function getTestSuiteLocation(TestSuite $testSuite): ?string
{
$firstTest = $this->getFirstTest($testSuite);
if (! $firstTest instanceof \PHPUnit\Event\Code\TestMethod) {
return null;
}
$path = $firstTest->testDox()->prettifiedClassName();
$classRelativePath = $this->toRelativePath($path);
if ($testSuite instanceof TestSuiteForTestMethodWithDataProvider) {
$methodName = $this->getTestMethodNameWithoutDatasetSuffix($firstTest);
return "$classRelativePath::$methodName";
}
return $classRelativePath;
}
/**
* Gets the prettified test method name without dataset-related suffix.
*/
private function getTestMethodNameWithoutDatasetSuffix(TestMethod $testMethod): string
{
return Str::beforeLast($testMethod->testDox()->prettifiedMethodName(), ' with data set ');
}
/**
* Gets the first test from the test suite.
*/
private function getFirstTest(TestSuite $testSuite): ?TestMethod
{
$tests = $testSuite->tests()->asArray();
// TODO: figure out how to get the file path without a test being there.
if ($tests === []) {
return null;
}
$firstTest = $tests[0];
if (! $firstTest instanceof TestMethod) {
throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
}
return $firstTest;
}
/**
* Gets the test suite size.
*/
public function getTestSuiteSize(TestSuite $testSuite): int
{
return $testSuite->count();
}
/**
* Transforms the given path in relative path.
*/
private function toRelativePath(string $path): string
{
// Remove cwd from the path.
return str_replace("$this->rootPath".DIRECTORY_SEPARATOR, '', $path);
}
/**
* Get the test result.
*/
public function getStateFromResult(PhpUnitTestResult $result): State
{
$events = [
...$result->testErroredEvents(),
...$result->testFailedEvents(),
...$result->testSkippedEvents(),
...array_merge(...array_values($result->testConsideredRiskyEvents())),
...$result->testMarkedIncompleteEvents(),
];
$numberOfNotPassedTests = count(
array_unique(
array_map(
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) {
return $event->testClassName();
}
return $this->getTestCaseLocation($event->test());
},
$events
)
)
);
$numberOfPassedTests = $result->numberOfTestsRun() - $numberOfNotPassedTests;
return $this->stateGenerator->fromPhpUnitTestResult($numberOfPassedTests, $result);
}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity;
/**
* @internal
*/
final class ServiceMessage
{
/**
* The flow ID.
*/
private static ?int $flowId = null;
/**
* @param array<string, string|int|null> $parameters
*/
public function __construct(
private readonly string $type,
private readonly array $parameters,
) {}
public function toString(): string
{
$paramsToString = '';
foreach ([...$this->parameters, 'flowId' => self::$flowId] as $key => $value) {
$value = $this->escapeServiceMessage((string) $value);
$paramsToString .= " $key='$value'";
}
return "##teamcity[$this->type$paramsToString]";
}
public static function testSuiteStarted(string $name, ?string $location): self
{
return new self('testSuiteStarted', [
'name' => $name,
'locationHint' => $location === null ? null : "pest_qn://$location",
]);
}
public static function testSuiteCount(int $count): self
{
return new self('testCount', [
'count' => $count,
]);
}
public static function testSuiteFinished(string $name): self
{
return new self('testSuiteFinished', [
'name' => $name,
]);
}
public static function testStarted(string $name, string $location): self
{
return new self('testStarted', [
'name' => $name,
'locationHint' => "pest_qn://$location",
]);
}
/**
* @param int $duration in milliseconds
*/
public static function testFinished(string $name, int $duration): self
{
return new self('testFinished', [
'name' => $name,
'duration' => $duration,
]);
}
public static function testStdOut(string $name, string $data): self
{
if (! str_ends_with($data, "\n")) {
$data .= "\n";
}
return new self('testStdOut', [
'name' => $name,
'out' => $data,
]);
}
public static function testFailed(string $name, string $message, string $details): self
{
return new self('testFailed', [
'name' => $name,
'message' => $message,
'details' => $details,
]);
}
public static function testStdErr(string $name, string $data): self
{
if (! str_ends_with($data, "\n")) {
$data .= "\n";
}
return new self('testStdErr', [
'name' => $name,
'out' => $data,
]);
}
public static function testIgnored(string $name, string $message, ?string $details = null): self
{
return new self('testIgnored', [
'name' => $name,
'message' => $message,
'details' => $details,
]);
}
public static function comparisonFailure(string $name, string $message, string $details, string $actual, string $expected): self
{
return new self('testFailed', [
'name' => $name,
'message' => $message,
'details' => $details,
'type' => 'comparisonFailure',
'actual' => $actual,
'expected' => $expected,
]);
}
private function escapeServiceMessage(string $text): string
{
return str_replace(
['|', "'", "\n", "\r", ']', '['],
['||', "|'", '|n', '|r', '|]', '|['],
$text
);
}
public static function setFlowId(int $flowId): void
{
self::$flowId = $flowId;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use Pest\Logging\TeamCity\TeamCityLogger;
/**
* @internal
*/
abstract class Subscriber // @pest-arch-ignore-line
{
/**
* Creates a new Subscriber instance.
*/
public function __construct(private readonly TeamCityLogger $logger) {}
/**
* Creates a new TeamCityLogger instance.
*/
final protected function logger(): TeamCityLogger // @pest-arch-ignore-line
{
return $this->logger;
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
/**
* @internal
*/
final class TestConsideredRiskySubscriber extends Subscriber implements ConsideredRiskySubscriber
{
public function notify(ConsideredRisky $event): void
{
$this->logger()->testConsideredRisky($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final class TestErroredSubscriber extends Subscriber implements ErroredSubscriber
{
public function notify(Errored $event): void
{
$this->logger()->testErrored($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\TestRunner\ExecutionFinished;
use PHPUnit\Event\TestRunner\ExecutionFinishedSubscriber;
/**
* @internal
*/
final class TestExecutionFinishedSubscriber extends Subscriber implements ExecutionFinishedSubscriber
{
public function notify(ExecutionFinished $event): void
{
$this->logger()->testExecutionFinished($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class TestFailedSubscriber extends Subscriber implements FailedSubscriber
{
public function notify(Failed $event): void
{
$this->logger()->testFailed($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* @internal
*/
final class TestFinishedSubscriber extends Subscriber implements FinishedSubscriber
{
public function notify(Finished $event): void
{
$this->logger()->testFinished($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
/**
* @internal
*/
final class TestPreparedSubscriber extends Subscriber implements PreparedSubscriber
{
public function notify(Prepared $event): void
{
$this->logger()->testPrepared($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
/**
* @internal
*/
final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber
{
public function notify(Skipped $event): void
{
$this->logger()->testSkipped($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\FinishedSubscriber;
/**
* @internal
*/
final class TestSuiteFinishedSubscriber extends Subscriber implements FinishedSubscriber
{
public function notify(Finished $event): void
{
$this->logger()->testSuiteFinished($event);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity\Subscriber;
use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\TestSuite\StartedSubscriber;
/**
* @internal
*/
final class TestSuiteStartedSubscriber extends Subscriber implements StartedSubscriber
{
public function notify(Started $event): void
{
$this->logger()->testSuiteStarted($event);
}
}
@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
namespace Pest\Logging\TeamCity;
use NunoMaduro\Collision\Adapters\Phpunit\Style;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Logging\Converter;
use Pest\Logging\TeamCity\Subscriber\TestConsideredRiskySubscriber;
use Pest\Logging\TeamCity\Subscriber\TestErroredSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestExecutionFinishedSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestFailedSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestFinishedSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestPreparedSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestSkippedSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestSuiteFinishedSubscriber;
use Pest\Logging\TeamCity\Subscriber\TestSuiteStartedSubscriber;
use PHPUnit\Event\Code\Test;
use PHPUnit\Event\EventFacadeIsSealedException;
use PHPUnit\Event\Facade;
use PHPUnit\Event\Telemetry\Duration;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\Info;
use PHPUnit\Event\Telemetry\Snapshot;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\TestRunner\ExecutionFinished;
use PHPUnit\Event\TestSuite\Finished as TestSuiteFinished;
use PHPUnit\Event\TestSuite\Started as TestSuiteStarted;
use PHPUnit\Event\UnknownSubscriberTypeException;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use ReflectionClass;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class TeamCityLogger
{
/**
* The current time.
*/
private ?HRTime $time = null;
/**
* Indicates if the summary test count has been printed.
*/
private bool $isSummaryTestCountPrinted = false;
/**
* @var array<string, bool>
*/
private array $testEvents = [];
/**
* @throws EventFacadeIsSealedException
* @throws UnknownSubscriberTypeException
*/
public function __construct(
private readonly OutputInterface $output,
private readonly Converter $converter,
private readonly ?int $flowId,
private readonly bool $withoutDuration,
) {
$this->registerSubscribers();
$this->setFlowId();
}
public function testSuiteStarted(TestSuiteStarted $event): void
{
$message = ServiceMessage::testSuiteStarted(
$this->converter->getTestSuiteName($event->testSuite()),
$this->converter->getTestSuiteLocation($event->testSuite())
);
$this->output($message);
if (! $this->isSummaryTestCountPrinted) {
$this->isSummaryTestCountPrinted = true;
$message = ServiceMessage::testSuiteCount(
$this->converter->getTestSuiteSize($event->testSuite())
);
$this->output($message);
}
}
public function testSuiteFinished(TestSuiteFinished $event): void
{
$message = ServiceMessage::testSuiteFinished(
$this->converter->getTestSuiteName($event->testSuite()),
);
$this->output($message);
}
public function testPrepared(Prepared $event): void
{
$message = ServiceMessage::testStarted(
$this->converter->getTestCaseMethodName($event->test()),
$this->converter->getTestCaseLocation($event->test()),
);
$this->output($message);
$this->time = $event->telemetryInfo()->time();
}
public function testMarkedIncomplete(): never
{
throw ShouldNotHappen::fromMessage('testMarkedIncomplete not implemented.');
}
public function testSkipped(Skipped $event): void
{
$this->whenFirstEventForTest($event->test(), function () use ($event): void {
$message = ServiceMessage::testIgnored(
$this->converter->getTestCaseMethodName($event->test()),
'This test was ignored.'
);
$this->output($message);
});
}
/**
* This will trigger in the following scenarios
* - When an exception is thrown
*/
public function testErrored(Errored $event): void
{
$this->whenFirstEventForTest($event->test(), function () use ($event): void {
$testName = $this->converter->getTestCaseMethodName($event->test());
$message = $this->converter->getExceptionMessage($event->throwable());
$details = $this->converter->getExceptionDetails($event->throwable());
$message = ServiceMessage::testFailed(
$testName,
$message,
$details,
);
$this->output($message);
});
}
/**
* This will trigger in the following scenarios
* - When an assertion fails
*/
public function testFailed(Failed $event): void
{
$this->whenFirstEventForTest($event->test(), function () use ($event): void {
$testName = $this->converter->getTestCaseMethodName($event->test());
$message = $this->converter->getExceptionMessage($event->throwable());
$details = $this->converter->getExceptionDetails($event->throwable());
if ($event->hasComparisonFailure()) {
$comparison = $event->comparisonFailure();
$message = ServiceMessage::comparisonFailure(
$testName,
$message,
$details,
$comparison->actual(),
$comparison->expected()
);
} else {
$message = ServiceMessage::testFailed(
$testName,
$message,
$details,
);
}
$this->output($message);
});
}
/**
* This will trigger in the following scenarios
* - When no assertions in a test
*/
public function testConsideredRisky(ConsideredRisky $event): void
{
$this->whenFirstEventForTest($event->test(), function () use ($event): void {
$message = ServiceMessage::testIgnored(
$this->converter->getTestCaseMethodName($event->test()),
$event->message()
);
$this->output($message);
});
}
public function testFinished(Finished $event): void
{
if (! $this->time instanceof \PHPUnit\Event\Telemetry\HRTime) {
throw ShouldNotHappen::fromMessage('Start time has not been set.');
}
$testName = $this->converter->getTestCaseMethodName($event->test());
$duration = $event->telemetryInfo()->time()->duration($this->time)->asFloat();
if ($this->withoutDuration) {
$duration = 100;
}
$message = ServiceMessage::testFinished(
$testName,
(int) ($duration * 1000)
);
$this->output($message);
}
public function testExecutionFinished(ExecutionFinished $event): void
{
$result = TestResultFacade::result();
$state = $this->converter->getStateFromResult($result);
assert($this->output instanceof ConsoleOutput);
$style = new Style($this->output);
$telemetry = $event->telemetryInfo();
if ($this->withoutDuration) {
$reflector = new ReflectionClass($telemetry);
$property = $reflector->getProperty('current');
$snapshot = $property->getValue($telemetry);
assert($snapshot instanceof Snapshot);
$telemetry = new Info(
$snapshot,
Duration::fromSecondsAndNanoseconds(1, 0),
$telemetry->memoryUsageSinceStart(),
$telemetry->durationSincePrevious(),
$telemetry->memoryUsageSincePrevious(),
);
}
$style->writeRecap($state, $telemetry, $result);
}
public function output(ServiceMessage $message): void
{
$this->output->writeln("{$message->toString()}");
}
/**
* @throws EventFacadeIsSealedException
* @throws UnknownSubscriberTypeException
*/
private function registerSubscribers(): void
{
$subscribers = [
new TestSuiteStartedSubscriber($this),
new TestSuiteFinishedSubscriber($this),
new TestPreparedSubscriber($this),
new TestFinishedSubscriber($this),
new TestErroredSubscriber($this),
new TestFailedSubscriber($this),
new TestSkippedSubscriber($this),
new TestConsideredRiskySubscriber($this),
new TestExecutionFinishedSubscriber($this),
];
Facade::instance()->registerSubscribers(...$subscribers);
}
private function setFlowId(): void
{
if ($this->flowId === null) {
return;
}
ServiceMessage::setFlowId($this->flowId);
}
private function whenFirstEventForTest(Test $test, callable $callback): void
{
$testIdentifier = $this->converter->getTestCaseLocation($test);
if (! isset($this->testEvents[$testIdentifier])) {
$this->testEvents[$testIdentifier] = true;
$callback();
}
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Pest\Matchers;
/**
* @internal
*/
final class Any {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Pest;
use NunoMaduro\Collision\Writer;
use Pest\Exceptions\TestDescriptionMissing;
use Pest\Support\Container;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use Whoops\Exception\Inspector;
final readonly class Panic
{
/**
* Creates a new Panic instance.
*/
private function __construct(
private Throwable $throwable
) {
// ...
}
/**
* Creates a new Panic instance, and exits the application.
*/
public static function with(Throwable $throwable): never
{
if ($throwable instanceof TestDescriptionMissing && ! is_null($previous = $throwable->getPrevious())) {
$throwable = $previous;
}
$panic = new self($throwable);
$panic->handle();
exit(1);
}
/**
* Handles the panic.
*/
private function handle(): void
{
try {
$output = Container::getInstance()->get(OutputInterface::class);
} catch (Throwable) {
$output = new ConsoleOutput;
}
assert($output instanceof OutputInterface);
if ($this->throwable instanceof Contracts\Panicable) {
$this->throwable->render($output);
exit($this->throwable->exitCode());
}
$writer = new Writer(null, $output);
$inspector = new Inspector($this->throwable);
$writer->write($inspector);
$output->writeln('');
exit(1);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls;
use Closure;
use Pest\PendingCalls\Concerns\Describable;
use Pest\Support\Arr;
use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*/
final class AfterEachCall
{
use Describable;
/**
* The "afterEach" closure.
*/
private readonly Closure $closure;
/**
* The calls that should be proxied.
*/
private readonly HigherOrderMessageCollection $proxies;
/**
* Creates a new Pending Call.
*/
public function __construct(
private readonly TestSuite $testSuite,
private readonly string $filename,
?Closure $closure = null
) {
$this->closure = $closure instanceof Closure ? $closure : NullClosure::create();
$this->proxies = new HigherOrderMessageCollection;
$this->describing = DescribeCall::describing();
}
/**
* Creates the Call.
*/
public function __destruct()
{
$describing = $this->describing;
$proxies = $this->proxies;
$afterEachTestCase = ChainableClosure::boundWhen(
fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
ChainableClosure::bound(fn () => $proxies->chain($this), $this->closure)->bindTo($this, self::class),
)->bindTo($this, self::class);
assert($afterEachTestCase instanceof Closure);
$this->testSuite->afterEach->set(
$this->filename,
$this,
$afterEachTestCase,
);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$this->proxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls;
use Closure;
use Pest\Exceptions\AfterBeforeTestFunction;
use Pest\PendingCalls\Concerns\Describable;
use Pest\Support\Arr;
use Pest\Support\Backtrace;
use Pest\Support\ChainableClosure;
use Pest\Support\HigherOrderMessageCollection;
use Pest\Support\NullClosure;
use Pest\TestSuite;
/**
* @internal
*
* @mixin TestCall
*/
final class BeforeEachCall
{
use Describable;
/**
* Holds the before each closure.
*/
private readonly Closure $closure;
/**
* The test call proxies.
*/
private readonly HigherOrderMessageCollection $testCallProxies;
/**
* The test case proxies.
*/
private readonly HigherOrderMessageCollection $testCaseProxies;
/**
* Creates a new Pending Call.
*/
public function __construct(
public readonly TestSuite $testSuite,
private readonly string $filename,
?Closure $closure = null
) {
$this->closure = $closure instanceof Closure ? $closure : NullClosure::create();
$this->testCallProxies = new HigherOrderMessageCollection;
$this->testCaseProxies = new HigherOrderMessageCollection;
$this->describing = DescribeCall::describing();
}
/**
* Creates the Call.
*/
public function __destruct()
{
$describing = $this->describing;
$testCaseProxies = $this->testCaseProxies;
$beforeEachTestCall = function (TestCall $testCall) use ($describing): void {
if ($this->describing !== []) {
if (Arr::last($describing) !== Arr::last($this->describing)) {
return;
}
if (! in_array(Arr::last($describing), $testCall->describing, true)) {
return;
}
}
$this->testCallProxies->chain($testCall);
};
$beforeEachTestCase = ChainableClosure::boundWhen(
fn (): bool => $describing === [] || in_array(Arr::last($describing), $this->__describing, true),
ChainableClosure::bound(fn () => $testCaseProxies->chain($this), $this->closure)->bindTo($this, self::class),
)->bindTo($this, self::class);
assert($beforeEachTestCase instanceof Closure);
$this->testSuite->beforeEach->set(
$this->filename,
$this,
$beforeEachTestCall,
$beforeEachTestCase,
);
}
/**
* Runs the given closure after the test.
*/
public function after(Closure $closure): self
{
if ($this->describing === []) {
throw new AfterBeforeTestFunction($this->filename);
}
return $this->__call('after', [$closure]);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
if (method_exists(TestCall::class, $name)) {
$this->testCallProxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
$this->testCaseProxies
->add(Backtrace::file(), Backtrace::line(), $name, $arguments);
return $this;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls\Concerns;
/**
* @internal
*/
trait Describable
{
/**
* Note: this is property is not used; however, it gets added automatically by rector php.
*
* @var array<int, \Pest\Support\Description>
*/
public array $__describing;
/**
* The describing of the test case.
*
* @var array<int, \Pest\Support\Description>
*/
public array $describing = [];
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls;
use Closure;
use Pest\Support\Backtrace;
use Pest\Support\Description;
use Pest\TestSuite;
/**
* @internal
*/
final class DescribeCall
{
/**
* The current describe call.
*
* @var array<int, Description>
*/
private static array $describing = [];
/**
* The describe "before each" call.
*/
private ?BeforeEachCall $currentBeforeEachCall = null;
/**
* Creates a new Pending Call.
*/
public function __construct(
public readonly TestSuite $testSuite,
public readonly string $filename,
public readonly Description $description,
public readonly Closure $tests
) {
//
}
/**
* What is the current describing.
*
* @return array<int, Description>
*/
public static function describing(): array
{
return self::$describing;
}
/**
* Creates the Call.
*/
public function __destruct()
{
unset($this->currentBeforeEachCall);
self::$describing[] = $this->description;
try {
($this->tests)();
} finally {
array_pop(self::$describing);
}
}
/**
* Dynamically calls methods on each test call.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
$filename = Backtrace::file();
if (! $this->currentBeforeEachCall instanceof \Pest\PendingCalls\BeforeEachCall) {
$this->currentBeforeEachCall = new BeforeEachCall(TestSuite::getInstance(), $filename);
$this->currentBeforeEachCall->describing[] = $this->description;
}
$this->currentBeforeEachCall->{$name}(...$arguments);
return $this;
}
}
@@ -0,0 +1,770 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls;
use Closure;
use Pest\Concerns\Testable;
use Pest\Exceptions\InvalidArgumentException;
use Pest\Exceptions\TestDescriptionMissing;
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;
use Pest\Support\Exporter;
use Pest\Support\HigherOrderCallables;
use Pest\Support\NullClosure;
use Pest\Support\Str;
use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\TestCase;
/**
* @internal
*
* @mixin HigherOrderCallables|TestCase|Testable
*/
final class TestCall // @phpstan-ignore-line
{
use Describable;
/**
* The list of test case factory attributes.
*
* @var array<int, Attribute>
*/
private array $testCaseFactoryAttributes = [];
/**
* The Test Case Factory.
*/
public readonly TestCaseMethodFactory $testCaseMethod;
/**
* If test call is descriptionLess.
*/
private readonly bool $descriptionLess;
/**
* Creates a new Pending Call.
*/
public function __construct(
private readonly TestSuite $testSuite,
private readonly string $filename,
private ?string $description = null,
?Closure $closure = null
) {
$this->testCaseMethod = new TestCaseMethodFactory($filename, $closure);
$this->descriptionLess = $description === null;
$this->describing = DescribeCall::describing();
$this->testSuite->beforeEach->get($this->filename)[0]($this);
}
/**
* Runs the given closure after the test.
*/
public function after(Closure $closure): self
{
if ($this->description === null) {
throw new TestDescriptionMissing($this->filename);
}
$description = $this->describing === []
? $this->description
: Str::describe($this->describing, $this->description);
$filename = $this->filename;
$when = function () use ($closure, $filename, $description): void {
if ($this::$__filename !== $filename) { // @phpstan-ignore-line
return;
}
if ($this->__description !== $description) { // @phpstan-ignore-line
return;
}
if ($this->__ran !== true) { // @phpstan-ignore-line
return;
}
$closure->call($this);
};
new AfterEachCall($this->testSuite, $this->filename, $when->bindTo(new \stdClass));
return $this;
}
/**
* Asserts that the test fails with the given message.
*/
public function fails(?string $message = null): self
{
return $this->throws(AssertionFailedError::class, $message);
}
/**
* Asserts that the test throws the given `$exceptionClass` when called.
*/
public function throws(string|int $exception, ?string $exceptionMessage = null, ?int $exceptionCode = null): self
{
if (is_int($exception)) {
$exceptionCode = $exception;
} elseif (class_exists($exception)) {
$this->testCaseMethod
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectException', [$exception]);
} else {
$exceptionMessage = $exception;
}
if (is_string($exceptionMessage)) {
$this->testCaseMethod
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectExceptionMessage', [$exceptionMessage]);
}
if (is_int($exceptionCode)) {
$this->testCaseMethod
->proxies
->add(Backtrace::file(), Backtrace::line(), 'expectExceptionCode', [$exceptionCode]);
}
return $this;
}
/**
* Asserts that the test throws the given `$exceptionClass` when called if the given condition is true.
*
* @param (callable(): bool)|bool $condition
*/
public function throwsIf(callable|bool $condition, string|int $exception, ?string $exceptionMessage = null, ?int $exceptionCode = null): self
{
$condition = is_callable($condition)
? $condition
: static fn (): bool => $condition;
if ($condition()) {
return $this->throws($exception, $exceptionMessage, $exceptionCode);
}
return $this;
}
/**
* Asserts that the test throws the given `$exceptionClass` when called if the given condition is false.
*
* @param (callable(): bool)|bool $condition
*/
public function throwsUnless(callable|bool $condition, string|int $exception, ?string $exceptionMessage = null, ?int $exceptionCode = null): self
{
$condition = is_callable($condition)
? $condition
: static fn (): bool => $condition;
if (! $condition()) {
return $this->throws($exception, $exceptionMessage, $exceptionCode);
}
return $this;
}
/**
* Runs the current test multiple times with each item of the given `iterable`.
*
* @param Closure|iterable<array-key, mixed>|string $data
*/
public function with(Closure|iterable|string ...$data): self
{
foreach ($data as $dataset) {
$this->testCaseMethod->datasets[] = $dataset;
}
return $this;
}
/**
* Sets the test depends.
*/
public function depends(string ...$depends): self
{
foreach ($depends as $depend) {
$this->testCaseMethod->depends[] = $depend;
}
return $this;
}
/**
* Sets the test group(s).
*/
public function group(string ...$groups): self
{
foreach ($groups as $group) {
$this->testCaseMethod->attributes[] = new Attribute(
\PHPUnit\Framework\Attributes\Group::class,
[$group],
);
}
return $this;
}
/**
* Filters the test suite by "only" tests.
*/
public function only(): self
{
Only::enable($this, ...func_get_args());
return $this;
}
/**
* Skips the current test.
*/
public function skip(Closure|bool|string $conditionOrMessage = true, string $message = ''): self
{
$condition = is_string($conditionOrMessage)
? NullClosure::create()
: $conditionOrMessage;
$condition = is_callable($condition)
? $condition
: fn (): bool => $condition;
$message = is_string($conditionOrMessage)
? $conditionOrMessage
: $message;
/** @var callable(): bool $condition */
$condition = $condition->bindTo(null);
$this->testCaseMethod
->chains
->addWhen($condition, $this->filename, Backtrace::line(), 'markTestSkipped', [$message]);
return $this;
}
/**
* Skips the current test on the given PHP version.
*/
public function skipOnPhp(string $version): self
{
if (mb_strlen($version) < 2) {
throw new InvalidArgumentException('The version must start with [<] or [>].');
}
if (str_starts_with($version, '>=') || str_starts_with($version, '<=')) {
$operator = substr($version, 0, 2);
$version = substr($version, 2);
} elseif (str_starts_with($version, '>') || str_starts_with($version, '<')) {
$operator = $version[0];
$version = substr($version, 1);
// ensure starts with number:
} elseif (is_numeric($version[0])) {
$operator = '==';
} else {
throw new InvalidArgumentException('The version must start with [<, >, <=, >=] or a number.');
}
return $this->skip(version_compare(PHP_VERSION, $version, $operator), sprintf('This test is skipped on PHP [%s%s].', $operator, $version));
}
/**
* Skips the current test if the given test is running on Windows.
*/
public function skipOnWindows(): self
{
return $this->skipOnOs('Windows', 'This test is skipped on [Windows].');
}
/**
* Skips the current test if the given test is running on Mac OS.
*/
public function skipOnMac(): self
{
return $this->skipOnOs('Darwin', 'This test is skipped on [Mac].');
}
/**
* Skips the current test if the given test is running on Linux.
*/
public function skipOnLinux(): self
{
return $this->skipOnOs('Linux', 'This test is skipped on [Linux].');
}
/**
* Skips the current test if the given test is running on the given operating systems.
*/
private function skipOnOs(string $osFamily, string $message): self
{
return $osFamily === PHP_OS_FAMILY
? $this->skip($message)
: $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.
*/
public function onlyOnWindows(): self
{
return $this->skipOnMac()->skipOnLinux();
}
/**
* Skips the current test unless the given test is running on Mac.
*/
public function onlyOnMac(): self
{
return $this->skipOnWindows()->skipOnLinux();
}
/**
* Skips the current test unless the given test is running on Linux.
*/
public function onlyOnLinux(): self
{
return $this->skipOnWindows()->skipOnMac();
}
/**
* Repeats the current test the given number of times.
*/
public function repeat(int $times): self
{
if ($times < 1) {
throw new InvalidArgumentException('The number of repetitions must be greater than 0.');
}
$this->testCaseMethod->repetitions = $times;
return $this;
}
/**
* Marks the test as "todo".
*/
public function todo(// @phpstan-ignore-line
array|string|null $note = null,
array|string|null $assignee = null,
array|string|int|null $issue = null,
array|string|int|null $pr = null,
): self {
$this->skip('__TODO__');
$this->testCaseMethod->todo = true;
if ($issue !== null) {
$this->issue($issue);
}
if ($pr !== null) {
$this->pr($pr);
}
if ($assignee !== null) {
$this->assignee($assignee);
}
if ($note !== null) {
$this->note($note);
}
return $this;
}
/**
* Sets the test as "work in progress".
*/
public function wip(// @phpstan-ignore-line
array|string|null $note = null,
array|string|null $assignee = null,
array|string|int|null $issue = null,
array|string|int|null $pr = null,
): self {
if ($issue !== null) {
$this->issue($issue);
}
if ($pr !== null) {
$this->pr($pr);
}
if ($assignee !== null) {
$this->assignee($assignee);
}
if ($note !== null) {
$this->note($note);
}
return $this;
}
/**
* Sets the test as "done".
*/
public function done(// @phpstan-ignore-line
array|string|null $note = null,
array|string|null $assignee = null,
array|string|int|null $issue = null,
array|string|int|null $pr = null,
): self {
if ($issue !== null) {
$this->issue($issue);
}
if ($pr !== null) {
$this->pr($pr);
}
if ($assignee !== null) {
$this->assignee($assignee);
}
if ($note !== null) {
$this->note($note);
}
return $this;
}
/**
* Associates the test with the given issue(s).
*
* @param array<int, string|int>|string|int $number
*/
public function issue(array|string|int $number): self
{
$number = is_array($number) ? $number : [$number];
$number = array_map(fn (string|int $number): int => (int) ltrim((string) $number, '#'), $number);
$this->testCaseMethod->issues = array_merge($this->testCaseMethod->issues, $number);
return $this;
}
/**
* Associates the test with the given ticket(s). (Alias for `issue`)
*
* @param array<int, string|int>|string|int $number
*/
public function ticket(array|string|int $number): self
{
return $this->issue($number);
}
/**
* Sets the test assignee(s).
*
* @param array<int, string>|string $assignee
*/
public function assignee(array|string $assignee): self
{
$assignees = is_array($assignee) ? $assignee : [$assignee];
$this->testCaseMethod->assignees = array_unique(array_merge($this->testCaseMethod->assignees, $assignees));
return $this;
}
/**
* Associates the test with the given pull request(s).
*
* @param array<int, string|int>|string|int $number
*/
public function pr(array|string|int $number): self
{
$number = is_array($number) ? $number : [$number];
$number = array_map(fn (string|int $number): int => (int) ltrim((string) $number, '#'), $number);
$this->testCaseMethod->prs = array_unique(array_merge($this->testCaseMethod->prs, $number));
return $this;
}
/**
* Adds a note to the test.
*
* @param array<int, string>|string $note
*/
public function note(array|string $note): self
{
$notes = is_array($note) ? $note : [$note];
$this->testCaseMethod->notes = array_unique(array_merge($this->testCaseMethod->notes, $notes));
return $this;
}
/**
* Sets the covered classes or methods.
*
* @param array<int, string>|string $classesOrFunctions
*/
public function covers(array|string ...$classesOrFunctions): self
{
/** @var array<int, string> $classesOrFunctions */
$classesOrFunctions = array_reduce($classesOrFunctions, fn ($carry, $item): array => is_array($item) ? array_merge($carry, $item) : array_merge($carry, [$item]), []); // @pest-ignore-type
foreach ($classesOrFunctions as $classOrFunction) {
$isClass = class_exists($classOrFunction) || interface_exists($classOrFunction) || enum_exists($classOrFunction);
$isTrait = trait_exists($classOrFunction);
$isFunction = function_exists($classOrFunction);
if (! $isClass && ! $isTrait && ! $isFunction) {
throw new InvalidArgumentException(sprintf('No class, trait or method named "%s" has been found.', $classOrFunction));
}
if ($isClass) {
$this->coversClass($classOrFunction);
} elseif ($isTrait) {
$this->coversTrait($classOrFunction);
} else {
$this->coversFunction($classOrFunction);
}
}
return $this;
}
/**
* Sets the covered classes.
*/
public function coversClass(string ...$classes): self
{
foreach ($classes as $class) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversClass::class,
[$class],
);
}
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if (! is_array($paths)) {
$configurationRepository->globalConfiguration('default')->class(...$classes); // @phpstan-ignore-line
}
return $this;
}
/**
* Sets the covered classes.
*/
public function coversTrait(string ...$traits): self
{
foreach ($traits as $trait) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversTrait::class,
[$trait],
);
}
/** @var ConfigurationRepository $configurationRepository */
$configurationRepository = Container::getInstance()->get(ConfigurationRepository::class);
$paths = $configurationRepository->cliConfiguration->toArray()['paths'] ?? false;
if (! is_array($paths)) {
$configurationRepository->globalConfiguration('default')->class(...$traits); // @phpstan-ignore-line
}
return $this;
}
/**
* Sets the covered functions.
*/
public function coversFunction(string ...$functions): self
{
foreach ($functions as $function) {
$this->testCaseFactoryAttributes[] = new Attribute(
\PHPUnit\Framework\Attributes\CoversFunction::class,
[$function],
);
}
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
* be executed without throwing exceptions.
*/
public function throwsNoExceptions(): self
{
$this->testCaseMethod->proxies->add(Backtrace::file(), Backtrace::line(), 'expectNotToPerformAssertions', []);
return $this;
}
/**
* Saves the property accessors to be used on the target.
*/
public function __get(string $name): self
{
return $this->addChain(Backtrace::file(), Backtrace::line(), $name);
}
/**
* Saves the calls to be used on the target.
*
* @param array<int, mixed> $arguments
*/
public function __call(string $name, array $arguments): self
{
return $this->addChain(Backtrace::file(), Backtrace::line(), $name, $arguments);
}
/**
* Add a chain to the test case factory. Omitting the arguments will treat it as a property accessor.
*
* @param array<int, mixed>|null $arguments
*/
private function addChain(string $file, int $line, string $name, ?array $arguments = null): self
{
$exporter = Exporter::default();
$this->testCaseMethod
->chains
->add($file, $line, $name, $arguments);
if ($this->descriptionLess) {
Exporter::default();
if ($this->description !== null) {
$this->description .= ' → ';
}
$this->description .= $arguments === null
? $name
: sprintf('%s %s', $name, $exporter->shortenedRecursiveExport($arguments));
}
return $this;
}
/**
* Creates the Call.
*/
public function __destruct()
{
if ($this->description === null) {
throw new TestDescriptionMissing($this->filename);
}
if ($this->describing !== []) {
$this->testCaseMethod->describing = $this->describing;
$this->testCaseMethod->description = Str::describe($this->describing, $this->description);
} else {
$this->testCaseMethod->description = $this->description;
}
$this->testSuite->tests->set($this->testCaseMethod);
if (! is_null($testCase = $this->testSuite->tests->get($this->filename))) {
$attributesToMerge = array_filter(
$this->testCaseFactoryAttributes,
fn (Attribute $attributeToMerge): bool => array_filter($testCase->attributes, fn (Attribute $attribute): bool => serialize($attributeToMerge) === serialize($attribute)) === []
);
$testCase->attributes = array_merge($testCase->attributes, $attributesToMerge);
}
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace Pest\PendingCalls;
use Closure;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use Pest\TestSuite;
/**
* @internal
*/
final class UsesCall
{
/**
* Contains a global before each hook closure to be executed.
*
* Array indices here matter. They are mapped as follows:
*
* - `0` => `beforeAll`
* - `1` => `beforeEach`
* - `2` => `afterEach`
* - `3` => `afterAll`
*
* @var array<int, Closure>
*/
private array $hooks = [];
/**
* Holds the targets of the uses.
*
* @var array<int, string>
*/
private array $targets;
/**
* Holds the groups of the uses.
*
* @var array<int, string>
*/
private array $groups = [];
/**
* Creates a new Pending Call.
*
* @param array<int, string> $classAndTraits
*/
public function __construct(
private readonly string $filename,
private array $classAndTraits
) {
$this->targets = [$filename];
}
/**
* @deprecated Use `pest()->printer()->compact()` instead.
*/
public function compact(): self
{
DefaultPrinter::compact(true);
return $this;
}
/**
* Specifies the class or traits to use.
*
* @alias extend
*/
public function use(string ...$classAndTraits): self
{
return $this->extend(...$classAndTraits);
}
/**
* Specifies the class or traits to use.
*/
public function extend(string ...$classAndTraits): self
{
$this->classAndTraits = array_merge($this->classAndTraits, array_values($classAndTraits));
return $this;
}
/**
* The directories or file where the class or traits should be used.
*/
public function in(string ...$targets): self
{
$targets = array_map(function (string $path): string {
$startChar = DIRECTORY_SEPARATOR;
if ('\\' === DIRECTORY_SEPARATOR || preg_match('~\A[A-Z]:(?![^/\\\\])~i', $path) > 0) {
$path = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', fn (array $match): string => strtolower($match['drive']), $path);
$startChar = strtolower((string) preg_replace('~^([a-z]+:\\\).*$~i', '$1', __DIR__));
}
return str_starts_with($path, $startChar)
? $path
: implode(DIRECTORY_SEPARATOR, [
is_dir($this->filename) ? $this->filename : dirname($this->filename),
$path,
]);
}, $targets);
$this->targets = array_reduce($targets, function (array $accumulator, string $target): array {
if (($matches = glob($target)) !== false) {
foreach ($matches as $file) {
$accumulator[] = (string) realpath($file);
}
}
return $accumulator;
}, []);
return $this;
}
/**
* Sets the test group(s).
*/
public function group(string ...$groups): self
{
$this->groups = array_values($groups);
return $this;
}
/**
* Sets the global beforeAll test hook.
*/
public function beforeAll(Closure $hook): self
{
$this->hooks[0] = $hook;
return $this;
}
/**
* Sets the global beforeEach test hook.
*/
public function beforeEach(Closure $hook): self
{
$this->hooks[1] = $hook;
return $this;
}
/**
* Sets the global afterEach test hook.
*/
public function afterEach(Closure $hook): self
{
$this->hooks[2] = $hook;
return $this;
}
/**
* Sets the global afterAll test hook.
*/
public function afterAll(Closure $hook): self
{
$this->hooks[3] = $hook;
return $this;
}
/**
* Creates the Call.
*/
public function __destruct()
{
TestSuite::getInstance()->tests->use(
$this->classAndTraits,
$this->groups,
$this->targets,
$this->hooks,
);
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Pest;
function version(): string
{
return '4.1.5';
}
function testDirectory(string $file = ''): string
{
return TestSuite::getInstance()->testPath.DIRECTORY_SEPARATOR.$file;
}

Some files were not shown because too many files have changed in this diff Show More