diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 3373eafb..5b9b0df5 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -24,15 +24,19 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.2 + php-version: 8.3 tools: composer:v2 coverage: none + extensions: sockets - name: Install Dependencies run: composer update --prefer-stable --no-interaction --no-progress --ansi - # - name: Type Check - # run: composer test:type:check + - name: Profanity Check + run: composer test:profanity + + - name: Type Check + run: composer test:type:check - name: Type Coverage run: composer test:type:coverage diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c4902b42..cda60c06 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,9 +13,9 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, macos-latest, windows-latest] - symfony: ['7.1'] - php: ['8.2', '8.3', '8.4'] - dependency_version: [prefer-lowest, prefer-stable] + symfony: ['7.3'] + php: ['8.3', '8.4'] + dependency_version: [prefer-stable] name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} @@ -29,6 +29,7 @@ jobs: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none + extensions: sockets - name: Setup Problem Matches run: | diff --git a/README.md b/README.md index 0f030829..4a11f1a7 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,13 @@ **Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP. - Explore our docs at **[pestphp.com »](https://pestphp.com)** -- Follow us on Twitter at **[@pestphp »](https://twitter.com/pestphp)** -- Join us at **[discord.gg/kaHY6p54JH »](https://discord.gg/kaHY6p54JH)** or **[t.me/+kYH5G4d5MV83ODk0 »](https://t.me/+kYH5G4d5MV83ODk0)** +- Follow the creator Nuno Maduro: + - YouTube: **[youtube.com/@nunomaduro](https://www.youtube.com/@nunomaduro)** — Videos every weekday + - Twitch: **[twitch.tv/enunomaduro](https://www.twitch.tv/enunomaduro)** — Streams (almost) every weekday + - Twitter / X: **[x.com/enunomaduro](https://x.com/enunomaduro)** + - LinkedIn: **[linkedin.com/in/nunomaduro](https://www.linkedin.com/in/nunomaduro)** + - Instagram: **[instagram.com/enunomaduro](https://www.instagram.com/enunomaduro)** + - Tiktok: **[tiktok.com/@enunomaduro](https://www.tiktok.com/@enunomaduro)** ## Sponsors @@ -29,17 +34,16 @@ We cannot thank our sponsors enough for their incredible support in funding Pest ### Gold Sponsors -- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)** -- **[LaraJobs](https://larajobs.com/?ref=pestphp)** - **[Brokerchooser](https://brokerchooser.com/?ref=pestphp)** -- **[Forge](https://forge.laravel.com/?ref=pestphp)** +- **[CodeRabbit](https://coderabbit.ai/?ref=pestphp)** +- **[NativePHP](https://nativephp.com/mobile?ref=pestphp.com)** ### Premium Sponsors - [Akaunting](https://akaunting.com/?ref=pestphp) -- [Codecourse](https://codecourse.com/?ref=pestphp) - [DocuWriter.ai](https://www.docuwriter.ai/?ref=pestphp) - [Localazy](https://localazy.com/?ref=pestphp) +- [Forge](https://forge.laravel.com/?ref=pestphp) - [Route4Me](https://www.route4me.com/?ref=pestphp) - [Spatie](https://spatie.be/?ref=pestphp) - [Worksome](https://www.worksome.com/?ref=pestphp) diff --git a/composer.json b/composer.json index bd8c21e3..9144724e 100644 --- a/composer.json +++ b/composer.json @@ -17,19 +17,21 @@ } ], "require": { - "php": "^8.2.0", - "brianium/paratest": "^7.7.0", - "nunomaduro/collision": "^8.6.1", - "nunomaduro/termwind": "^2.3.0", - "pestphp/pest-plugin": "^3.0.0", - "pestphp/pest-plugin-arch": "^3.0.0", - "pestphp/pest-plugin-mutate": "^3.0.5", - "phpunit/phpunit": "^11.5.6" + "php": "^8.3.0", + "brianium/paratest": "^7.11.0", + "nunomaduro/collision": "^8.8.2", + "nunomaduro/termwind": "^2.3.1", + "pestphp/pest-plugin": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-mutate": "^4.0.0", + "pestphp/pest-plugin-profanity": "^4.0.0", + "phpunit/phpunit": "^12.2.7", + "symfony/process": "^7.3.0" }, "conflict": { - "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.6", - "sebastian/exporter": "<6.0.0", + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.2.7", + "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "autoload": { @@ -53,9 +55,10 @@ ] }, "require-dev": { - "pestphp/pest-dev-tools": "^3.3.0", - "pestphp/pest-plugin-type-coverage": "^3.2.3", - "symfony/process": "^7.2.0" + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-browser": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0", + "psy/psysh": "^0.12.9" }, "minimum-stability": "dev", "prefer-stable": true, @@ -71,16 +74,17 @@ ], "scripts": { "refacto": "rector", - "lint": "pint", + "lint": "pint --parallel", "test:refacto": "rector --dry-run", - "test:lint": "pint --test", + "test:lint": "pint --parallel --test", + "test:profanity": "php bin/pest --profanity --compact", "test:type:check": "phpstan analyse --ansi --memory-limit=-1 --debug", "test:type:coverage": "php -d memory_limit=-1 bin/pest --type-coverage --min=100", - "test:unit": "php bin/pest --colors=always --exclude-group=integration --compact", - "test:inline": "php bin/pest --colors=always --configuration=phpunit.inline.xml", - "test:parallel": "php bin/pest --colors=always --exclude-group=integration --parallel --processes=3", - "test:integration": "php bin/pest --colors=always --group=integration -v", - "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --colors=always --update-snapshots", + "test:unit": "php bin/pest --exclude-group=integration --compact", + "test:inline": "php bin/pest --configuration=phpunit.inline.xml", + "test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3", + "test:integration": "php bin/pest --group=integration -v", + "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "test": [ "@test:refacto", "@test:lint", @@ -111,6 +115,7 @@ "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", "Pest\\Plugins\\Parallel" ] }, diff --git a/overrides/Event/Value/ThrowableBuilder.php b/overrides/Event/Value/ThrowableBuilder.php index 21d428e9..d446d03c 100644 --- a/overrides/Event/Value/ThrowableBuilder.php +++ b/overrides/Event/Value/ThrowableBuilder.php @@ -52,6 +52,8 @@ use PHPUnit\Util\Filter; use PHPUnit\Util\ThrowableToStringMapper; /** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * * @internal This class is not covered by the backward compatibility promise for PHPUnit */ final readonly class ThrowableBuilder @@ -82,7 +84,7 @@ final readonly class ThrowableBuilder $t->getMessage(), ThrowableToStringMapper::map($t), $trace, - $previous + $previous, ); } } diff --git a/overrides/Logging/JUnit/JunitXmlLogger.php b/overrides/Logging/JUnit/JunitXmlLogger.php index ca5c02c4..b7362b1f 100644 --- a/overrides/Logging/JUnit/JunitXmlLogger.php +++ b/overrides/Logging/JUnit/JunitXmlLogger.php @@ -27,6 +27,7 @@ use PHPUnit\Event\Test\Finished; use PHPUnit\Event\Test\MarkedIncomplete; use PHPUnit\Event\Test\PreparationStarted; use PHPUnit\Event\Test\Prepared; +use PHPUnit\Event\Test\PrintedUnexpectedOutput; use PHPUnit\Event\Test\Skipped; use PHPUnit\Event\TestSuite\Started; use PHPUnit\Event\UnknownSubscriberTypeException; @@ -41,6 +42,8 @@ use function str_replace; use function trim; /** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * * @internal This class is not covered by the backward compatibility promise for PHPUnit */ final class JunitXmlLogger @@ -59,32 +62,32 @@ final class JunitXmlLogger private array $testSuites = []; /** - * @psalm-var array + * @var array */ private array $testSuiteTests = [0]; /** - * @psalm-var array + * @var array */ private array $testSuiteAssertions = [0]; /** - * @psalm-var array + * @var array */ private array $testSuiteErrors = [0]; /** - * @psalm-var array + * @var array */ private array $testSuiteFailures = [0]; /** - * @psalm-var array + * @var array */ private array $testSuiteSkipped = [0]; /** - * @psalm-var array + * @var array */ private array $testSuiteTimes = [0]; @@ -113,7 +116,7 @@ final class JunitXmlLogger public function flush(): void { - $this->printer->print($this->document->saveXML()); + $this->printer->print($this->document->saveXML() ?: ''); $this->printer->flush(); } @@ -195,28 +198,34 @@ final class JunitXmlLogger $this->createTestCase($event); } - /** - * @throws InvalidArgumentException - */ public function testPreparationFailed(): void { $this->preparationFailed = true; } - /** - * @throws InvalidArgumentException - */ public function testPrepared(): void { $this->prepared = true; } + public function testPrintedUnexpectedOutput(PrintedUnexpectedOutput $event): void + { + assert($this->currentTestCase !== null); + + $systemOut = $this->document->createElement( + 'system-out', + Xml::prepareString($event->output()), + ); + + $this->currentTestCase->appendChild($systemOut); + } + /** * @throws InvalidArgumentException */ public function testFinished(Finished $event): void { - if ($this->preparationFailed) { + if (! $this->prepared || $this->preparationFailed) { return; } @@ -305,9 +314,11 @@ final class JunitXmlLogger new TestPreparationStartedSubscriber($this), new TestPreparationFailedSubscriber($this), new TestPreparedSubscriber($this), + new TestPrintedUnexpectedOutputSubscriber($this), new TestFinishedSubscriber($this), new TestErroredSubscriber($this), new TestFailedSubscriber($this), + new TestMarkedIncompleteSubscriber($this), new TestSkippedSubscriber($this), new TestRunnerExecutionFinishedSubscriber($this), ); @@ -431,7 +442,7 @@ final class JunitXmlLogger /** * @throws InvalidArgumentException * - * @psalm-assert !null $this->currentTestCase + * @phpstan-assert !null $this->currentTestCase */ private function createTestCase(Errored|Failed|MarkedIncomplete|PreparationStarted|Prepared|Skipped $event): void { diff --git a/overrides/Report/PHP.php b/overrides/Report/PHP.php new file mode 100644 index 00000000..0cd7e27b --- /dev/null +++ b/overrides/Report/PHP.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SebastianBergmann\CodeCoverage\Report; + +use const PHP_EOL; + +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Util\Filesystem; +use SebastianBergmann\CodeCoverage\WriteOperationFailedException; + +use function dirname; +use function serialize; +use function str_contains; + +final class PHP +{ + public function process(CodeCoverage $coverage, ?string $target = null): string + { + $coverage->clearCache(); + + $buffer = " + * @var array */ private array $defects = []; /** - * @psalm-var array + * @var array */ private array $times = []; @@ -95,28 +95,39 @@ final class DefaultResultCache implements ResultCache $this->cacheFilename = $filepath ?? $_ENV['PHPUNIT_RESULT_CACHE'] ?? self::DEFAULT_RESULT_CACHE_FILENAME; } - public function setStatus(string $id, TestStatus $status): void + public function setStatus(ResultCacheId $id, TestStatus $status): void { if ($status->isSuccess()) { return; } - $this->defects[$id] = $status; + $this->defects[$id->asString()] = $status; } - public function status(string $id): TestStatus + public function status(ResultCacheId $id): TestStatus { - return $this->defects[$id] ?? TestStatus::unknown(); + return $this->defects[$id->asString()] ?? TestStatus::unknown(); } - public function setTime(string $id, float $time): void + public function setTime(ResultCacheId $id, float $time): void { - $this->times[$id] = $time; + $this->times[$id->asString()] = $time; } - public function time(string $id): float + public function time(ResultCacheId $id): float { - return $this->times[$id] ?? 0.0; + return $this->times[$id->asString()] ?? 0.0; + } + + public function mergeWith(self $other): void + { + foreach ($other->defects as $id => $defect) { + $this->defects[$id] = $defect; + } + + foreach ($other->times as $id => $time) { + $this->times[$id] = $time; + } } public function load(): void @@ -165,7 +176,7 @@ final class DefaultResultCache implements ResultCache public function persist(): void { if (! Filesystem::createDirectory(dirname($this->cacheFilename))) { - throw new DirectoryCannotBeCreatedException($this->cacheFilename); + throw new DirectoryDoesNotExistException(dirname($this->cacheFilename)); } $data = [ diff --git a/overrides/TextUI/TestSuiteFilterProcessor.php b/overrides/TextUI/TestSuiteFilterProcessor.php index 536ab208..e13d5c98 100644 --- a/overrides/TextUI/TestSuiteFilterProcessor.php +++ b/overrides/TextUI/TestSuiteFilterProcessor.php @@ -45,6 +45,7 @@ declare(strict_types=1); namespace PHPUnit\TextUI; use Pest\Plugins\Only; +use Pest\Runner\Filter\EnsureTestCaseIsInitiatedFilter; use PHPUnit\Event; use PHPUnit\Framework\TestSuite; use PHPUnit\Runner\Filter\Factory; @@ -66,6 +67,12 @@ final readonly class TestSuiteFilterProcessor { $factory = new Factory; + // @phpstan-ignore-next-line + (fn () => $this->filters[] = [ + 'className' => EnsureTestCaseIsInitiatedFilter::class, + 'argument' => '', + ])->call($factory); + if (! $configuration->hasFilter() && ! $configuration->hasGroups() && ! $configuration->hasExcludeGroups() && @@ -73,6 +80,8 @@ final readonly class TestSuiteFilterProcessor ! $configuration->hasTestsCovering() && ! $configuration->hasTestsUsing() && ! Only::isEnabled()) { + $suite->injectFilter($factory); + return; } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..99bbdec9 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,199 @@ +parameters: + ignoreErrors: + - + message: '#^Parameter \#1 of callable callable\(Pest\\Expectation\\)\: Pest\\Arch\\Contracts\\ArchExpectation expects Pest\\Expectation\, Pest\\Expectation\ given\.$#' + identifier: argument.type + count: 1 + path: src/ArchPresets/AbstractPreset.php + + - + message: '#^Trait Pest\\Concerns\\Expectable is used zero times and is not analysed\.$#' + identifier: trait.unused + count: 1 + path: src/Concerns/Expectable.php + + - + message: '#^Trait Pest\\Concerns\\Logging\\WritesToConsole is used zero times and is not analysed\.$#' + identifier: trait.unused + count: 1 + path: src/Concerns/Logging/WritesToConsole.php + + - + message: '#^Trait Pest\\Concerns\\Testable is used zero times and is not analysed\.$#' + identifier: trait.unused + count: 1 + path: src/Concerns/Testable.php + + - + message: '#^Loose comparison using \!\= between \(Closure\|null\) and false will always evaluate to false\.$#' + identifier: notEqual.alwaysFalse + count: 1 + path: src/Expectation.php + + - + message: '#^Method Pest\\Expectation\:\:and\(\) should return Pest\\Expectation\ but returns \(Pest\\Expectation&TAndValue\)\|Pest\\Expectation\\.$#' + identifier: return.type + count: 1 + path: src/Expectation.php + + - + message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$each contains generic class Pest\\Expectations\\EachExpectation but does not specify its types\: TValue$#' + identifier: missingType.generics + count: 1 + path: src/Expectation.php + + - + message: '#^PHPDoc tag @property for property Pest\\Expectation\:\:\$not contains generic class Pest\\Expectations\\OppositeExpectation but does not specify its types\: TValue$#' + identifier: missingType.generics + count: 1 + path: src/Expectation.php + + - + message: '#^Parameter \#2 \$newScope of method Closure\:\:bindTo\(\) expects ''static''\|class\-string\|object\|null, string given\.$#' + identifier: argument.type + count: 1 + path: src/Expectation.php + + - + message: '#^Function expect\(\) should return Pest\\Expectation\ but returns Pest\\Expectation\\.$#' + identifier: return.type + count: 1 + path: src/Functions.php + + - + message: '#^Parameter \#1 \$argv of method PHPUnit\\TextUI\\Application\:\:run\(\) expects list\, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Kernel.php + + - + message: '#^Call to an undefined method object&TValue of mixed\:\:__toString\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Mixins/Expectation.php + + - + message: '#^Call to an undefined method object&TValue of mixed\:\:toArray\(\)\.$#' + identifier: method.notFound + count: 4 + path: src/Mixins/Expectation.php + + - + message: '#^Call to an undefined method object&TValue of mixed\:\:toSnapshot\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Mixins/Expectation.php + + - + message: '#^Call to an undefined method object&TValue of mixed\:\:toString\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/Mixins/Expectation.php + + - + message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' + identifier: staticMethod.alreadyNarrowedType + count: 2 + path: src/Mixins/Expectation.php + + - + message: '#^PHPDoc tag @var with type callable\(\)\: bool is not subtype of native type Closure\|null\.$#' + identifier: varTag.nativeType + count: 1 + path: src/PendingCalls/TestCall.php + + - + message: '#^Parameter \#1 \$argv of class Symfony\\Component\\Console\\Input\\ArgvInput constructor expects list\\|null, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel.php + + - + message: '#^Parameter \#13 \$testRunnerTriggeredDeprecationEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#14 \$testRunnerTriggeredWarningEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#15 \$errors of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#16 \$deprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#17 \$notices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#18 \$warnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#19 \$phpDeprecations of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#20 \$phpNotices of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#21 \$phpWarnings of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#4 \$testErroredEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#5 \$testFailedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#7 \$testSuiteSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#8 \$testSkippedEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Parameter \#9 \$testMarkedIncompleteEvents of class PHPUnit\\TestRunner\\TestResult\\TestResult constructor expects list\, array given\.$#' + identifier: argument.type + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php + + - + message: '#^Property Pest\\Plugins\\Parallel\\Paratest\\WrapperRunner\:\:\$pending \(list\\) does not accept array\\.$#' + identifier: assign.propertyType + count: 1 + path: src/Plugins/Parallel/Paratest/WrapperRunner.php diff --git a/phpstan.neon b/phpstan.neon index 9ed48871..391daf0b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,14 +1,12 @@ includes: - - vendor/phpstan/phpstan-strict-rules/rules.neon - - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon + - phpstan-baseline.neon parameters: - level: max + level: 7 paths: - src - checkMissingIterableValueType: true - reportUnmatchedIgnoredErrors: true + reportUnmatchedIgnoredErrors: false ignoreErrors: - "#type mixed is not subtype of native#" diff --git a/resources/views/installers/plugin-browser.php b/resources/views/installers/plugin-browser.php new file mode 100644 index 00000000..d717867b --- /dev/null +++ b/resources/views/installers/plugin-browser.php @@ -0,0 +1,22 @@ +
+

