diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 491cd60d..5b9b0df5 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -27,6 +27,7 @@ jobs: php-version: 8.3 tools: composer:v2 coverage: none + extensions: sockets - name: Install Dependencies run: composer update --prefer-stable --no-interaction --no-progress --ansi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index abafbbbb..1a1ea7eb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, macos-latest, windows-latest] - symfony: ['7.1'] + symfony: ['7.3'] php: ['8.3', '8.4'] dependency_version: [prefer-lowest, prefer-stable] @@ -29,6 +29,7 @@ jobs: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none + extensions: sockets - name: Setup Problem Matches run: | diff --git a/composer.json b/composer.json index f5718b7b..26d92d49 100644 --- a/composer.json +++ b/composer.json @@ -18,18 +18,19 @@ ], "require": { "php": "^8.3.0", - "brianium/paratest": "^7.10.2", - "nunomaduro/collision": "^8.8.1", + "brianium/paratest": "^7.10.3", + "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.1" + "phpunit/phpunit": "^12.2.6", + "symfony/process": "^7.3.0" }, "conflict": { - "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">12.2.1", + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.2.6", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -55,8 +56,8 @@ }, "require-dev": { "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-type-coverage": "^4.0.0", - "symfony/process": "^7.3.0" + "pestphp/pest-plugin-browser": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" }, "minimum-stability": "dev", "prefer-stable": true, @@ -72,9 +73,9 @@ ], "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", @@ -113,6 +114,7 @@ "Pest\\Plugins\\Snapshot", "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", "Pest\\Plugins\\Parallel" ] }, 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 = " +

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

+ +
+ - + composer require pestphp/pest-plugin-browser:^4.0 --dev +
+ +
+ - + npx playwright install +
+ diff --git a/src/Bootstrappers/BootOverrides.php b/src/Bootstrappers/BootOverrides.php index ae2fa070..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 array 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', + '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/Concerns/Testable.php b/src/Concerns/Testable.php index 9918eb83..f233b6ac 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -438,15 +438,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)); } /** 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/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 @@ +|string> $data + * @param Closure|iterable|string $data */ public function with(Closure|iterable|string ...$data): self { diff --git a/src/Pest.php b/src/Pest.php index 865c18d0..7ddec711 100644 --- a/src/Pest.php +++ b/src/Pest.php @@ -6,7 +6,7 @@ namespace Pest; function version(): string { - return '4.0.0-alpha.2'; + return '4.0.0-alpha.5'; } function testDirectory(string $file = ''): string diff --git a/src/Plugins/Only.php b/src/Plugins/Only.php index 7c2809f1..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 @@ -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/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/Result.php b/src/Result.php index 22e1e895..4dcbe4a1 100644 --- a/src/Result.php +++ b/src/Result.php @@ -40,7 +40,7 @@ final class Result */ public static function exitCode(Configuration $configuration, TestResult $result): int { - if ($result->wasSuccessfulIgnoringPhpunitWarnings()) { + if ($result->wasSuccessful()) { if ($configuration->failOnWarning()) { $warnings = $result->numberOfTestsWithTestTriggeredPhpunitWarningEvents() + count($result->warnings()) @@ -60,7 +60,7 @@ final class Result return self::FAILURE_EXIT; } - if ($result->wasSuccessfulIgnoringPhpunitWarnings()) { + if ($result->wasSuccessful()) { if ($configuration->failOnRisky() && $result->hasTestConsideredRiskyEvents()) { $returnCode = self::FAILURE_EXIT; } 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/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 b2f7a23f..8a174927 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 4.0.0-alpha.2. + Pest Testing Framework 4.0.0-alpha.5. 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 @@ -69,10 +69,21 @@ --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 @@ -106,6 +117,7 @@ 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 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 d72ea0de..f54244da 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 4.0.0-alpha.2. + Pest Testing Framework 4.0.0-alpha.5. diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 5245e72c..7b78f425 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1040,6 +1040,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 @@ -1708,4 +1712,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, 1146 passed (2739 assertions) \ No newline at end of file diff --git a/tests/Arch.php b/tests/Arch.php index d8deb460..905515d2 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -37,6 +37,7 @@ arch('dependencies') 'Termwind', 'ParaTest', 'Pest\Arch', + 'Pest\Browser', 'Pest\Mutate\Contracts\Configuration', 'Pest\Mutate\Decorators\TestCallDecorator', 'Pest\Mutate\Repositories\ConfigurationRepository', 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/Visual/Parallel.php b/tests/Visual/Parallel.php index 313f8208..01492414 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, 1136 passed (2715 assertions)') ->toContain('Parallel: 3 processes'); })->skipOnWindows();