HTML, $note,
));
}
}
}
================================================
FILE: src/Concerns/Expectable.php
================================================
*/
public function expect(mixed $value): Expectation
{
return new Expectation($value);
}
}
================================================
FILE: src/Concerns/Extendable.php
================================================
*/
private static array $extends = [];
/**
* Register a new extend.
*
* @param-closure-this T $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);
}
}
================================================
FILE: src/Concerns/Logging/WritesToConsole.php
================================================
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();
}
}
================================================
FILE: src/Concerns/Pipeable.php
================================================
>
*/
private static array $pipes = [];
/**
* The list of interceptors.
*
* @var array>
*/
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
*/
private function pipes(string $name, object $context, string $scope): array
{
return array_map(fn (Closure $pipe): Closure => $pipe->bindTo($context, $scope), self::$pipes[$name] ?? []);
}
}
================================================
FILE: src/Concerns/Retrievable.php
================================================
|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;
}
}
================================================
FILE: src/Concerns/Testable.php
================================================
*/
private static array $__latestIssues = [];
/**
* The test's PRs.
*
* @var array
*/
private static array $__latestPrs = [];
/**
* The test's describing, if any.
*
* @var array
*/
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();
}
}
================================================
FILE: src/Configuration/Presets.php
================================================
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;
}
}
================================================
FILE: src/Configuration.php
================================================
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);
}
/**
* Marks all tests in the current file to be run exclusively.
*/
public function only(): void
{
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
}
/**
* 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 $arguments
*/
public function __call(string $name, array $arguments): mixed
{
return $this->uses()->$name(...$arguments); // @phpstan-ignore-line
}
}
================================================
FILE: src/Console/Help.php
================================================
*/
private const array HELP_MESSAGES = [
'Pest Options:',
' --init Initialise a standard Pest configuration',
' --coverage Enable coverage and output to standard output',
' --min=> Set the minimum required coverage percentage (), and fail if not met',
' --group=> 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);
}
}
}
================================================
FILE: src/Console/Thanks.php
================================================
*/
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(
' 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');
}
}
}
}
================================================
FILE: src/Contracts/ArchPreset.php
================================================
$arguments
* @return array
*/
public function handleArguments(array $arguments): array;
}
================================================
FILE: src/Contracts/Plugins/HandlesOriginalArguments.php
================================================
$arguments
*/
public function handleOriginalArguments(array $arguments): void;
}
================================================
FILE: src/Contracts/Plugins/Terminable.php
================================================
$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)));
}
}
================================================
FILE: src/Exceptions/AfterAllAlreadyExist.php
================================================
$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,
));
}
}
================================================
FILE: src/Exceptions/ExpectationNotFound.php
================================================
$methods
*
* @throws self
*/
public static function fromMethods(array $methods): never
{
throw new self(sprintf('Expectation [%s] is not valid.', implode('->', $methods)));
}
}
================================================
FILE: src/Exceptions/InvalidExpectationValue.php
================================================
writeln([
'',
' INFO > No "dirty" tests found.',
'',
]);
}
/**
* The exit code to be used.
*/
public function exitCode(): int
{
return 0;
}
}
================================================
FILE: src/Exceptions/ShouldNotHappen.php
================================================
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));
}
}
================================================
FILE: src/Exceptions/TestAlreadyExist.php
================================================
description,
$method->filename
)
);
}
}
================================================
FILE: src/Exceptions/TestDescriptionMissing.php
================================================
* @mixin PendingArchExpectation
*/
final class Expectation
{
/** @use Extendable> */
use Extendable;
use Pipeable;
use Retrievable;
/**
* Creates a new expectation.
*
* @param TValue $value
*/
public function __construct(
public mixed $value
) {
// ..
}
/**
* Creates a new expectation.
*
* @template TAndValue
*
* @param TAndValue $value
* @return self
*/
public function and(mixed $value): Expectation
{
return $value instanceof self ? $value : new self($value);
}
/**
* Creates a new expectation with the decoded JSON value.
*
* @return self|bool>
*/
public function json(): Expectation
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$this->toBeJson();
/** @var array|bool $value */
$value = json_decode($this->value, true, 512, JSON_THROW_ON_ERROR);
return $this->and($value);
}
/**
* Dump the expectation value.
*
* @return self
*/
public function dump(mixed ...$arguments): self
{
if (function_exists('dump')) {
dump($this->value, ...$arguments);
} else {
var_dump($this->value);
}
return $this;
}
/**
* Dump the expectation value and end the script.
*
* @return never
*/
public function dd(mixed ...$arguments): void
{
if (function_exists('dd')) {
dd($this->value, ...$arguments);
}
var_dump($this->value);
exit(1);
}
/**
* Dump the expectation value when the result of the condition is truthy.
*
* @param (Closure(TValue): bool)|bool $condition
* @return self
*/
public function ddWhen(Closure|bool $condition, mixed ...$arguments): Expectation
{
$condition = $condition instanceof Closure ? $condition($this->value) : $condition;
if (! $condition) {
return $this;
}
$this->dd(...$arguments);
}
/**
* Dump the expectation value when the result of the condition is falsy.
*
* @param (Closure(TValue): bool)|bool $condition
* @return self
*/
public function ddUnless(Closure|bool $condition, mixed ...$arguments): Expectation
{
$condition = $condition instanceof Closure ? $condition($this->value) : $condition;
if ($condition) {
return $this;
}
$this->dd(...$arguments);
}
/**
* Send the expectation value to Ray along with all given arguments.
*
* @return self
*/
public function ray(mixed ...$arguments): self
{
if (function_exists('ray')) {
ray($this->value, ...$arguments);
}
return $this;
}
/**
* Creates the opposite expectation for the value.
*
* @return OppositeExpectation
*/
public function not(): OppositeExpectation
{
return new OppositeExpectation($this);
}
/**
* Creates an expectation on each item of the iterable "value".
*
* @return EachExpectation
*/
public function each(?callable $callback = null): EachExpectation
{
if (! is_iterable($this->value)) {
throw new BadMethodCallException('Expectation value is not iterable.');
}
if (is_callable($callback)) {
foreach ($this->value as $key => $item) {
$callback(new self($item), $key);
}
}
return new EachExpectation($this);
}
/**
* Allows you to specify a sequential set of expectations for each item in a iterable "value".
*
* @template TSequenceValue
*
* @param (callable(self, self): void)|TSequenceValue ...$callbacks
* @return self
*/
public function sequence(mixed ...$callbacks): self
{
if (! is_iterable($this->value)) {
throw new BadMethodCallException('Expectation value is not iterable.');
}
if ($callbacks === []) {
throw new InvalidArgumentException('No sequence expectations defined.');
}
$index = $valuesCount = 0;
foreach ($this->value as $key => $value) {
$valuesCount++;
if ($callbacks[$index] instanceof Closure) {
$callbacks[$index](new self($value), new self($key));
} else {
(new self($value))->toEqual($callbacks[$index]);
}
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
}
if ($valuesCount < count($callbacks)) {
throw new OutOfRangeException('Sequence expectations are more than the iterable items.');
}
return $this;
}
/**
* If the subject matches one of the given "expressions", the expression callback will run.
*
* @template TMatchSubject of array-key
*
* @param (callable(): TMatchSubject)|TMatchSubject $subject
* @param array): mixed)|TValue> $expressions
* @return self
*/
public function match(mixed $subject, array $expressions): self
{
$subject = $subject instanceof Closure ? $subject() : $subject;
$matched = false;
foreach ($expressions as $key => $callback) {
if ($subject != $key) { // @pest-arch-ignore-line
continue;
}
$matched = true;
if (is_callable($callback)) {
$callback(new self($this->value));
continue;
}
$this->and($this->value)->toEqual($callback);
break;
}
if ($matched === false) {
throw new ExpectationFailedException('Unhandled match value.');
}
return $this;
}
/**
* Apply the callback if the given "condition" is falsy.
*
* @param (callable(): bool)|bool $condition
* @param callable(Expectation): mixed $callback
* @return self
*/
public function unless(callable|bool $condition, callable $callback): Expectation
{
$condition = is_callable($condition)
? $condition
: static fn (): bool => $condition;
return $this->when(! $condition(), $callback);
}
/**
* Apply the callback if the given "condition" is truthy.
*
* @param (callable(): bool)|bool $condition
* @param callable(self): mixed $callback
* @return self
*/
public function when(callable|bool $condition, callable $callback): self
{
$condition = is_callable($condition)
? $condition
: static fn (): bool => $condition;
if ($condition()) {
$callback($this->and($this->value));
}
return $this;
}
/**
* Dynamically calls methods on the class or creates a new higher order expectation.
*
* @param array $parameters
* @return Expectation|HigherOrderExpectation, TValue>
*/
public function __call(string $method, array $parameters): Expectation|HigherOrderExpectation|PendingArchExpectation|ArchExpectation
{
if (! self::hasMethod($method)) {
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $method)) {
$pendingArchExpectation = new PendingArchExpectation($this, []);
return $pendingArchExpectation->$method(...$parameters); // @phpstan-ignore-line
}
if (! is_object($this->value)) {
throw new BadMethodCallException(sprintf(
'Method "%s" does not exist in %s.',
$method,
gettype($this->value)
));
}
/* @phpstan-ignore-next-line */
return new HigherOrderExpectation($this, call_user_func_array($this->value->$method(...), $parameters));
}
$closure = $this->getExpectationClosure($method);
$reflectionClosure = new \ReflectionFunction($closure);
$expectation = $reflectionClosure->getClosureThis();
if ($reflectionClosure->getReturnType()?->__toString() === ArchExpectation::class) {
return $closure(...$parameters);
}
assert(is_object($expectation));
ExpectationPipeline::for($closure)
->send(...$parameters)
->through($this->pipes($method, $expectation, Expectation::class))
->run();
return $this;
}
/**
* Creates a new expectation closure from the given name.
*
* @throws ExpectationNotFound
*/
private function getExpectationClosure(string $name): Closure
{
if (method_exists(Mixins\Expectation::class, $name)) {
// @phpstan-ignore-next-line
return Closure::fromCallable([new Mixins\Expectation($this->value), $name]);
}
if (self::hasExtend($name)) {
$extend = self::$extends[$name]->bindTo($this, Expectation::class);
if ($extend != false) { // @pest-arch-ignore-line
return $extend;
}
}
throw ExpectationNotFound::fromName($name);
}
/**
* Dynamically calls methods on the class without any arguments or creates a new higher order expectation.
*
* @return Expectation|OppositeExpectation|EachExpectation|HigherOrderExpectation, TValue|null>|TValue
*/
public function __get(string $name): mixed
{
if (! self::hasMethod($name)) {
if (! is_object($this->value) && method_exists(PendingArchExpectation::class, $name)) {
/* @phpstan-ignore-next-line */
return $this->{$name}();
}
/* @phpstan-ignore-next-line */
return new HigherOrderExpectation($this, $this->retrieve($name, $this->value));
}
/* @phpstan-ignore-next-line */
return $this->{$name}();
}
/**
* Checks if the given expectation method exists.
*/
public static function hasMethod(string $name): bool
{
return method_exists(self::class, $name)
|| method_exists(Mixins\Expectation::class, $name)
|| self::hasExtend($name);
}
/**
* Matches any value.
*/
public function any(): Any
{
return new Any;
}
/**
* Asserts that the given expectation target use the given dependencies.
*
* @param array|string $targets
*/
public function toUse(array|string $targets): ArchExpectation
{
return ToUse::make($this, $targets);
}
/**
* Asserts that the given expectation target does have the given permissions
*/
public function toHaveFileSystemPermissions(string $permissions): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) === $permissions,
sprintf('permissions to be [%s]', $permissions),
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' count(file($object->path)) < $lines, // @phpstan-ignore-line
sprintf('to have less than %d lines of code', $lines),
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' 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 with documentation / annotations',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
}
/**
* Asserts that the given expectation target have all properties documented.
*/
public function toHavePropertiesDocumented(): ArchExpectation
{
return Targeted::make(
$this,
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 with documentation / annotations',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
}
/**
* Asserts that the given expectation target use the "declare(strict_types=1)" declaration.
*/
public function toUseStrictTypes(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => (bool) preg_match('/^<\?php\s*(\/\*[\s\S]*?\*\/|\/\/[^\r\n]*(?:\r?\n|$)|\s)*declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/m', (string) file_get_contents($object->path)),
'to use strict types',
FileLineFinder::where(fn (string $line): bool => str_contains($line, ' ! 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 final.
*/
public function toBeFinal(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isFinal(),
'to be final',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target is readonly.
*/
public function toBeReadonly(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line
'to be readonly',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target is trait.
*/
public function toBeTrait(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isTrait(),
'to be trait',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation targets are traits.
*/
public function toBeTraits(): ArchExpectation
{
return $this->toBeTrait();
}
/**
* Asserts that the given expectation target is abstract.
*/
public function toBeAbstract(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isAbstract(),
'to be abstract',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target has a specific method.
*
* @param array|string $method
*/
public function toHaveMethod(array|string $method): ArchExpectation
{
$methods = is_array($method) ? $method : [$method];
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod($method))) === count($methods),
sprintf("to have method '%s'", implode("', '", $methods)),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target has a specific methods.
*
* @param array $methods
*/
public function toHaveMethods(array $methods): ArchExpectation
{
return $this->toHaveMethod($methods);
}
/**
* Not supported.
*/
public function toHavePublicMethodsBesides(): void
{
throw InvalidExpectation::fromMethods(['toHavePublicMethodsBesides']);
}
/**
* Not supported.
*/
public function toHavePublicMethods(): void
{
throw InvalidExpectation::fromMethods(['toHavePublicMethods']);
}
/**
* Not supported.
*/
public function toHaveProtectedMethodsBesides(): void
{
throw InvalidExpectation::fromMethods(['toHaveProtectedMethodsBesides']);
}
/**
* Not supported.
*/
public function toHaveProtectedMethods(): void
{
throw InvalidExpectation::fromMethods(['toHaveProtectedMethods']);
}
/**
* Not supported.
*/
public function toHavePrivateMethodsBesides(): void
{
throw InvalidExpectation::fromMethods(['toHavePrivateMethodsBesides']);
}
/**
* Not supported.
*/
public function toHavePrivateMethods(): void
{
throw InvalidExpectation::fromMethods(['toHavePrivateMethods']);
}
/**
* Asserts that the given expectation target is enum.
*/
public function toBeEnum(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isEnum(),
'to be enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation targets are enums.
*/
public function toBeEnums(): ArchExpectation
{
return $this->toBeEnum();
}
/**
* Asserts that the given expectation target is a class.
*/
public function toBeClass(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => class_exists($object->name) && ! enum_exists($object->name),
'to be class',
FileLineFinder::where(fn (string $line): bool => true),
);
}
/**
* Asserts that the given expectation targets are classes.
*/
public function toBeClasses(): ArchExpectation
{
return $this->toBeClass();
}
/**
* Asserts that the given expectation target is interface.
*/
public function toBeInterface(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isInterface(),
'to be interface',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation targets are interfaces.
*/
public function toBeInterfaces(): ArchExpectation
{
return $this->toBeInterface();
}
/**
* Asserts that the given expectation target to be subclass of the given class.
*/
public function toExtend(string $class): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && ($class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class)),
sprintf("to extend '%s'", $class),
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to be have a parent class.
*/
public function toExtendNothing(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() === false,
'to extend nothing',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to use the given trait.
*/
public function toUseTrait(string $trait): ArchExpectation
{
return $this->toUseTraits($trait);
}
/**
* Asserts that the given expectation target to use the given traits.
*
* @param array|string $traits
*/
public function toUseTraits(array|string $traits): ArchExpectation
{
$traits = is_array($traits) ? $traits : [$traits];
return Targeted::make(
$this,
function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (isset($object->reflectionClass) === false) {
return false;
}
if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
"to use traits '".implode("', '", $traits)."'",
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
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getInterfaceNames() === [],
'to implement nothing',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to only implement the given interfaces.
*
* @param array|string $interfaces
*/
public function toOnlyImplement(array|string $interfaces): ArchExpectation
{
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& (count($interfaces) === count($object->reflectionClass->getInterfaceNames()))
&& array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [],
"to only implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to have the given prefix.
*/
public function toHavePrefix(string $prefix): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_starts_with($object->reflectionClass->getShortName(), $prefix),
"to have prefix '{$prefix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to have the given suffix.
*/
public function toHaveSuffix(string $suffix): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_ends_with($object->reflectionClass->getName(), $suffix),
"to have suffix '{$suffix}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target to implement the given interfaces.
*
* @param array|string $interfaces
*/
public function toImplement(array|string $interfaces): ArchExpectation
{
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
return Targeted::make(
$this,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
"to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target "only" use on the given dependencies.
*
* @param array|string $targets
*/
public function toOnlyUse(array|string $targets): ArchExpectation
{
return ToOnlyUse::make($this, $targets);
}
/**
* Asserts that the given expectation target does not use any dependencies.
*/
public function toUseNothing(): ArchExpectation
{
return ToUseNothing::make($this);
}
/**
* Asserts that the source code of the given expectation target does not include suspicious characters.
*/
public function toHaveSuspiciousCharacters(): ArchExpectation
{
throw InvalidExpectation::fromMethods(['toHaveSuspiciousCharacters']);
}
/**
* Not supported.
*/
public function toBeUsed(): void
{
throw InvalidExpectation::fromMethods(['toBeUsed']);
}
/**
* Asserts that the given expectation dependency is used by the given targets.
*
* @param array|string $targets
*/
public function toBeUsedIn(array|string $targets): ArchExpectation
{
return ToBeUsedIn::make($this, $targets);
}
/**
* Asserts that the given expectation dependency is "only" used by the given targets.
*
* @param array|string $targets
*/
public function toOnlyBeUsedIn(array|string $targets): ArchExpectation
{
return ToOnlyBeUsedIn::make($this, $targets);
}
/**
* Asserts that the given expectation dependency is not used.
*/
public function toBeUsedInNothing(): ArchExpectation
{
return ToBeUsedInNothing::make($this);
}
/**
* Asserts that the given expectation dependency is an invokable class.
*/
public function toBeInvokable(): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod('__invoke'),
'to be invokable',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class'))
);
}
/**
* Asserts that the given expectation is iterable and contains snake_case keys.
*
* @return self
*/
public function toHaveSnakeCaseKeys(string $message = ''): self
{
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
foreach ($this->value as $k => $item) {
if (is_string($k)) {
$this->and($k)->toBeSnakeCase($message);
}
if (is_array($item)) {
$this->and($item)->toHaveSnakeCaseKeys($message);
}
}
return $this;
}
/**
* Asserts that the given expectation is iterable and contains kebab-case keys.
*
* @return self
*/
public function toHaveKebabCaseKeys(string $message = ''): self
{
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
foreach ($this->value as $k => $item) {
if (is_string($k)) {
$this->and($k)->toBeKebabCase($message);
}
if (is_array($item)) {
$this->and($item)->toHaveKebabCaseKeys($message);
}
}
return $this;
}
/**
* Asserts that the given expectation is iterable and contains camelCase keys.
*
* @return self
*/
public function toHaveCamelCaseKeys(string $message = ''): self
{
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
foreach ($this->value as $k => $item) {
if (is_string($k)) {
$this->and($k)->toBeCamelCase($message);
}
if (is_array($item)) {
$this->and($item)->toHaveCamelCaseKeys($message);
}
}
return $this;
}
/**
* Asserts that the given expectation is iterable and contains StudlyCase keys.
*
* @return self
*/
public function toHaveStudlyCaseKeys(string $message = ''): self
{
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
foreach ($this->value as $k => $item) {
if (is_string($k)) {
$this->and($k)->toBeStudlyCase($message);
}
if (is_array($item)) {
$this->and($item)->toHaveStudlyCaseKeys($message);
}
}
return $this;
}
/**
* Asserts that the given expectation target to have the given attribute.
*/
public function toHaveAttribute(string $attribute): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [],
"to have attribute '{$attribute}'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation target has a constructor method.
*/
public function toHaveConstructor(): ArchExpectation
{
return $this->toHaveMethod('__construct');
}
/**
* Asserts that the given expectation target has a destructor method.
*/
public function toHaveDestructor(): ArchExpectation
{
return $this->toHaveMethod('__destruct');
}
/**
* Asserts that the given expectation target is a backed enum of given type.
*/
private function toBeBackedEnum(string $backingType): ArchExpectation
{
return Targeted::make(
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum()
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
}
/**
* Asserts that the given expectation targets are string backed enums.
*/
public function toBeStringBackedEnums(): ArchExpectation
{
return $this->toBeStringBackedEnum();
}
/**
* Asserts that the given expectation targets are int backed enums.
*/
public function toBeIntBackedEnums(): ArchExpectation
{
return $this->toBeIntBackedEnum();
}
/**
* Asserts that the given expectation target is a string backed enum.
*/
public function toBeStringBackedEnum(): ArchExpectation
{
return $this->toBeBackedEnum('string');
}
/**
* Asserts that the given expectation target is an int backed enum.
*/
public function toBeIntBackedEnum(): ArchExpectation
{
return $this->toBeBackedEnum('int');
}
}
================================================
FILE: src/Expectations/EachExpectation.php
================================================
*/
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 $original
*/
public function __construct(private readonly Expectation $original) {}
/**
* Creates a new expectation.
*
* @template TAndValue
*
* @param TAndValue $value
* @return Expectation
*/
public function and(mixed $value): Expectation
{
return $this->original->and($value);
}
/**
* Creates the opposite expectation for the value.
*
* @return self
*/
public function not(): self
{
$this->opposite = true;
return $this;
}
/**
* Dynamically calls methods on the class with the given arguments on each item.
*
* @param array $arguments
* @return self
*/
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
*/
public function __get(string $name): self
{
/* @phpstan-ignore-next-line */
return $this->$name();
}
}
================================================
FILE: src/Expectations/HigherOrderExpectation.php
================================================
*/
final class HigherOrderExpectation
{
use Retrievable;
/**
* @var Expectation|EachExpectation
*/
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 $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
*/
public function not(): self
{
$this->opposite = ! $this->opposite;
return $this;
}
/**
* Creates a new Expectation.
*
* @template TExpectValue
*
* @param TExpectValue $value
* @return Expectation
*/
public function expect(mixed $value): Expectation
{
return new Expectation($value);
}
/**
* Creates a new expectation.
*
* @template TExpectValue
*
* @param TExpectValue $value
* @return Expectation
*/
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): void $expectation
* @return HigherOrderExpectation
*/
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|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 $arguments
* @return self|self
*/
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|self
*/
public function __get(string $name): self
{
if ($name === 'not') {
return $this->not();
}
if (! $this->expectationHasMethod($name)) {
/** @var array|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 $arguments
* @return self
*/
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;
}
}
================================================
FILE: src/Expectations/OppositeExpectation.php
================================================
*/
final readonly class OppositeExpectation
{
/**
* Creates a new opposite expectation.
*
* @param Expectation $original
*/
public function __construct(private Expectation $original) {}
/**
* Asserts that the value array not has the provided $keys.
*
* @param array> $keys
* @return Expectation
*/
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|string $targets
*/
public function toUse(array|string $targets): ArchExpectation
{
/** @var Expectation|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|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, '|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|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|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, '|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|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|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|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|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|string $method
*/
public function toHaveMethod(array|string $method): ArchExpectation
{
$methods = is_array($method) ? $method : [$method];
/** @var Expectation|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
{
if (! class_exists(Spoofchecker::class)) {
throw new MissingDependency(__FUNCTION__, 'ext-intl >= 2.0');
}
$checker = new Spoofchecker;
/** @var Expectation|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 $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|string $methods
*/
public function toHavePublicMethodsBesides(array|string $methods): ArchExpectation
{
$methods = is_array($methods) ? $methods : [$methods];
$state = new stdClass;
/** @var Expectation|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|string $methods
*/
public function toHaveProtectedMethodsBesides(array|string $methods): ArchExpectation
{
$methods = is_array($methods) ? $methods : [$methods];
$state = new stdClass;
/** @var Expectation|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|string $methods
*/
public function toHavePrivateMethodsBesides(array|string $methods): ArchExpectation
{
$methods = is_array($methods) ? $methods : [$methods];
$state = new stdClass;
/** @var Expectation|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|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|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|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|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|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|string $traits
*/
public function toUseTraits(array|string $traits): ArchExpectation
{
$traits = is_array($traits) ? $traits : [$traits];
/** @var Expectation|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|string $interfaces
*/
public function toImplement(array|string $interfaces): ArchExpectation
{
$interfaces = is_array($interfaces) ? $interfaces : [$interfaces];
/** @var Expectation|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|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|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|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|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|string $targets
*/
public function toBeUsedIn(array|string $targets): ArchExpectation
{
/** @var Expectation|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|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|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 $arguments
* @return Expectation|Expectation|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|Expectation|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|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('/(? $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|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');
}
}
================================================
FILE: src/Factories/Attribute.php
================================================
$arguments
*/
public function __construct(public string $name, public iterable $arguments)
{
//
}
}
================================================
FILE: src/Factories/Concerns/HigherOrderable.php
================================================
chains = new HigherOrderMessageCollection;
$this->factoryProxies = new HigherOrderMessageCollection;
$this->proxies = new HigherOrderMessageCollection;
}
}
================================================
FILE: src/Factories/Covers/CoversClass.php
================================================
*/
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
*/
public array $methods = [];
/**
* The list of class traits.
*
* @var array
*/
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 $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[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(
TestDox::class,
[$this->filename],
),
...$this->attributes,
];
$attributesCode = Attributes::code($this->attributes);
$methodsCode = implode('', array_map(
fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation(),
$methods
));
try {
$classCode = <<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));
}
}
================================================
FILE: src/Factories/TestCaseMethodFactory.php
================================================
*/
public array $attributes = [];
/**
* The test's describing, if any.
*
* @var array
*/
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
*/
public array $issues = [];
/**
* The test assignees.
*
* @var array
*/
public array $assignees = [];
/**
* The associated PRs numbers.
*
* @var array
*/
public array $prs = [];
/**
* The test's notes.
*
* @var array
*/
public array $notes = [];
/**
* The test's datasets.
*
* @var array|string>
*/
public array $datasets = [];
/**
* The test's dependencies.
*
* @var array
*/
public array $depends = [];
/**
* The test's groups.
*
* @var array
*/
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(
Test::class,
[],
),
new Attribute(
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(
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 <<__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 <<
*/
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|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 $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|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 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|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|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 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|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 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|string
*
* @param TUrl $url
* @param array $options
* @return (TUrl is array ? 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);
}
}
================================================
FILE: src/Installers/PluginBrowser.php
================================================
*/
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 $originalArguments
* @param array $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([
'',
' 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);
}
}
}
================================================
FILE: src/KernelDump.php
================================================
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');
}
}
================================================
FILE: src/Logging/Converter.php
================================================
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 `
$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 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 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);
}
}
================================================
FILE: src/Logging/TeamCity/ServiceMessage.php
================================================
$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;
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/Subscriber.php
================================================
logger;
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php
================================================
logger()->testConsideredRisky($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php
================================================
logger()->testErrored($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php
================================================
logger()->testExecutionFinished($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php
================================================
logger()->testFailed($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php
================================================
logger()->testFinished($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php
================================================
logger()->testPrepared($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php
================================================
logger()->testSkipped($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php
================================================
logger()->testSuiteFinished($event);
}
}
================================================
FILE: src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php
================================================
logger()->testSuiteStarted($event);
}
}
================================================
FILE: src/Logging/TeamCity/TeamCityLogger.php
================================================
*/
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 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();
}
}
}
================================================
FILE: src/Matchers/Any.php
================================================
*/
final class Expectation
{
/**
* The exporter instance, if any.
*/
private ?Exporter $exporter = null;
/**
* Creates a new expectation.
*
* @param TValue $value
*/
public function __construct(
public mixed $value
) {
// ..
}
/**
* Asserts that two variables have the same type and
* value. Used on objects, it asserts that two
* variables reference the same object.
*
* @return self
*/
public function toBe(mixed $expected, string $message = ''): self
{
Assert::assertSame($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the value is empty.
*
* @return self
*/
public function toBeEmpty(string $message = ''): self
{
Assert::assertEmpty($this->value, $message);
return $this;
}
/**
* Asserts that the value is true.
*
* @return self
*/
public function toBeTrue(string $message = ''): self
{
Assert::assertTrue($this->value, $message);
return $this;
}
/**
* Asserts that the value is truthy.
*
* @return self
*/
public function toBeTruthy(string $message = ''): self
{
Assert::assertTrue((bool) $this->value, $message);
return $this;
}
/**
* Asserts that the value is false.
*
* @return self
*/
public function toBeFalse(string $message = ''): self
{
Assert::assertFalse($this->value, $message);
return $this;
}
/**
* Asserts that the value is falsy.
*
* @return self
*/
public function toBeFalsy(string $message = ''): self
{
Assert::assertFalse((bool) $this->value, $message);
return $this;
}
/**
* Asserts that the value is greater than $expected.
*
* @return self
*/
public function toBeGreaterThan(int|float|string|DateTimeInterface $expected, string $message = ''): self
{
Assert::assertGreaterThan($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the value is greater than or equal to $expected.
*
* @return self
*/
public function toBeGreaterThanOrEqual(int|float|string|DateTimeInterface $expected, string $message = ''): self
{
Assert::assertGreaterThanOrEqual($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the value is less than or equal to $expected.
*
* @return self
*/
public function toBeLessThan(int|float|string|DateTimeInterface $expected, string $message = ''): self
{
Assert::assertLessThan($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the value is less than $expected.
*
* @return self
*/
public function toBeLessThanOrEqual(int|float|string|DateTimeInterface $expected, string $message = ''): self
{
Assert::assertLessThanOrEqual($expected, $this->value, $message);
return $this;
}
/**
* Asserts that $needle is an element of the value.
*
* @return self
*/
public function toContain(mixed ...$needles): self
{
foreach ($needles as $needle) {
if (is_string($this->value)) {
Assert::assertStringContainsString((string) $needle, $this->value);
} else {
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
Assert::assertContains($needle, $this->value);
}
}
return $this;
}
/**
* Asserts that $needle equal an element of the value.
*
* @return self
*/
public function toContainEqual(mixed ...$needles): self
{
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
foreach ($needles as $needle) {
Assert::assertContainsEquals($needle, $this->value);
}
return $this;
}
/**
* Asserts that the value starts with $expected.
*
* @param non-empty-string $expected
* @return self
*/
public function toStartWith(string $expected, string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertStringStartsWith($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the value ends with $expected.
*
* @param non-empty-string $expected
* @return self
*/
public function toEndWith(string $expected, string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertStringEndsWith($expected, $this->value, $message);
return $this;
}
/**
* Asserts that $number matches value's Length.
*
* @return self
*/
public function toHaveLength(int $number, string $message = ''): self
{
if (is_string($this->value)) {
Assert::assertEquals($number, mb_strlen($this->value), $message);
return $this;
}
if (is_iterable($this->value)) {
return $this->toHaveCount($number, $message);
}
if (is_object($this->value)) {
$array = method_exists($this->value, 'toArray') ? $this->value->toArray() : (array) $this->value;
Assert::assertCount($number, $array, $message);
return $this;
}
throw new BadMethodCallException('Expectation value length is not countable.');
}
/**
* Asserts that $count matches the number of elements of the value.
*
* @return self
*/
public function toHaveCount(int $count, string $message = ''): self
{
if (! is_countable($this->value) && ! is_iterable($this->value)) {
InvalidExpectationValue::expected('countable|iterable');
}
Assert::assertCount($count, $this->value, $message);
return $this;
}
/**
* Asserts that the size of the value and $expected are the same.
*
* @param Countable|iterable $expected
* @return self
*/
public function toHaveSameSize(Countable|iterable $expected, string $message = ''): self
{
if (! is_countable($this->value) && ! is_iterable($this->value)) {
InvalidExpectationValue::expected('countable|iterable');
}
Assert::assertSameSize($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the value contains the property $name.
*
* @return self
*/
public function toHaveProperty(string $name, mixed $value = new Any, string $message = ''): self
{
$this->toBeObject();
// @phpstan-ignore-next-line
Assert::assertTrue(property_exists($this->value, $name), $message);
if (! $value instanceof Any) {
/* @phpstan-ignore-next-line */
Assert::assertEquals($value, $this->value->{$name}, $message);
}
return $this;
}
/**
* Asserts that the value contains the provided properties $names.
*
* @param iterable|iterable $names
* @return self
*/
public function toHaveProperties(iterable $names, string $message = ''): self
{
foreach ($names as $name => $value) {
is_int($name) ? $this->toHaveProperty($value, message: $message) : $this->toHaveProperty($name, $value, $message); // @phpstan-ignore-line
}
return $this;
}
/**
* Asserts that two variables have the same value.
*
* @return self
*/
public function toEqual(mixed $expected, string $message = ''): self
{
Assert::assertEquals($expected, $this->value, $message);
return $this;
}
/**
* Asserts that two variables have the same value.
* The contents of $expected and the $this->value are
* canonicalized before they are compared. For instance, when the two
* variables $expected and $this->value are arrays, then these arrays
* are sorted before they are compared. When $expected and $this->value
* are objects, each object is converted to an array containing all
* private, protected and public attributes.
*
* @return self
*/
public function toEqualCanonicalizing(mixed $expected, string $message = ''): self
{
Assert::assertEqualsCanonicalizing($expected, $this->value, $message);
return $this;
}
/**
* Asserts that the absolute difference between the value and $expected
* is lower than $delta.
*
* @return self
*/
public function toEqualWithDelta(mixed $expected, float $delta, string $message = ''): self
{
Assert::assertEqualsWithDelta($expected, $this->value, $delta, $message);
return $this;
}
/**
* Asserts that the value is one of the given values.
*
* @param iterable $values
* @return self
*/
public function toBeIn(iterable $values, string $message = ''): self
{
Assert::assertContains($this->value, $values, $message);
return $this;
}
/**
* Asserts that the value is infinite.
*
* @return self
*/
public function toBeInfinite(string $message = ''): self
{
Assert::assertInfinite($this->value, $message);
return $this;
}
/**
* Asserts that the value is an instance of $class.
*
* @param class-string $class
* @return self
*/
public function toBeInstanceOf(string $class, string $message = ''): self
{
Assert::assertInstanceOf($class, $this->value, $message);
return $this;
}
/**
* Asserts that the value is an array.
*
* @return self
*/
public function toBeArray(string $message = ''): self
{
Assert::assertIsArray($this->value, $message);
return $this;
}
/**
* Asserts that the value is a list.
*
* @return self
*/
public function toBeList(string $message = ''): self
{
Assert::assertIsList($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type bool.
*
* @return self
*/
public function toBeBool(string $message = ''): self
{
Assert::assertIsBool($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type callable.
*
* @return self
*/
public function toBeCallable(string $message = ''): self
{
Assert::assertIsCallable($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type float.
*
* @return self
*/
public function toBeFloat(string $message = ''): self
{
Assert::assertIsFloat($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type int.
*
* @return self
*/
public function toBeInt(string $message = ''): self
{
Assert::assertIsInt($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type iterable.
*
* @return self
*/
public function toBeIterable(string $message = ''): self
{
Assert::assertIsIterable($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type numeric.
*
* @return self
*/
public function toBeNumeric(string $message = ''): self
{
Assert::assertIsNumeric($this->value, $message);
return $this;
}
/**
* Asserts that the value contains only digits.
*
* @return self
*/
public function toBeDigits(string $message = ''): self
{
Assert::assertTrue(ctype_digit((string) $this->value), $message);
return $this;
}
/**
* Asserts that the value is of type object.
*
* @return self
*/
public function toBeObject(string $message = ''): self
{
Assert::assertIsObject($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type resource.
*
* @return self
*/
public function toBeResource(string $message = ''): self
{
Assert::assertIsResource($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type scalar.
*
* @return self
*/
public function toBeScalar(string $message = ''): self
{
Assert::assertIsScalar($this->value, $message);
return $this;
}
/**
* Asserts that the value is of type string.
*
* @return self
*/
public function toBeString(string $message = ''): self
{
Assert::assertIsString($this->value, $message);
return $this;
}
/**
* Asserts that the value is a JSON string.
*
* @return self
*/
public function toBeJson(string $message = ''): self
{
Assert::assertIsString($this->value, $message);
Assert::assertJson($this->value, $message);
return $this;
}
/**
* Asserts that the value is NAN.
*
* @return self
*/
public function toBeNan(string $message = ''): self
{
Assert::assertNan($this->value, $message);
return $this;
}
/**
* Asserts that the value is null.
*
* @return self
*/
public function toBeNull(string $message = ''): self
{
Assert::assertNull($this->value, $message);
return $this;
}
/**
* Asserts that the value array has the provided $key.
*
* @return self
*/
public function toHaveKey(string|int $key, mixed $value = new Any, string $message = ''): self
{
if (is_object($this->value) && method_exists($this->value, 'toArray')) {
$array = $this->value->toArray();
} else {
$array = (array) $this->value;
}
try {
Assert::assertTrue(Arr::has($array, $key));
/* @phpstan-ignore-next-line */
} catch (ExpectationFailedException $exception) {
if ($message === '') {
$message = "Failed asserting that an array has the key '$key'";
}
throw new ExpectationFailedException($message, $exception->getComparisonFailure());
}
if (! $value instanceof Any) {
Assert::assertEquals($value, Arr::get($array, $key), $message);
}
return $this;
}
/**
* Asserts that the value array has the provided $keys.
*
* @param array> $keys
* @return self
*/
public function toHaveKeys(array $keys, string $message = ''): self
{
foreach ($keys as $k => $key) {
if (is_array($key)) {
$this->toHaveKeys(array_keys(Arr::dot($key, $k.'.')), $message);
} else {
$this->toHaveKey($key, message: $message);
}
}
return $this;
}
/**
* Asserts that the value is a directory.
*
* @return self
*/
public function toBeDirectory(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertDirectoryExists($this->value, $message);
return $this;
}
/**
* Asserts that the value is a directory and is readable.
*
* @return self
*/
public function toBeReadableDirectory(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertDirectoryIsReadable($this->value, $message);
return $this;
}
/**
* Asserts that the value is a directory and is writable.
*
* @return self
*/
public function toBeWritableDirectory(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertDirectoryIsWritable($this->value, $message);
return $this;
}
/**
* Asserts that the value is a file.
*
* @return self
*/
public function toBeFile(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertFileExists($this->value, $message);
return $this;
}
/**
* Asserts that the value is a file and is readable.
*
* @return self
*/
public function toBeReadableFile(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertFileIsReadable($this->value, $message);
return $this;
}
/**
* Asserts that the value is a file and is writable.
*
* @return self
*/
public function toBeWritableFile(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertFileIsWritable($this->value, $message);
return $this;
}
/**
* Asserts that the value array matches the given array subset.
*
* @param iterable $array
* @return self
*/
public function toMatchArray(iterable $array, string $message = ''): self
{
if (is_object($this->value) && method_exists($this->value, 'toArray')) {
$valueAsArray = $this->value->toArray();
} else {
$valueAsArray = (array) $this->value;
}
foreach ($array as $key => $value) {
Assert::assertArrayHasKey($key, $valueAsArray, $message);
$assertMessage = $message !== '' ? $message : sprintf(
'Failed asserting that an array has a key %s with the value %s.',
$this->export($key),
$this->export($valueAsArray[$key]),
);
Assert::assertEquals($value, $valueAsArray[$key], $assertMessage);
}
return $this;
}
/**
* Asserts that the value object matches a subset
* of the properties of an given object.
*
* @param iterable $object
* @return self
*/
public function toMatchObject(object|iterable $object, string $message = ''): self
{
foreach ((array) $object as $property => $value) {
if (! is_object($this->value) && ! is_string($this->value)) {
InvalidExpectationValue::expected('object|string');
}
Assert::assertTrue(property_exists($this->value, $property), $message);
/* @phpstan-ignore-next-line */
$propertyValue = $this->value->{$property};
$assertMessage = $message !== '' ? $message : sprintf(
'Failed asserting that an object has a property %s with the value %s.',
$this->export($property),
$this->export($propertyValue),
);
Assert::assertEquals($value, $propertyValue, $assertMessage);
}
return $this;
}
/**
* Asserts that the value "stringable" matches the given snapshot..
*
* @return self
*/
public function toMatchSnapshot(string $message = ''): self
{
$snapshots = TestSuite::getInstance()->snapshots;
$snapshots->startNewExpectation();
$testCase = TestSuite::getInstance()->test;
assert($testCase instanceof TestCase);
$string = match (true) {
is_string($this->value) => $this->value,
is_object($this->value) && method_exists($this->value, 'toSnapshot') => $this->value->toSnapshot(),
is_object($this->value) && method_exists($this->value, '__toString') => $this->value->__toString(),
is_object($this->value) && method_exists($this->value, 'toString') => $this->value->toString(),
$this->value instanceof TestResponse => $this->value->getContent(), // @phpstan-ignore-line
is_array($this->value) => json_encode($this->value, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof Traversable => json_encode(iterator_to_array($this->value), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
$this->value instanceof JsonSerializable => json_encode($this->value->jsonSerialize(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
is_object($this->value) && method_exists($this->value, 'toArray') => json_encode($this->value->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
default => InvalidExpectationValue::expected('array|object|string'),
};
if ($snapshots->has()) {
[$filename, $content] = $snapshots->get();
Assert::assertSame(
strtr($content, ["\r\n" => "\n", "\r" => "\n"]),
strtr($string, ["\r\n" => "\n", "\r" => "\n"]),
$message === '' ? "Failed asserting that the string value matches its snapshot ($filename)." : $message
);
} else {
$filename = $snapshots->save($string);
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
}
return $this;
}
/**
* Asserts that the value matches a regular expression.
*
* @return self
*/
public function toMatch(string $expression, string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertMatchesRegularExpression($expression, $this->value, $message);
return $this;
}
/**
* Asserts that the value matches a constraint.
*
* @return self
*/
public function toMatchConstraint(Constraint $constraint, string $message = ''): self
{
Assert::assertThat($this->value, $constraint, $message);
return $this;
}
/**
* @param class-string $class
* @return self
*/
public function toContainOnlyInstancesOf(string $class, string $message = ''): self
{
if (! is_iterable($this->value)) {
InvalidExpectationValue::expected('iterable');
}
Assert::assertContainsOnlyInstancesOf($class, $this->value, $message);
return $this;
}
/**
* Asserts that executing value throws an exception.
*
* @param (Closure(Throwable): mixed)|string $exception
* @return self
*/
public function toThrow(callable|string|Throwable $exception, ?string $exceptionMessage = null, string $message = ''): self
{
$callback = NullClosure::create();
if ($exception instanceof Closure) {
$callback = $exception;
$parameters = (new ReflectionFunction($exception))->getParameters();
if (count($parameters) !== 1) {
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
}
if (! ($type = $parameters[0]->getType()) instanceof ReflectionNamedType) {
throw new InvalidArgumentException('The given closure\'s parameter must be type-hinted as the class string.');
}
$exception = $type->getName();
}
try {
($this->value)();
} catch (Throwable $e) {
if ($exception instanceof Throwable) {
expect($e)
->toBeInstanceOf($exception::class, $message)
->and($e->getMessage())->toBe($exceptionMessage ?? $exception->getMessage(), $message);
return $this;
}
if (! class_exists($exception)) {
if ($e instanceof Error && "Class \"$exception\" not found" === $e->getMessage()) {
Assert::assertTrue(true);
throw $e;
}
Assert::assertStringContainsString($exception, $e->getMessage(), $message);
return $this;
}
if ($exceptionMessage !== null) {
Assert::assertStringContainsString($exceptionMessage, $e->getMessage(), $message);
}
Assert::assertInstanceOf($exception, $e, $message);
$callback($e);
return $this;
}
Assert::assertTrue(true);
if (! $exception instanceof Throwable && ! class_exists($exception)) {
throw new ExpectationFailedException("Exception with message \"$exception\" not thrown.");
}
throw new ExpectationFailedException("Exception \"$exception\" not thrown.");
}
/**
* Exports the given value.
*/
private function export(mixed $value): string
{
if (! $this->exporter instanceof Exporter) {
$this->exporter = Exporter::default();
}
return $this->exporter->shortenedExport($value);
}
/**
* Asserts that the value is uppercase.
*
* @return self
*/
public function toBeUppercase(string $message = ''): self
{
Assert::assertTrue(ctype_upper((string) $this->value), $message);
return $this;
}
/**
* Asserts that the value is lowercase.
*
* @return self
*/
public function toBeLowercase(string $message = ''): self
{
Assert::assertTrue(ctype_lower((string) $this->value), $message);
return $this;
}
/**
* Asserts that the value is alphanumeric.
*
* @return self
*/
public function toBeAlphaNumeric(string $message = ''): self
{
Assert::assertTrue(ctype_alnum((string) $this->value), $message);
return $this;
}
/**
* Asserts that the value is alpha.
*
* @return self
*/
public function toBeAlpha(string $message = ''): self
{
Assert::assertTrue(ctype_alpha((string) $this->value), $message);
return $this;
}
/**
* Asserts that the value is snake_case.
*
* @return self