Merge branch '2.x' into feature/compact-dataset-description

This commit is contained in:
Nuno Maduro
2023-02-13 23:32:02 +00:00
committed by GitHub
66 changed files with 1904 additions and 452 deletions

3
.gitattributes vendored
View File

@ -1,4 +1,5 @@
/art export-ignore /art export-ignore
/docker export-ignore
/docs export-ignore /docs export-ignore
/tests export-ignore /tests export-ignore
/scripts export-ignore /scripts export-ignore
@ -11,5 +12,7 @@ phpstan.neon export-ignore
/phpunit.xml export-ignore /phpunit.xml export-ignore
CHANGELOG.md export-ignore CHANGELOG.md export-ignore
CONTRIBUTING.md export-ignore CONTRIBUTING.md export-ignore
docker-compose.yml export-ignore
Makefile export-ignore
README.md export-ignore README.md export-ignore

View File

@ -1,6 +1,10 @@
name: Static Analysis name: Static Analysis
on: ['push', 'pull_request'] on:
push:
pull_request:
schedule:
- cron: '0 0 * * *'
jobs: jobs:
static: static:

View File

@ -1,13 +1,18 @@
name: Tests name: Tests
on: ['push', 'pull_request'] on:
push:
pull_request:
schedule:
- cron: '0 0 * * *'
jobs: jobs:
ci: ci:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: 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'] php: ['8.1', '8.2']
dependency-version: [prefer-lowest, prefer-stable] dependency-version: [prefer-lowest, prefer-stable]
parallel: ['', '--parallel'] parallel: ['', '--parallel']

2
.gitignore vendored
View File

