Edit File: TestCaseFactory.php
<?php declare(strict_types=1); namespace Pest\Factories; use ParseError; use Pest\Concerns; use Pest\Contracts\AddsAnnotations; use Pest\Contracts\HasPrintableTestCaseName; use Pest\Exceptions\DatasetMissing; use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\TestAlreadyExist; use Pest\Exceptions\TestDescriptionMissing; use Pest\Factories\Concerns\HigherOrderable; use Pest\Support\Reflection; use Pest\Support\Str; use Pest\TestSuite; use PHPUnit\Framework\TestCase; use RuntimeException; /** * @internal */ final class TestCaseFactory { use HigherOrderable; /** * The list of annotations. * * @var array<int, class-string<AddsAnnotations>> */ private const ANNOTATIONS = [ Annotations\Depends::class, Annotations\Groups::class, Annotations\CoversNothing::class, Annotations\TestDox::class, ]; /** * The list of attributes. * * @var array<int, class-string<\Pest\Factories\Attributes\Attribute>> */ private const ATTRIBUTES = [ Attributes\Covers::class, ]; /** * The FQN of the Test Case class. * * @var class-string */ public string $class = TestCase::class; /** * The list of class methods. * * @var array<string, TestCaseMethodFactory> */ public array $methods = []; /** * The list of class traits. * * @var array <int, class-string> */ public array $traits = [ Concerns\Testable::class, Concerns\Expectable::class, ]; /** * Creates a new Factory instance. */ public function __construct( public string $filename ) { $this->bootHigherOrderable(); } public function make(): void { $methods = $this->methods; if ($methods !== []) { $this->evaluate($this->filename, $methods); } } /** * Creates a Test Case class using a runtime evaluate. * * @param array<string, TestCaseMethodFactory> $methods */ public function evaluate(string $filename, array $methods): void { if ('\\' === DIRECTORY_SEPARATOR) { // In case Windows, strtolower drive name, like in UsesCall. $filename = (string) preg_replace_callback('~^(?P<drive>[a-z]+:\\\)~i', static fn (array $match): string => strtolower($match['drive']), $filename); } $filename = str_replace('\\\\', '\\', addslashes((string) realpath($filename))); $rootPath = TestSuite::getInstance()->rootPath; $relativePath = str_replace($rootPath.DIRECTORY_SEPARATOR, '', $filename); $relativePath = ltrim($relativePath, DIRECTORY_SEPARATOR); $basename = basename($relativePath, '.php'); $dotPos = strpos($basename, '.'); if ($dotPos !== false) { $basename = substr($basename, 0, $dotPos); } $relativePath = dirname(ucfirst($relativePath)).DIRECTORY_SEPARATOR.$basename; $relativePath = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath); // Strip out any %-encoded octets. $relativePath = (string) preg_replace('|%[a-fA-F0-9][a-fA-F0-9]|', '', $relativePath); // Remove escaped quote sequences (maintain namespace) $relativePath = str_replace(array_map(fn (string $quote): string => sprintf('\\%s', $quote), ['\'', '"']), '', $relativePath); // Limit to A-Z, a-z, 0-9, '_', '-'. $relativePath = (string) preg_replace('/[^A-Za-z0-9\\\\]/', '', $relativePath); $classFQN = 'P\\'.$relativePath; if (class_exists($classFQN)) { return; } $hasPrintableTestCaseClassFQN = sprintf('\%s', HasPrintableTestCaseName::class); $traitsCode = sprintf('use %s;', implode(', ', array_map( static fn (string $trait): string => sprintf('\%s', $trait), $this->traits)) ); $partsFQN = explode('\\', $classFQN); $className = array_pop($partsFQN); $namespace = implode('\\', $partsFQN); $baseClass = sprintf('\%s', $this->class); if (trim($className) === '') { $className = 'InvalidTestName'.Str::random(); } $classAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute): bool => $attribute::$above); $methodAvailableAttributes = array_filter(self::ATTRIBUTES, fn (string $attribute): bool => ! $attribute::$above); $classAttributes = []; foreach ($classAvailableAttributes as $attribute) { $classAttributes = array_reduce( $methods, fn (array $carry, TestCaseMethodFactory $methodFactory): array => (new $attribute)->__invoke($methodFactory, $carry), $classAttributes ); } $methodsCode = implode('', array_map( fn (TestCaseMethodFactory $methodFactory): string => $methodFactory->buildForEvaluation( self::ANNOTATIONS, $methodAvailableAttributes ), $methods )); $classAttributesCode = implode('', array_map( static fn (string $attribute): string => sprintf("\n%s", $attribute), array_unique($classAttributes), )); try { $classCode = <<<PHP namespace $namespace; use Pest\Repositories\DatasetsRepository as __PestDatasets; use Pest\TestSuite as __PestTestSuite; /** * @testdox $filename */ $classAttributesCode #[\AllowDynamicProperties] final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { $traitsCode private static \$__filename = '$filename'; $methodsCode } PHP; eval($classCode); // @phpstan-ignore-line } catch (ParseError $caught) { throw new RuntimeException(sprintf( "Unable to create test case for test file at %s. \n %s", $filename, $classCode ), 1, $caught); } } /** * Adds the given Method to the Test Case. */ public function addMethod(TestCaseMethodFactory $method): void { if ($method->description === null) { throw new TestDescriptionMissing($method->filename); } if (array_key_exists($method->description, $this->methods)) { throw new TestAlreadyExist($method->filename, $method->description); } if (! $method->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)); } }
Back