diff --git a/.gitattributes b/.gitattributes index c6e004ff..3015580b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ /art export-ignore +/docker export-ignore /docs export-ignore /tests export-ignore /scripts export-ignore @@ -11,5 +12,7 @@ phpstan.neon export-ignore /phpunit.xml export-ignore CHANGELOG.md export-ignore CONTRIBUTING.md export-ignore +docker-compose.yml export-ignore +Makefile export-ignore README.md export-ignore diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 0a8089f6..ed46ad6e 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -1,6 +1,10 @@ name: Static Analysis -on: ['push', 'pull_request'] +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' jobs: static: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d45b90ee..efc2aa5d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,13 +1,18 @@ name: Tests -on: ['push', 'pull_request'] +on: + push: + pull_request: + schedule: + - cron: '0 0 * * *' + jobs: ci: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] # "windows-latest" is waiting for https://github.com/pestphp/pest/issues/638 + os: [ubuntu-latest, macos-latest, windows-latest] php: ['8.1', '8.2'] dependency-version: [prefer-lowest, prefer-stable] parallel: ['', '--parallel'] diff --git a/.gitignore b/.gitignore index 235a8cff..17e605fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .idea/* .idea/codeStyleSettings.xml +.temp/* composer.lock /vendor/ coverage.xml @@ -8,7 +9,6 @@ coverage.xml /.php-cs-fixer.php .php-cs-fixer.cache .temp/coverage.php -.temp/retry.json *.swp *.swo .vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4037f219..4f24ec41 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,3 +54,22 @@ Integration tests: ```bash composer test:integration ``` + +## Simplified setup using Docker + +If you have Docker installed, you can quickly get all dependencies for Pest in place using +our Docker files. Assuming you have the repository cloned, you may run the following +commands: + +1. `make build` to build the Docker image +2. `make install` to install Composer dependencies +3. `make test` to run the project tests and analysis tools + +If you want to check things work against a specific version of PHP, you may include +the `PHP` build argument when building the image: + +```bash +make build ARGS="--build-arg PHP=8.2" +``` + +The default PHP version will always be the lowest version of PHP supported by Pest. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ec4faa84 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +# Well documented Makefiles +DEFAULT_GOAL := help +help: + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-40s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +build: ## Build all docker images. Specify the command e.g. via make build ARGS="--build-arg PHP=8.2" + docker compose build $(ARGS) + +##@ [Application] +install: ## Install the composer dependencies + docker compose run --rm composer install + +test: ## Run the tests + docker compose run --rm composer test diff --git a/bin/pest b/bin/pest index bae7b4eb..e99c09bb 100755 --- a/bin/pest +++ b/bin/pest @@ -17,6 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface; $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; $args = $_SERVER['argv']; + $dirty = false; $todo = false; @@ -68,11 +69,11 @@ use Symfony\Component\Console\Output\OutputInterface; // Get $rootPath based on $autoloadPath $rootPath = dirname($autoloadPath, 2); - $argv = new ArgvInput(); + $input = new ArgvInput(); $testSuite = TestSuite::getInstance( $rootPath, - $argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), + $input->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), ); if ($dirty) { @@ -83,19 +84,13 @@ use Symfony\Component\Console\Output\OutputInterface; $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); } - $isDecorated = $argv->getParameterOption('--colors', 'always') !== 'never'; + $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never'; $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); - $container = Container::getInstance(); - $container->add(TestSuite::class, $testSuite); - $container->add(OutputInterface::class, $output); - $container->add(InputInterface::class, $argv); - $container->add(Container::class, $container); + $kernel = Kernel::boot($testSuite, $input, $output); - $kernel = Kernel::boot(); - - $result = $kernel->handle($output, $args); + $result = $kernel->handle($args); $kernel->shutdown(); diff --git a/bin/worker.php b/bin/worker.php new file mode 100644 index 00000000..19aa4790 --- /dev/null +++ b/bin/worker.php @@ -0,0 +1,101 @@ +getParameterOption( + '--test-directory', + (new ConfigLoader($rootPath))->getTestsDirectory() + )); + + $input = new ArgvInput(); + + $output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, true); + + Kernel::boot($testSuite, $input, $output); +}); + +(static function () use ($bootPest): void { + $getopt = getopt('', [ + 'status-file:', + 'progress-file:', + 'testresult-file:', + 'teamcity-file:', + 'testdox-file:', + 'testdox-color', + 'phpunit-argv:', + ]); + + $composerAutoloadFiles = [ + dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'autoload.php', + dirname(__DIR__, 2).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.php', + dirname(__DIR__).DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR.'autoload.php', + ]; + + foreach ($composerAutoloadFiles as $file) { + if (file_exists($file)) { + require_once $file; + define('PHPUNIT_COMPOSER_INSTALL', $file); + + break; + } + } + + assert(isset($getopt['status-file']) && is_string($getopt['status-file'])); + $statusFile = fopen($getopt['status-file'], 'wb'); + assert(is_resource($statusFile)); + + assert(isset($getopt['progress-file']) && is_string($getopt['progress-file'])); + assert(isset($getopt['testresult-file']) && is_string($getopt['testresult-file'])); + assert(! isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file'])); + assert(! isset($getopt['testdox-file']) || is_string($getopt['testdox-file'])); + + assert(isset($getopt['phpunit-argv']) && is_string($getopt['phpunit-argv'])); + $phpunitArgv = unserialize($getopt['phpunit-argv'], ['allowed_classes' => false]); + assert(is_array($phpunitArgv)); + + $bootPest(); + + $phpunitArgv = (new CallsHandleArguments())($phpunitArgv); + + $application = new ApplicationForWrapperWorker( + $phpunitArgv, + $getopt['progress-file'], + $getopt['testresult-file'], + $getopt['teamcity-file'] ?? null, + $getopt['testdox-file'] ?? null, + isset($getopt['testdox-color']), + ); + + while (true) { + if (feof(STDIN)) { + $application->end(); + exit; + } + + $testPath = fgets(STDIN); + if ($testPath === false || $testPath === WrapperWorker::COMMAND_EXIT) { + $application->end(); + + exit; + } + + $exitCode = $application->runTest(trim($testPath)); + + fwrite($statusFile, (string) $exitCode); + fflush($statusFile); + } +})(); diff --git a/composer.json b/composer.json index df90e538..e66fa490 100644 --- a/composer.json +++ b/composer.json @@ -18,10 +18,13 @@ ], "require": { "php": "^8.1.0", - "nunomaduro/collision": "^7.0.0", - "nunomaduro/termwind": "^1.15", + "nunomaduro/collision": "^7.0.2", + "nunomaduro/termwind": "^1.15.1", "pestphp/pest-plugin": "^2.0.0", - "phpunit/phpunit": "10.0.x-dev" + "phpunit/phpunit": "^10.0.7" + }, + "conflict": { + "brianium/paratest": "<7.0.4" }, "version": "2.x-dev", "autoload": { @@ -43,9 +46,10 @@ ] }, "require-dev": { - "pestphp/pest-dev-tools": "^2.2", + "brianium/paratest": "^7.0.6", + "pestphp/pest-dev-tools": "^2.4.0", "pestphp/pest-plugin-arch": "^2.0.0", - "symfony/process": "^6.2.0" + "symfony/process": "^6.2.5" }, "minimum-stability": "dev", "prefer-stable": true, @@ -81,6 +85,7 @@ "extra": { "pest": { "plugins": [ + "Pest\\Plugins\\Cache", "Pest\\Plugins\\Coverage", "Pest\\Plugins\\Init", "Pest\\Plugins\\Environment", @@ -88,7 +93,8 @@ "Pest\\Plugins\\Memory", "Pest\\Plugins\\Printer", "Pest\\Plugins\\Retry", - "Pest\\Plugins\\Version" + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Parallel" ] } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..70b75de2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.8" + +services: + php: + build: + context: ./docker + volumes: + - .:/var/www/html + composer: + build: + context: ./docker + volumes: + - .:/var/www/html + entrypoint: ["composer"] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..ff10bcb2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,23 @@ +ARG PHP=8.1 +FROM php:${PHP}-cli-alpine + +RUN apk update \ + && apk add zip libzip-dev icu-dev + +RUN docker-php-ext-configure zip +RUN docker-php-ext-install zip +RUN docker-php-ext-enable zip + +RUN docker-php-ext-configure intl +RUN docker-php-ext-install intl +RUN docker-php-ext-enable intl + +RUN apk add --no-cache $PHPIZE_DEPS linux-headers +RUN pecl install xdebug +RUN docker-php-ext-enable xdebug + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +ENTRYPOINT ["php"] diff --git a/overrides/TextUI/Output/Default/ProgressPrinter/TestSkippedSubscriber.php b/overrides/TextUI/Output/Default/ProgressPrinter/TestSkippedSubscriber.php new file mode 100644 index 00000000..606175a0 --- /dev/null +++ b/overrides/TextUI/Output/Default/ProgressPrinter/TestSkippedSubscriber.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PHPUnit\TextUI\Output\Default\ProgressPrinter; + +use PHPUnit\Event\Test\Skipped; +use PHPUnit\Event\Test\SkippedSubscriber; +use ReflectionClass; + +/** + * @internal This class is not covered by the backward compatibility promise for PHPUnit + * + * This file is overridden to allow Pest Parallel to show todo items in the progress output. + */ +final class TestSkippedSubscriber extends Subscriber implements SkippedSubscriber +{ + /** + * Notifies the printer that a test was skipped. + */ + public function notify(Skipped $event): void + { + str_contains($event->message(), '__TODO__') + ? $this->printTodoItem() + : $this->printer()->testSkipped(); + } + + /** + * Prints a "T" to the standard PHPUnit output to indicate a todo item. + */ + private function printTodoItem(): void + { + $mirror = new ReflectionClass($this->printer()); + $printerMirror = $mirror->getMethod('printProgress'); + $printerMirror->invoke($this->printer(), 'T'); + } +} diff --git a/phpstan.neon b/phpstan.neon index e74b4290..a75d68cc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,10 +12,10 @@ parameters: reportUnmatchedIgnoredErrors: true ignoreErrors: - - '#Cannot instantiate interface PHPUnit\\Util\\Exception#' + - "#Language construct isset\\(\\) should not be used.#" + - "#is not allowed to extend#" - "#with a nullable type declaration#" - "#type mixed is not subtype of native#" - - "#is not allowed to extend#" - "# with null as default value#" - "#has parameter \\$closure with default value.#" - "#has parameter \\$description with default value.#" diff --git a/phpunit.xml b/phpunit.xml index d1b754d0..13e5b2af 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,14 +5,12 @@ beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" bootstrap="vendor/autoload.php" - cacheResult="false" colors="true" failOnRisky="true" failOnWarning="true" processIsolation="false" stopOnError="false" stopOnFailure="false" - cacheDirectory=".phpunit.cache" backupStaticProperties="false" displayDetailsOnIncompleteTests="true" displayDetailsOnSkippedTests="true" diff --git a/src/Bootstrappers/BootExceptionHandler.php b/src/Bootstrappers/BootExceptionHandler.php index 99119f41..bcebf5a6 100644 --- a/src/Bootstrappers/BootExceptionHandler.php +++ b/src/Bootstrappers/BootExceptionHandler.php @@ -13,7 +13,7 @@ use Pest\Contracts\Bootstrapper; final class BootExceptionHandler implements Bootstrapper { /** - * Boots the Exception Handler. + * Boots the "Collision" exception handler. */ public function boot(): void { diff --git a/src/Bootstrappers/BootFiles.php b/src/Bootstrappers/BootFiles.php index 44235b80..e4fbbe26 100644 --- a/src/Bootstrappers/BootFiles.php +++ b/src/Bootstrappers/BootFiles.php @@ -19,7 +19,7 @@ use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator; final class BootFiles implements Bootstrapper { /** - * The Pest convention. + * The structure of the tests directory. * * @var array */ @@ -32,7 +32,7 @@ final class BootFiles implements Bootstrapper ]; /** - * Boots the Subscribers. + * Boots the structure of the tests directory. */ public function boot(): void { diff --git a/src/Bootstrappers/BootOverrides.php b/src/Bootstrappers/BootOverrides.php index 9c637d0f..3d3e7fa8 100644 --- a/src/Bootstrappers/BootOverrides.php +++ b/src/Bootstrappers/BootOverrides.php @@ -20,10 +20,11 @@ final class BootOverrides implements Bootstrapper private const FILES = [ 'Runner/Filter/NameFilterIterator.php', 'Runner/TestSuiteLoader.php', + 'TextUI/Output/Default/ProgressPrinter/TestSkippedSubscriber.php', ]; /** - * Boots the Subscribers. + * Boots the list of files to be overridden. */ public function boot(): void { diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 8c21c35d..b3f14659 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -16,21 +16,18 @@ use PHPUnit\Event\Subscriber; final class BootSubscribers implements Bootstrapper { /** - * The Kernel subscribers. + * The list of Subscribers. * * @var array> */ private const SUBSCRIBERS = [ Subscribers\EnsureConfigurationIsValid::class, - Subscribers\EnsureConfigurationDefaults::class, - Subscribers\EnsureRetryRepositoryExists::class, - Subscribers\EnsureErroredTestsAreRetryable::class, - Subscribers\EnsureFailedTestsAreRetryable::class, + Subscribers\EnsureConfigurationIsAvailable::class, Subscribers\EnsureTeamCityEnabled::class, ]; /** - * Creates a new Subscriber instance. + * Creates a new instance of the Boot Subscribers. */ public function __construct( private readonly Container $container, @@ -38,7 +35,7 @@ final class BootSubscribers implements Bootstrapper } /** - * Boots the Subscribers. + * Boots the list of Subscribers. */ public function boot(): void { diff --git a/src/Bootstrappers/BootView.php b/src/Bootstrappers/BootView.php index 2ae4c509..680c78ce 100644 --- a/src/Bootstrappers/BootView.php +++ b/src/Bootstrappers/BootView.php @@ -13,6 +13,9 @@ use Symfony\Component\Console\Output\OutputInterface; */ final class BootView implements Bootstrapper { + /** + * Creates a new instance of the Boot View. + */ public function __construct( private readonly OutputInterface $output ) { diff --git a/src/Contracts/Panicable.php b/src/Contracts/Panicable.php new file mode 100644 index 00000000..f56b4b78 --- /dev/null +++ b/src/Contracts/Panicable.php @@ -0,0 +1,23 @@ +writeln([ + '', + ' INFO No "dirty" tests found.', + '', + ]); + } + + /** + * The exit code to be used. + */ + public function exitCode(): int + { + return 0; + } +} diff --git a/src/Exceptions/NoTestsFound.php b/src/Exceptions/NoTestsFound.php deleted file mode 100644 index 951b9ec7..00000000 --- a/src/Exceptions/NoTestsFound.php +++ /dev/null @@ -1,26 +0,0 @@ -description); - $retryRepository = TestSuite::getInstance()->retryRepository; - - if (Retry::$retrying && ! $retryRepository->isEmpty() && ! $retryRepository->exists(sprintf('%s::%s', $classFQN, $methodName))) { - return ''; - } - $datasetsCode = ''; $annotations = ['@test']; $attributes = []; @@ -188,7 +181,7 @@ final class TestCaseMethodFactory return <<add(TestSuite::class, $testSuite) + ->add(InputInterface::class, $input) + ->add(OutputInterface::class, $output) + ->add(Container::class, $container); + foreach (self::BOOTSTRAPPERS as $bootstrapper) { $bootstrapper = Container::getInstance()->get($bootstrapper); assert($bootstrapper instanceof Bootstrapper); @@ -61,24 +70,25 @@ final class Kernel (new CallsBoot())->__invoke(); - return new self(new Application()); + return new self( + new Application(), + $output, + ); } /** - * Handles the given argv. + * Runs the application, and returns the exit code. * - * @param array $argv - * - * @throws Exception + * @param array $args */ - public function handle(OutputInterface $output, array $argv): int + public function handle(array $args): int { - $argv = (new Plugins\Actions\CallsHandleArguments())->__invoke($argv); + $args = (new Plugins\Actions\CallsHandleArguments())->__invoke($args); try { - $this->application->run($argv); - } catch (NoTestsFound) { - $output->writeln([ + $this->application->run($args); + } catch (NoDirtyTestsFound) { + $this->output->writeln([ '', ' INFO No tests found.', '', diff --git a/src/Logging/TeamCity/Converter.php b/src/Logging/TeamCity/Converter.php index 31495162..1f0de446 100644 --- a/src/Logging/TeamCity/Converter.php +++ b/src/Logging/TeamCity/Converter.php @@ -5,20 +5,14 @@ declare(strict_types=1); namespace Pest\Logging\TeamCity; use NunoMaduro\Collision\Adapters\Phpunit\State; -use NunoMaduro\Collision\Adapters\Phpunit\TestResult; use Pest\Exceptions\ShouldNotHappen; +use Pest\Support\StateGenerator; use Pest\Support\Str; use PHPUnit\Event\Code\Test; -use PHPUnit\Event\Code\TestDox; use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\Throwable; -use PHPUnit\Event\Test\Errored; -use PHPUnit\Event\TestData\TestDataCollection; use PHPUnit\Event\TestSuite\TestSuite; use PHPUnit\Framework\Exception as FrameworkException; -use PHPUnit\Framework\IncompleteTestError; -use PHPUnit\Framework\SkippedWithMessageException; -use PHPUnit\Metadata\MetadataCollection; use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult; /** @@ -28,12 +22,15 @@ final class Converter { private const PREFIX = 'P\\'; + private readonly StateGenerator $stateGenerator; + /** * Creates a new instance of the Converter. */ public function __construct( private readonly string $rootPath, ) { + $this->stateGenerator = new StateGenerator(); } /** @@ -175,7 +172,7 @@ final class Converter private function toRelativePath(string $path): string { // Remove cwd from the path. - return str_replace("$this->rootPath/", '', $path); + return str_replace("$this->rootPath".DIRECTORY_SEPARATOR, '', $path); } /** @@ -183,83 +180,6 @@ final class Converter */ public function getStateFromResult(PhpUnitTestResult $result): State { - $state = new State(); - - foreach ($result->testErroredEvents() as $resultEvent) { - assert($resultEvent instanceof Errored); - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::FAIL, - $resultEvent->throwable() - )); - } - - foreach ($result->testFailedEvents() as $resultEvent) { - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::FAIL, - $resultEvent->throwable() - )); - } - - foreach ($result->testMarkedIncompleteEvents() as $resultEvent) { - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::INCOMPLETE, - $resultEvent->throwable() - )); - } - - foreach ($result->testConsideredRiskyEvents() as $riskyEvents) { - foreach ($riskyEvents as $riskyEvent) { - $state->add(TestResult::fromTestCase( - $riskyEvent->test(), - TestResult::RISKY, - Throwable::from(new IncompleteTestError($riskyEvent->message())) - )); - } - } - - foreach ($result->testSkippedEvents() as $resultEvent) { - if ($resultEvent->message() === '__TODO__') { - $state->add(TestResult::fromTestCase($resultEvent->test(), TestResult::TODO)); - - continue; - } - - $state->add(TestResult::fromTestCase( - $resultEvent->test(), - TestResult::SKIPPED, - Throwable::from(new SkippedWithMessageException($resultEvent->message())) - )); - } - - $numberOfPassedTests = $result->numberOfTests() - - $result->numberOfTestErroredEvents() - - $result->numberOfTestFailedEvents() - - $result->numberOfTestSkippedEvents() - - $result->numberOfTestsWithTestConsideredRiskyEvents() - - $result->numberOfTestMarkedIncompleteEvents(); - - for ($i = 0; $i < $numberOfPassedTests; $i++) { - $state->add(TestResult::fromTestCase( - - new TestMethod( - /** @phpstan-ignore-next-line */ - "$i", - /** @phpstan-ignore-next-line */ - '', - '', - 1, - /** @phpstan-ignore-next-line */ - TestDox::fromClassNameAndMethodName('', ''), - MetadataCollection::fromArray([]), - TestDataCollection::fromArray([]) - ), - TestResult::PASS - )); - } - - return $state; + return $this->stateGenerator->fromPhpUnitTestResult($result); } } diff --git a/src/Logging/TeamCity/TeamCityLogger.php b/src/Logging/TeamCity/TeamCityLogger.php index 747d4c4e..9e5d6312 100644 --- a/src/Logging/TeamCity/TeamCityLogger.php +++ b/src/Logging/TeamCity/TeamCityLogger.php @@ -211,7 +211,7 @@ final class TeamCityLogger ); } - $style->writeRecap($state, $telemetry); + $style->writeRecap($state, $telemetry, $result); } public function output(ServiceMessage $message): void diff --git a/src/Mixins/Expectation.php b/src/Mixins/Expectation.php index 886ffab2..afec5ea0 100644 --- a/src/Mixins/Expectation.php +++ b/src/Mixins/Expectation.php @@ -302,6 +302,36 @@ final class Expectation return $this; } + /** + * Asserts that the value has the method $name. + * + * @return self + */ + public function toHaveMethod(string $name, string $message = ''): self + { + $this->toBeObject(); + + // @phpstan-ignore-next-line + Assert::assertTrue(method_exists($this->value, $name), $message); + + return $this; + } + + /** + * Asserts that the value has the provided methods $names. + * + * @param iterable $names + * @return self + */ + public function toHaveMethods(iterable $names, string $message = ''): self + { + foreach ($names as $name) { + $this->toHaveMethod($name, message: $message); + } + + return $this; + } + /** * Asserts that two variables have the same value. * diff --git a/src/Panic.php b/src/Panic.php new file mode 100644 index 00000000..a14bf428 --- /dev/null +++ b/src/Panic.php @@ -0,0 +1,60 @@ +handle(); + + exit(1); + } + + /** + * Handles the panic. + */ + private function handle(): void + { + /** @var OutputInterface $output */ + $output = Container::getInstance()->get(OutputInterface::class); + + if ($this->throwable instanceof Contracts\Panicable) { + $this->throwable->render($output); + + exit($this->throwable->exitCode()); + } + + $writer = new Writer(null, $output); + + $inspector = new Inspector($this->throwable); + + $output->writeln(''); + $writer->write($inspector); + $output->writeln(''); + + exit(1); + } +} diff --git a/src/Plugins/Cache.php b/src/Plugins/Cache.php new file mode 100644 index 00000000..f1929b9d --- /dev/null +++ b/src/Plugins/Cache.php @@ -0,0 +1,44 @@ +hasArgument('--parallel', $arguments)) { + $arguments = $this->pushArgument( + sprintf('--cache-directory=%s', realpath(self::TEMPORARY_FOLDER)), + $arguments + ); + + $arguments = $this->pushArgument('--cache-result', $arguments); + } + + return $arguments; + } +} diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index e3873657..07b21232 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -55,6 +55,7 @@ final class Coverage implements AddsOutput, HandlesArguments if ($original === sprintf('--%s', $option)) { return true; } + if (Str::startsWith($original, sprintf('--%s=', $option))) { return true; } diff --git a/src/Plugins/Parallel.php b/src/Plugins/Parallel.php new file mode 100644 index 00000000..b7f59137 --- /dev/null +++ b/src/Plugins/Parallel.php @@ -0,0 +1,190 @@ +hasParameterOption('--parallel')) { + return true; + } + + return $argv->hasParameterOption('-p'); + } + + /** + * If this code is running in a worker process rather than the main process. + */ + public static function isWorker(): bool + { + $argvValue = Arr::get($_SERVER, 'PARATEST'); + + assert(is_string($argvValue) || is_int($argvValue) || is_null($argvValue)); + + return ((int) $argvValue) === 1; + } + + /** + * {@inheritdoc} + */ + public function handleArguments(array $arguments): array + { + if ($this->hasArgumentsThatWouldBeFasterWithoutParallel()) { + return $this->runTestSuiteInSeries($arguments); + } + + if (self::isEnabled()) { + exit($this->runTestSuiteInParallel($arguments)); + } + + if (self::isWorker()) { + return $this->runWorkerHandlers($arguments); + } + + return $arguments; + } + + /** + * Runs the test suite in parallel. This method will exit the process upon completion. + * + * @param array $arguments + */ + private function runTestSuiteInParallel(array $arguments): int + { + if (! class_exists(ParaTestCommand::class)) { + $this->askUserToInstallParatest(); + + return Command::FAILURE; + } + + $handlers = array_filter( + array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler): bool => $handler instanceof HandlesArguments, + ); + + $filteredArguments = array_reduce( + $handlers, + fn ($arguments, HandlesArguments $handler): array => $handler->handleArguments($arguments), + $arguments + ); + + $exitCode = $this->paratestCommand()->run(new ArgvInput($filteredArguments), new CleanConsoleOutput()); + + return (new CallsAddsOutput())($exitCode); + } + + /** + * Runs any handlers that have been registered to handle worker arguments, and returns the modified arguments. + * + * @param array $arguments + * @return array + */ + private function runWorkerHandlers(array $arguments): array + { + $handlers = array_filter( + array_map(fn ($handler): object|string => Container::getInstance()->get($handler), self::HANDLERS), + fn ($handler): bool => $handler instanceof HandlersWorkerArguments, + ); + + return array_reduce( + $handlers, + fn ($arguments, HandlersWorkerArguments $handler): array => $handler->handleWorkerArguments($arguments), + $arguments + ); + } + + /** + * Outputs a message to the user asking them to install ParaTest as a dev dependency. + */ + private function askUserToInstallParatest(): void + { + /** @var OutputInterface $output */ + $output = Container::getInstance()->get(OutputInterface::class); + + $output->writeln([ + 'Pest Parallel requires ParaTest to run.', + 'Please run composer require --dev brianium/paratest.', + ]); + } + + /** + * Builds an instance of the Paratest command. + */ + private function paratestCommand(): Application + { + /** @var non-empty-string $rootPath */ + $rootPath = TestSuite::getInstance()->rootPath; + + $command = ParaTestCommand::applicationFactory($rootPath); + $command->setAutoExit(false); + $command->setName('Pest'); + $command->setVersion(version()); + + return $command; + } + + /** + * Whether the command line arguments contain any arguments that are + * not supported or are suboptimal when running in parallel. + */ + private function hasArgumentsThatWouldBeFasterWithoutParallel(): bool + { + $arguments = new ArgvInput(); + + foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) { + if ($arguments->hasParameterOption($unsupportedArgument)) { + return true; + } + } + + return false; + } + + /** + * Removes any parallel arguments. + * + * @param array $arguments + * @return array + */ + private function runTestSuiteInSeries(array $arguments): array + { + $arguments = $this->popArgument('--parallel', $arguments); + + return $this->popArgument('-p', $arguments); + } +} diff --git a/src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php b/src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php new file mode 100644 index 00000000..f953ecb7 --- /dev/null +++ b/src/Plugins/Parallel/Contracts/HandlersWorkerArguments.php @@ -0,0 +1,14 @@ + $arguments + * @return array + */ + public function handleWorkerArguments(array $arguments): array; +} diff --git a/src/Plugins/Parallel/Handlers/Laravel.php b/src/Plugins/Parallel/Handlers/Laravel.php new file mode 100644 index 00000000..a1573b8e --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Laravel.php @@ -0,0 +1,106 @@ +ensureRunnerIsResolvable(); + + $arguments = $this->ensureEnvironmentVariables($arguments); + + return $this->ensureRunner($arguments); + }); + } + + /** + * Executes the given closure when running Laravel. + * + * @param array $arguments + * @param CLosure(array): array $closure + * @return array + */ + private static function whenUsingLaravel(array $arguments, Closure $closure): array + { + $isLaravelApplication = InstalledVersions::isInstalled('laravel/framework', false); + $isLaravelPackage = class_exists(\Orchestra\Testbench\TestCase::class); + + if ($isLaravelApplication && ! $isLaravelPackage) { + return $closure($arguments); + } + + return $arguments; + } + + /** + * Ensures the runner is resolvable. + */ + private function ensureRunnerIsResolvable(): void + { + ParallelRunner::resolveRunnerUsing( // @phpstan-ignore-line + fn (Options $options, OutputInterface $output): RunnerInterface => new WrapperRunner($options, $output) + ); + } + + /** + * Ensures the environment variables are set. + * + * @param array $arguments + * @return array + */ + private function ensureEnvironmentVariables(array $arguments): array + { + $_ENV['LARAVEL_PARALLEL_TESTING'] = 1; + + if ($this->hasArgument('--recreate-databases', $arguments)) { + $_ENV['LARAVEL_PARALLEL_TESTING_RECREATE_DATABASES'] = 1; + } + + if ($this->hasArgument('--drop-databases', $arguments)) { + $_ENV['LARAVEL_PARALLEL_TESTING_DROP_DATABASES'] = 1; + } + + $arguments = $this->popArgument('--recreate-databases', $arguments); + + return $this->popArgument('--drop-databases', $arguments); + } + + /** + * Ensure the runner is set. + * + * @param array $arguments + * @return array + */ + private function ensureRunner(array $arguments): array + { + foreach ($arguments as $value) { + if (str_starts_with($value, '--runner')) { + $arguments = $this->popArgument($value, $arguments); + } + } + + return $this->pushArgument('--runner=\Illuminate\Testing\ParallelRunner', $arguments); + } +} diff --git a/src/Plugins/Parallel/Handlers/Parallel.php b/src/Plugins/Parallel/Handlers/Parallel.php new file mode 100644 index 00000000..2e87a521 --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Parallel.php @@ -0,0 +1,37 @@ + $this->popArgument($arg, $args), $arguments); + + return $this->pushArgument('--runner='.WrapperRunner::class, $args); + } +} diff --git a/src/Plugins/Parallel/Handlers/Pest.php b/src/Plugins/Parallel/Handlers/Pest.php new file mode 100644 index 00000000..eb9132b7 --- /dev/null +++ b/src/Plugins/Parallel/Handlers/Pest.php @@ -0,0 +1,23 @@ +isOpeningHeadline($message)) { + return; + } + + parent::doWrite($message, $newline); + } + + /** + * Removes the opening headline, witch is not needed. + */ + private function isOpeningHeadline(string $message): bool + { + return str_contains($message, 'by Sebastian Bergmann and contributors.'); + } +} diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php new file mode 100644 index 00000000..e2f44e9a --- /dev/null +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -0,0 +1,203 @@ + */ + private array $tailPositions; + + public function __construct( + private readonly OutputInterface $output, + private readonly Options $options + ) { + $this->printer = new class($this->output) implements Printer + { + public function __construct( + private readonly OutputInterface $output, + ) { + } + + public function print(string $buffer): void + { + $this->output->write(OutputFormatter::escape($buffer)); + } + + public function flush(): void + { + } + }; + + $this->compactPrinter = CompactPrinter::default(); + + if (! $this->options->configuration->hasLogfileTeamcity()) { + return; + } + + $teamcityLogFileHandle = fopen($this->options->configuration->logfileTeamcity(), 'ab+'); + assert($teamcityLogFileHandle !== false); + $this->teamcityLogFileHandle = $teamcityLogFileHandle; + } + + public function start(int $numberOfTests): void + { + $this->compactPrinter->line(sprintf( + 'Running %d test%s using %d process%s', + $numberOfTests, + $numberOfTests === 1 ? '' : 's', + $this->options->processes, + $this->options->processes === 1 ? '' : 'es') + ); + } + + /** @param array $teamcityFiles */ + public function printFeedback(SplFileInfo $progressFile, array $teamcityFiles): void + { + if ($this->options->needsTeamcity) { + $teamcityProgress = $this->tailMultiple($teamcityFiles); + + if ($this->teamcityLogFileHandle !== null) { + fwrite($this->teamcityLogFileHandle, $teamcityProgress); + } + } + + if ($this->options->configuration->outputIsTeamCity()) { + assert(isset($teamcityProgress)); + $this->output->write($teamcityProgress); + + return; + } + + if ($this->options->configuration->noProgress()) { + return; + } + + $feedbackItems = $this->tail($progressFile); + if ($feedbackItems === '') { + return; + } + + $feedbackItems = (string) preg_replace('/ +\\d+ \\/ \\d+ \\( ?\\d+%\\)\\s*/', '', $feedbackItems); + + $actualTestCount = strlen($feedbackItems); + for ($index = 0; $index < $actualTestCount; $index++) { + $this->printFeedbackItem($feedbackItems[$index]); + } + } + + /** + * @param array $teamcityFiles + * @param array $testdoxFiles + */ + public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles, Duration $duration): void + { + if ($this->options->needsTeamcity) { + $teamcityProgress = $this->tailMultiple($teamcityFiles); + + if ($this->teamcityLogFileHandle !== null) { + fwrite($this->teamcityLogFileHandle, $teamcityProgress); + $resource = $this->teamcityLogFileHandle; + $this->teamcityLogFileHandle = null; + fclose($resource); + } + } + + if ($this->options->configuration->outputIsTeamCity()) { + assert(isset($teamcityProgress)); + $this->output->write($teamcityProgress); + + return; + } + + if ($this->options->configuration->outputIsTestDox()) { + $this->output->write($this->tailMultiple($testdoxFiles)); + + return; + } + + $state = (new StateGenerator())->fromPhpUnitTestResult($testResult); + + $this->compactPrinter->errors($state); + $this->compactPrinter->recap($state, $testResult, $duration); + } + + private function printFeedbackItem(string $item): void + { + $this->compactPrinter->descriptionItem($item); + } + + /** @param array $files */ + private function tailMultiple(array $files): string + { + $content = ''; + foreach ($files as $file) { + if (! $file->isFile()) { + continue; + } + + $content .= $this->tail($file); + } + + return $content; + } + + private function tail(SplFileInfo $file): string + { + $path = $file->getPathname(); + $handle = fopen($path, 'r'); + assert($handle !== false); + $fseek = fseek($handle, $this->tailPositions[$path] ?? 0); + assert($fseek === 0); + + $contents = ''; + while (! feof($handle)) { + $fread = fread($handle, 8192); + assert($fread !== false); + $contents .= $fread; + } + + $ftell = ftell($handle); + assert($ftell !== false); + $this->tailPositions[$path] = $ftell; + fclose($handle); + + return $contents; + } +} diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php new file mode 100644 index 00000000..e3426363 --- /dev/null +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -0,0 +1,402 @@ + */ + private array $pending = []; + + private int $exitCode = -1; + + /** @var array */ + private array $workers = []; + + /** @var array */ + private array $batches = []; + + /** @var array */ + private array $testresultFiles = []; + + /** @var array */ + private array $coverageFiles = []; + + /** @var array */ + private array $junitFiles = []; + + /** @var array */ + private array $teamcityFiles = []; + + /** @var array */ + private array $testdoxFiles = []; + + /** @var array */ + private readonly array $parameters; + + private readonly CodeCoverageFilterRegistry $codeCoverageFilterRegistry; + + public function __construct( + private readonly Options $options, + private readonly OutputInterface $output + ) { + $this->printer = new ResultPrinter($output, $options); + $this->timer = new Timer(); + + $worker = realpath( + dirname(__DIR__, 4).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'worker.php', + ); + + assert($worker !== false); + $phpFinder = new PhpExecutableFinder(); + $phpBin = $phpFinder->find(false); + assert($phpBin !== false); + $parameters = [$phpBin]; + $parameters = array_merge($parameters, $phpFinder->findArguments()); + + if ($options->passthruPhp !== null) { + $parameters = array_merge($parameters, $options->passthruPhp); + } + + $parameters[] = $worker; + + $this->parameters = $parameters; + $this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry(); + } + + public function run(): int + { + $directory = dirname(__DIR__); + assert(strlen($directory) > 0); + ExcludeList::addDirectory($directory); + + TestResultFacade::init(); + EventFacade::seal(); + + $suiteLoader = new SuiteLoader($this->options, $this->output, $this->codeCoverageFilterRegistry); + $this->pending = $this->getTestFiles($suiteLoader); + + $result = TestResultFacade::result(); + + $this->printer->start($suiteLoader->testCount); + + $this->timer->start(); + + $this->startWorkers(); + $this->assignAllPendingTests(); + $this->waitForAllToFinish(); + + return $this->complete($result); + } + + private function startWorkers(): void + { + for ($token = 1; $token <= $this->options->processes; $token++) { + $this->startWorker($token); + } + } + + private function assignAllPendingTests(): void + { + $batchSize = $this->options->maxBatchSize; + + while ($this->pending !== [] && $this->workers !== []) { + foreach ($this->workers as $token => $worker) { + if (! $worker->isRunning()) { + throw $worker->getWorkerCrashedException(); + } + + if (! $worker->isFree()) { + continue; + } + + $this->flushWorker($worker); + + if ($batchSize !== 0 && $this->batches[$token] === $batchSize) { + $this->destroyWorker($token); + $worker = $this->startWorker($token); + } + + if ( + $this->exitCode > 0 + && $this->options->configuration->stopOnFailure() + ) { + $this->pending = []; + } elseif (($pending = array_shift($this->pending)) !== null) { + $this->debug(sprintf('Assigning %s to worker %d', $pending, $token)); + + $worker->assign($pending); + $this->batches[$token]++; + } + } + + usleep(self::CYCLE_SLEEP); + } + } + + private function flushWorker(WrapperWorker $worker): void + { + $this->exitCode = max($this->exitCode, $worker->getExitCode()); + $this->printer->printFeedback( + $worker->progressFile, + $this->teamcityFiles, + ); + $worker->reset(); + } + + private function waitForAllToFinish(): void + { + $stopped = []; + while ($this->workers !== []) { + foreach ($this->workers as $index => $worker) { + if ($worker->isRunning()) { + if (! array_key_exists($index, $stopped) && $worker->isFree()) { + $worker->stop(); + $stopped[$index] = true; + } + + continue; + } + + if (! $worker->isFree()) { + throw $worker->getWorkerCrashedException(); + } + + $this->flushWorker($worker); + unset($this->workers[$index]); + } + + usleep(self::CYCLE_SLEEP); + } + } + + private function startWorker(int $token): WrapperWorker + { + /** @var array $parameters */ + $parameters = $this->parameters; + + $worker = new WrapperWorker( + $this->output, + $this->options, + $parameters, + $token, + ); + + $worker->start(); + + $this->batches[$token] = 0; + + $this->testresultFiles[] = $worker->testresultFile; + + if (isset($worker->junitFile)) { + $this->junitFiles[] = $worker->junitFile; + } + + if (isset($worker->coverageFile)) { + $this->coverageFiles[] = $worker->coverageFile; + } + + if (isset($worker->teamcityFile)) { + $this->teamcityFiles[] = $worker->teamcityFile; + } + + if (isset($worker->testdoxFile)) { + $this->testdoxFiles[] = $worker->testdoxFile; + } + + return $this->workers[$token] = $worker; + } + + private function destroyWorker(int $token): void + { + // Mutation Testing tells us that the following `unset()` already destroys + // the `WrapperWorker`, which destroys the Symfony's `Process`, which + // automatically calls `Process::stop` within `Process::__destruct()`. + // But we prefer to have an explicit stops. + $this->workers[$token]->stop(); + + unset($this->workers[$token]); + } + + private function complete(TestResult $testResultSum): int + { + foreach ($this->testresultFiles as $testresultFile) { + if (! $testresultFile->isFile()) { + continue; + } + + $contents = file_get_contents($testresultFile->getPathname()); + assert($contents !== false); + $testResult = unserialize($contents); + assert($testResult instanceof TestResult); + + $testResultSum = new TestResult( + $testResultSum->numberOfTests() + $testResult->numberOfTests(), + $testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(), + $testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(), + array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), + array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()), + 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->testTriggeredDeprecationEvents(), $testResult->testTriggeredDeprecationEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpDeprecationEvents(), $testResult->testTriggeredPhpDeprecationEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()), + array_merge_recursive($testResultSum->testTriggeredErrorEvents(), $testResult->testTriggeredErrorEvents()), + array_merge_recursive($testResultSum->testTriggeredNoticeEvents(), $testResult->testTriggeredNoticeEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpNoticeEvents(), $testResult->testTriggeredPhpNoticeEvents()), + array_merge_recursive($testResultSum->testTriggeredWarningEvents(), $testResult->testTriggeredWarningEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpWarningEvents(), $testResult->testTriggeredPhpWarningEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()), + array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()), + array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()), + array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), + ); + } + + $this->printer->printResults( + $testResultSum, + $this->teamcityFiles, + $this->testdoxFiles, + $this->timer->stop(), + ); + $this->generateCodeCoverageReports(); + $this->generateLogs(); + + $exitcode = (new ShellExitCodeCalculator())->calculate( + $this->options->configuration->failOnEmptyTestSuite(), + $this->options->configuration->failOnRisky(), + $this->options->configuration->failOnWarning(), + $this->options->configuration->failOnIncomplete(), + $this->options->configuration->failOnSkipped(), + $testResultSum, + ); + + $this->clearFiles($this->testresultFiles); + $this->clearFiles($this->coverageFiles); + $this->clearFiles($this->junitFiles); + $this->clearFiles($this->teamcityFiles); + $this->clearFiles($this->testdoxFiles); + + return $exitcode; + } + + private function generateCodeCoverageReports(): void + { + if ($this->coverageFiles === []) { + return; + } + + $coverageManager = new CodeCoverage(); + $coverageManager->init($this->options->configuration, $this->codeCoverageFilterRegistry); + $coverageMerger = new CoverageMerger($coverageManager->codeCoverage()); + foreach ($this->coverageFiles as $coverageFile) { + $coverageMerger->addCoverageFromFile($coverageFile); + } + + $coverageManager->generateReports( + $this->printer->printer, + $this->options->configuration, + ); + } + + private function generateLogs(): void + { + if ($this->junitFiles === []) { + return; + } + + $testSuite = (new LogMerger())->merge($this->junitFiles); + (new Writer())->write( + $testSuite, + $this->options->configuration->logfileJunit(), + ); + } + + /** @param array $files */ + private function clearFiles(array $files): void + { + foreach ($files as $file) { + if (! $file->isFile()) { + continue; + } + + unlink($file->getPathname()); + } + } + + /** + * Returns the test files to be executed. + * + * @return array + */ + private function getTestFiles(SuiteLoader $suiteLoader): array + { + $this->debug(sprintf('Found %d test file%s', count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's')); + + /** @var array $files */ + $files = $suiteLoader->files; + + return [ + ...array_values(array_filter( + $files, + fn (string $filename): bool => ! str_ends_with($filename, "eval()'d code") + )), + ...TestSuite::getInstance()->tests->getFilenames(), + ]; + } + + /** + * Prints a debug message. + */ + private function debug(string $message): void + { + if ($this->options->verbose) { + $this->output->writeln(" {$message} "); + } + } +} diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php new file mode 100644 index 00000000..339ad748 --- /dev/null +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -0,0 +1,143 @@ +> + */ + private const LOOKUP_TABLE = [ + '.' => ['gray', '.'], + 'S' => ['yellow', 's'], + 'T' => ['cyan', 't'], + 'I' => ['yellow', 'i'], + 'N' => ['yellow', 'i'], + 'R' => ['yellow', '!'], + 'W' => ['yellow', '!'], + 'E' => ['red', '⨯'], + 'F' => ['red', '⨯'], + ]; + + /** + * Creates a new instance of the Compact Printer. + */ + public function __construct( + private readonly Terminal $terminal, + private readonly OutputInterface $output, + private readonly Style $style, + private readonly int $compactSymbolsPerLine, + ) { + // .. + } + + /** + * Creates a new instance of the Compact Printer. + */ + public static function default(): self + { + return new self( + terminal(), + new ConsoleOutput(decorated: true), + new Style(new ConsoleOutput(decorated: true)), + terminal()->width() - 4, + ); + } + + /** + * Output an empty line in the console. Useful for providing a little breathing room. + */ + public function newLine(): void + { + render('
'); + } + + /** + * Write the given message to the console, adding vertical and horizontal padding. + */ + public function line(string $message): void + { + render("{$message}"); + } + + /** + * Outputs the given description item from the ProgressPrinter as a gorgeous, colored symbol. + */ + public function descriptionItem(string $item): void + { + [$color, $icon] = self::LOOKUP_TABLE[$item] ?? self::LOOKUP_TABLE['.']; + + $symbolsOnCurrentLine = $this->processed % $this->compactSymbolsPerLine; + + if ($symbolsOnCurrentLine >= $this->terminal->width() - 4) { + $symbolsOnCurrentLine = 0; + } + + if ($symbolsOnCurrentLine === 0) { + $this->output->writeln(''); + $this->output->write(' '); + } + + $this->output->write(sprintf('%s', $color, $icon)); + + $this->processed++; + } + + /** + * Outputs all errors from the given state using Collision's beautiful error output. + */ + public function errors(State $state): void + { + $this->style->writeErrorsSummary($state, false); + } + + /** + * Outputs a clean recap of the test run, including the number of tests, assertions, and failures. + */ + public function recap(State $state, PHPUnitTestResult $testResult, Duration $duration): void + { + assert($this->output instanceof ConsoleOutput); + + $nanoseconds = $duration->asNanoseconds() % 1_000_000_000; + $snapshotDuration = HRTime::fromSecondsAndNanoseconds((int) $duration->asSeconds(), $nanoseconds); + $telemetryDuration = \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds((int) $duration->asSeconds(), $nanoseconds); + + $telemetry = new Info( + new Snapshot( + $snapshotDuration, + MemoryUsage::fromBytes(0), + MemoryUsage::fromBytes(0), + ), + $telemetryDuration, + MemoryUsage::fromBytes(0), + \PHPUnit\Event\Telemetry\Duration::fromSecondsAndNanoseconds(0, 0), + MemoryUsage::fromBytes(0), + ); + + $this->style->writeRecap($state, $telemetry, $testResult); + } +} diff --git a/src/Plugins/Retry.php b/src/Plugins/Retry.php index f961018e..f17d74c4 100644 --- a/src/Plugins/Retry.php +++ b/src/Plugins/Retry.php @@ -13,18 +13,19 @@ final class Retry implements HandlesArguments { use Concerns\HandleArguments; - /** - * Whether it should show retry or not. - */ - public static bool $retrying = false; - /** * {@inheritDoc} */ public function handleArguments(array $arguments): array { - self::$retrying = $this->hasArgument('--retry', $arguments); + if (! $this->hasArgument('--retry', $arguments)) { + return $arguments; + } - return $this->popArgument('--retry', $arguments); + $arguments = $this->popArgument('--retry', $arguments); + + $arguments = $this->pushArgument('--order-by=defects', $arguments); + + return $this->pushArgument('--stop-on-failure', $arguments); } } diff --git a/src/Repositories/AfterEachRepository.php b/src/Repositories/AfterEachRepository.php index 0dec040e..affc4493 100644 --- a/src/Repositories/AfterEachRepository.php +++ b/src/Repositories/AfterEachRepository.php @@ -33,7 +33,7 @@ final class AfterEachRepository } /** - * Gets a after each closure by the given filename. + * Gets an after each closure by the given filename. */ public function get(string $filename): Closure { diff --git a/src/Repositories/RetryRepository.php b/src/Repositories/RetryRepository.php deleted file mode 100644 index e2a158c2..00000000 --- a/src/Repositories/RetryRepository.php +++ /dev/null @@ -1,91 +0,0 @@ -save([...$this->all(), ...[$element]]); - } - - /** - * Clears the existing file, if any, and re-creates it. - */ - public function boot(): void - { - @unlink(self::TEMPORARY_FOLDER.'/'.$this->filename.'.json'); // @phpstan-ignore-line - - $this->save([]); - } - - /** - * Checks if there is any element. - */ - public function isEmpty(): bool - { - return $this->all() === []; - } - - /** - * Checks if the given element exists. - */ - public function exists(string $element): bool - { - return in_array($element, $this->all(), true); - } - - /** - * Gets all elements. - * - * @return array - */ - private function all(): array - { - $path = self::TEMPORARY_FOLDER.'/'.$this->filename.'.json'; - - $contents = file_exists($path) ? file_get_contents($path) : '{}'; - - assert(is_string($contents)); - - $all = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); - - return is_array($all) ? $all : []; - } - - /** - * Save the given elements. - * - * @param array $elements - */ - private function save(array $elements): void - { - $contents = json_encode($elements, JSON_THROW_ON_ERROR); - - file_put_contents(self::TEMPORARY_FOLDER.'/'.$this->filename.'.json', $contents); - } -} diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php index e20b67ab..d698fbd8 100644 --- a/src/Repositories/TestRepository.php +++ b/src/Repositories/TestRepository.php @@ -125,6 +125,12 @@ final class TestRepository */ public function set(TestCaseMethodFactory $method): void { + foreach ($this->testCaseFilters as $filter) { + if (! $filter->accept($method->filename)) { + return; + } + } + foreach ($this->testCaseMethodFilters as $filter) { if (! $filter->accept($method)) { return; @@ -147,15 +153,13 @@ final class TestRepository return; } - $accepted = array_reduce( - $this->testCaseFilters, - fn (bool $carry, TestCaseFilter $filter): bool => $carry && $filter->accept($filename), - true, - ); - - if ($accepted) { - $this->make($this->testCases[$filename]); + foreach ($this->testCaseFilters as $filter) { + if (! $filter->accept($filename)) { + return; + } } + + $this->make($this->testCases[$filename]); } /** diff --git a/src/Subscribers/EnsureConfigurationDefaults.php b/src/Subscribers/EnsureConfigurationIsAvailable.php similarity index 54% rename from src/Subscribers/EnsureConfigurationDefaults.php rename to src/Subscribers/EnsureConfigurationIsAvailable.php index ce94089d..f23ee108 100644 --- a/src/Subscribers/EnsureConfigurationDefaults.php +++ b/src/Subscribers/EnsureConfigurationIsAvailable.php @@ -4,19 +4,21 @@ declare(strict_types=1); namespace Pest\Subscribers; +use Pest\Support\Container; use PHPUnit\Event\TestRunner\Configured; use PHPUnit\Event\TestRunner\ConfiguredSubscriber; +use PHPUnit\TextUI\Configuration\Configuration; /** * @internal */ -final class EnsureConfigurationDefaults implements ConfiguredSubscriber +final class EnsureConfigurationIsAvailable implements ConfiguredSubscriber { /** * Runs the subscriber. */ public function notify(Configured $event): void { - // TODO... + Container::getInstance()->add(Configuration::class, $event->configuration()); } } diff --git a/src/Subscribers/EnsureErroredTestsAreRetryable.php b/src/Subscribers/EnsureErroredTestsAreRetryable.php deleted file mode 100644 index 6eb2661d..00000000 --- a/src/Subscribers/EnsureErroredTestsAreRetryable.php +++ /dev/null @@ -1,23 +0,0 @@ -retryRepository->add($event->test()->id()); - } -} diff --git a/src/Subscribers/EnsureFailedTestsAreRetryable.php b/src/Subscribers/EnsureFailedTestsAreRetryable.php deleted file mode 100644 index 73468723..00000000 --- a/src/Subscribers/EnsureFailedTestsAreRetryable.php +++ /dev/null @@ -1,23 +0,0 @@ -retryRepository->add($event->test()->id()); - } -} diff --git a/src/Subscribers/EnsureRetryRepositoryExists.php b/src/Subscribers/EnsureRetryRepositoryExists.php deleted file mode 100644 index 91226b0c..00000000 --- a/src/Subscribers/EnsureRetryRepositoryExists.php +++ /dev/null @@ -1,23 +0,0 @@ -retryRepository->boot(); - } -} diff --git a/src/Subscribers/EnsureTeamCityEnabled.php b/src/Subscribers/EnsureTeamCityEnabled.php index 5da91a70..bd6b1bf2 100644 --- a/src/Subscribers/EnsureTeamCityEnabled.php +++ b/src/Subscribers/EnsureTeamCityEnabled.php @@ -21,8 +21,8 @@ final class EnsureTeamCityEnabled implements ConfiguredSubscriber * Creates a new Configured Subscriber instance. */ public function __construct( - private readonly OutputInterface $output, private readonly InputInterface $input, + private readonly OutputInterface $output, private readonly TestSuite $testSuite, ) { } diff --git a/src/Support/Backtrace.php b/src/Support/Backtrace.php index 5077fb5a..78d7cfc7 100644 --- a/src/Support/Backtrace.php +++ b/src/Support/Backtrace.php @@ -54,7 +54,9 @@ final class Backtrace foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) { assert(array_key_exists(self::FILE, $trace)); - if (Str::endsWith($trace['file'], 'Bootstrappers/BootFiles.php') || Str::endsWith($trace[self::FILE], 'overrides/Runner/TestSuiteLoader.php')) { + $traceFile = str_replace(DIRECTORY_SEPARATOR, '/', $trace[self::FILE]); + + if (Str::endsWith($traceFile, 'Bootstrappers/BootFiles.php') || Str::endsWith($traceFile, 'overrides/Runner/TestSuiteLoader.php')) { break; } diff --git a/src/Support/Container.php b/src/Support/Container.php index 3afa9818..af15d003 100644 --- a/src/Support/Container.php +++ b/src/Support/Container.php @@ -47,10 +47,14 @@ final class Container /** * Adds the given instance to the container. + * + * @return $this */ - public function add(string $id, object|string $instance): void + public function add(string $id, object|string $instance): self { $this->instances[$id] = $instance; + + return $this; } /** diff --git a/src/Support/Printer.php b/src/Support/Printer.php deleted file mode 100644 index 02f74d2f..00000000 --- a/src/Support/Printer.php +++ /dev/null @@ -1,62 +0,0 @@ -stream = fsockopen($tmp[0], (int) $tmp[1]); - $this->isOpen = true; - - return; - } - - $this->isPhpStream = str_starts_with($out, 'php://'); - - if (! $this->isPhpStream && ! Filesystem::createDirectory(dirname($out))) { - throw new Exception(sprintf('Directory "%s" was not created', dirname($out))); - } - - $this->stream = fopen($out, 'wb'); - $this->isOpen = true; - } - - final public function print(string $buffer): void - { - assert($this->isOpen); - assert($this->stream !== false); - - // @phpstan-ignore-next-line - fwrite($this->stream, $buffer); - } - - final public function flush(): void - { - if ($this->isOpen && $this->isPhpStream && $this->stream !== false) { - // @phpstan-ignore-next-line - fclose($this->stream); - - $this->isOpen = false; - } - } -} diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php new file mode 100644 index 00000000..7d06b7b4 --- /dev/null +++ b/src/Support/StateGenerator.php @@ -0,0 +1,105 @@ +testErroredEvents() as $testResultEvent) { + if ($testResultEvent instanceof Errored) { + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::FAIL, + $testResultEvent->throwable() + )); + } else { + $state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent)); + } + } + + foreach ($testResult->testFailedEvents() as $testResultEvent) { + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::FAIL, + $testResultEvent->throwable() + )); + } + + foreach ($testResult->testMarkedIncompleteEvents() as $testResultEvent) { + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::INCOMPLETE, + $testResultEvent->throwable() + )); + } + + foreach ($testResult->testConsideredRiskyEvents() as $riskyEvents) { + foreach ($riskyEvents as $riskyEvent) { + $state->add(TestResult::fromTestCase( + $riskyEvent->test(), + TestResult::RISKY, + Throwable::from(new IncompleteTestError($riskyEvent->message())) + )); + } + } + + foreach ($testResult->testSkippedEvents() as $testResultEvent) { + if ($testResultEvent->message() === '__TODO__') { + $state->add(TestResult::fromTestCase($testResultEvent->test(), TestResult::TODO)); + + continue; + } + + $state->add(TestResult::fromTestCase( + $testResultEvent->test(), + TestResult::SKIPPED, + Throwable::from(new SkippedWithMessageException($testResultEvent->message())) + )); + } + + $numberOfPassedTests = $testResult->numberOfTestsRun() + - $testResult->numberOfTestErroredEvents() + - $testResult->numberOfTestFailedEvents() + - $testResult->numberOfTestSkippedEvents() + - $testResult->numberOfTestsWithTestConsideredRiskyEvents() + - $testResult->numberOfTestMarkedIncompleteEvents(); + + for ($i = 0; $i < $numberOfPassedTests; $i++) { + $state->add(TestResult::fromTestCase( + + new TestMethod( + /** @phpstan-ignore-next-line */ + "$i", + /** @phpstan-ignore-next-line */ + '', + '', + 1, + /** @phpstan-ignore-next-line */ + TestDox::fromClassNameAndMethodName('', ''), + MetadataCollection::fromArray([]), + TestDataCollection::fromArray([]) + ), + TestResult::PASS + )); + } + + return $state; + } +} diff --git a/src/TestCaseFilters/GitDirtyTestCaseFilter.php b/src/TestCaseFilters/GitDirtyTestCaseFilter.php index 3083c3f1..c38e49c7 100644 --- a/src/TestCaseFilters/GitDirtyTestCaseFilter.php +++ b/src/TestCaseFilters/GitDirtyTestCaseFilter.php @@ -6,7 +6,8 @@ namespace Pest\TestCaseFilters; use Pest\Contracts\TestCaseFilter; use Pest\Exceptions\MissingDependency; -use Pest\Exceptions\NoTestsFound; +use Pest\Exceptions\NoDirtyTestsFound; +use Pest\Panic; use Pest\TestSuite; use Symfony\Component\Process\Process; @@ -66,7 +67,7 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter $dirtyFiles = array_values($dirtyFiles); if ($dirtyFiles === []) { - throw new NoTestsFound(); + Panic::with(new NoDirtyTestsFound()); } $this->changedFiles = $dirtyFiles; diff --git a/src/TestSuite.php b/src/TestSuite.php index bdec4d4e..68bc842f 100644 --- a/src/TestSuite.php +++ b/src/TestSuite.php @@ -9,7 +9,6 @@ use Pest\Repositories\AfterAllRepository; use Pest\Repositories\AfterEachRepository; use Pest\Repositories\BeforeAllRepository; use Pest\Repositories\BeforeEachRepository; -use Pest\Repositories\RetryRepository; use Pest\Repositories\TestRepository; use PHPUnit\Framework\TestCase; @@ -48,11 +47,6 @@ final class TestSuite */ public AfterAllRepository $afterAll; - /** - * Holds the retry repository. - */ - public RetryRepository $retryRepository; - /** * Holds the root path. */ @@ -75,7 +69,6 @@ final class TestSuite $this->tests = new TestRepository(); $this->afterEach = new AfterEachRepository(); $this->afterAll = new AfterAllRepository(); - $this->retryRepository = new RetryRepository('retry'); $this->rootPath = (string) realpath($rootPath); } diff --git a/tests/.snapshots/Failure.php.inc b/tests/.snapshots/Failure.php.inc index 5a422a49..2acc8cc8 100644 --- a/tests/.snapshots/Failure.php.inc +++ b/tests/.snapshots/Failure.php.inc @@ -1,6 +1,6 @@ -##teamcity[testSuiteStarted name='Tests\tests\Failure' locationHint='file://tests/.tests/Failure.php' flowId='1234'] +##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='file://tests/.tests/Failure.php' flowId='1234'] ##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234'] -##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at src/Mixins/Expectation.php:312|nat src/Support/ExpectationPipeline.php:75|nat src/Support/ExpectationPipeline.php:79|nat src/Expectation.php:300|nat tests/.tests/Failure.php:6|nat src/Factories/TestCaseMethodFactory.php:106|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:79' type='comparisonFailure' actual='true' expected='false' flowId='1234'] +##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at src/Mixins/Expectation.php:342|nat src/Support/ExpectationPipeline.php:75|nat src/Support/ExpectationPipeline.php:79|nat src/Expectation.php:300|nat tests/.tests/Failure.php:6|nat src/Factories/TestCaseMethodFactory.php:105|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:89' type='comparisonFailure' actual='true' expected='false' flowId='1234'] ##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234'] ##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234'] ##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234'] @@ -9,7 +9,7 @@ ##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234'] ##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234'] ##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234'] -##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18|nat src/Factories/TestCaseMethodFactory.php:106|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:79' flowId='1234'] +##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18|nat src/Factories/TestCaseMethodFactory.php:105|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:89' flowId='1234'] ##teamcity[testFinished name='it can fail' duration='100000' flowId='1234'] ##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234'] ##teamcity[testIgnored name='it is not done yet' message='This test was ignored.' details='' flowId='1234'] @@ -17,7 +17,7 @@ ##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234'] ##teamcity[testIgnored name='build this one.' message='This test was ignored.' details='' flowId='1234'] ##teamcity[testFinished name='build this one.' duration='100000' flowId='1234'] -##teamcity[testSuiteFinished name='Tests\tests\Failure' flowId='1234'] +##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234'] Tests: 2 failed, 1 risky, 2 todos, 1 skipped (2 assertions) Duration: 1.00s diff --git a/tests/.snapshots/SuccessOnly.php.inc b/tests/.snapshots/SuccessOnly.php.inc index cdcb71a9..21e8a362 100644 --- a/tests/.snapshots/SuccessOnly.php.inc +++ b/tests/.snapshots/SuccessOnly.php.inc @@ -1,9 +1,9 @@ -##teamcity[testSuiteStarted name='Tests\tests\SuccessOnly' locationHint='file://tests/.tests/SuccessOnly.php' flowId='1234'] +##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='file://tests/.tests/SuccessOnly.php' flowId='1234'] ##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234'] ##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234'] ##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234'] ##teamcity[testFinished name='can also pass' duration='100000' flowId='1234'] -##teamcity[testSuiteFinished name='Tests\tests\SuccessOnly' flowId='1234'] +##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234'] Tests: 2 passed (2 assertions) Duration: 1.00s diff --git a/tests/.snapshots/help-command.txt b/tests/.snapshots/help-command.txt index b291ec65..4327381e 100644 --- a/tests/.snapshots/help-command.txt +++ b/tests/.snapshots/help-command.txt @@ -49,7 +49,6 @@ --fail-on-risky .............................. Treat risky tests as failures --fail-on-skipped .......................... Treat skipped tests as failures --fail-on-warning .................... Treat tests with warnings as failures - --repeat ...................................... Runs the test(s) repeatedly --cache-result ............................ Write test results to cache file --do-not-cache-result .............. Do not write test results to cache file --order-by Run tests in order: default|defects|duration|no-depends|random|reverse|size @@ -78,7 +77,6 @@ --log-teamcity .............. Log test execution in TeamCity format to file --testdox-html ................. Write documentation in HTML format to file --testdox-text ................. Write documentation in Text format to file - --testdox-xml ................... Write documentation in XML format to file --log-events-text ..................... Stream events as plain text to file --log-events-verbose-text Stream events as plain text to file (with telemetry information) --no-logging .................................. Ignore logging configuration diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 5fa5e48c..d38b77ad 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -29,7 +29,7 @@ ✓ it does not append CoversNothing to other methods ✓ it throws exception if no class nor method has been found - PASS Tests\Features\DatasetsTests + PASS Tests\Features\DatasetsTests - 1 todo ✓ it throws exception if dataset does not exist ✓ it throws exception if dataset already exist ✓ it sets closures @@ -556,6 +556,18 @@ ✓ it fails ✓ it fails with message + PASS Tests\Features\Expect\toHaveMethod + ✓ pass + ✓ failures + ✓ failures with message + ✓ not failures + + PASS Tests\Features\Expect\toHaveMethods + ✓ pass + ✓ failures + ✓ failures with custom message + ✓ not failures + PASS Tests\Features\Expect\toHaveProperties ✓ pass ✓ failures @@ -732,7 +744,7 @@ ✓ it allows access to the underlying expectNotToPerformAssertions method ✓ it allows performing no expectations without being risky - PASS Tests\Features\Todo + PASS Tests\Features\Todo - 3 todos ↓ something todo later ↓ something todo later chained ↓ something todo later chained and with function body @@ -833,7 +845,7 @@ ✓ environment is set to Local when --ci option is not used PASS Tests\Unit\Plugins\Retry - ✓ it retries if --retry argument is used + ✓ it orders by defects and stop on defects if when --retry is used PASS Tests\Unit\Support\Backtrace ✓ it gets file name from called file @@ -886,6 +898,9 @@ PASS Tests\Visual\Help ✓ visual snapshot of help command output + PASS Tests\Visual\Parallel + ✓ parallel + PASS Tests\Visual\SingleTestOrDirectory ✓ allows to run a single test ✓ allows to run a directory @@ -904,4 +919,5 @@ PASS Tests\Visual\Version ✓ visual snapshot of help command output - Tests: 4 incomplete, 4 todos, 18 skipped, 627 passed (1514 assertions) \ No newline at end of file + Tests: 4 incomplete, 4 todos, 18 skipped, 627 passed (1514 assertions) + diff --git a/tests/.snapshots/todo.txt b/tests/.snapshots/todo.txt index af7d2a8c..a3abcfe3 100644 --- a/tests/.snapshots/todo.txt +++ b/tests/.snapshots/todo.txt @@ -1,7 +1,7 @@ - PASS Tests\Features\DatasetsTests + TODO Tests\Features\DatasetsTests - 1 todo ↓ forbids to define tests in Datasets dirs and Datasets.php files - PASS Tests\Features\Todo + TODO Tests\Features\Todo - 3 todos ↓ something todo later ↓ something todo later chained ↓ something todo later chained and with function body diff --git a/tests/Features/Expect/toHaveMethod.php b/tests/Features/Expect/toHaveMethod.php new file mode 100644 index 00000000..421c1ddd --- /dev/null +++ b/tests/Features/Expect/toHaveMethod.php @@ -0,0 +1,28 @@ +toHaveMethod('foo') + ->and($object)->toHaveMethod('foo') + ->and($object)->not->toHaveMethod('fooNull'); +}); + +test('failures', function () use ($object) { + expect($object)->toHaveMethod('bar'); +})->throws(ExpectationFailedException::class); + +test('failures with message', function () use ($object) { + expect($object)->toHaveMethod(name: 'bar', message: 'oh no!'); +})->throws(ExpectationFailedException::class, 'oh no!'); + +test('not failures', function () use ($object) { + expect($object)->not->toHaveMethod('foo'); +})->throws(ExpectationFailedException::class); diff --git a/tests/Features/Expect/toHaveMethods.php b/tests/Features/Expect/toHaveMethods.php new file mode 100644 index 00000000..a02a3275 --- /dev/null +++ b/tests/Features/Expect/toHaveMethods.php @@ -0,0 +1,30 @@ +toHaveMethods(['foo', 'bar']); +}); + +test('failures', function () use ($object) { + expect($object)->toHaveMethods(['foo', 'bar', 'baz']); +})->throws(ExpectationFailedException::class); + +test('failures with custom message', function () use ($object) { + expect($object)->toHaveMethods(['foo', 'bar', 'baz'], 'oh no!'); +})->throws(ExpectationFailedException::class, 'oh no!'); + +test('not failures', function () use ($object) { + expect($object)->not->toHaveMethods(['foo', 'bar']); +})->throws(ExpectationFailedException::class); diff --git a/tests/Hooks/BeforeAllTest.php b/tests/Hooks/BeforeAllTest.php index 12524554..c687fbfb 100644 --- a/tests/Hooks/BeforeAllTest.php +++ b/tests/Hooks/BeforeAllTest.php @@ -1,11 +1,12 @@ calls baseline. This is because // two other tests are executed before this one due to filename ordering. $args = $_SERVER['argv'] ?? []; -$single = (isset($args[1]) && Str::endsWith(__FILE__, $args[1])) || ($_SERVER['PEST_PARALLEL'] ?? false); +$single = (isset($args[1]) && Str::endsWith(__FILE__, $args[1])) || Parallel::isWorker(); $offset = $single ? 0 : 2; uses()->beforeAll(function () use ($offset) { diff --git a/tests/Unit/Plugins/Retry.php b/tests/Unit/Plugins/Retry.php index e0033821..b7877769 100644 --- a/tests/Unit/Plugins/Retry.php +++ b/tests/Unit/Plugins/Retry.php @@ -2,14 +2,13 @@ use Pest\Plugins\Retry; -beforeEach(fn () => Retry::$retrying = false); - -afterEach(fn () => Retry::$retrying = false); - -it('retries if --retry argument is used', function () { +it('orders by defects and stop on defects if when --retry is used ', function () { $retry = new Retry(); - $retry->handleArguments(['--retry']); + $arguments = $retry->handleArguments(['--retry']); - expect(Retry::$retrying)->toBeTrue(); + expect($arguments)->toBe([ + '--order-by=defects', + '--stop-on-failure', + ]); }); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php new file mode 100644 index 00000000..5b622870 --- /dev/null +++ b/tests/Visual/Parallel.php @@ -0,0 +1,18 @@ + 'DefaultPrinter', 'COLLISION_IGNORE_DURATION' => 'true'], + ); + + $process->run(); + + return preg_replace('#\\x1b[[][^A-Za-z]*[A-Za-z]#', '', $process->getOutput()); +}; + +test('parallel', function () use ($run) { + expect($run())->toContain('Running 650 tests using 3 processes') + ->toContain('Tests: 4 incomplete, 4 todos, 15 skipped, 627 passed (1546 assertions)'); +}); diff --git a/tests/Visual/TeamCity.php b/tests/Visual/TeamCity.php index 0ea3bc08..c02ee21e 100644 --- a/tests/Visual/TeamCity.php +++ b/tests/Visual/TeamCity.php @@ -1,5 +1,12 @@ toEqual(file_get_contents($snapshot)); + expect(normalize_windows_os_output($output()))->toEqual(file_get_contents($snapshot)); } })->with([ 'Failure.php',