@ -1,5 +1,6 @@
.idea/* .idea/*
.idea/codeStyleSettings.xml .idea/codeStyleSettings.xml
.temp/*
composer.lock composer.lock
/vendor/ /vendor/
coverage.xml coverage.xml
@ -8,7 +9,6 @@ coverage.xml
/.php-cs-fixer.php /.php-cs-fixer.php
.php-cs-fixer.cache .php-cs-fixer.cache
.temp/coverage.php .temp/coverage.php
.temp/retry.json
*.swp *.swp
*.swo *.swo
.vscode/ .vscode/

View File

@ -54,3 +54,22 @@ Integration tests:
```bash ```bash
composer test:integration 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.

14
Makefile Normal file
View File

@ -0,0 +1,14 @@
# Well documented Makefiles
DEFAULT_GOAL := help
help:
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\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

View File

@ -17,6 +17,7 @@ use Symfony\Component\Console\Output\OutputInterface;
$_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter'; $_SERVER['COLLISION_PRINTER'] = 'DefaultPrinter';
$args = $_SERVER['argv']; $args = $_SERVER['argv'];
$dirty = false; $dirty = false;
$todo = false; $todo = false;
@ -68,11 +69,11 @@ use Symfony\Component\Console\Output\OutputInterface;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $rootPath = dirname($autoloadPath, 2);
$argv = new ArgvInput(); $input = new ArgvInput();
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
$rootPath, $rootPath,
$argv->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()), $input->getParameterOption('--test-directory', (new ConfigLoader($rootPath))->getTestsDirectory()),
); );
if ($dirty) { if ($dirty) {
@ -83,19 +84,13 @@ use Symfony\Component\Console\Output\OutputInterface;
$testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter()); $testSuite->tests->addTestCaseMethodFilter(new TodoTestCaseFilter());
} }
$isDecorated = $argv->getParameterOption('--colors', 'always') !== 'never'; $isDecorated = $input->getParameterOption('--colors', 'always') !== 'never';
$output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated); $output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, $isDecorated);
$container = Container::getInstance(); $kernel = Kernel::boot($testSuite, $input, $output);
$container->add(TestSuite::class, $testSuite);
$container->add(OutputInterface::class, $output);
$container->add(InputInterface::class, $argv);
$container->add(Container::class, $container);
$kernel = Kernel::boot(); $result = $kernel->handle($args);
$result = $kernel->handle($output, $args);
$kernel->shutdown(); $kernel->shutdown();

101
bin/worker.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\ConfigLoader;
use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
$bootPest = (static function (): void {
$workerArgv = new ArgvInput();
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
$testSuite = TestSuite::getInstance($rootPath, $workerArgv->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);
}
})();

View File

@ -18,10 +18,13 @@
], ],
"require": { "require": {
"php": "^8.1.0", "php": "^8.1.0",
"nunomaduro/collision": "^7.0.0", "nunomaduro/collision": "^7.0.2",
"nunomaduro/termwind": "^1.15", "nunomaduro/termwind": "^1.15.1",
"pestphp/pest-plugin": "^2.0.0", "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", "version": "2.x-dev",
"autoload": { "autoload": {
@ -43,9 +46,10 @@
] ]
}, },
"require-dev": { "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", "pestphp/pest-plugin-arch": "^2.0.0",
"symfony/process": "^6.2.0" "symfony/process": "^6.2.5"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
@ -81,6 +85,7 @@
"extra": { "extra": {
"pest": { "pest": {
"plugins": [ "plugins": [
"Pest\\Plugins\\Cache",
"Pest\\Plugins\\Coverage", "Pest\\Plugins\\Coverage",
"Pest\\Plugins\\Init", "Pest\\Plugins\\Init",
"Pest\\Plugins\\Environment", "Pest\\Plugins\\Environment",
@ -88,7 +93,8 @@
"Pest\\Plugins\\Memory", "Pest\\Plugins\\Memory",
"Pest\\Plugins\\Printer", "Pest\\Plugins\\Printer",
"Pest\\Plugins\\Retry", "Pest\\Plugins\\Retry",
"Pest\\Plugins\\Version" "Pest\\Plugins\\Version",
"Pest\\Plugins\\Parallel"
] ]
} }
} }

14
docker-compose.yml Normal file
View File

@ -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"]

23
docker/Dockerfile Normal file
View File

@ -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"]

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <sebastian@phpunit.de>
*
* 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');
}
}

View File

@ -12,10 +12,10 @@ parameters:
reportUnmatchedIgnoredErrors: true reportUnmatchedIgnoredErrors: true
ignoreErrors: ignoreErrors:
- '#Cannot instantiate interface PHPUnit\\Util\\Exception#' - "#Language construct isset\\(\\) should not be used.#"
- "#is not allowed to extend#"
- "#with a nullable type declaration#" - "#with a nullable type declaration#"
- "#type mixed is not subtype of native#" - "#type mixed is not subtype of native#"
- "#is not allowed to extend#"
- "# with null as default value#" - "# with null as default value#"
- "#has parameter \\$closure with default value.#" - "#has parameter \\$closure with default value.#"
- "#has parameter \\$description with default value.#" - "#has parameter \\$description with default value.#"

View File

@ -5,14 +5,12 @@
beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true" beStrictAboutOutputDuringTests="true"
bootstrap="vendor/autoload.php" bootstrap="vendor/autoload.php"
cacheResult="false"
colors="true" colors="true"
failOnRisky="true" failOnRisky="true"
failOnWarning="true" failOnWarning="true"
processIsolation="false" processIsolation="false"
stopOnError="false" stopOnError="false"
stopOnFailure="false" stopOnFailure="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false" backupStaticProperties="false"
displayDetailsOnIncompleteTests="true" displayDetailsOnIncompleteTests="true"
displayDetailsOnSkippedTests="true" displayDetailsOnSkippedTests="true"

View File

@ -13,7 +13,7 @@ use Pest\Contracts\Bootstrapper;
final class BootExceptionHandler implements Bootstrapper final class BootExceptionHandler implements Bootstrapper
{ {
/** /**
* Boots the Exception Handler. * Boots the "Collision" exception handler.
*/ */
public function boot(): void public function boot(): void
{ {

View File

@ -19,7 +19,7 @@ use SebastianBergmann\FileIterator\Facade as PhpUnitFileIterator;
final class BootFiles implements Bootstrapper final class BootFiles implements Bootstrapper
{ {
/** /**
* The Pest convention. * The structure of the tests directory.
* *
* @var array<int, string> * @var array<int, string>
*/ */
@ -32,7 +32,7 @@ final class BootFiles implements Bootstrapper
]; ];
/** /**
* Boots the Subscribers. * Boots the structure of the tests directory.
*/ */
public function boot(): void public function boot(): void
{ {

View File

@ -20,10 +20,11 @@ final class BootOverrides implements Bootstrapper
private const FILES = [ private const FILES = [
'Runner/Filter/NameFilterIterator.php', 'Runner/Filter/NameFilterIterator.php',
'Runner/TestSuiteLoader.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 public function boot(): void
{ {

View File

@ -16,21 +16,18 @@ use PHPUnit\Event\Subscriber;
final class BootSubscribers implements Bootstrapper final class BootSubscribers implements Bootstrapper
{ {
/** /**
* The Kernel subscribers. * The list of Subscribers.
* *
* @var array<int, class-string<Subscriber>> * @var array<int, class-string<Subscriber>>
*/ */
private const SUBSCRIBERS = [ private const SUBSCRIBERS = [
Subscribers\EnsureConfigurationIsValid::class, Subscribers\EnsureConfigurationIsValid::class,
Subscribers\EnsureConfigurationDefaults::class, Subscribers\EnsureConfigurationIsAvailable::class,
Subscribers\EnsureRetryRepositoryExists::class,
Subscribers\EnsureErroredTestsAreRetryable::class,
Subscribers\EnsureFailedTestsAreRetryable::class,
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
]; ];
/** /**
* Creates a new Subscriber instance. * Creates a new instance of the Boot Subscribers.
*/ */
public function __construct( public function __construct(
private readonly Container $container, 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 public function boot(): void
{ {

View File

@ -13,6 +13,9 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class BootView implements Bootstrapper final class BootView implements Bootstrapper
{ {
/**
* Creates a new instance of the Boot View.
*/
public function __construct( public function __construct(
private readonly OutputInterface $output private readonly OutputInterface $output
) { ) {

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
interface Panicable
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void;
/**
* The exit code to be used.
*/
public function exitCode(): int;
}

View File

@ -12,7 +12,7 @@ use Symfony\Component\Console\Exception\ExceptionInterface;
/** /**
* @internal * @internal
*/ */
final class InvalidConsoleArgument extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace final class InvalidOption extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{ {
/** /**
* Creates a new Exception instance. * Creates a new Exception instance.

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable;
use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class NoDirtyTestsFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace, Panicable
{
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void
{
$output->writeln([
'',
' <fg=white;options=bold;bg=blue> INFO </> No "dirty" tests found.',
'',
]);
}
/**
* The exit code to be used.
*/
public function exitCode(): int
{
return 0;
}
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Exceptions;
use InvalidArgumentException;
use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Symfony\Component\Console\Exception\ExceptionInterface;
/**
* @internal
*/
final class NoTestsFound extends InvalidArgumentException implements ExceptionInterface, RenderlessEditor, RenderlessTrace
{
/**
* Creates a new Exception instance.
*/
public function __construct()
{
parent::__construct('No tests found.');
// ...
}
}

View File

@ -8,7 +8,6 @@ use Closure;
use Pest\Contracts\AddsAnnotations; use Pest\Contracts\AddsAnnotations;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Factories\Concerns\HigherOrderable; use Pest\Factories\Concerns\HigherOrderable;
use Pest\Plugins\Retry;
use Pest\Repositories\DatasetsRepository; use Pest\Repositories\DatasetsRepository;
use Pest\Support\Str; use Pest\Support\Str;
use Pest\TestSuite; use Pest\TestSuite;
@ -129,12 +128,6 @@ final class TestCaseMethodFactory
$methodName = Str::evaluable($this->description); $methodName = Str::evaluable($this->description);
$retryRepository = TestSuite::getInstance()->retryRepository;
if (Retry::$retrying && ! $retryRepository->isEmpty() && ! $retryRepository->exists(sprintf('%s::%s', $classFQN, $methodName))) {
return '';
}
$datasetsCode = ''; $datasetsCode = '';
$annotations = ['@test']; $annotations = ['@test'];
$attributes = []; $attributes = [];
@ -188,7 +181,7 @@ final class TestCaseMethodFactory
return <<<EOF return <<<EOF
public function $dataProviderName() public static function $dataProviderName()
{ {
return __PestDatasets::get(self::\$__filename, "$methodName"); return __PestDatasets::get(self::\$__filename, "$methodName");
} }

View File

@ -5,13 +5,13 @@ declare(strict_types=1);
namespace Pest; namespace Pest;
use Pest\Contracts\Bootstrapper; use Pest\Contracts\Bootstrapper;
use Pest\Exceptions\NoTestsFound; use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Plugins\Actions\CallsAddsOutput; use Pest\Plugins\Actions\CallsAddsOutput;
use Pest\Plugins\Actions\CallsBoot; use Pest\Plugins\Actions\CallsBoot;
use Pest\Plugins\Actions\CallsShutdown; use Pest\Plugins\Actions\CallsShutdown;
use Pest\Support\Container; use Pest\Support\Container;
use PHPUnit\TextUI\Application; use PHPUnit\TextUI\Application;
use PHPUnit\TextUI\Exception; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
/** /**
@ -36,7 +36,8 @@ final class Kernel
* Creates a new Kernel instance. * Creates a new Kernel instance.
*/ */
public function __construct( public function __construct(
private readonly Application $application private readonly Application $application,
private readonly OutputInterface $output,
) { ) {
register_shutdown_function(function (): void { register_shutdown_function(function (): void {
if (error_get_last() !== null) { if (error_get_last() !== null) {
@ -50,8 +51,16 @@ final class Kernel
/** /**
* Boots the Kernel. * Boots the Kernel.
*/ */
public static function boot(): self public static function boot(TestSuite $testSuite, InputInterface $input, OutputInterface $output): self
{ {
$container = Container::getInstance();
$container
->add(TestSuite::class, $testSuite)
->add(InputInterface::class, $input)
->add(OutputInterface::class, $output)
->add(Container::class, $container);
foreach (self::BOOTSTRAPPERS as $bootstrapper) { foreach (self::BOOTSTRAPPERS as $bootstrapper) {
$bootstrapper = Container::getInstance()->get($bootstrapper); $bootstrapper = Container::getInstance()->get($bootstrapper);
assert($bootstrapper instanceof Bootstrapper); assert($bootstrapper instanceof Bootstrapper);
@ -61,24 +70,25 @@ final class Kernel
(new CallsBoot())->__invoke(); (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<int, string> $argv * @param array<int, string> $args
*
* @throws Exception
*/ */
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 { try {
$this->application->run($argv); $this->application->run($args);
} catch (NoTestsFound) { } catch (NoDirtyTestsFound) {
$output->writeln([ $this->output->writeln([
'', '',
' <fg=white;options=bold;bg=blue> INFO </> No tests found.', ' <fg=white;options=bold;bg=blue> INFO </> No tests found.',
'', '',

View File

@ -5,20 +5,14 @@ declare(strict_types=1);
namespace Pest\Logging\TeamCity; namespace Pest\Logging\TeamCity;
use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
use Pest\Exceptions\ShouldNotHappen; use Pest\Exceptions\ShouldNotHappen;
use Pest\Support\StateGenerator;
use Pest\Support\Str; use Pest\Support\Str;
use PHPUnit\Event\Code\Test; use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestDox;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\TestData\TestDataCollection;
use PHPUnit\Event\TestSuite\TestSuite; use PHPUnit\Event\TestSuite\TestSuite;
use PHPUnit\Framework\Exception as FrameworkException; 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; use PHPUnit\TestRunner\TestResult\TestResult as PhpUnitTestResult;
/** /**
@ -28,12 +22,15 @@ final class Converter
{ {
private const PREFIX = 'P\\'; private const PREFIX = 'P\\';
private readonly StateGenerator $stateGenerator;
/** /**
* Creates a new instance of the Converter. * Creates a new instance of the Converter.
*/ */
public function __construct( public function __construct(
private readonly string $rootPath, private readonly string $rootPath,
) { ) {
$this->stateGenerator = new StateGenerator();
} }
/** /**
@ -175,7 +172,7 @@ final class Converter
private function toRelativePath(string $path): string private function toRelativePath(string $path): string
{ {
// Remove cwd from the path. // 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 public function getStateFromResult(PhpUnitTestResult $result): State
{ {
$state = new State(); return $this->stateGenerator->fromPhpUnitTestResult($result);
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;
} }
} }

View File

@ -211,7 +211,7 @@ final class TeamCityLogger
); );
} }
$style->writeRecap($state, $telemetry); $style->writeRecap($state, $telemetry, $result);
} }
public function output(ServiceMessage $message): void public function output(ServiceMessage $message): void

View File

@ -302,6 +302,36 @@ final class Expectation
return $this; return $this;
} }
/**
* Asserts that the value has the method $name.
*
* @return self<TValue>
*/
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<array-key, string> $names
* @return self<TValue>
*/
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. * Asserts that two variables have the same value.
* *

60
src/Panic.php Normal file
View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Pest;
use NunoMaduro\Collision\Writer;
use Pest\Support\Container;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use Whoops\Exception\Inspector;
final class Panic
{
/**
* Creates a new Panic instance.
*/
private function __construct(
private readonly Throwable $throwable
) {
// ...
}
/**
* Creates a new Panic instance, and exits the application.
*/
public static function with(Throwable $throwable): never
{
$panic = new self($throwable);
$panic->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);
}
}

44
src/Plugins/Cache.php Normal file
View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Concerns\HandleArguments;
/**
* @internal
*/
final class Cache implements HandlesArguments
{
use HandleArguments;
/**
* The temporary folder.
*/
private const TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'.temp';
/**
* Handles the arguments, adding the cache directory and the cache result arguments.
*/
public function handleArguments(array $arguments): array
{
if (! $this->hasArgument('--parallel', $arguments)) {
$arguments = $this->pushArgument(
sprintf('--cache-directory=%s', realpath(self::TEMPORARY_FOLDER)),
$arguments
);
$arguments = $this->pushArgument('--cache-result', $arguments);
}
return $arguments;
}
}

View File

@ -55,6 +55,7 @@ final class Coverage implements AddsOutput, HandlesArguments
if ($original === sprintf('--%s', $option)) { if ($original === sprintf('--%s', $option)) {
return true; return true;
} }
if (Str::startsWith($original, sprintf('--%s=', $option))) { if (Str::startsWith($original, sprintf('--%s=', $option))) {
return true; return true;
} }

190
src/Plugins/Parallel.php Normal file
View File

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use ParaTest\ParaTestCommand;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Actions\CallsAddsOutput;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Contracts\HandlersWorkerArguments;
use Pest\Plugins\Parallel\Paratest\CleanConsoleOutput;
use Pest\Support\Arr;
use Pest\Support\Container;
use Pest\TestSuite;
use function Pest\version;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\OutputInterface;
final class Parallel implements HandlesArguments
{
use HandleArguments;
private const HANDLERS = [
Parallel\Handlers\Parallel::class,
Parallel\Handlers\Pest::class,
Parallel\Handlers\Laravel::class,
];
/**
* @var string[]
*/
private const UNSUPPORTED_ARGUMENTS = ['--todo', '--retry'];
/**
* Whether the given command line arguments indicate that the test suite should be run in parallel.
*/
public static function isEnabled(): bool
{
$argv = new ArgvInput();
if ($argv->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<int, string> $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<int, string> $arguments
* @return array<int, string>
*/
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([
'<fg=red>Pest Parallel requires ParaTest to run.</>',
'Please run <fg=yellow>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<int, string> $arguments
* @return array<int, string>
*/
private function runTestSuiteInSeries(array $arguments): array
{
$arguments = $this->popArgument('--parallel', $arguments);
return $this->popArgument('-p', $arguments);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Contracts;
interface HandlersWorkerArguments
{
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
public function handleWorkerArguments(array $arguments): array;
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Handlers;
use Closure;
use Composer\InstalledVersions;
use Illuminate\Testing\ParallelRunner;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Paratest\WrapperRunner;
use Symfony\Component\Console\Output\OutputInterface;
/**
* @internal
*/
final class Laravel implements HandlesArguments
{
use HandleArguments;
/**
* {@inheritdoc}
*/
public function handleArguments(array $arguments): array
{
return self::whenUsingLaravel($arguments, function (array $arguments): array {
$this->ensureRunnerIsResolvable();
$arguments = $this->ensureEnvironmentVariables($arguments);
return $this->ensureRunner($arguments);
});
}
/**
* Executes the given closure when running Laravel.
*
* @param array<int, string> $arguments
* @param CLosure(array<int, string>): array<int, string> $closure
* @return array<int, string>
*/
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<int, string> $arguments
* @return array<int, string>
*/
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<int, string> $arguments
* @return array<int, string>
*/
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);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Handlers;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Paratest\WrapperRunner;
/**
* @internal
*/
final class Parallel implements HandlesArguments
{
use HandleArguments;
/**
* The list of arguments to remove.
*/
private const ARGS_TO_REMOVE = [
'--parallel',
'-p',
'--no-output',
'--cache-result',
];
/**
* Handles the arguments, removing the ones that are not needed, and adds the "runner" argument.
*/
public function handleArguments(array $arguments): array
{
$args = array_reduce(self::ARGS_TO_REMOVE, fn ($args, $arg): array => $this->popArgument($arg, $args), $arguments);
return $this->pushArgument('--runner='.WrapperRunner::class, $args);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Handlers;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel\Contracts\HandlersWorkerArguments;
final class Pest implements HandlersWorkerArguments
{
use HandleArguments;
/**
* Handles the arguments, adding the "PEST_PARALLEL" environment variable to the global $_SERVER.
*/
public function handleWorkerArguments(array $arguments): array
{
$_SERVER['PEST_PARALLEL'] = '1';
return $arguments;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Paratest;
use Symfony\Component\Console\Output\ConsoleOutput;
final class CleanConsoleOutput extends ConsoleOutput
{
/**
* {@inheritdoc}
*/
protected function doWrite(string $message, bool $newline): void
{
if ($this->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.');
}
}

View File

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Paratest;
use function assert;
use function fclose;
use function feof;
use function fopen;
use function fread;
use function fseek;
use function ftell;
use function fwrite;
use ParaTest\Options;
use Pest\Plugins\Parallel\Support\CompactPrinter;
use Pest\Support\StateGenerator;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Output\Printer;
use function preg_replace;
use SebastianBergmann\Timer\Duration;
use SplFileInfo;
use function sprintf;
use function strlen;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;
/** @internal */
final class ResultPrinter
{
/**
* The "native" printer.
*/
public readonly Printer $printer;
/**
* The "compact" printer.
*/
private readonly CompactPrinter $compactPrinter;
/** @var resource|null */
private $teamcityLogFileHandle;
/** @var array<string, int> */
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<int, SplFileInfo> $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<int, SplFileInfo> $teamcityFiles
* @param array<int, SplFileInfo> $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<int, SplFileInfo> $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;
}
}

View File

@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Paratest;
use function array_merge;
use function array_merge_recursive;
use function array_shift;
use function assert;
use function count;
use const DIRECTORY_SEPARATOR;
use function dirname;
use function file_get_contents;
use function max;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use ParaTest\WrapperRunner\SuiteLoader;
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\TestSuite;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\ShellExitCodeCalculator;
use PHPUnit\Util\ExcludeList;
use function realpath;
use SebastianBergmann\Timer\Timer;
use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use function unlink;
use function unserialize;
use function usleep;
/**
* @internal
*/
final class WrapperRunner implements RunnerInterface
{
private const CYCLE_SLEEP = 10000;
private readonly ResultPrinter $printer;
private readonly Timer $timer;
/** @var array<int, string> */
private array $pending = [];
private int $exitCode = -1;
/** @var array<int,WrapperWorker> */
private array $workers = [];
/** @var array<int,int> */
private array $batches = [];
/** @var array<int, SplFileInfo> */
private array $testresultFiles = [];
/** @var array<int, SplFileInfo> */
private array $coverageFiles = [];
/** @var array<int, SplFileInfo> */
private array $junitFiles = [];
/** @var array<int, SplFileInfo> */
private array $teamcityFiles = [];
/** @var array<int, SplFileInfo> */
private array $testdoxFiles = [];
/** @var array<int, string> */
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<non-empty-string> $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<int, SplFileInfo> $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<int, string>
*/
private function getTestFiles(SuiteLoader $suiteLoader): array
{
$this->debug(sprintf('Found %d test file%s', count($suiteLoader->files), count($suiteLoader->files) === 1 ? '' : 's'));
/** @var array<string, string> $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(" <fg=blue>{$message}</> ");
}
}
}

View File

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Parallel\Support;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\Style;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\Telemetry\Info;
use PHPUnit\Event\Telemetry\MemoryUsage;
use PHPUnit\Event\Telemetry\Snapshot;
use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult;
use SebastianBergmann\Timer\Duration;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use function Termwind\render;
use Termwind\Terminal;
use function Termwind\terminal;
/**
* @internal
*/
final class CompactPrinter
{
/**
* The number of processed tests.
*/
private int $processed = 0;
/**
* @var array<string, array<int, string>>
*/
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('<div class="py-1"></div>');
}
/**
* Write the given message to the console, adding vertical and horizontal padding.
*/
public function line(string $message): void
{
render("<span class='mx-2 py-1 text-gray-700'>{$message}</span>");
}
/**
* 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('<fg=%s;options=bold>%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);
}
}

View File

@ -13,18 +13,19 @@ final class Retry implements HandlesArguments
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
/**
* Whether it should show retry or not.
*/
public static bool $retrying = false;
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
public function handleArguments(array $arguments): array 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);
} }
} }

View File

@ -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 public function get(string $filename): Closure
{ {

View File

@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Repositories;
/**
* @internal
*/
final class RetryRepository
{
private const TEMPORARY_FOLDER = __DIR__
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'..'
.DIRECTORY_SEPARATOR
.'.temp';
/**
* Creates a new Temp Repository instance.
*/
public function __construct(private readonly string $filename)
{
// ..
}
/**
* Adds a new element.
*/
public function add(string $element): void
{
$this->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<int, string>
*/
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<int, string> $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);
}
}

View File

@ -125,6 +125,12 @@ final class TestRepository
*/ */
public function set(TestCaseMethodFactory $method): void public function set(TestCaseMethodFactory $method): void
{ {
foreach ($this->testCaseFilters as $filter) {
if (! $filter->accept($method->filename)) {
return;
}
}
foreach ($this->testCaseMethodFilters as $filter) { foreach ($this->testCaseMethodFilters as $filter) {
if (! $filter->accept($method)) { if (! $filter->accept($method)) {
return; return;
@ -147,15 +153,13 @@ final class TestRepository
return; return;
} }
$accepted = array_reduce( foreach ($this->testCaseFilters as $filter) {
$this->testCaseFilters, if (! $filter->accept($filename)) {
fn (bool $carry, TestCaseFilter $filter): bool => $carry && $filter->accept($filename), return;
true, }
);
if ($accepted) {
$this->make($this->testCases[$filename]);
} }
$this->make($this->testCases[$filename]);
} }
/** /**

View File

@ -4,19 +4,21 @@ declare(strict_types=1);
namespace Pest\Subscribers; namespace Pest\Subscribers;
use Pest\Support\Container;
use PHPUnit\Event\TestRunner\Configured; use PHPUnit\Event\TestRunner\Configured;
use PHPUnit\Event\TestRunner\ConfiguredSubscriber; use PHPUnit\Event\TestRunner\ConfiguredSubscriber;
use PHPUnit\TextUI\Configuration\Configuration;
/** /**
* @internal * @internal
*/ */
final class EnsureConfigurationDefaults implements ConfiguredSubscriber final class EnsureConfigurationIsAvailable implements ConfiguredSubscriber
{ {
/** /**
* Runs the subscriber. * Runs the subscriber.
*/ */
public function notify(Configured $event): void public function notify(Configured $event): void
{ {
// TODO... Container::getInstance()->add(Configuration::class, $event->configuration());
} }
} }

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\TestSuite;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final class EnsureErroredTestsAreRetryable implements ErroredSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Errored $event): void
{
TestSuite::getInstance()->retryRepository->add($event->test()->id());
}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\TestSuite;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class EnsureFailedTestsAreRetryable implements FailedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Failed $event): void
{
TestSuite::getInstance()->retryRepository->add($event->test()->id());
}
}

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\TestSuite;
use PHPUnit\Event\TestRunner\Started;
use PHPUnit\Event\TestRunner\StartedSubscriber;
/**
* @internal
*/
final class EnsureRetryRepositoryExists implements StartedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Started $event): void
{
TestSuite::getInstance()->retryRepository->boot();
}
}

View File

@ -21,8 +21,8 @@ final class EnsureTeamCityEnabled implements ConfiguredSubscriber
* Creates a new Configured Subscriber instance. * Creates a new Configured Subscriber instance.
*/ */
public function __construct( public function __construct(
private readonly OutputInterface $output,
private readonly InputInterface $input, private readonly InputInterface $input,
private readonly OutputInterface $output,
private readonly TestSuite $testSuite, private readonly TestSuite $testSuite,
) { ) {
} }

View File

@ -54,7 +54,9 @@ final class Backtrace
foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) { foreach (debug_backtrace(self::BACKTRACE_OPTIONS) as $trace) {
assert(array_key_exists(self::FILE, $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; break;
} }

View File

@ -47,10 +47,14 @@ final class Container
/** /**
* Adds the given instance to the 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; $this->instances[$id] = $instance;
return $this;
} }
/** /**

View File

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use PHPUnit\Util\Exception;
use PHPUnit\Util\Filesystem;
abstract class Printer implements \PHPUnit\Util\Printer
{
/** @var resource|bool */
private $stream;
private readonly bool $isPhpStream;
private bool $isOpen;
private function __construct(string $out)
{
if (str_starts_with($out, 'socket://')) {
$tmp = explode(':', str_replace('socket://', '', $out));
if (count($tmp) !== 2) {
throw new Exception(sprintf('"%s" does not match "socket://hostname:port" format', $out));
}
$this->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;
}
}
}

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use NunoMaduro\Collision\Adapters\Phpunit\State;
use NunoMaduro\Collision\Adapters\Phpunit\TestResult;
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\Framework\IncompleteTestError;
use PHPUnit\Framework\SkippedWithMessageException;
use PHPUnit\Metadata\MetadataCollection;
use PHPUnit\TestRunner\TestResult\TestResult as PHPUnitTestResult;
final class StateGenerator
{
public function fromPhpUnitTestResult(PHPUnitTestResult $testResult): State
{
$state = new State();
foreach ($testResult->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;
}
}

View File

@ -6,7 +6,8 @@ namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter; use Pest\Contracts\TestCaseFilter;
use Pest\Exceptions\MissingDependency; use Pest\Exceptions\MissingDependency;
use Pest\Exceptions\NoTestsFound; use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Panic;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
@ -66,7 +67,7 @@ final class GitDirtyTestCaseFilter implements TestCaseFilter
$dirtyFiles = array_values($dirtyFiles); $dirtyFiles = array_values($dirtyFiles);
if ($dirtyFiles === []) { if ($dirtyFiles === []) {
throw new NoTestsFound(); Panic::with(new NoDirtyTestsFound());
} }
$this->changedFiles = $dirtyFiles; $this->changedFiles = $dirtyFiles;

View File

@ -9,7 +9,6 @@ use Pest\Repositories\AfterAllRepository;
use Pest\Repositories\AfterEachRepository; use Pest\Repositories\AfterEachRepository;
use Pest\Repositories\BeforeAllRepository; use Pest\Repositories\BeforeAllRepository;
use Pest\Repositories\BeforeEachRepository; use Pest\Repositories\BeforeEachRepository;
use Pest\Repositories\RetryRepository;
use Pest\Repositories\TestRepository; use Pest\Repositories\TestRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@ -48,11 +47,6 @@ final class TestSuite
*/ */
public AfterAllRepository $afterAll; public AfterAllRepository $afterAll;
/**
* Holds the retry repository.
*/
public RetryRepository $retryRepository;
/** /**
* Holds the root path. * Holds the root path.
*/ */
@ -75,7 +69,6 @@ final class TestSuite
$this->tests = new TestRepository(); $this->tests = new TestRepository();
$this->afterEach = new AfterEachRepository(); $this->afterEach = new AfterEachRepository();
$this->afterAll = new AfterAllRepository(); $this->afterAll = new AfterAllRepository();
$this->retryRepository = new RetryRepository('retry');
$this->rootPath = (string) realpath($rootPath); $this->rootPath = (string) realpath($rootPath);
} }

View File

@ -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[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[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[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'] ##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[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[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[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[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[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'] ##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[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[testIgnored name='build this one.' message='This test was ignored.' details='' flowId='1234']
##teamcity[testFinished name='build this one.' duration='100000' 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) Tests: 2 failed, 1 risky, 2 todos, 1 skipped (2 assertions)
Duration: 1.00s Duration: 1.00s

View File

@ -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[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[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[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[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) Tests: 2 passed (2 assertions)
Duration: 1.00s Duration: 1.00s

View File

@ -49,7 +49,6 @@
--fail-on-risky .............................. Treat risky tests as failures --fail-on-risky .............................. Treat risky tests as failures
--fail-on-skipped .......................... Treat skipped tests as failures --fail-on-skipped .......................... Treat skipped tests as failures
--fail-on-warning .................... Treat tests with warnings as failures --fail-on-warning .................... Treat tests with warnings as failures
--repeat <times> ...................................... Runs the test(s) repeatedly
--cache-result ............................ Write test results to cache file --cache-result ............................ Write test results to cache file
--do-not-cache-result .............. Do not 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|duration|no-depends|random|reverse|size --order-by <order> Run tests in order: default|defects|duration|no-depends|random|reverse|size
@ -78,7 +77,6 @@
--log-teamcity <file> .............. Log test execution in TeamCity format to file --log-teamcity <file> .............. Log test execution in TeamCity format to file
--testdox-html <file> ................. Write documentation in HTML format to file --testdox-html <file> ................. Write documentation in HTML format to file
--testdox-text <file> ................. Write documentation in Text format to file --testdox-text <file> ................. Write documentation in Text format to file
--testdox-xml <file> ................... Write documentation in XML format to file
--log-events-text <file> ..................... Stream events as plain text to file --log-events-text <file> ..................... Stream events as plain text to file
--log-events-verbose-text <file> Stream events as plain text to file (with telemetry information) --log-events-verbose-text <file> Stream events as plain text to file (with telemetry information)
--no-logging .................................. Ignore logging configuration --no-logging .................................. Ignore logging configuration

View File

@ -29,7 +29,7 @@
✓ it does not append CoversNothing to other methods ✓ it does not append CoversNothing to other methods
✓ it throws exception if no class nor method has been found ✓ 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 does not exist
✓ it throws exception if dataset already exist ✓ it throws exception if dataset already exist
✓ it sets closures ✓ it sets closures
@ -556,6 +556,18 @@
✓ it fails ✓ it fails
✓ it fails with message ✓ 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 Tests\Features\Expect\toHaveProperties
✓ pass ✓ pass
✓ failures ✓ failures
@ -732,7 +744,7 @@
✓ it allows access to the underlying expectNotToPerformAssertions method ✓ it allows access to the underlying expectNotToPerformAssertions method
✓ it allows performing no expectations without being risky ✓ it allows performing no expectations without being risky
PASS Tests\Features\Todo PASS Tests\Features\Todo - 3 todos
↓ something todo later ↓ something todo later
↓ something todo later chained ↓ something todo later chained
↓ something todo later chained and with function body ↓ something todo later chained and with function body
@ -833,7 +845,7 @@
✓ environment is set to Local when --ci option is not used ✓ environment is set to Local when --ci option is not used
PASS Tests\Unit\Plugins\Retry 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 PASS Tests\Unit\Support\Backtrace
✓ it gets file name from called file ✓ it gets file name from called file
@ -886,6 +898,9 @@
PASS Tests\Visual\Help PASS Tests\Visual\Help
✓ visual snapshot of help command output ✓ visual snapshot of help command output
PASS Tests\Visual\Parallel
✓ parallel
PASS Tests\Visual\SingleTestOrDirectory PASS Tests\Visual\SingleTestOrDirectory
✓ allows to run a single test ✓ allows to run a single test
✓ allows to run a directory ✓ allows to run a directory
@ -905,3 +920,4 @@
✓ visual snapshot of help command output ✓ visual snapshot of help command output
Tests: 4 incomplete, 4 todos, 18 skipped, 627 passed (1514 assertions) Tests: 4 incomplete, 4 todos, 18 skipped, 627 passed (1514 assertions)

View File

@ -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 ↓ 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
↓ something todo later chained ↓ something todo later chained
↓ something todo later chained and with function body ↓ something todo later chained and with function body

View File

@ -0,0 +1,28 @@
<?php
use PHPUnit\Framework\ExpectationFailedException;
$object = new class
{
public function foo(): void
{
}
};
test('pass', function () use ($object) {
expect($object)->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);

View File

@ -0,0 +1,30 @@
<?php
use PHPUnit\Framework\ExpectationFailedException;
$object = new class
{
public function foo(): void
{
}
public function bar(): void
{
}
};
test('pass', function () use ($object) {
expect($object)->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);

View File

@ -1,11 +1,12 @@
<?php <?php
use Pest\Plugins\Parallel;
use Pest\Support\Str; use Pest\Support\Str;
// HACK: we have to determine our $_SERVER['globalHook-]>calls baseline. This is because // HACK: we have to determine our $_SERVER['globalHook-]>calls baseline. This is because
// two other tests are executed before this one due to filename ordering. // two other tests are executed before this one due to filename ordering.
$args = $_SERVER['argv'] ?? []; $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; $offset = $single ? 0 : 2;
uses()->beforeAll(function () use ($offset) { uses()->beforeAll(function () use ($offset) {

View File

@ -2,14 +2,13 @@
use Pest\Plugins\Retry; use Pest\Plugins\Retry;
beforeEach(fn () => Retry::$retrying = false); it('orders by defects and stop on defects if when --retry is used ', function () {
afterEach(fn () => Retry::$retrying = false);
it('retries if --retry argument is used', function () {
$retry = new Retry(); $retry = new Retry();
$retry->handleArguments(['--retry']); $arguments = $retry->handleArguments(['--retry']);
expect(Retry::$retrying)->toBeTrue(); expect($arguments)->toBe([
'--order-by=defects',
'--stop-on-failure',
]);
}); });

18
tests/Visual/Parallel.php Normal file
View File

@ -0,0 +1,18 @@
<?php
use Symfony\Component\Process\Process;
$run = function () {
$process = new Process(['php', 'bin/pest', '--parallel', '--processes=3', '--exclude-group=integration'], dirname(__DIR__, 2),
['COLLISION_PRINTER' => '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)');
});

View File

@ -1,5 +1,12 @@
<?php <?php
function normalize_windows_os_output(string $text): string
{
$text = str_replace('\r', '', $text);
return str_replace('\\', '/', $text);
}
test('visual snapshot of team city', function (string $testFile) { test('visual snapshot of team city', function (string $testFile) {
$testsPath = dirname(__DIR__)."/.tests/$testFile"; $testsPath = dirname(__DIR__)."/.tests/$testFile";
@ -28,13 +35,9 @@ test('visual snapshot of team city', function (string $testFile) {
}; };
if (getenv('REBUILD_SNAPSHOTS')) { if (getenv('REBUILD_SNAPSHOTS')) {
$outputContent = explode("\n", $output()); file_put_contents($snapshot, normalize_windows_os_output($output()));
file_put_contents($snapshot, implode("\n", $outputContent));
} elseif (! getenv('EXCLUDE')) { } elseif (! getenv('EXCLUDE')) {
$output = explode("\n", $output()); expect(normalize_windows_os_output($output()))->toEqual(file_get_contents($snapshot));
expect(implode("\n", $output))->toEqual(file_get_contents($snapshot));
} }
})->with([ })->with([
'Failure.php', 'Failure.php',