+ Using the visit() function requires the Pest Plugin Browser to be installed. + + Run: +

+ +
+ - + composer require pestphp/pest-plugin-browser:^4.0 --dev +
+ +
+ - + npm install playwright@latest +
+ +
+ - + npx playwright install +
+
diff --git a/src/Bootstrappers/BootExcludeList.php b/src/Bootstrappers/BootExcludeList.php index abd1552c..69d9dce1 100644 --- a/src/Bootstrappers/BootExcludeList.php +++ b/src/Bootstrappers/BootExcludeList.php @@ -17,7 +17,7 @@ final class BootExcludeList implements Bootstrapper * * @var array */ - private const EXCLUDE_LIST = [ + private const array EXCLUDE_LIST = [ 'bin', 'overrides', 'resources', diff --git a/src/Bootstrappers/BootFiles.php b/src/Bootstrappers/BootFiles.php index 2017a796..ea7e60fa 100644 --- a/src/Bootstrappers/BootFiles.php +++ b/src/Bootstrappers/BootFiles.php @@ -24,7 +24,7 @@ final class BootFiles implements Bootstrapper * * @var array */ - private const STRUCTURE = [ + private const array STRUCTURE = [ 'Expectations', 'Expectations.php', 'Helpers', diff --git a/src/Bootstrappers/BootOverrides.php b/src/Bootstrappers/BootOverrides.php index efbcf7a3..a4eef19f 100644 --- a/src/Bootstrappers/BootOverrides.php +++ b/src/Bootstrappers/BootOverrides.php @@ -15,17 +15,18 @@ final class BootOverrides implements Bootstrapper /** * The list of files to be overridden. * - * @var array + * @var array */ - public const FILES = [ - '53c246e5f416a39817ac81124cdd64ea8403038d01d7a202e1ffa486fbdf3fa7' => 'Runner/Filter/NameFilterIterator.php', - '77ffb7647b583bd82e37962c6fbdc4b04d3344d8a2c1ed103e625ed1ff7cb5c2' => 'Runner/ResultCache/DefaultResultCache.php', - 'd0e81317889ad88c707db4b08a94cadee4c9010d05ff0a759f04e71af5efed89' => 'Runner/TestSuiteLoader.php', - '3bb609b0d3bf6dee8df8d6cd62a3c8ece823c4bb941eaaae39e3cb267171b9d2' => 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php', - '8abdad6413329c6fe0d7d44a8b9926e390af32c0b3123f3720bb9c5bbc6fbb7e' => 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php', - 'b4250fc3ffad5954624cb5e682fd940b874e8d3422fa1ee298bd7225e1aa5fc2' => 'TextUI/TestSuiteFilterProcessor.php', - '8cfcb4999af79463eca51a42058e502ea4ddc776cba5677bf2f8eb6093e21a5c' => 'Event/Value/ThrowableBuilder.php', - '86cd9bcaa53cdd59c5b13e58f30064a015c549501e7629d93b96893d4dee1eb1' => 'Logging/JUnit/JunitXmlLogger.php', + public const array FILES = [ + 'Runner/Filter/NameFilterIterator.php', + 'Runner/ResultCache/DefaultResultCache.php', + 'Runner/TestSuiteLoader.php', + 'TextUI/Command/Commands/WarmCodeCoverageCacheCommand.php', + 'TextUI/Output/Default/ProgressPrinter/Subscriber/TestSkippedSubscriber.php', + 'TextUI/TestSuiteFilterProcessor.php', + 'Event/Value/ThrowableBuilder.php', + 'Logging/JUnit/JunitXmlLogger.php', + 'Report/PHP.php', ]; /** diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 57f98e33..7877b237 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -20,7 +20,7 @@ final readonly class BootSubscribers implements Bootstrapper * * @var array> */ - private const SUBSCRIBERS = [ + private const array SUBSCRIBERS = [ Subscribers\EnsureConfigurationIsAvailable::class, Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureKernelDumpIsFlushed::class, diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 37d3b175..767a7c69 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -6,10 +6,12 @@ namespace Pest\Concerns; use Closure; use Pest\Exceptions\DatasetArgumentsMismatch; +use Pest\Panic; use Pest\Preset; use Pest\Support\ChainableClosure; use Pest\Support\ExceptionTrace; use Pest\Support\Reflection; +use Pest\Support\Shell; use Pest\TestSuite; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\TestCase; @@ -101,27 +103,6 @@ trait Testable */ private array $__snapshotChanges = []; - /** - * Creates a new Test Case instance. - */ - public function __construct(string $name) - { - parent::__construct($name); - - $test = TestSuite::getInstance()->tests->get(self::$__filename); - - if ($test->hasMethod($name)) { - $method = $test->getMethod($name); - $this->__description = self::$__latestDescription = $method->description; - self::$__latestAssignees = $method->assignees; - self::$__latestNotes = $method->notes; - self::$__latestIssues = $method->issues; - self::$__latestPrs = $method->prs; - $this->__describing = $method->describing; - $this->__test = $method->getClosure(); - } - } - /** * Resets the test case static properties. */ @@ -214,7 +195,11 @@ trait Testable $beforeAll = ChainableClosure::boundStatically(self::$__beforeAll, $beforeAll); } - call_user_func(Closure::bind($beforeAll, null, self::class)); + try { + call_user_func(Closure::bind($beforeAll, null, self::class)); + } catch (Throwable $e) { + Panic::with($e); + } } /** @@ -242,8 +227,6 @@ trait Testable $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); - $method->setUp($this); - $description = $method->description; if ($this->dataName()) { $description = str_contains((string) $description, ':dataset') @@ -285,6 +268,33 @@ trait Testable $this->__callClosure($beforeEach, $arguments); } + /** + * Initialize test case properties from TestSuite. + */ + public function __initializeTestCase(): void + { + // Return if the test case has already been initialized + if (isset($this->__test)) { + return; + } + + $name = $this->name(); + $test = TestSuite::getInstance()->tests->get(self::$__filename); + + if ($test->hasMethod($name)) { + $method = $test->getMethod($name); + $this->__description = self::$__latestDescription = $method->description; + self::$__latestAssignees = $method->assignees; + self::$__latestNotes = $method->notes; + self::$__latestIssues = $method->issues; + self::$__latestPrs = $method->prs; + $this->__describing = $method->describing; + $this->__test = $method->getClosure(); + + $method->setUp($this); + } + } + /** * Gets executed after the Test Case. */ @@ -434,15 +444,7 @@ trait Testable return; } - if (count($this->__snapshotChanges) === 1) { - $this->markTestIncomplete($this->__snapshotChanges[0]); - - return; - } - - $messages = implode(PHP_EOL, array_map(static fn (string $message): string => '- $message', $this->__snapshotChanges)); - - $this->markTestIncomplete($messages); + $this->markTestIncomplete(implode('. ', $this->__snapshotChanges)); } /** @@ -466,7 +468,7 @@ trait Testable */ public static function getLatestPrintableTestCaseMethodName(): string { - return self::$__latestDescription; + return self::$__latestDescription ?? ''; } /** @@ -481,4 +483,12 @@ trait Testable 'notes' => self::$__latestNotes, ]; } + + /** + * Opens a shell for the test case. + */ + public function shell(): void + { + Shell::open(); + } } diff --git a/src/Configuration.php b/src/Configuration.php index c504aa65..fb4f45a4 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -102,6 +102,14 @@ final readonly class Configuration return Configuration\Project::getInstance(); } + /** + * Gets the browser configuration. + */ + public function browser(): Browser\Configuration + { + return new Browser\Configuration; + } + /** * Proxies calls to the uses method. * diff --git a/src/Console/Help.php b/src/Console/Help.php index 3d09d5f5..50823d59 100644 --- a/src/Console/Help.php +++ b/src/Console/Help.php @@ -16,7 +16,7 @@ final readonly class Help * * @var array */ - private const HELP_MESSAGES = [ + private const array HELP_MESSAGES = [ 'Pest Options:', ' --init Initialise a standard Pest configuration', ' --coverage Enable coverage and output to standard output', diff --git a/src/Console/Thanks.php b/src/Console/Thanks.php index 332c311b..fc9f558a 100644 --- a/src/Console/Thanks.php +++ b/src/Console/Thanks.php @@ -22,10 +22,14 @@ final readonly class Thanks * * @var array */ - private const FUNDING_MESSAGES = [ + private const array FUNDING_MESSAGES = [ 'Star' => 'https://github.com/pestphp/pest', - 'News' => 'https://twitter.com/pestphp', - 'Videos' => 'https://youtube.com/@nunomaduro', + 'YouTube' => 'https://youtube.com/@nunomaduro', + 'TikTok' => 'https://tiktok.com/@nunomaduro', + 'Twitch' => 'https://twitch.tv/enunomaduro', + 'LinkedIn' => 'https://linkedin.com/in/nunomaduro', + 'Instagram' => 'https://instagram.com/enunomaduro', + 'X' => 'https://x.com/enunomaduro', 'Sponsor' => 'https://github.com/sponsors/nunomaduro', ]; diff --git a/src/Expectation.php b/src/Expectation.php index ebfd6302..1bef5a8c 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -535,7 +535,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isFinal(), + fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isFinal(), 'to be final', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -548,7 +548,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => ! enum_exists($object->name) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line + fn (ObjectDescription $object): bool => ! enum_exists($object->name) && isset($object->reflectionClass) && $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line 'to be readonly', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -561,7 +561,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->isTrait(), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isTrait(), 'to be trait', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -582,7 +582,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->isAbstract(), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isAbstract(), 'to be abstract', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -599,7 +599,7 @@ final class Expectation return Targeted::make( $this, - fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => $object->reflectionClass->hasMethod($method))) === count($methods), + fn (ObjectDescription $object): bool => count(array_filter($methods, fn (string $method): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod($method))) === count($methods), sprintf("to have method '%s'", implode("', '", $methods)), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -670,7 +670,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum(), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isEnum(), 'to be enum', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -712,7 +712,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->isInterface(), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->isInterface(), 'to be interface', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -733,7 +733,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && ($class === $object->reflectionClass->getName() || $object->reflectionClass->isSubclassOf($class)), sprintf("to extend '%s'", $class), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -773,6 +773,10 @@ final class Expectation $this, function (ObjectDescription $object) use ($traits): bool { foreach ($traits as $trait) { + if (isset($object->reflectionClass) === false) { + return false; + } + if (! in_array($trait, $object->reflectionClass->getTraitNames(), true)) { return false; } @@ -792,7 +796,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() === [], + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getInterfaceNames() === [], 'to implement nothing', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -809,7 +813,8 @@ final class Expectation return Targeted::make( $this, - fn (ObjectDescription $object): bool => count($interfaces) === count($object->reflectionClass->getInterfaceNames()) + fn (ObjectDescription $object): bool => isset($object->reflectionClass) + && (count($interfaces) === count($object->reflectionClass->getInterfaceNames())) && array_diff($interfaces, $object->reflectionClass->getInterfaceNames()) === [], "to only implement '".implode("', '", $interfaces)."'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), @@ -823,7 +828,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => str_starts_with($object->reflectionClass->getShortName(), $prefix), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_starts_with($object->reflectionClass->getShortName(), $prefix), "to have prefix '{$prefix}'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -836,7 +841,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => str_ends_with($object->reflectionClass->getName(), $suffix), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && str_ends_with($object->reflectionClass->getName(), $suffix), "to have suffix '{$suffix}'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -855,7 +860,7 @@ final class Expectation $this, function (ObjectDescription $object) use ($interfaces): bool { foreach ($interfaces as $interface) { - if (! $object->reflectionClass->implementsInterface($interface)) { + if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) { return false; } } @@ -928,7 +933,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->hasMethod('__invoke'), + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->hasMethod('__invoke'), 'to be invokable', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')) ); @@ -1037,7 +1042,7 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) !== [], + fn (ObjectDescription $object): bool => isset($object->reflectionClass) && $object->reflectionClass->getAttributes($attribute) !== [], "to have attribute '{$attribute}'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -1066,7 +1071,8 @@ final class Expectation { return Targeted::make( $this, - fn (ObjectDescription $object): bool => $object->reflectionClass->isEnum() + fn (ObjectDescription $object): bool => isset($object->reflectionClass) + && $object->reflectionClass->isEnum() && (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line && (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line 'to be '.$backingType.' backed enum', diff --git a/src/Expectations/OppositeExpectation.php b/src/Expectations/OppositeExpectation.php index 59a25b4a..d5c3f083 100644 --- a/src/Expectations/OppositeExpectation.php +++ b/src/Expectations/OppositeExpectation.php @@ -74,7 +74,10 @@ final readonly class OppositeExpectation */ public function toUse(array|string $targets): ArchExpectation { - return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($this->original, $target)->opposite( + /** @var Expectation|string> $original */ + $original = $this->original; + + return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): SingleArchExpectation => ToUse::make($original, $target)->opposite( fn () => $this->throwExpectationFailedException('toUse', $target), ), is_string($targets) ? [$targets] : $targets)); } @@ -84,8 +87,11 @@ final readonly class OppositeExpectation */ public function toHaveFileSystemPermissions(string $permissions): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => substr(sprintf('%o', fileperms($object->path)), -4) !== $permissions, sprintf('permissions not to be [%s]', $permissions), FileLineFinder::where(fn (string $line): bool => str_contains($line, '|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || array_filter( Reflection::getMethodsFromReflectionClass($object->reflectionClass), @@ -124,8 +133,11 @@ final readonly class OppositeExpectation */ public function toHavePropertiesDocumented(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || array_filter( Reflection::getPropertiesFromReflectionClass($object->reflectionClass), @@ -144,8 +156,11 @@ final readonly class OppositeExpectation */ public function toUseStrictTypes(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => ! (bool) preg_match('/^<\?php\s+declare\(.*?strict_types\s?=\s?1.*?\);/', (string) file_get_contents($object->path)), 'not to use strict types', FileLineFinder::where(fn (string $line): bool => str_contains($line, '|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => ! str_contains((string) file_get_contents($object->path), ' === ') && ! str_contains((string) file_get_contents($object->path), ' !== '), 'to use strict equality', FileLineFinder::where(fn (string $line): bool => str_contains($line, ' === ') || str_contains($line, ' !== ')), @@ -170,9 +188,12 @@ final readonly class OppositeExpectation */ public function toBeFinal(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isFinal(), + $original, + fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isFinal()), 'not to be final', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -183,9 +204,12 @@ final readonly class OppositeExpectation */ public function toBeReadonly(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! enum_exists($object->name) && ! $object->reflectionClass->isReadOnly() && assert(true), // @phpstan-ignore-line + $original, + fn (ObjectDescription $object): bool => ! enum_exists($object->name) && (isset($object->reflectionClass) === false || ! $object->reflectionClass->isReadOnly()) && assert(true), // @phpstan-ignore-line 'not to be readonly', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -196,9 +220,12 @@ final readonly class OppositeExpectation */ public function toBeTrait(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->isTrait(), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isTrait(), 'not to be trait', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -217,9 +244,12 @@ final readonly class OppositeExpectation */ public function toBeAbstract(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->isAbstract(), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isAbstract(), 'not to be abstract', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -234,11 +264,14 @@ final readonly class OppositeExpectation { $methods = is_array($method) ? $method : [$method]; + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => array_filter( $methods, - fn (string $method): bool => $object->reflectionClass->hasMethod($method), + fn (string $method): bool => isset($object->reflectionClass) === false || $object->reflectionClass->hasMethod($method), ) === [], 'to not have methods: '.implode(', ', $methods), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), @@ -266,8 +299,11 @@ final readonly class OppositeExpectation $state = new stdClass; + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, function (ObjectDescription $object) use ($methods, &$state): bool { $reflectionMethods = isset($object->reflectionClass) ? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PUBLIC) @@ -309,8 +345,11 @@ final readonly class OppositeExpectation $state = new stdClass; + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, function (ObjectDescription $object) use ($methods, &$state): bool { $reflectionMethods = isset($object->reflectionClass) ? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PROTECTED) @@ -352,8 +391,11 @@ final readonly class OppositeExpectation $state = new stdClass; + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, function (ObjectDescription $object) use ($methods, &$state): bool { $reflectionMethods = isset($object->reflectionClass) ? Reflection::getMethodsFromReflectionClass($object->reflectionClass, ReflectionMethod::IS_PRIVATE) @@ -389,9 +431,12 @@ final readonly class OppositeExpectation */ public function toBeEnum(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum(), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isEnum(), 'not to be enum', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -410,8 +455,11 @@ final readonly class OppositeExpectation */ public function toBeClass(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, fn (ObjectDescription $object): bool => ! class_exists($object->name), 'not to be class', FileLineFinder::where(fn (string $line): bool => true), @@ -431,9 +479,12 @@ final readonly class OppositeExpectation */ public function toBeInterface(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->isInterface(), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isInterface(), 'not to be interface', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -452,9 +503,12 @@ final readonly class OppositeExpectation */ public function toExtend(string $class): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->isSubclassOf($class), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->isSubclassOf($class), sprintf("not to extend '%s'", $class), FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -465,9 +519,12 @@ final readonly class OppositeExpectation */ public function toExtendNothing(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => $object->reflectionClass->getParentClass() !== false, + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getParentClass() !== false, 'to extend a class', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -490,11 +547,14 @@ final readonly class OppositeExpectation { $traits = is_array($traits) ? $traits : [$traits]; + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, function (ObjectDescription $object) use ($traits): bool { foreach ($traits as $trait) { - if (in_array($trait, $object->reflectionClass->getTraitNames(), true)) { + if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) { return false; } } @@ -515,11 +575,14 @@ final readonly class OppositeExpectation { $interfaces = is_array($interfaces) ? $interfaces : [$interfaces]; + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, + $original, function (ObjectDescription $object) use ($interfaces): bool { foreach ($interfaces as $interface) { - if ($object->reflectionClass->implementsInterface($interface)) { + if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) { return false; } } @@ -536,9 +599,12 @@ final readonly class OppositeExpectation */ public function toImplementNothing(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => $object->reflectionClass->getInterfaceNames() !== [], + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getInterfaceNames() !== [], 'to implement an interface', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -557,9 +623,12 @@ final readonly class OppositeExpectation */ public function toHavePrefix(string $prefix): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! str_starts_with($object->reflectionClass->getShortName(), $prefix), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_starts_with($object->reflectionClass->getShortName(), $prefix), "not to have prefix '{$prefix}'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -570,9 +639,12 @@ final readonly class OppositeExpectation */ public function toHaveSuffix(string $suffix): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! str_ends_with($object->reflectionClass->getName(), $suffix), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! str_ends_with($object->reflectionClass->getName(), $suffix), "not to have suffix '{$suffix}'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')), ); @@ -599,7 +671,10 @@ final readonly class OppositeExpectation */ public function toBeUsed(): ArchExpectation { - return ToBeUsedInNothing::make($this->original); + /** @var Expectation|string> $original */ + $original = $this->original; + + return ToBeUsedInNothing::make($original); } /** @@ -609,7 +684,10 @@ final readonly class OppositeExpectation */ public function toBeUsedIn(array|string $targets): ArchExpectation { - return GroupArchExpectation::fromExpectations($this->original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($this->original, $target)->opposite( + /** @var Expectation|string> $original */ + $original = $this->original; + + return GroupArchExpectation::fromExpectations($original, array_map(fn (string $target): GroupArchExpectation => ToBeUsedIn::make($original, $target)->opposite( fn () => $this->throwExpectationFailedException('toBeUsedIn', $target), ), is_string($targets) ? [$targets] : $targets)); } @@ -632,9 +710,12 @@ final readonly class OppositeExpectation */ public function toBeInvokable(): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->hasMethod('__invoke'), + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || ! $object->reflectionClass->hasMethod('__invoke'), 'to not be invokable', FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')) ); @@ -645,9 +726,12 @@ final readonly class OppositeExpectation */ public function toHaveAttribute(string $attribute): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => $object->reflectionClass->getAttributes($attribute) === [], + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false || $object->reflectionClass->getAttributes($attribute) === [], "to not have attribute '{$attribute}'", FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')) ); @@ -737,9 +821,13 @@ final readonly class OppositeExpectation */ private function toBeBackedEnum(string $backingType): ArchExpectation { + /** @var Expectation|string> $original */ + $original = $this->original; + return Targeted::make( - $this->original, - fn (ObjectDescription $object): bool => ! $object->reflectionClass->isEnum() + $original, + fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false + || ! $object->reflectionClass->isEnum() || ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line || (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line 'not to be '.$backingType.' backed enum', diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php index fb763c75..bac50071 100644 --- a/src/Factories/TestCaseMethodFactory.php +++ b/src/Factories/TestCaseMethodFactory.php @@ -155,7 +155,7 @@ final class TestCaseMethodFactory assert($testCase instanceof TestCaseFactory); $method = $this; - return function (...$arguments) use ($testCase, $method, $closure): mixed { // @phpstan-ignore-line + return function (...$arguments) use ($testCase, $method, $closure): mixed { /* @var TestCase $this */ $testCase->proxies->proxy($this); $method->proxies->proxy($this); diff --git a/src/Functions.php b/src/Functions.php index 1e12fe7e..1702f94a 100644 --- a/src/Functions.php +++ b/src/Functions.php @@ -2,11 +2,14 @@ declare(strict_types=1); +use Pest\Browser\Api\ArrayablePendingAwaitablePage; +use Pest\Browser\Api\PendingAwaitablePage; use Pest\Concerns\Expectable; use Pest\Configuration; use Pest\Exceptions\AfterAllWithinDescribe; use Pest\Exceptions\BeforeAllWithinDescribe; use Pest\Expectation; +use Pest\Installers\PluginBrowser; use Pest\Mutate\Contracts\MutationTestRunner; use Pest\Mutate\Repositories\ConfigurationRepository; use Pest\PendingCalls\AfterEachCall; @@ -278,3 +281,51 @@ if (! function_exists('mutates')) { } } } + +if (! function_exists('fixture')) { + /** + * Returns the absolute path to a fixture file. + */ + function fixture(string $file): string + { + $file = implode(DIRECTORY_SEPARATOR, [ + TestSuite::getInstance()->rootPath, + TestSuite::getInstance()->testPath, + 'Fixtures', + str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file), + ]); + + $fileRealPath = realpath($file); + + if ($fileRealPath === false) { + throw new InvalidArgumentException( + 'The fixture file ['.$file.'] does not exist.', + ); + } + + return $fileRealPath; + } +} + +if (! function_exists('visit')) { + /** + * Browse to the given URL. + * + * @template TUrl of array|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); + } +} diff --git a/src/Installers/PluginBrowser.php b/src/Installers/PluginBrowser.php new file mode 100644 index 00000000..2d36ed3d --- /dev/null +++ b/src/Installers/PluginBrowser.php @@ -0,0 +1,15 @@ + */ - private const BOOTSTRAPPERS = [ + private const array BOOTSTRAPPERS = [ Bootstrappers\BootOverrides::class, Bootstrappers\BootSubscribers::class, Bootstrappers\BootFiles::class, diff --git a/src/Logging/Converter.php b/src/Logging/Converter.php index b4560e22..4805a946 100644 --- a/src/Logging/Converter.php +++ b/src/Logging/Converter.php @@ -31,7 +31,7 @@ final readonly class Converter /** * The prefix for the test suite name. */ - private const PREFIX = 'P\\'; + private const string PREFIX = 'P\\'; /** * The state generator. diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php index f802bc11..6ebcb546 100644 --- a/src/Mixins/Expectation.php +++ b/src/Mixins/Expectation.php @@ -183,7 +183,6 @@ final class Expectation { foreach ($needles as $needle) { if (is_string($this->value)) { - // @phpstan-ignore-next-line Assert::assertStringContainsString((string) $needle, $this->value); } else { if (! is_iterable($this->value)) { @@ -1159,4 +1158,21 @@ final class Expectation return $this; } + + /** + * Asserts that the value can be converted to a slug + * + * @return self + */ + public function toBeSlug(string $message = ''): self + { + if ($message === '') { + $message = "Failed asserting that {$this->value} can be converted to a slug."; + } + + $slug = Str::slugify((string) $this->value); + Assert::assertNotEmpty($slug, $message); + + return $this; + } } diff --git a/src/Panic.php b/src/Panic.php index aca23b5e..a204472c 100644 --- a/src/Panic.php +++ b/src/Panic.php @@ -46,7 +46,7 @@ final readonly class Panic { try { $output = Container::getInstance()->get(OutputInterface::class); - } catch (Throwable) { // @phpstan-ignore-line + } catch (Throwable) { $output = new ConsoleOutput; } diff --git a/src/PendingCalls/DescribeCall.php b/src/PendingCalls/DescribeCall.php index b015595c..de472960 100644 --- a/src/PendingCalls/DescribeCall.php +++ b/src/PendingCalls/DescribeCall.php @@ -78,7 +78,7 @@ final class DescribeCall $this->currentBeforeEachCall->describing[] = $this->description; } - $this->currentBeforeEachCall->{$name}(...$arguments); // @phpstan-ignore-line + $this->currentBeforeEachCall->{$name}(...$arguments); return $this; } diff --git a/src/PendingCalls/TestCall.php b/src/PendingCalls/TestCall.php index a22ff11d..d10505f0 100644 --- a/src/PendingCalls/TestCall.php +++ b/src/PendingCalls/TestCall.php @@ -12,6 +12,7 @@ use Pest\Factories\Attribute; use Pest\Factories\TestCaseMethodFactory; use Pest\Mutate\Repositories\ConfigurationRepository; use Pest\PendingCalls\Concerns\Describable; +use Pest\Plugins\Environment; use Pest\Plugins\Only; use Pest\Support\Backtrace; use Pest\Support\Container; @@ -178,10 +179,9 @@ final class TestCall // @phpstan-ignore-line } /** - * Runs the current test multiple times with - * each item of the given `iterable`. + * Runs the current test multiple times with each item of the given `iterable`. * - * @param array<\Closure|iterable|string> $data + * @param Closure|iterable|string $data */ public function with(Closure|iterable|string ...$data): self { @@ -224,7 +224,7 @@ final class TestCall // @phpstan-ignore-line */ public function only(): self { - Only::enable($this, ...func_get_args()); // @phpstan-ignore-line + Only::enable($this, ...func_get_args()); return $this; } @@ -315,6 +315,61 @@ final class TestCall // @phpstan-ignore-line : $this; } + /** + * Weather the current test is running on a CI environment. + */ + private function runningOnCI(): bool + { + foreach ([ + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'APPVEYOR', + 'BITBUCKET_BUILD_NUMBER', + 'BUILDKITE', + 'TEAMCITY_VERSION', + 'JENKINS_URL', + 'SYSTEM_COLLECTIONURI', + 'CI_NAME', + 'TASKCLUSTER_ROOT_URL', + 'DRONE', + 'WERCKER', + 'NEVERCODE', + 'SEMAPHORE', + 'NETLIFY', + 'NOW_BUILDER', + ] as $env) { + if (getenv($env) !== false) { + return true; + } + } + + return Environment::name() === Environment::CI; + } + + /** + * Skips the current test when running on a CI environments. + */ + public function skipOnCI(): self + { + if ($this->runningOnCI()) { + return $this->skip('This test is skipped on [CI].'); + } + + return $this; + } + + public function skipLocally(): self + { + if ($this->runningOnCI() === false) { + return $this->skip('This test is skipped [locally].'); + } + + return $this; + } + /** * Skips the current test unless the given test is running on Windows. */ @@ -616,6 +671,30 @@ final class TestCall // @phpstan-ignore-line return $this; } + /** + * Adds one or more references to the tested method or class. This helps + * to link test cases to the source code for easier navigation. + * + * @param array|class-string ...$classes + */ + public function references(string|array ...$classes): self + { + assert($classes !== []); + + return $this; + } + + /** + * Adds one or more references to the tested method or class. This helps + * to link test cases to the source code for easier navigation. + * + * @param array|class-string ...$classes + */ + public function see(string|array ...$classes): self + { + return $this->references(...$classes); + } + /** * Informs the test runner that no expectations happen in this test, * and its purpose is simply to check whether the given code can diff --git a/src/Pest.php b/src/Pest.php index 11a52114..52de2a13 100644 --- a/src/Pest.php +++ b/src/Pest.php @@ -6,7 +6,7 @@ namespace Pest; function version(): string { - return '3.7.4'; + return '4.0.0-alpha.6'; } function testDirectory(string $file = ''): string diff --git a/src/Plugins/Cache.php b/src/Plugins/Cache.php index ea3abb78..3ae0433b 100644 --- a/src/Plugins/Cache.php +++ b/src/Plugins/Cache.php @@ -21,7 +21,7 @@ final class Cache implements HandlesArguments /** * The temporary folder. */ - private const TEMPORARY_FOLDER = __DIR__ + private const string TEMPORARY_FOLDER = __DIR__ .DIRECTORY_SEPARATOR .'..' .DIRECTORY_SEPARATOR diff --git a/src/Plugins/Configuration.php b/src/Plugins/Configuration.php index acae8fb4..54c9627f 100644 --- a/src/Plugins/Configuration.php +++ b/src/Plugins/Configuration.php @@ -21,7 +21,7 @@ final class Configuration implements HandlesArguments, Terminable /** * The base PHPUnit file. */ - public const BASE_PHPUNIT_FILE = __DIR__ + public const string BASE_PHPUNIT_FILE = __DIR__ .DIRECTORY_SEPARATOR .'..' .DIRECTORY_SEPARATOR @@ -34,7 +34,7 @@ final class Configuration implements HandlesArguments, Terminable */ public function handleArguments(array $arguments): array { - if ($this->hasArgument('--configuration', $arguments) || $this->hasCustomConfigurationFile()) { + if ($this->hasArgument('--configuration', $arguments) || $this->hasArgument('-c', $arguments) || $this->hasCustomConfigurationFile()) { return $arguments; } diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index a5061d25..712f5de5 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -17,20 +17,11 @@ use Symfony\Component\Console\Output\OutputInterface; */ final class Coverage implements AddsOutput, HandlesArguments { - /** - * @var string - */ - private const COVERAGE_OPTION = 'coverage'; + private const string COVERAGE_OPTION = 'coverage'; - /** - * @var string - */ - private const MIN_OPTION = 'min'; + private const string MIN_OPTION = 'min'; - /** - * @var string - */ - private const EXACTLY_OPTION = 'exactly'; + private const string EXACTLY_OPTION = 'exactly'; /** * Whether it should show the coverage or not. diff --git a/src/Plugins/Environment.php b/src/Plugins/Environment.php index 8ff10b4c..7edbbbd3 100644 --- a/src/Plugins/Environment.php +++ b/src/Plugins/Environment.php @@ -14,12 +14,12 @@ final class Environment implements HandlesArguments /** * The continuous integration environment. */ - public const CI = 'ci'; + public const string CI = 'ci'; /** * The local environment. */ - public const LOCAL = 'local'; + public const string LOCAL = 'local'; /** * The current environment. diff --git a/src/Plugins/Init.php b/src/Plugins/Init.php index 6bb92365..c31dd759 100644 --- a/src/Plugins/Init.php +++ b/src/Plugins/Init.php @@ -20,12 +20,12 @@ final readonly class Init implements HandlesArguments /** * The option the triggers the init job. */ - private const INIT_OPTION = '--init'; + private const string INIT_OPTION = '--init'; /** * The files that will be created. */ - private const STUBS = [ + private const array STUBS = [ 'phpunit.xml.stub' => 'phpunit.xml', 'Pest.php.stub' => 'tests/Pest.php', 'TestCase.php.stub' => 'tests/TestCase.php', @@ -119,6 +119,6 @@ final readonly class Init implements HandlesArguments */ private function isLaravelInstalled(): bool { - return InstalledVersions::isInstalled('laravel/laravel'); + return InstalledVersions::isInstalled('laravel/framework'); } } diff --git a/src/Plugins/Only.php b/src/Plugins/Only.php index 0d958173..fd1001de 100644 --- a/src/Plugins/Only.php +++ b/src/Plugins/Only.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace Pest\Plugins; use Pest\Contracts\Plugins\Terminable; +use Pest\Factories\Attribute; +use Pest\Factories\TestCaseMethodFactory; use Pest\PendingCalls\TestCall; +use PHPUnit\Framework\Attributes\Group; /** * @internal @@ -15,7 +18,7 @@ final class Only implements Terminable /** * The temporary folder. */ - private const TEMPORARY_FOLDER = __DIR__ + private const string TEMPORARY_FOLDER = __DIR__ .DIRECTORY_SEPARATOR .'..' .DIRECTORY_SEPARATOR @@ -23,28 +26,19 @@ final class Only implements Terminable .DIRECTORY_SEPARATOR .'.temp'; - /** - * {@inheritDoc} - */ - public function terminate(): void - { - if (Parallel::isWorker()) { - return; - } - - $lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock'; - - if (file_exists($lockFile)) { - unlink($lockFile); - } - } - /** * Creates the lock file. */ - public static function enable(TestCall $testCall, string $group = '__pest_only'): void + public static function enable(TestCall|TestCaseMethodFactory $testCall, string $group = '__pest_only'): void { - $testCall->group($group); + if ($testCall instanceof TestCall) { + $testCall->group($group); + } else { + $testCall->attributes[] = new Attribute( + Group::class, + [$group], + ); + } if (Environment::name() === Environment::CI || Parallel::isWorker()) { return; @@ -88,4 +82,20 @@ final class Only implements Terminable return file_get_contents($lockFile) ?: '__pest_only'; // @phpstan-ignore-line } + + /** + * {@inheritDoc} + */ + public function terminate(): void + { + if (Parallel::isWorker()) { + return; + } + + $lockFile = self::TEMPORARY_FOLDER.DIRECTORY_SEPARATOR.'only.lock'; + + if (file_exists($lockFile)) { + unlink($lockFile); + } + } } diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php index 1632a050..94902823 100644 --- a/src/Plugins/Parallel.php +++ b/src/Plugins/Parallel.php @@ -23,9 +23,9 @@ final class Parallel implements HandlesArguments { use HandleArguments; - private const GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_'; + private const string GLOBAL_PREFIX = 'PEST_PARALLEL_GLOBAL_'; - private const HANDLERS = [ + private const array HANDLERS = [ Parallel\Handlers\Parallel::class, Parallel\Handlers\Pest::class, Parallel\Handlers\Laravel::class, @@ -34,7 +34,7 @@ final class Parallel implements HandlesArguments /** * @var string[] */ - private const UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request']; + private const array UNSUPPORTED_ARGUMENTS = ['--todo', '--todos', '--retry', '--notes', '--issue', '--pr', '--pull-request']; /** * Whether the given command line arguments indicate that the test suite should be run in parallel. diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php index 76a59af6..d99139b2 100644 --- a/src/Plugins/Parallel/Handlers/Parallel.php +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -18,7 +18,7 @@ final class Parallel implements HandlesArguments /** * The list of arguments to remove. */ - private const ARGS_TO_REMOVE = [ + private const array ARGS_TO_REMOVE = [ '--parallel', '-p', '--no-output', diff --git a/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php b/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php index d2801ced..cf5272b1 100644 --- a/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php +++ b/src/Plugins/Parallel/Paratest/CleanConsoleOutput.php @@ -11,6 +11,7 @@ final class CleanConsoleOutput extends ConsoleOutput /** * {@inheritdoc} */ + #[\Override] protected function doWrite(string $message, bool $newline): void // @pest-arch-ignore-line { if ($this->isOpeningHeadline($message)) { diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php index bd416e1e..e7a1c24d 100644 --- a/src/Plugins/Parallel/Paratest/ResultPrinter.php +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -59,10 +59,10 @@ final class ResultPrinter private readonly OutputInterface $output, private readonly Options $options ) { - $this->printer = new class($this->output) implements Printer + $this->printer = new readonly class($this->output) implements Printer { public function __construct( - private readonly OutputInterface $output, + private OutputInterface $output, ) {} public function print(string $buffer): void diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 282749d3..469f2aa6 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -17,6 +17,7 @@ use ParaTest\WrapperRunner\WrapperWorker; use Pest\Result; use Pest\TestSuite; use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Event\Test\AfterLastTestMethodFailed; use PHPUnit\Event\TestRunner\WarningTriggered; use PHPUnit\Runner\CodeCoverage; use PHPUnit\Runner\ResultCache\DefaultResultCache; @@ -50,7 +51,7 @@ final class WrapperRunner implements RunnerInterface /** * The time to sleep between cycles. */ - private const CYCLE_SLEEP = 10000; + private const int CYCLE_SLEEP = 10000; /** * The result printer. @@ -313,27 +314,42 @@ final class WrapperRunner implements RunnerInterface $testResult = unserialize($contents); assert($testResult instanceof TestResult); + /** @var list $failedEvents */ + $failedEvents = array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()); + $testResultSum = new TestResult( (int) $testResultSum->hasTests() + (int) $testResult->hasTests(), $testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(), $testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(), array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), - array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()), + $failedEvents, array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()), array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()), array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()), array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()), array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()), array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()), array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()), + // @phpstan-ignore-next-line + array_merge_recursive($testResultSum->testRunnerTriggeredNoticeEvents(), $testResult->testRunnerTriggeredNoticeEvents()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->errors(), $testResult->errors()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->notices(), $testResult->notices()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->warnings(), $testResult->warnings()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()), + // @phpstan-ignore-next-line array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()), $testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(), ); @@ -351,8 +367,10 @@ final class WrapperRunner implements RunnerInterface $testResultSum->testMarkedIncompleteEvents(), $testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResultSum->testTriggeredPhpunitErrorEvents(), + $testResultSum->testTriggeredPhpunitNoticeEvents(), $testResultSum->testTriggeredPhpunitWarningEvents(), $testResultSum->testRunnerTriggeredDeprecationEvents(), + $testResultSum->testRunnerTriggeredNoticeEvents(), array_values(array_filter( $testResultSum->testRunnerTriggeredWarningEvents(), fn (WarningTriggered $event): bool => ! str_contains($event->message(), 'No tests found') diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index 25226b10..aa2da210 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -34,7 +34,7 @@ final class CompactPrinter /** * @var array> */ - private const LOOKUP_TABLE = [ + private const array LOOKUP_TABLE = [ '.' => ['gray', '.'], 'S' => ['yellow', 's'], 'T' => ['cyan', 't'], @@ -131,14 +131,14 @@ final class CompactPrinter $status['collected'], $status['threshold'], $status['roots'], - null, - null, - null, - null, - null, - null, - null, - null, + 0.00, + 0.00, + 0.00, + 0.00, + false, + false, + false, + 0, ); $telemetry = new Info( diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php new file mode 100644 index 00000000..f48260bb --- /dev/null +++ b/src/Plugins/Shard.php @@ -0,0 +1,177 @@ +hasArgument('--shard', $arguments)) { + return $arguments; + } + + // @phpstan-ignore-next-line + $input = new ArgvInput($arguments); + + ['index' => $index, 'total' => $total] = self::getShard($input); + + $arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument( + "$index/$total", + $arguments, + ))); + + /** @phpstan-ignore-next-line */ + $tests = $this->allTests($arguments); + $testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? []; + + self::$shard = [ + 'index' => $index, + 'total' => $total, + 'testsRan' => count($testsToRun), + 'testsCount' => count($tests), + ]; + + return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)]; + } + + /** + * Returns all tests that the test suite would run. + * + * @param list $arguments + * @return list + */ + private function allTests(array $arguments): array + { + $output = (new Process([ + 'php', + ...$this->removeParallelArguments($arguments), + '--list-tests', + ]))->mustRun()->getOutput(); + + preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches); + + return array_values(array_unique($matches[1])); + } + + /** + * @param array $arguments + * @return array + */ + private function removeParallelArguments(array $arguments): array + { + return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true)); + } + + /** + * Builds the filter argument for the given tests to run. + */ + private function buildFilterArgument(mixed $testsToRun): string + { + return addslashes(implode('|', $testsToRun)); + } + + /** + * Adds output after the Test Suite execution. + */ + public function addOutput(int $exitCode): int + { + if (self::$shard === null) { + return $exitCode; + } + + [ + 'index' => $index, + 'total' => $total, + 'testsRan' => $testsRan, + 'testsCount' => $testsCount, + ] = self::$shard; + + $this->output->writeln(sprintf( + ' Shard: %d of %d — %d file%s ran, out of %d.', + $index, + $total, + $testsRan, + $testsRan === 1 ? '' : 's', + $testsCount, + )); + + return $exitCode; + } + + /** + * Returns the shard information. + * + * @return array{index: int, total: int} + */ + public static function getShard(InputInterface $input): array + { + if ($input->hasParameterOption('--'.self::SHARD_OPTION)) { + $shard = $input->getParameterOption('--'.self::SHARD_OPTION); + } else { + $shard = null; + } + + if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) { + throw new InvalidOption('The [--shard] option must be in the format "index/total".'); + } + + [$index, $total] = explode('/', $shard); + + if (! is_numeric($index) || ! is_numeric($total)) { + throw new InvalidOption('The [--shard] option must be in the format "index/total".'); + } + + if ($index <= 0 || $total <= 0 || $index > $total) { + throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.'); + } + + $index = (int) $index; + $total = (int) $total; + + return [ + 'index' => $index, + 'total' => $total, + ]; + } +} diff --git a/src/Plugins/Verbose.php b/src/Plugins/Verbose.php index e37938a3..9cec77de 100644 --- a/src/Plugins/Verbose.php +++ b/src/Plugins/Verbose.php @@ -16,7 +16,7 @@ final class Verbose implements HandlesArguments /** * The list of verbosity levels. */ - private const VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q']; + private const array VERBOSITY_LEVELS = ['v', 'vv', 'vvv', 'q']; /** * {@inheritDoc} diff --git a/src/Repositories/DatasetsRepository.php b/src/Repositories/DatasetsRepository.php index 1c296fc9..3deee5bd 100644 --- a/src/Repositories/DatasetsRepository.php +++ b/src/Repositories/DatasetsRepository.php @@ -19,7 +19,7 @@ use function sprintf; */ final class DatasetsRepository { - private const SEPARATOR = '>>'; + private const string SEPARATOR = '>>'; /** * Holds the datasets. @@ -71,7 +71,7 @@ final class DatasetsRepository * * @throws ShouldNotHappen */ - public static function get(string $filename, string $description): Closure|array + public static function get(string $filename, string $description): Closure|array // @phpstan-ignore-line { $dataset = self::$withs[$filename.self::SEPARATOR.$description]; @@ -110,7 +110,6 @@ final class DatasetsRepository foreach ($datasetCombination as $datasetCombinationElement) { $partialDescriptions[] = $datasetCombinationElement['label']; - // @phpstan-ignore-next-line $values = array_merge($values, $datasetCombinationElement['values']); } @@ -221,7 +220,6 @@ final class DatasetsRepository $result = $tmp; } - // @phpstan-ignore-next-line return $result; } diff --git a/src/Repositories/SnapshotRepository.php b/src/Repositories/SnapshotRepository.php index 9a33a5c5..c719f219 100644 --- a/src/Repositories/SnapshotRepository.php +++ b/src/Repositories/SnapshotRepository.php @@ -19,9 +19,9 @@ final class SnapshotRepository * Creates a snapshot repository instance. */ public function __construct( - readonly private string $rootPath, - readonly private string $testsPath, - readonly private string $snapshotsPath, + private readonly string $rootPath, + private readonly string $testsPath, + private readonly string $snapshotsPath, ) {} /** diff --git a/src/Result.php b/src/Result.php index 98e9e8b6..97eda17f 100644 --- a/src/Result.php +++ b/src/Result.php @@ -4,20 +4,16 @@ declare(strict_types=1); namespace Pest; -use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection; use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TextUI\Configuration\Configuration; +use PHPUnit\TextUI\ShellExitCodeCalculator; /** * @internal */ final class Result { - private const SUCCESS_EXIT = 0; - - private const FAILURE_EXIT = 1; - - private const EXCEPTION_EXIT = 2; + private const int SUCCESS_EXIT = 0; /** * If the exit code is different from 0. @@ -40,44 +36,8 @@ final class Result */ public static function exitCode(Configuration $configuration, TestResult $result): int { - if ($result->wasSuccessfulIgnoringPhpunitWarnings()) { - if ($configuration->failOnWarning()) { - $warnings = $result->numberOfTestsWithTestTriggeredPhpunitWarningEvents() - + count($result->warnings()) - + count($result->phpWarnings()); + $shell = new ShellExitCodeCalculator; - if ($warnings > 0) { - return self::FAILURE_EXIT; - } - } - - if (! $result->hasTestTriggeredPhpunitWarningEvents()) { - return self::SUCCESS_EXIT; - } - } - - if ($configuration->failOnEmptyTestSuite() && ResultReflection::numberOfTests($result) === 0) { - return self::FAILURE_EXIT; - } - - if ($result->wasSuccessfulIgnoringPhpunitWarnings()) { - if ($configuration->failOnRisky() && $result->hasTestConsideredRiskyEvents()) { - $returnCode = self::FAILURE_EXIT; - } - - if ($configuration->failOnIncomplete() && $result->hasTestMarkedIncompleteEvents()) { - $returnCode = self::FAILURE_EXIT; - } - - if ($configuration->failOnSkipped() && $result->hasTestSkippedEvents()) { - $returnCode = self::FAILURE_EXIT; - } - } - - if ($result->hasTestErroredEvents()) { - return self::EXCEPTION_EXIT; - } - - return self::FAILURE_EXIT; + return $shell->calculate($configuration, $result); } } diff --git a/src/Runner/Filter/EnsureTestCaseIsInitiatedFilter.php b/src/Runner/Filter/EnsureTestCaseIsInitiatedFilter.php new file mode 100644 index 00000000..614b38a4 --- /dev/null +++ b/src/Runner/Filter/EnsureTestCaseIsInitiatedFilter.php @@ -0,0 +1,39 @@ + $iterator + */ + public function __construct(RecursiveIterator $iterator) + { + parent::__construct($iterator); + } + + /** + * {@inheritdoc} + */ + public function accept(): bool + { + $test = $this->getInnerIterator()->current(); + + if ($test instanceof HasPrintableTestCaseName) { + /** @phpstan-ignore-next-line */ + $test->__initializeTestCase(); + } + + return true; + } +} diff --git a/src/Subscribers/EnsureIgnorableTestCasesAreIgnored.php b/src/Subscribers/EnsureIgnorableTestCasesAreIgnored.php index 1cf3d55a..a6e837bf 100644 --- a/src/Subscribers/EnsureIgnorableTestCasesAreIgnored.php +++ b/src/Subscribers/EnsureIgnorableTestCasesAreIgnored.php @@ -35,7 +35,7 @@ final class EnsureIgnorableTestCasesAreIgnored implements StartedSubscriber /** @var array $testRunnerTriggeredWarningEvents */ $testRunnerTriggeredWarningEvents = $property->getValue($collector); - $testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => $event->message() !== 'No tests found in class "Pest\TestCases\IgnorableTestCase".')); + $testRunnerTriggeredWarningEvents = array_values(array_filter($testRunnerTriggeredWarningEvents, fn (WarningTriggered $event): bool => str_contains($event->message(), 'No tests found in class') === false)); $property->setValue($collector, $testRunnerTriggeredWarningEvents); } diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 03001976..652eb442 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -11,12 +11,9 @@ use Pest\Exceptions\ShouldNotHappen; */ final class Backtrace { - /** - * @var string - */ - private const FILE = 'file'; + private const string FILE = 'file'; - private const BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS; + private const int BACKTRACE_OPTIONS = DEBUG_BACKTRACE_IGNORE_ARGS; /** * Returns the current test file. diff --git a/src/Support/Closure.php b/src/Support/Closure.php index e96ec29e..e447903f 100644 --- a/src/Support/Closure.php +++ b/src/Support/Closure.php @@ -15,7 +15,6 @@ final class Closure /** * Binds the given closure to the given "this". * - * * @throws ShouldNotHappen */ public static function bind(?BaseClosure $closure, ?object $newThis, object|string|null $newScope = 'static'): BaseClosure @@ -24,6 +23,7 @@ final class Closure throw ShouldNotHappen::fromMessage('Could not bind null closure.'); } + // @phpstan-ignore-next-line $closure = BaseClosure::bind($closure, $newThis, $newScope); if (! $closure instanceof \Closure) { diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 955bbfc4..3f543790 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Pest\Support; use Pest\Exceptions\ShouldNotHappen; -use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\File; use SebastianBergmann\Environment\Runtime; @@ -88,10 +87,20 @@ final class Coverage throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath)); } - /** @var CodeCoverage $codeCoverage */ - $codeCoverage = require $reportPath; + $handle = fopen($reportPath, 'r'); + $code = ''; + while (is_resource($handle) && ! feof($handle)) { + $code .= fread($handle, 8192); + } + + if (is_resource($handle)) { + fclose($handle); + } + unlink($reportPath); + $codeCoverage = eval(substr($code, 5)); + $totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); /** @var Directory $report */ diff --git a/src/Support/DatasetInfo.php b/src/Support/DatasetInfo.php index 46f39c41..c67f317c 100644 --- a/src/Support/DatasetInfo.php +++ b/src/Support/DatasetInfo.php @@ -11,9 +11,9 @@ use function Pest\testDirectory; */ final class DatasetInfo { - public const DATASETS_DIR_NAME = 'Datasets'; + public const string DATASETS_DIR_NAME = 'Datasets'; - public const DATASETS_FILE_NAME = 'Datasets.php'; + public const string DATASETS_FILE_NAME = 'Datasets.php'; public static function isInsideADatasetsDirectory(string $file): bool { diff --git a/src/Support/ExceptionTrace.php b/src/Support/ExceptionTrace.php index 9af6aa5b..9d4132e2 100644 --- a/src/Support/ExceptionTrace.php +++ b/src/Support/ExceptionTrace.php @@ -13,7 +13,7 @@ use Throwable; */ final class ExceptionTrace { - private const UNDEFINED_METHOD = 'Call to undefined method P\\'; + private const string UNDEFINED_METHOD = 'Call to undefined method P\\'; /** * Ensures the given closure reports the good execution context. diff --git a/src/Support/Exporter.php b/src/Support/Exporter.php index a486445f..44367c08 100644 --- a/src/Support/Exporter.php +++ b/src/Support/Exporter.php @@ -15,7 +15,7 @@ final readonly class Exporter /** * The maximum number of items in an array to export. */ - private const MAX_ARRAY_ITEMS = 3; + private const int MAX_ARRAY_ITEMS = 3; /** * Creates a new Exporter instance. @@ -66,6 +66,7 @@ final readonly class Exporter $result[] = $context->contains($data[$key]) !== false ? '*RECURSION*' + // @phpstan-ignore-next-line : sprintf('[%s]', $this->shortenedRecursiveExport($data[$key], $context)); } diff --git a/src/Support/HigherOrderMessage.php b/src/Support/HigherOrderMessage.php index aefc356e..ce948244 100644 --- a/src/Support/HigherOrderMessage.php +++ b/src/Support/HigherOrderMessage.php @@ -13,7 +13,7 @@ use Throwable; */ final class HigherOrderMessage { - public const UNDEFINED_METHOD = 'Method %s does not exist'; + public const string UNDEFINED_METHOD = 'Method %s does not exist'; /** * An optional condition that will determine if the message will be executed. @@ -50,14 +50,13 @@ final class HigherOrderMessage } if ($this->hasHigherOrderCallable()) { - /* @phpstan-ignore-next-line */ return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments); } try { return is_array($this->arguments) ? Reflection::call($target, $this->name, $this->arguments) - : $target->{$this->name}; /* @phpstan-ignore-line */ + : $target->{$this->name}; } catch (Throwable $throwable) { Reflection::setPropertyValue($throwable, 'file', $this->filename); Reflection::setPropertyValue($throwable, 'line', $this->line); @@ -65,7 +64,6 @@ final class HigherOrderMessage if ($throwable->getMessage() === $this->getUndefinedMethodMessage($target, $this->name)) { /** @var ReflectionClass $reflection */ $reflection = new ReflectionClass($target); - /* @phpstan-ignore-next-line */ $reflection = $reflection->getParentClass() ?: $reflection; Reflection::setPropertyValue($throwable, 'message', sprintf('Call to undefined method %s::%s()', $reflection->getName(), $this->name)); } @@ -96,10 +94,6 @@ final class HigherOrderMessage private function getUndefinedMethodMessage(object $target, string $methodName): string { - if (\PHP_MAJOR_VERSION >= 8) { - return sprintf(self::UNDEFINED_METHOD, sprintf('%s::%s()', $target::class, $methodName)); - } - - return sprintf(self::UNDEFINED_METHOD, $methodName); + return sprintf(self::UNDEFINED_METHOD, sprintf('%s::%s()', $target::class, $methodName)); } } diff --git a/src/Support/HigherOrderMessageCollection.php b/src/Support/HigherOrderMessageCollection.php index da13a16c..41245108 100644 --- a/src/Support/HigherOrderMessageCollection.php +++ b/src/Support/HigherOrderMessageCollection.php @@ -40,7 +40,6 @@ final class HigherOrderMessageCollection public function chain(object $target): void { foreach ($this->messages as $message) { - // @phpstan-ignore-next-line $target = $message->call($target) ?? $target; } } diff --git a/src/Support/HigherOrderTapProxy.php b/src/Support/HigherOrderTapProxy.php index 151b2b80..08eb5ea7 100644 --- a/src/Support/HigherOrderTapProxy.php +++ b/src/Support/HigherOrderTapProxy.php @@ -26,7 +26,7 @@ final class HigherOrderTapProxy */ public function __set(string $property, mixed $value): void { - $this->target->{$property} = $value; // @phpstan-ignore-line + $this->target->{$property} = $value; } /** @@ -37,7 +37,7 @@ final class HigherOrderTapProxy public function __get(string $property) { if (property_exists($this->target, $property)) { - return $this->target->{$property}; // @phpstan-ignore-line + return $this->target->{$property}; } $className = (new ReflectionClass($this->target))->getName(); diff --git a/src/Support/Shell.php b/src/Support/Shell.php new file mode 100644 index 00000000..b5c5b157 --- /dev/null +++ b/src/Support/Shell.php @@ -0,0 +1,101 @@ +setUpdateCheck(Checker::NEVER); + + $config->getPresenter()->addCasters(self::casters()); + + $shell = new PsyShell($config); + + $loader = self::tinkered($shell); + + try { + $shell->run(); + } finally { + $loader?->unregister(); // @phpstan-ignore-line + } + } + + /** + * Returns the casters for the Psy Shell. + * + * @return array + */ + private static function casters(): array + { + $casters = [ + 'Illuminate\Support\Collection' => 'Laravel\Tinker\TinkerCaster::castCollection', + 'Illuminate\Support\HtmlString' => 'Laravel\Tinker\TinkerCaster::castHtmlString', + 'Illuminate\Support\Stringable' => 'Laravel\Tinker\TinkerCaster::castStringable', + ]; + + if (class_exists('Illuminate\Database\Eloquent\Model')) { + $casters['Illuminate\Database\Eloquent\Model'] = 'Laravel\Tinker\TinkerCaster::castModel'; + } + + if (class_exists('Illuminate\Process\ProcessResult')) { + $casters['Illuminate\Process\ProcessResult'] = 'Laravel\Tinker\TinkerCaster::castProcessResult'; + } + + if (class_exists('Illuminate\Foundation\Application')) { + $casters['Illuminate\Foundation\Application'] = 'Laravel\Tinker\TinkerCaster::castApplication'; + } + + if (function_exists('app') === false) { + return $casters; // @phpstan-ignore-line + } + + $config = app()->make('config'); + + return array_merge($casters, (array) $config->get('tinker.casters', [])); + } + + /** + * Tinkers the current shell, if the Tinker package is available. + */ + private static function tinkered(PsyShell $shell): ?object + { + if (function_exists('app') === false + || ! class_exists(Env::class) + || ! class_exists(ClassAliasAutoloader::class) + ) { + return null; + } + + $path = Env::get('COMPOSER_VENDOR_DIR', app()->basePath().DIRECTORY_SEPARATOR.'vendor'); + + $path .= '/composer/autoload_classmap.php'; + + if (! file_exists($path)) { + $path = TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'composer'.DIRECTORY_SEPARATOR.'autoload_classmap.php'; + } + + $config = app()->make('config'); + + return ClassAliasAutoloader::register( + $shell, $path, $config->get('tinker.alias', []), $config->get('tinker.dont_alias', []) + ); + } +} diff --git a/src/Support/Str.php b/src/Support/Str.php index 0e654bc8..ff4bf5f4 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -13,12 +13,9 @@ final class Str * Pool of alpha-numeric characters for generating (unsafe) random strings * from. */ - private const POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + private const string POOL = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - /** - * @var string - */ - private const PREFIX = '__pest_evaluable_'; + private const string PREFIX = '__pest_evaluable_'; /** * Create a (unsecure & non-cryptographically safe) random alpha-numeric @@ -120,4 +117,14 @@ final class Str { return (bool) filter_var($value, FILTER_VALIDATE_URL); } + + /** + * Converts the given `$target` to a URL-friendly "slug". + */ + public static function slugify(string $target): string + { + $target = preg_replace('/[^a-zA-Z0-9]+/', '-', $target); + + return strtolower(trim((string) $target, '-')); + } } diff --git a/stubs/init-laravel/Pest.php.stub b/stubs/init-laravel/Pest.php.stub index 40d096b5..60f04a45 100644 --- a/stubs/init-laravel/Pest.php.stub +++ b/stubs/init-laravel/Pest.php.stub @@ -12,7 +12,7 @@ */ pest()->extend(Tests\TestCase::class) - ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); /* diff --git a/stubs/init-laravel/phpunit.xml.stub b/stubs/init-laravel/phpunit.xml.stub index 83ba26be..506b9a38 100644 --- a/stubs/init-laravel/phpunit.xml.stub +++ b/stubs/init-laravel/phpunit.xml.stub @@ -1,31 +1,33 @@ - ./tests/Unit + tests/Unit - ./tests/Feature + tests/Feature + + + app + + + - + + - - - ./app - - diff --git a/stubs/init/Pest.php.stub b/stubs/init/Pest.php.stub index fd279ada..b239048c 100644 --- a/stubs/init/Pest.php.stub +++ b/stubs/init/Pest.php.stub @@ -11,7 +11,7 @@ | */ -// pest()->extend(Tests\TestCase::class)->in('Feature'); +pest()->extend(Tests\TestCase::class)->in('Feature'); /* |-------------------------------------------------------------------------- diff --git a/stubs/init/phpunit.xml.stub b/stubs/init/phpunit.xml.stub index 7d0904f7..e6198e0e 100644 --- a/stubs/init/phpunit.xml.stub +++ b/stubs/init/phpunit.xml.stub @@ -1,6 +1,6 @@ @@ -11,8 +11,8 @@ - ./app - ./src + app + src diff --git a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap index 106d718c..5adc938a 100644 --- a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap +++ b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap @@ -1,5 +1,5 @@ - Pest Testing Framework 3.7.4. + Pest Testing Framework 4.0.0-alpha.6. USAGE: pest [options] @@ -53,7 +53,7 @@ --disallow-test-output ................. Be strict about output during tests --enforce-time-limit ................. Enforce time limit based on test size --default-time-limit [sec] Timeout in seconds for tests that have no declared size - --dont-report-useless-tests .. Do not report tests that do not test anything + --do-not-report-useless-tests Do not report tests that do not test anything --stop-on-defect ... Stop after first error, failure, warning, or risky test --stop-on-error ..................................... Stop after first error --stop-on-failure ................................. Stop after first failure @@ -68,9 +68,22 @@ --fail-on-risky Signal failure using shell exit code when a test was considered risky --fail-on-deprecation Signal failure using shell exit code when a deprecation was triggered --fail-on-phpunit-deprecation Signal failure using shell exit code when a PHPUnit deprecation was triggered + --fail-on-phpunit-notice Signal failure using shell exit code when a PHPUnit notice was triggered + --fail-on-phpunit-warning Signal failure using shell exit code when a PHPUnit warning was triggered --fail-on-notice Signal failure using shell exit code when a notice was triggered --fail-on-skipped Signal failure using shell exit code when a test was skipped --fail-on-incomplete Signal failure using shell exit code when a test was marked incomplete + --fail-on-all-issues Signal failure using shell exit code when an issue is triggered + --do-not-fail-on-empty-test-suite Do not signal failure using shell exit code when no tests were run + --do-not-fail-on-warning Do not signal failure using shell exit code when a warning was triggered + --do-not-fail-on-risky Do not signal failure using shell exit code when a test was considered risky + --do-not-fail-on-deprecation Do not signal failure using shell exit code when a deprecation was triggered + --do-not-fail-on-phpunit-deprecation Do not signal failure using shell exit code when a PHPUnit deprecation was triggered + --do-not-fail-on-phpunit-notice Do not signal failure using shell exit code when a PHPUnit notice was triggered + --do-not-fail-on-phpunit-warning Do not signal failure using shell exit code when a PHPUnit warning was triggered + --do-not-fail-on-notice Do not signal failure using shell exit code when a notice was triggered + --do-not-fail-on-skipped Do not signal failure using shell exit code when a test was skipped + --do-not-fail-on-incomplete Do not signal failure using shell exit code when a test was marked incomplete --cache-result ............................ Write test results to cache file --do-not-cache-result .............. Do not write test results to cache file --order-by [order] Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size @@ -88,18 +101,23 @@ --display-skipped ........................ Display details for skipped tests --display-deprecations . Display details for deprecations triggered by tests --display-phpunit-deprecations .... Display details for PHPUnit deprecations + --display-phpunit-notices .............. Display details for PHPUnit notices --display-errors ............. Display details for errors triggered by tests --display-notices ........... Display details for notices triggered by tests --display-warnings ......... Display details for warnings triggered by tests + --display-all-issues ..... Display details for all issues that are triggered --reverse-list .............................. Print defects in reverse order --teamcity . Replace default progress and result output with TeamCity format --testdox ................ Replace default result output with TestDox format --testdox-summary Repeat TestDox output for tests with errors, failures, or issues --debug Replace default progress and result output with debugging information + --with-telemetry Include telemetry information in debugging information output --compact ................ Replace default result output with Compact format LOGGING OPTIONS: --log-junit [file] .......... Write test results in JUnit XML format to file + --log-otr [file] Write test results in Open Test Reporting XML format to file + --include-git-information Include Git information in Open Test Reporting XML logfile --log-teamcity [file] ........ Write test results in TeamCity format to file --testdox-html [file] .. Write test results in TestDox format (HTML) to file --testdox-text [file] Write test results in TestDox format (plain text) to file @@ -111,6 +129,7 @@ --coverage ..... Generate code coverage report and output to standard output --coverage --min Set the minimum required coverage percentage, and fail if not met --coverage-clover [file] Write code coverage report in Clover XML format to file + --coverage-openclover [file] Write code coverage report in OpenClover XML format to file --coverage-cobertura [file] Write code coverage report in Cobertura XML format to file --coverage-crap4j [file] Write code coverage report in Crap4J XML format to file --coverage-html [dir] Write code coverage report in HTML format to directory diff --git a/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap index 63be8fd3..6c48a1b3 100644 --- a/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap +++ b/tests/.pest/snapshots/Visual/Version/visual_snapshot_of_help_command_output.snap @@ -1,3 +1,3 @@ - Pest Testing Framework 3.7.4. + Pest Testing Framework 4.0.0-alpha.6. diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 513a804e..a1638158 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -4,7 +4,6 @@ ✓ preset → strict → ignoring ['usleep'] ✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …] ✓ globals - ✓ dependencies ✓ contracts PASS Tests\Environments\Windows @@ -68,14 +67,24 @@ ✓ it adds coverage if --min exist ✓ it generates coverage based on file input - PASS Tests\Features\Covers + PASS Tests\Features\Covers\ClassCoverage ✓ it uses the correct PHPUnit attribute for class - ✓ it uses the correct PHPUnit attribute for function - ✓ it guesses if the given argument is a class or function - ✓ it uses the correct PHPUnit attribute for trait + + PASS Tests\Features\Covers\CoversNothing ✓ it uses the correct PHPUnit attribute for covers nothing + + PASS Tests\Features\Covers\ExceptionHandling ✓ it throws exception if no class nor method has been found + PASS Tests\Features\Covers\FunctionCoverage + ✓ it uses the correct PHPUnit attribute for function + + PASS Tests\Features\Covers\GuessCoverage + ✓ it guesses if the given argument is a class or function + + PASS Tests\Features\Covers\TraitCoverage + ✓ it uses the correct PHPUnit attribute for trait + PASS Tests\Features\DatasetsTests - 1 todo ✓ it throws exception if dataset does not exist ✓ it throws exception if dataset already exist @@ -619,6 +628,13 @@ ✓ pass ✓ failures ✓ failures with custom message + ✓ not failures + + PASS Tests\Features\Expect\toBeSlug + ✓ pass + ✓ failures + ✓ failures with custom message + ✓ failures with default message ✓ not failures PASS Tests\Features\Expect\toBeSnakeCase @@ -1030,6 +1046,10 @@ ✓ it may fail ✓ it may fail with the given message + PASS Tests\Features\Fixture + ✓ it may return a file path + ✓ it may throw an exception if the file does not exist + WARN Tests\Features\Helpers ✓ it can set/get properties on $this ! it gets null if property do not exist → Undefined property Tests\Features\Helpers::$wqdwqdqw @@ -1119,6 +1139,10 @@ ✓ nested → it may be associated with an pr #1, #4, #5, #6, #3 // an note between an the pr + PASS Tests\Features\References + ✓ it can reference a specific class + ✓ it can reference a specific class method + PASS Tests\Features\Repeat ✓ once ✓ multiple times @ repetition 1 of 5 @@ -1295,6 +1319,10 @@ ✓ it can see datasets defined in Pest.php file with ('B') ✓ Pest.php dataset is taken + PASS Tests\Features\See + ✓ it can reference a specific class + ✓ it can reference a specific class method + WARN Tests\Features\Skip ✓ it do not skips - it skips with truthy → 1 @@ -1425,6 +1453,11 @@ ✓ nested → nested afterEach execution order ✓ global afterEach execution order + PASS Tests\Hooks\BeforeAllTest + ✓ it gets called before all tests 1 @ repetition 1 of 2 + ✓ it gets called before all tests 1 @ repetition 2 of 2 + ✓ it gets called before all tests 2 + PASS Tests\Hooks\BeforeEachTest ✓ global beforeEach execution order @@ -1698,4 +1731,4 @@ WARN Tests\Visual\Version - visual snapshot of help command output - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 38 todos, 33 skipped, 1144 passed (2736 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 38 todos, 33 skipped, 1157 passed (2766 assertions) \ No newline at end of file diff --git a/tests/Arch.php b/tests/Arch.php index d8deb460..6348a0f3 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -27,26 +27,6 @@ arch('globals') ->not->toBeUsed() ->ignoring(Expectation::class); -arch('dependencies') - ->expect('Pest') - ->toOnlyUse([ - 'dd', - 'dump', - 'expect', - 'uses', - 'Termwind', - 'ParaTest', - 'Pest\Arch', - 'Pest\Mutate\Contracts\Configuration', - 'Pest\Mutate\Decorators\TestCallDecorator', - 'Pest\Mutate\Repositories\ConfigurationRepository', - 'Pest\Plugin', - 'NunoMaduro\Collision', - 'Whoops', - 'Symfony\Component\Console', - 'Symfony\Component\Process', - ])->ignoring(['Composer', 'PHPUnit', 'SebastianBergmann']); - arch('contracts') ->expect('Pest\Contracts') ->toOnlyUse([ diff --git a/tests/Features/Covers.php b/tests/Features/Covers.php deleted file mode 100644 index 386d523f..00000000 --- a/tests/Features/Covers.php +++ /dev/null @@ -1,59 +0,0 @@ -getAttributes(); - - expect($attributes[1]->getName())->toBe('PHPUnit\Framework\Attributes\CoversClass'); - expect($attributes[1]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversClass1'); -}); - -it('uses the correct PHPUnit attribute for function', function () { - $attributes = (new ReflectionClass($this))->getAttributes(); - - expect($attributes[3]->getName())->toBe('PHPUnit\Framework\Attributes\CoversFunction'); - expect($attributes[3]->getArguments()[0])->toBe('testCoversFunction'); -})->coversFunction('testCoversFunction'); - -it('guesses if the given argument is a class or function', function () { - $attributes = (new ReflectionClass($this))->getAttributes(); - - expect($attributes[5]->getName())->toBe(CoversClass::class); - expect($attributes[5]->getArguments()[0])->toBe(CoversClass3::class); - - expect($attributes[6]->getName())->toBe(CoversFunction::class); - expect($attributes[6]->getArguments()[0])->toBe('testCoversFunction'); -})->covers(CoversClass3::class, 'testCoversFunction'); - -it('uses the correct PHPUnit attribute for trait', function () { - $attributes = (new ReflectionClass($this))->getAttributes(); - - expect($attributes[8]->getName())->toBe('PHPUnit\Framework\Attributes\CoversTrait'); - expect($attributes[8]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversTrait'); -})->coversTrait(CoversTrait::class); - -it('uses the correct PHPUnit attribute for covers nothing', function () { - $attributes = (new ReflectionMethod($this, $this->name()))->getAttributes(); - - expect($attributes[3]->getName())->toBe('PHPUnit\Framework\Attributes\CoversNothing'); - expect($attributes[3]->getArguments())->toHaveCount(0); -})->coversNothing(); - -it('throws exception if no class nor method has been found', function () { - $testCall = new TestCall(TestSuite::getInstance(), 'filename', 'description', fn () => 'closure'); - - $testCall->covers('fakeName'); -})->throws(InvalidArgumentException::class, 'No class, trait or method named "fakeName" has been found.'); diff --git a/tests/Features/Covers/ClassCoverage.php b/tests/Features/Covers/ClassCoverage.php new file mode 100644 index 00000000..ab805e17 --- /dev/null +++ b/tests/Features/Covers/ClassCoverage.php @@ -0,0 +1,13 @@ +getAttributes(); + + expect($attributes[1]->getName())->toBe(CoversClass::class); + expect($attributes[1]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversClass1'); +}); diff --git a/tests/Features/Covers/CoversNothing.php b/tests/Features/Covers/CoversNothing.php new file mode 100644 index 00000000..6918414c --- /dev/null +++ b/tests/Features/Covers/CoversNothing.php @@ -0,0 +1,10 @@ +name()))->getAttributes(); + + expect($attributes[2]->getName())->toBe(CoversNothing::class); + expect($attributes[2]->getArguments())->toHaveCount(0); +})->coversNothing(); diff --git a/tests/Features/Covers/ExceptionHandling.php b/tests/Features/Covers/ExceptionHandling.php new file mode 100644 index 00000000..de86bb02 --- /dev/null +++ b/tests/Features/Covers/ExceptionHandling.php @@ -0,0 +1,10 @@ + 'closure'); + + $testCall->covers('fakeName'); +})->throws(InvalidArgumentException::class, 'No class, trait or method named "fakeName" has been found.'); diff --git a/tests/Features/Covers/FunctionCoverage.php b/tests/Features/Covers/FunctionCoverage.php new file mode 100644 index 00000000..fba97080 --- /dev/null +++ b/tests/Features/Covers/FunctionCoverage.php @@ -0,0 +1,12 @@ +getAttributes(); + + expect($attributes[1]->getName())->toBe(CoversFunction::class); + expect($attributes[1]->getArguments()[0])->toBe('testCoversFunction'); +})->coversFunction('testCoversFunction'); diff --git a/tests/Features/Covers/GuessCoverage.php b/tests/Features/Covers/GuessCoverage.php new file mode 100644 index 00000000..e8a84035 --- /dev/null +++ b/tests/Features/Covers/GuessCoverage.php @@ -0,0 +1,17 @@ +getAttributes(); + + expect($attributes[1]->getName())->toBe(CoversClass::class); + expect($attributes[1]->getArguments()[0])->toBe(CoversClass3::class); + + expect($attributes[2]->getName())->toBe(CoversFunction::class); + expect($attributes[2]->getArguments()[0])->toBe('testCoversFunction2'); +})->covers(CoversClass3::class, 'testCoversFunction2'); diff --git a/tests/Features/Covers/TraitCoverage.php b/tests/Features/Covers/TraitCoverage.php new file mode 100644 index 00000000..57bc3680 --- /dev/null +++ b/tests/Features/Covers/TraitCoverage.php @@ -0,0 +1,11 @@ +getAttributes(); + + expect($attributes[1]->getName())->toBe(PHPUnitCoversTrait::class); + expect($attributes[1]->getArguments()[0])->toBe('Tests\Fixtures\Covers\CoversTrait'); +})->coversTrait(CoversTrait::class); diff --git a/tests/Features/Expect/toBeSlug.php b/tests/Features/Expect/toBeSlug.php new file mode 100644 index 00000000..2d7c19f8 --- /dev/null +++ b/tests/Features/Expect/toBeSlug.php @@ -0,0 +1,24 @@ +toBeSlug() + ->and('Another Test String')->toBeSlug(); +}); + +test('failures', function () { + expect('')->toBeSlug(); +})->throws(ExpectationFailedException::class); + +test('failures with custom message', function () { + expect('')->toBeSlug('oh no!'); +})->throws(ExpectationFailedException::class, 'oh no!'); + +test('failures with default message', function () { + expect('')->toBeSlug(); +})->throws(ExpectationFailedException::class, 'Failed asserting that can be converted to a slug.'); + +test('not failures', function () { + expect('This is a Test String!')->not->toBeSlug(); +})->throws(ExpectationFailedException::class); diff --git a/tests/Features/Fixture.php b/tests/Features/Fixture.php new file mode 100644 index 00000000..1ac538b8 --- /dev/null +++ b/tests/Features/Fixture.php @@ -0,0 +1,12 @@ +toBeString() + ->toBeFile(); +}); + +it('may throw an exception if the file does not exist', function () { + fixture('file-that-does-not-exist.php'); +})->throws(InvalidArgumentException::class); diff --git a/tests/Features/References.php b/tests/Features/References.php new file mode 100644 index 00000000..a19db35a --- /dev/null +++ b/tests/Features/References.php @@ -0,0 +1,11 @@ +toBeString(); +})->references(Panic::class); + +it('can reference a specific class method', function () { + expect(Panic::with(...))->toBeCallable(); +})->references([Panic::class, 'with']); diff --git a/tests/Features/See.php b/tests/Features/See.php new file mode 100644 index 00000000..7c9393ee --- /dev/null +++ b/tests/Features/See.php @@ -0,0 +1,11 @@ +toBeString(); +})->see(Panic::class); + +it('can reference a specific class method', function () { + expect(Panic::with(...))->toBeCallable(); +})->see([Panic::class, 'with']); diff --git a/tests/Hooks/BeforeAllTest.php b/tests/Hooks/BeforeAllTest.php new file mode 100644 index 00000000..d411d263 --- /dev/null +++ b/tests/Hooks/BeforeAllTest.php @@ -0,0 +1,16 @@ +beforeAll(function () { + expect($_SERVER['globalHook']->calls->beforeAll) + ->toBe(0); + + $_SERVER['globalHook']->calls->beforeAll++; +}); + +it('gets called before all tests 1', function () { + expect($_SERVER['globalHook']->calls->beforeAll)->toBe(1); +})->repeat(2); + +it('gets called before all tests 2', function () { + expect($_SERVER['globalHook']->calls->beforeAll)->toBe(1); +}); diff --git a/tests/Pest.php b/tests/Pest.php index a938fc7e..e498450c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -29,7 +29,6 @@ pest() }) ->beforeAll(function () { $_SERVER['globalHook']->beforeAll = 0; - $_SERVER['globalHook']->calls->beforeAll++; }) ->afterEach(function () { if (! isset($this->ith)) { diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 313f8208..226a01e7 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -16,7 +16,7 @@ $run = function () { test('parallel', function () use ($run) { expect($run('--exclude-group=integration')) - ->toContain('Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 38 todos, 24 skipped, 1134 passed (2712 assertions)') + ->toContain('Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 38 todos, 24 skipped, 1147 passed (2742 assertions)') ->toContain('Parallel: 3 processes'); })->skipOnWindows();