clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

19
vendor/brianium/paratest/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2013 Brian Scaturro
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

234
vendor/brianium/paratest/README.md vendored Normal file
View File

@@ -0,0 +1,234 @@
ParaTest
========
[![Latest Stable Version](https://img.shields.io/packagist/v/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest)
[![Downloads](https://img.shields.io/packagist/dt/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest)
[![Integrate](https://github.com/paratestphp/paratest/workflows/Integrate/badge.svg?branch=6.x)](https://github.com/paratestphp/paratest/actions)
[![Code Coverage](https://codecov.io/gh/paratestphp/paratest/coverage.svg?branch=6.x)](https://codecov.io/gh/paratestphp/paratest?branch=6.x)
[![Type Coverage](https://shepherd.dev/github/paratestphp/paratest/coverage.svg)](https://shepherd.dev/github/paratestphp/paratest)
[![Infection MSI](https://badge.stryker-mutator.io/github.com/paratestphp/paratest/6.x)](https://dashboard.stryker-mutator.io/reports/github.com/paratestphp/paratest/6.x)
The objective of ParaTest is to support parallel testing in PHPUnit. Provided you have well-written PHPUnit tests, you can drop `paratest` in your project and
start using it with no additional bootstrap or configurations!
Benefits:
* Zero configuration. After the installation, run with `vendor/bin/paratest`. That's it!
* Code Coverage report combining. Run your tests in N parallel processes and all the code coverage output will be combined into one report.
* Flexible. Isolate test files in separate processes or take advantage of `WrapperRunner` for even faster runs.
# Installation
To install with composer run the following command:
composer require --dev brianium/paratest
# Versions
Only the latest version of PHPUnit is supported, and thus only the latest version of ParaTest is actively maintained.
This is because of the following reasons:
1. To reduce bugs, code duplication and incompatibilities with PHPUnit, from version 5 ParaTest heavily relies on PHPUnit `@internal` classes
1. The fast pace both PHP and PHPUnit have taken recently adds too much maintenance burden, which we can only afford for the latest versions to stay up-to-date
# Usage
After installation, the binary can be found at `vendor/bin/paratest`. Run it
with `--help` option to see a complete list of the available options.
## Optimizing Speed
To get the most out of ParaTest, you have to adjust the parameters carefully.
1. **Use the WrapperRunner if possible**
The default Runner for PHPUnit spawns a new process for each testcase (or method in functional mode). This provides
the highest compatibility but comes with the cost of many spawned processes and a bootstrapping for each process.
Especially when you have a slow bootstrapping in your tests (like a database setup) you should try the `WrapperRunner`
with `--runner WrapperRunner`. It spawns one "worker"-process for each parallel process (`-p`), executes the
bootstrapping once and reuses these processes for each test executed. That way the overhead of process spawning and
bootstrapping is reduced to the minimum.
Using the `--max-batch-size` option with the WrapperRunner will reset each worker after `--max-batch-size` testcases, which might help solve memory leaks problems.
2. **Adjust the number of processes with `-p`**
To allow full usage of your cpu cores, you should have at least one process per core. More processes allow better
resource usage but keep in mind that each process has its own costs for spawning. The default is auto, which means
the number of logical CPU cores is set as the number of processes. You might try something like logical `CPU cores * 2`
(e.g. if you have 8 logical cores, you might try `16`), but keep in mind that each process generates a little bit
of overhead as well.
3. **Choose between per-testcase- and per-testmethod-parallelization with `-f`**
Given you have few testcases (classes) with many long running methods, you should use the `-f` option to enable the
`functional mode` and allow different methods of the same class to be executed in parallel. Keep in mind that the
default is per-testcase-parallelization to address inter-testmethod dependencies. Note that in most projects, using
`-f` is **slower** since each test **method** will need to be bootstrapped separately.
4. **Tune batch max size `--max-batch-size`**
Batch size will affect the max amount of atomic tests which will be used for a single test method.
Please note that it only works with either the `functional mode` OR `--runner WrapperRunner` (mutually exclusive). The following describes the `functional mode` system.
One atomic test will be either one test method from test class if no data provider available for
method or will be only one item from dataset for method.
Increase this value to reduce per-process overhead and in most cases it will also reduce parallel efficiency.
Decrease this value to increase per-process overhead and in most cases it will also increase parallel efficiency.
If the amount of all tests is less than the max batch size then everything will be processed in one
process thread so ParaTest is completely useless in that case.
The best way to find the most effective batch size is to test with different batch size values
and select best.
Max batch size = 0 means that grouping in batches will not be used and one batch will equal
all method tests (one or all from data provider).
Max batch size = 1 means that each batch will contain only one test from the data provider or one
method if the data provider is not used.
Bigger max batch size can significantly increase phpunit command line length so the process can fail.
Decrease max batch size to reduce command line length.
Windows has a limit around 32k, Linux - 2048k, Mac OS X - 256k.
## Test token
The `TEST_TOKEN` environment variable is guaranteed to have a value that is different
from every other currently running test. This is useful to e.g. use a different database
for each test:
```php
if (getenv('TEST_TOKEN') !== false) { // Using ParaTest
$dbname = 'testdb_' . getenv('TEST_TOKEN');
} else {
$dbname = 'testdb';
}
```
A `UNIQUE_TEST_TOKEN` environment variable is also available and guaranteed to have a value that is unique both
per run and per process.
## Code coverage
Beginning from PHPUnit 9.3.4, it is strongly advised to set a coverage cache directory,
see [PHPUnit Changelog @ 9.3.4](https://github.com/sebastianbergmann/phpunit/blob/9.3.4/ChangeLog-9.3.md#934---2020-08-10).
The cache is always warmed up by ParaTest before executing the test suite.
### PCOV
If you have installed `pcov` but need to enable it only while running tests, you have to pass thru the needed PHP binary
option:
```
php -d pcov.enabled=1 vendor/bin/paratest --passthru-php="'-d' 'pcov.enabled=1'"
```
### xDebug
If you have `xDebug` installed, activating it by the environment variable is enough to have it running even in the subprocesses:
```
XDEBUG_MODE=coverage vendor/bin/paratest
```
### PHPDBG
`PHPDBG` is automatically detected and used in the subprocesses if it's the running binary of the main process:
```
phpdbg vendor/bin/paratest
```
## Initial setup for all tests
Because ParaTest runs multiple processes in parallel, each with their own instance of the PHP interpreter,
techniques used to perform an initialization step exactly once for each test work different from PHPUnit.
The following pattern will not work as expected - run the initialization exactly once - and instead run the
initialization once per process:
```php
private static bool $initialized = false;
public function setUp(): void
{
if (! self::$initialized) {
self::initialize();
self::$initialized = true;
}
}
```
This is because static variables persist during the execution of a single process.
In parallel testing each process has a separate instance of `$initialized`.
You can use the following pattern to ensure your initialization runs exactly once for the entire test invocation:
```php
static bool $initialized = false;
public function setUp(): void
{
if (! self::$initialized) {
// We utilize the filesystem as shared mutable state to coordinate between processes
touch('/tmp/test-initialization-lock-file');
$lockFile = fopen('/tmp/test-initialization-lock-file', 'r');
// Attempt to get an exclusive lock - first process wins
if (flock($lockFile, LOCK_EX | LOCK_NB)) {
// Since we are the single process that has an exclusive lock, we run the initialization
self::initialize();
} else {
// If no exclusive lock is available, block until the first process is done with initialization
flock($lockFile, LOCK_SH);
}
self::$initialized = true;
}
}
```
## Troubleshooting
If you run into problems with `paratest`, try to get more information about the issue by enabling debug output via
`--verbose --debug`.
When a sub-process fails, the originating command is given in the output and can then be copy-pasted in the terminal
to be run and debugged. All internal commands run with `--printer [...]\NullPhpunitPrinter` which silence the original
PHPUnit output: during a debugging run remove that option to restore the output and see what PHPUnit is doing.
## Windows
Windows users be sure to use the appropriate batch files.
An example being:
`vendor\bin\paratest.bat ...`
ParaTest assumes [PSR-0](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md) for loading tests.
For convenience, ParaTest for Windows uses 79 columns mode to prevent blank lines in the standard
80x25 windows console.
## Caveats
1. Constants, static methods, static variables and everything exposed by test classes consumed by other test classes
(including Reflection) are not supported. This is due to a limitation of the current implementation of `WrapperRunner`
and how PHPUnit searches for classes. The fix is to put shared code into classes which are not tests _themselves_.
## Integration with PHPStorm
ParaTest provides a dedicated binary to work with PHPStorm; follow these steps to have ParaTest working within it:
1. Be sure you have PHPUnit already configured in PHPStorm: https://www.jetbrains.com/help/phpstorm/using-phpunit-framework.html#php_test_frameworks_phpunit_integrate
2. Go to `Run` -> `Edit configurations...`
3. Select `Add new Configuration`, select the `PHPUnit` type and name it `ParaTest`
4. In the `Command Line` -> `Interpreter options` add `./vendor/bin/paratest_for_phpstorm`
5. Any additional ParaTest options you want to pass to ParaTest should go within the `Test runner` -> `Test runner options` section
You should now have a `ParaTest` run within your configurations list.
It should natively work with the `Rerun failed tests` and `Toggle auto-test` buttons of the `Run` overlay.
### Run with Coverage
Coverage with one of the [available coverage engines](#code-coverage) must already be [configured in PHPStorm](https://www.jetbrains.com/help/phpstorm/code-coverage.html)
and working when running tests sequentially in order for the helper binary to correctly handle code coverage
# For Contributors: testing ParaTest itself
Before creating a Pull Request be sure to run all the necessary checks with `make` command.

37
vendor/brianium/paratest/bin/paratest vendored Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env php
<?php
$cwd = getcwd();
$files = array(
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'
);
$found = false;
foreach ($files as $file) {
if (file_exists($file)) {
require $file;
$found = true;
break;
}
}
if (!$found) {
die(
'You need to set up the project dependencies using the following commands:' . PHP_EOL .
'curl -s http://getcomposer.org/installer | php' . PHP_EOL .
'php composer.phar install' . PHP_EOL
);
}
if (false === in_array(PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
echo PHP_EOL . 'ParaTest may only be invoked from a command line, got "' . PHP_SAPI . '"' . PHP_EOL;
exit(1);
}
assert(is_string($cwd));
\ParaTest\Console\Commands\ParaTestCommand::applicationFactory($cwd)->run();

3
vendor/brianium/paratest/bin/paratest.bat vendored Executable file
View File

@@ -0,0 +1,3 @@
@ECHO OFF
SET BIN_TARGET=%~dp0\"../bin"\paratest
php "%BIN_TARGET%" %*

View File

@@ -0,0 +1,10 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use ParaTest\Util\PhpstormHelper;
require dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Util' . DIRECTORY_SEPARATOR . 'PhpstormHelper.php';
require PhpstormHelper::handleArgvFromPhpstorm($_SERVER['argv'], __DIR__ . '/paratest');

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use ParaTest\Runners\PHPUnit\Worker\WrapperWorker;
(static function (): void {
$opts = getopt('', ['write-to:']);
$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)) {
define('PHPUNIT_COMPOSER_INSTALL', $file);
require_once $file;
break;
}
}
assert(isset($opts['write-to']) && is_string($opts['write-to']));
$writeTo = fopen($opts['write-to'], 'wb');
assert(is_resource($writeTo));
$i = 0;
while (true) {
$i++;
if (feof(STDIN)) {
exit;
}
$command = fgets(STDIN);
if ($command === false || $command === WrapperWorker::COMMAND_EXIT) {
exit;
}
$arguments = unserialize(trim($command));
(new PHPUnit\TextUI\Command())->run($arguments, false);
fwrite($writeTo, WrapperWorker::TEST_EXECUTED_MARKER);
fflush($writeTo);
}
})();

85
vendor/brianium/paratest/composer.json vendored Normal file
View File

@@ -0,0 +1,85 @@
{
"name": "brianium/paratest",
"description": "Parallel testing for PHP",
"license": "MIT",
"type": "library",
"keywords": [
"testing",
"PHPUnit",
"concurrent",
"parallel"
],
"authors": [
{
"name": "Brian Scaturro",
"email": "scaturrob@gmail.com",
"role": "Developer"
},
{
"name": "Filippo Tessarotto",
"email": "zoeslam@gmail.com",
"role": "Developer"
}
],
"homepage": "https://github.com/paratestphp/paratest",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Slamdunk"
},
{
"type": "paypal",
"url": "https://paypal.me/filippotessarotto"
}
],
"require": {
"php": "^7.3 || ^8.0",
"ext-dom": "*",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-simplexml": "*",
"fidry/cpu-core-counter": "^0.4.1 || ^0.5.1 || ^1.0.0",
"jean85/pretty-package-versions": "^2.0.5",
"phpunit/php-code-coverage": "^9.2.25",
"phpunit/php-file-iterator": "^3.0.6",
"phpunit/php-timer": "^5.0.3",
"phpunit/phpunit": "^9.6.4",
"sebastian/environment": "^5.1.5",
"symfony/console": "^5.4.28 || ^6.3.4 || ^7.0.0",
"symfony/process": "^5.4.28 || ^6.3.4 || ^7.0.0"
},
"require-dev": {
"ext-pcov": "*",
"ext-posix": "*",
"doctrine/coding-standard": "^12.0.0",
"infection/infection": "^0.27.6",
"squizlabs/php_codesniffer": "^3.7.2",
"symfony/filesystem": "^5.4.25 || ^6.3.1 || ^7.0.0",
"vimeo/psalm": "^5.7.7"
},
"autoload": {
"psr-4": {
"ParaTest\\": [
"src/"
]
}
},
"autoload-dev": {
"psr-4": {
"ParaTest\\Tests\\": "test/"
}
},
"bin": [
"bin/paratest",
"bin/paratest.bat",
"bin/paratest_for_phpstorm"
],
"config": {
"allow-plugins": {
"composer/package-versions-deprecated": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"infection/extension-installer": true
},
"sort-packages": true
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace ParaTest\Console\Commands;
use InvalidArgumentException;
use Jean85\PrettyVersions;
use ParaTest\Runners\PHPUnit\Options;
use ParaTest\Runners\PHPUnit\Runner;
use ParaTest\Runners\PHPUnit\RunnerInterface;
use ParaTest\Runners\PHPUnit\WrapperRunner;
use PHPUnit\Runner\Version;
use SebastianBergmann\Environment\Console;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function assert;
use function class_exists;
use function is_string;
use function is_subclass_of;
use function sprintf;
/** @internal */
final class ParaTestCommand extends Command
{
public const COMMAND_NAME = 'paratest';
private const KNOWN_RUNNERS = [
'Runner' => Runner::class,
'WrapperRunner' => WrapperRunner::class,
];
/** @var string */
private $cwd;
public function __construct(string $cwd, ?string $name = null)
{
$this->cwd = $cwd;
parent::__construct($name);
}
public static function applicationFactory(string $cwd): Application
{
$application = new Application();
$command = new self($cwd, self::COMMAND_NAME);
$application->setName('ParaTest');
$application->setVersion(PrettyVersions::getVersion('brianium/paratest')->getPrettyVersion());
$application->add($command);
$application->setDefaultCommand((string) $command->getName(), true);
return $application;
}
/**
* Ubiquitous configuration options for ParaTest.
*/
protected function configure(): void
{
Options::setInputDefinition($this->getDefinition());
}
/**
* {@inheritDoc}
*/
public function mergeApplicationDefinition($mergeArgs = true): void
{
}
/**
* Executes the specified tester.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$application = $this->getApplication();
assert($application !== null);
$output->write(sprintf(
"%s upon %s\n",
$application->getLongVersion(),
Version::getVersionString(),
));
$output->write("\n");
$options = Options::fromConsoleInput(
$input,
$this->cwd,
(new Console())->hasColorSupport(),
);
if ($options->configuration() === null && $options->path() === null) {
return $this->displayHelp($output);
}
$runnerClass = $this->getRunnerClass($input);
$runner = new $runnerClass($options, $output);
$runner->run();
return $runner->getExitCode();
}
/**
* Displays help for the ParaTestCommand.
*/
private function displayHelp(OutputInterface $output): int
{
$app = $this->getApplication();
assert($app !== null);
$help = $app->find('help');
$input = new ArrayInput(['command_name' => $this->getName()]);
return $help->run($input, $output);
}
/** @return class-string<RunnerInterface> */
private function getRunnerClass(InputInterface $input): string
{
$runnerClass = $input->getOption('runner');
assert(is_string($runnerClass));
$runnerClass = self::KNOWN_RUNNERS[$runnerClass] ?? $runnerClass;
if (! class_exists($runnerClass) || ! is_subclass_of($runnerClass, RunnerInterface::class)) {
throw new InvalidArgumentException(sprintf(
'Selected runner class "%s" does not exist or does not implement %s',
$runnerClass,
RunnerInterface::class,
));
}
return $runnerClass;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace ParaTest\Coverage;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\ProcessedCodeCoverageData;
use SebastianBergmann\Environment\Runtime;
use function array_map;
use function array_slice;
use function assert;
use function filesize;
use function is_file;
use function unlink;
/** @internal */
final class CoverageMerger
{
/** @var CodeCoverage|null */
private $coverage;
/** @var int */
private $testLimit;
public function __construct(int $testLimit)
{
$this->testLimit = $testLimit;
}
private function addCoverage(CodeCoverage $coverage): void
{
if ($this->coverage === null) {
$this->coverage = $coverage;
} else {
$this->coverage->merge($coverage);
}
$this->limitCoverageTests($this->coverage);
}
/**
* Adds the coverage contained in $coverageFile and deletes the file afterwards.
*
* @param string $coverageFile Code coverage file
*/
public function addCoverageFromFile(string $coverageFile): void
{
if (! is_file($coverageFile) || filesize($coverageFile) === 0) {
$extra = 'This means a PHPUnit process has crashed.';
if (! (new Runtime())->canCollectCodeCoverage()) {
// @codeCoverageIgnoreStart
$extra = 'No coverage driver found! Enable one of Xdebug, PHPDBG or PCOV for coverage.';
// @codeCoverageIgnoreEnd
}
throw new EmptyCoverageFileException("Coverage file {$coverageFile} is empty. " . $extra);
}
/** @psalm-suppress UnresolvableInclude **/
$coverage = include $coverageFile;
assert($coverage instanceof CodeCoverage);
$this->addCoverage($coverage);
unlink($coverageFile);
}
public function getCodeCoverageObject(): ?CodeCoverage
{
return $this->coverage;
}
private function limitCoverageTests(CodeCoverage $coverage): void
{
if ($this->testLimit === 0) {
return;
}
$testLimit = $this->testLimit;
$data = $coverage->getData(true);
$newData = array_map(
static function (array $lines) use ($testLimit): array {
return array_map(static function (array $value) use ($testLimit): array {
return array_slice($value, 0, $testLimit);
}, $lines);
},
$data->lineCoverage(),
);
$processedData = new ProcessedCodeCoverageData();
$processedData->setLineCoverage($newData);
$coverage->setData($processedData);
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ParaTest\Coverage;
use PHPUnit\TextUI\XmlConfiguration\CodeCoverage\CodeCoverage as CodeCoverageConfiguration;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Report\Clover;
use SebastianBergmann\CodeCoverage\Report\Cobertura;
use SebastianBergmann\CodeCoverage\Report\Crap4j;
use SebastianBergmann\CodeCoverage\Report\Html;
use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Text;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as XmlReport;
use SebastianBergmann\CodeCoverage\Version;
/** @internal */
final class CoverageReporter
{
/** @var CodeCoverage */
private $coverage;
/** @var CodeCoverageConfiguration|null */
private $codeCoverageConfiguration;
public function __construct(CodeCoverage $coverage, ?CodeCoverageConfiguration $codeCoverageConfiguration)
{
$this->coverage = $coverage;
$this->codeCoverageConfiguration = $codeCoverageConfiguration;
}
/**
* Generate clover coverage report.
*
* @param string $target Report filename
*/
public function clover(string $target): void
{
$clover = new Clover();
$clover->process($this->coverage, $target);
}
public function cobertura(string $target): void
{
$clover = new Cobertura();
$clover->process($this->coverage, $target);
}
/**
* Generate Crap4J XML coverage report.
*
* @param string $target Report filename
*/
public function crap4j(string $target): void
{
$xml = new Crap4j();
if ($this->codeCoverageConfiguration !== null && $this->codeCoverageConfiguration->hasCrap4j()) {
$xml = new Crap4j($this->codeCoverageConfiguration->crap4j()->threshold());
}
$xml->process($this->coverage, $target);
}
/**
* Generate html coverage report.
*
* @param string $target Report filename
*/
public function html(string $target): void
{
$html = new Html\Facade();
if ($this->codeCoverageConfiguration !== null && $this->codeCoverageConfiguration->hasHtml()) {
$html = new Html\Facade(
$this->codeCoverageConfiguration->html()->lowUpperBound(),
$this->codeCoverageConfiguration->html()->highLowerBound(),
);
}
$html->process($this->coverage, $target);
}
/**
* Generate php coverage report.
*
* @param string $target Report filename
*/
public function php(string $target): void
{
$php = new PHP();
$php->process($this->coverage, $target);
}
/**
* Generate text coverage report.
*
* @param bool $colors Coverage colors
*/
public function text(bool $colors): string
{
$text = new Text();
if ($this->codeCoverageConfiguration !== null && $this->codeCoverageConfiguration->hasText()) {
$hasHtml = $this->codeCoverageConfiguration->hasHtml();
$text = new Text(
$hasHtml ? $this->codeCoverageConfiguration->html()->lowUpperBound() : 50,
$hasHtml ? $this->codeCoverageConfiguration->html()->highLowerBound() : 90,
$this->codeCoverageConfiguration->text()->showUncoveredFiles(),
$this->codeCoverageConfiguration->text()->showOnlySummary(),
);
}
return $text->process($this->coverage, $colors);
}
/**
* Generate PHPUnit XML coverage report.
*
* @param string $target Report filename
*/
public function xml(string $target): void
{
$xml = new XmlReport(Version::id());
$xml->process($this->coverage, $target);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace ParaTest\Coverage;
use RuntimeException;
/** @internal */
final class EmptyCoverageFileException extends RuntimeException
{
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/** @internal */
final class ErrorTestCase extends TestCaseWithMessage
{
public function getXmlTagName(): string
{
return 'error';
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/** @internal */
final class FailureTestCase extends TestCaseWithMessage
{
public function getXmlTagName(): string
{
return 'failure';
}
}

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
use InvalidArgumentException;
use ParaTest\Logging\MetaProviderInterface;
use SimpleXMLElement;
use function array_filter;
use function array_map;
use function array_merge;
use function array_sum;
use function array_values;
use function assert;
use function file_exists;
use function file_get_contents;
use function filesize;
use function str_repeat;
use function unlink;
/** @internal */
final class Reader implements MetaProviderInterface
{
/** @var TestSuite */
private $suite;
/** @var string */
private $logFile;
public function __construct(string $logFile)
{
if (! file_exists($logFile)) {
throw new InvalidArgumentException("Log file {$logFile} does not exist");
}
if (filesize($logFile) === 0) {
throw new InvalidArgumentException(
"Log file {$logFile} is empty. This means a PHPUnit process has crashed.",
);
}
$this->logFile = $logFile;
$logFileContents = file_get_contents($this->logFile);
assert($logFileContents !== false);
$node = new SimpleXMLElement($logFileContents);
$this->suite = $this->parseTestSuite($node, true);
}
private function parseTestSuite(SimpleXMLElement $node, bool $isRootSuite): TestSuite
{
assert($node->testsuite !== null);
if ($isRootSuite) {
foreach ($node->testsuite as $singleTestSuiteXml) {
return $this->parseTestSuite($singleTestSuiteXml, false);
}
}
$suites = [];
foreach ($node->testsuite as $singleTestSuiteXml) {
$testSuite = $this->parseTestSuite($singleTestSuiteXml, false);
$suites[$testSuite->name] = $testSuite;
}
assert($node->testcase !== null);
$cases = [];
foreach ($node->testcase as $singleTestCase) {
$cases[] = TestCase::caseFromNode($singleTestCase);
}
$risky = array_sum(array_map(static function (TestCase $testCase): int {
return (int) ($testCase instanceof RiskyTestCase);
}, $cases));
$risky += array_sum(array_map(static function (TestSuite $testSuite): int {
return $testSuite->risky;
}, $suites));
return new TestSuite(
(string) $node['name'],
(int) $node['tests'],
(int) $node['assertions'],
(int) $node['failures'],
(int) $node['errors'] - $risky,
(int) $node['warnings'],
$risky,
(int) $node['skipped'],
(float) $node['time'],
(string) $node['file'],
$suites,
$cases,
);
}
public function getSuite(): TestSuite
{
return $this->suite;
}
public function getFeedback(): string
{
return str_repeat('E', $this->suite->errors)
. str_repeat('W', $this->suite->warnings)
. str_repeat('F', $this->suite->failures)
. str_repeat('R', $this->suite->risky)
. str_repeat('S', $this->suite->skipped)
. str_repeat(
'.',
$this->suite->tests
- $this->suite->errors
- $this->suite->warnings
- $this->suite->failures
- $this->suite->risky
- $this->suite->skipped,
);
}
public function removeLog(): void
{
unlink($this->logFile);
}
public function getTotalTests(): int
{
return $this->suite->tests;
}
public function getTotalAssertions(): int
{
return $this->suite->assertions;
}
public function getTotalErrors(): int
{
return $this->suite->errors;
}
public function getTotalFailures(): int
{
return $this->suite->failures;
}
public function getTotalWarnings(): int
{
return $this->suite->warnings;
}
public function getTotalSkipped(): int
{
return $this->suite->skipped;
}
public function getTotalTime(): float
{
return $this->suite->time;
}
/**
* {@inheritDoc}
*/
public function getErrors(): array
{
return $this->getMessagesOfType($this->suite, static function (TestCase $case): bool {
return $case instanceof ErrorTestCase;
});
}
/**
* {@inheritDoc}
*/
public function getWarnings(): array
{
return $this->getMessagesOfType($this->suite, static function (TestCase $case): bool {
return $case instanceof WarningTestCase;
});
}
/**
* {@inheritDoc}
*/
public function getFailures(): array
{
return $this->getMessagesOfType($this->suite, static function (TestCase $case): bool {
return $case instanceof FailureTestCase;
});
}
/**
* {@inheritDoc}
*/
public function getRisky(): array
{
return $this->getMessagesOfType($this->suite, static function (TestCase $case): bool {
return $case instanceof RiskyTestCase;
});
}
/**
* {@inheritDoc}
*/
public function getSkipped(): array
{
return $this->getMessagesOfType($this->suite, static function (TestCase $case): bool {
return $case instanceof SkippedTestCase;
});
}
/**
* @param callable(TestCase):bool $callback
*
* @return string[]
*/
private function getMessagesOfType(TestSuite $testSuite, callable $callback): array
{
$messages = array_filter($testSuite->cases, $callback);
$messages = array_map(static function (TestCaseWithMessage $testCase): string {
return $testCase->text;
}, $messages);
foreach ($testSuite->suites as $suite) {
$messages = array_merge($messages, $this->getMessagesOfType($suite, $callback));
}
return array_values($messages);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/** @internal */
final class RiskyTestCase extends TestCaseWithMessage
{
public function getXmlTagName(): string
{
return 'error';
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/** @internal */
final class SkippedTestCase extends TestCaseWithMessage
{
public function getXmlTagName(): string
{
return 'skipped';
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/** @internal */
final class SuccessTestCase extends TestCase
{
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
use PHPUnit\Framework\RiskyTestError;
use SimpleXMLElement;
use function assert;
use function class_exists;
use function count;
use function current;
use function is_subclass_of;
use function iterator_to_array;
use function sprintf;
/**
* A simple data structure for tracking
* the results of a testcase node in a
* JUnit xml document
*
* @internal
*
* @readonly
*/
abstract class TestCase
{
/** @var string */
public $name;
/** @var string */
public $class;
/** @var string */
public $file;
/** @var int */
public $line;
/** @var int */
public $assertions;
/** @var float */
public $time;
public function __construct(
string $name,
string $class,
string $file,
int $line,
int $assertions,
float $time
) {
$this->name = $name;
$this->class = $class;
$this->file = $file;
$this->line = $line;
$this->assertions = $assertions;
$this->time = $time;
}
/**
* Factory method that creates a TestCase object
* from a SimpleXMLElement.
*
* @return TestCase
*/
final public static function caseFromNode(SimpleXMLElement $node): self
{
$systemOutput = null;
$systemOutputs = $node->xpath('system-out');
if ($systemOutputs !== null && $systemOutputs !== []) {
assert(count($systemOutputs) === 1);
$systemOutput = (string) current($systemOutputs);
}
$getFirstNode = static function (array $nodes): SimpleXMLElement {
assert(count($nodes) === 1);
$node = current($nodes);
assert($node instanceof SimpleXMLElement);
return $node;
};
$getType = static function (SimpleXMLElement $node): string {
$element = $node->attributes();
assert($element !== null);
$attributes = iterator_to_array($element);
assert($attributes !== []);
return (string) $attributes['type'];
};
if (($errors = $node->xpath('error')) !== null && $errors !== []) {
$error = $getFirstNode($errors);
$type = $getType($error);
$text = (string) $error;
if (
class_exists($type)
&& ($type === RiskyTestError::class || is_subclass_of($type, RiskyTestError::class))
) {
return new RiskyTestCase(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
$systemOutput,
);
}
return new ErrorTestCase(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
$systemOutput,
);
}
if (($warnings = $node->xpath('warning')) !== null && $warnings !== []) {
$warning = $getFirstNode($warnings);
$type = $getType($warning);
$text = (string) $warning;
return new WarningTestCase(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
$systemOutput,
);
}
if (($failures = $node->xpath('failure')) !== null && $failures !== []) {
$failure = $getFirstNode($failures);
$type = $getType($failure);
$text = (string) $failure;
return new FailureTestCase(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
$systemOutput,
);
}
if ($node->xpath('skipped') !== []) {
$text = (string) $node['name'];
if ((string) $node['class'] !== '') {
$text = sprintf(
"%s::%s\n\n%s:%s",
(string) $node['class'],
(string) $node['name'],
(string) $node['file'],
(int) $node['line'],
);
}
return new SkippedTestCase(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
null,
$text,
$systemOutput,
);
}
return new SuccessTestCase(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/**
* @internal
*
* @readonly
*/
abstract class TestCaseWithMessage extends TestCase
{
/** @var string|null */
public $type;
/** @var string */
public $text;
/** @var string|null */
public $systemOutput;
public function __construct(
string $name,
string $class,
string $file,
int $line,
int $assertions,
float $time,
?string $type,
string $text,
?string $systemOutput
) {
parent::__construct($name, $class, $file, $line, $assertions, $time);
$this->type = $type;
$this->text = $text;
$this->systemOutput = $systemOutput;
}
abstract public function getXmlTagName(): string;
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/**
* A simple data structure for tracking
* data associated with a testsuite node
* in a JUnit xml document
*
* @internal
*/
final class TestSuite
{
/** @var string */
public $name;
/** @var int */
public $tests;
/** @var int */
public $assertions;
/** @var int */
public $failures;
/** @var int */
public $errors;
/** @var int */
public $warnings;
/** @var int */
public $risky;
/** @var int */
public $skipped;
/** @var float */
public $time;
/** @var string */
public $file;
/**
* Nested suites.
*
* @var array<string, TestSuite>
*/
public $suites = [];
/**
* Cases belonging to this suite.
*
* @var TestCase[]
*/
public $cases = [];
/**
* @param array<string, TestSuite> $suites
* @param TestCase[] $cases
*/
public function __construct(
string $name,
int $tests,
int $assertions,
int $failures,
int $errors,
int $warnings,
int $risky,
int $skipped,
float $time,
string $file,
array $suites,
array $cases
) {
$this->name = $name;
$this->tests = $tests;
$this->assertions = $assertions;
$this->failures = $failures;
$this->skipped = $skipped;
$this->errors = $errors;
$this->warnings = $warnings;
$this->time = $time;
$this->file = $file;
$this->suites = $suites;
$this->cases = $cases;
$this->risky = $risky;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
/** @internal */
final class WarningTestCase extends TestCaseWithMessage
{
public function getXmlTagName(): string
{
return 'warning';
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging\JUnit;
use DOMDocument;
use DOMElement;
use ParaTest\Logging\LogInterpreter;
use function assert;
use function dirname;
use function file_put_contents;
use function htmlspecialchars;
use function is_dir;
use function mkdir;
use function sprintf;
use function str_replace;
use const ENT_XML1;
/** @internal */
final class Writer
{
/**
* The name attribute of the testsuite being
* written.
*
* @var string
*/
private $name;
/** @var LogInterpreter */
private $interpreter;
/** @var DOMDocument */
private $document;
public function __construct(LogInterpreter $interpreter, string $name)
{
$this->name = $name;
$this->interpreter = $interpreter;
$this->document = new DOMDocument('1.0', 'UTF-8');
$this->document->formatOutput = true;
}
/**
* Get the name of the root suite being written.
*/
public function getName(): string
{
return $this->name;
}
/**
* Returns the xml structure the writer
* will use.
*/
public function getXml(): string
{
$mainSuite = $this->interpreter->mergeReaders();
if ($mainSuite->name === '') {
$mainSuite->name = $this->name;
}
$xmlTestsuites = $this->document->createElement('testsuites');
$xmlTestsuites->appendChild($this->createSuiteNode($mainSuite));
$this->document->appendChild($xmlTestsuites);
$xml = $this->document->saveXML();
assert($xml !== false);
return $xml;
}
/**
* Write the xml structure to a file path.
*/
public function write(string $path): void
{
$dir = dirname($path);
if (! is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($path, $this->getXml());
}
/**
* Append a testsuite node to the given
* root element.
*/
private function createSuiteNode(TestSuite $parentSuite): DOMElement
{
$suiteNode = $this->document->createElement('testsuite');
$suiteNode->setAttribute('name', $parentSuite->name);
if ($parentSuite->file !== '') {
$suiteNode->setAttribute('file', $parentSuite->file);
}
$suiteNode->setAttribute('tests', (string) $parentSuite->tests);
$suiteNode->setAttribute('assertions', (string) $parentSuite->assertions);
$suiteNode->setAttribute('errors', (string) ($parentSuite->errors + $parentSuite->risky));
$suiteNode->setAttribute('warnings', (string) $parentSuite->warnings);
$suiteNode->setAttribute('failures', (string) $parentSuite->failures);
$suiteNode->setAttribute('skipped', (string) $parentSuite->skipped);
$suiteNode->setAttribute('time', (string) $parentSuite->time);
foreach ($parentSuite->suites as $suite) {
$suiteNode->appendChild($this->createSuiteNode($suite));
}
foreach ($parentSuite->cases as $case) {
$suiteNode->appendChild($this->createCaseNode($case));
}
return $suiteNode;
}
/**
* Append a testcase node to the given testsuite
* node.
*/
private function createCaseNode(TestCase $case): DOMElement
{
$caseNode = $this->document->createElement('testcase');
$caseNode->setAttribute('name', $case->name);
$caseNode->setAttribute('class', $case->class);
$caseNode->setAttribute('classname', str_replace('\\', '.', $case->class));
$caseNode->setAttribute('file', $case->file);
$caseNode->setAttribute('line', (string) $case->line);
$caseNode->setAttribute('assertions', (string) $case->assertions);
$caseNode->setAttribute('time', sprintf('%F', $case->time));
if ($case instanceof TestCaseWithMessage) {
if ($case instanceof SkippedTestCase) {
$defectNode = $this->document->createElement($case->getXmlTagName());
} else {
$defectNode = $this->document->createElement($case->getXmlTagName(), htmlspecialchars($case->text, ENT_XML1));
$type = $case->type;
if ($type !== null) {
$defectNode->setAttribute('type', $type);
}
}
$caseNode->appendChild($defectNode);
}
return $caseNode;
}
}

View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging;
use ParaTest\Logging\JUnit\Reader;
use ParaTest\Logging\JUnit\TestSuite;
use function array_merge;
use function array_reduce;
use function assert;
/** @internal */
final class LogInterpreter implements MetaProviderInterface
{
/**
* A collection of Reader objects
* to aggregate results from.
*
* @var Reader[]
*/
private $readers = [];
/**
* Add a new Reader to be included
* in the final results.
*/
public function addReader(Reader $reader): void
{
$this->readers[] = $reader;
}
/**
* Return all Reader objects associated
* with the LogInterpreter.
*
* @return Reader[]
*/
public function getReaders(): array
{
return $this->readers;
}
/**
* Returns true if total errors and failures
* equals 0, false otherwise
* TODO: Remove this comment if we don't care about skipped tests in callers.
*/
public function isSuccessful(): bool
{
return $this->getTotalFailures() === 0 && $this->getTotalErrors() === 0;
}
/**
* Flattens all cases into their respective suites.
*/
public function mergeReaders(): TestSuite
{
$mainSuite = null;
foreach ($this->readers as $reader) {
$otherSuite = $reader->getSuite();
if ($mainSuite === null) {
$mainSuite = $otherSuite;
continue;
}
if ($mainSuite->name !== $otherSuite->name) {
if ($mainSuite->name !== '') {
$mainSuite2 = clone $mainSuite;
$mainSuite2->name = '';
$mainSuite2->file = '';
$mainSuite2->suites = [$mainSuite->name => $mainSuite];
$mainSuite2->cases = [];
$mainSuite = $mainSuite2;
}
if ($otherSuite->name !== '') {
$otherSuite2 = clone $otherSuite;
$otherSuite2->name = '';
$otherSuite2->file = '';
$otherSuite2->suites = [$otherSuite->name => $otherSuite];
$otherSuite2->cases = [];
$otherSuite = $otherSuite2;
}
}
$this->mergeSuites($mainSuite, $otherSuite);
}
assert($mainSuite !== null);
return $mainSuite;
}
private function mergeSuites(TestSuite $suite1, TestSuite $suite2): TestSuite
{
assert($suite1->name === $suite2->name);
foreach ($suite2->suites as $suite2suiteName => $suite2suite) {
if (! isset($suite1->suites[$suite2suiteName])) {
$suite1->suites[$suite2suiteName] = $suite2suite;
continue;
}
$suite1->suites[$suite2suiteName] = $this->mergeSuites(
$suite1->suites[$suite2suiteName],
$suite2suite,
);
}
$suite1->tests += $suite2->tests;
$suite1->assertions += $suite2->assertions;
$suite1->failures += $suite2->failures;
$suite1->errors += $suite2->errors;
$suite1->warnings += $suite2->warnings;
$suite1->risky += $suite2->risky;
$suite1->skipped += $suite2->skipped;
$suite1->time += $suite2->time;
$suite1->cases = array_merge(
$suite1->cases,
$suite2->cases,
);
return $suite1;
}
public function getTotalTests(): int
{
return array_reduce($this->readers, static function (int $result, Reader $reader): int {
return $result + $reader->getTotalTests();
}, 0);
}
public function getTotalAssertions(): int
{
return array_reduce($this->readers, static function (int $result, Reader $reader): int {
return $result + $reader->getTotalAssertions();
}, 0);
}
public function getTotalErrors(): int
{
return array_reduce($this->readers, static function (int $result, Reader $reader): int {
return $result + $reader->getTotalErrors();
}, 0);
}
public function getTotalFailures(): int
{
return array_reduce($this->readers, static function (int $result, Reader $reader): int {
return $result + $reader->getTotalFailures();
}, 0);
}
public function getTotalWarnings(): int
{
return array_reduce($this->readers, static function (int $result, Reader $reader): int {
return $result + $reader->getTotalWarnings();
}, 0);
}
public function getTotalSkipped(): int
{
return array_reduce($this->readers, static function (int $result, Reader $reader): int {
return $result + $reader->getTotalSkipped();
}, 0);
}
public function getTotalTime(): float
{
return array_reduce($this->readers, static function (float $result, Reader $reader): float {
return $result + $reader->getTotalTime();
}, 0.0);
}
/**
* {@inheritDoc}
*/
public function getErrors(): array
{
$messages = [];
foreach ($this->readers as $reader) {
$messages = array_merge($messages, $reader->getErrors());
}
return $messages;
}
/**
* {@inheritDoc}
*/
public function getWarnings(): array
{
$messages = [];
foreach ($this->readers as $reader) {
$messages = array_merge($messages, $reader->getWarnings());
}
return $messages;
}
/**
* {@inheritDoc}
*/
public function getFailures(): array
{
$messages = [];
foreach ($this->readers as $reader) {
$messages = array_merge($messages, $reader->getFailures());
}
return $messages;
}
/**
* {@inheritDoc}
*/
public function getRisky(): array
{
$messages = [];
foreach ($this->readers as $reader) {
$messages = array_merge($messages, $reader->getRisky());
}
return $messages;
}
/**
* {@inheritDoc}
*/
public function getSkipped(): array
{
$messages = [];
foreach ($this->readers as $reader) {
$messages = array_merge($messages, $reader->getSkipped());
}
return $messages;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace ParaTest\Logging;
/** @internal */
interface MetaProviderInterface
{
public function getTotalTests(): int;
public function getTotalAssertions(): int;
public function getTotalErrors(): int;
public function getTotalWarnings(): int;
public function getTotalFailures(): int;
public function getTotalSkipped(): int;
public function getTotalTime(): float;
/** @return string[] */
public function getErrors(): array;
/** @return string[] */
public function getWarnings(): array;
/** @return string[] */
public function getFailures(): array;
/** @return string[] */
public function getRisky(): array;
/** @return string[] */
public function getSkipped(): array;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace ParaTest\Parser;
use Exception;
/** @internal */
final class NoClassInFileException extends Exception
{
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace ParaTest\Parser;
use ReflectionMethod;
/** @internal */
final class ParsedClass
{
/** @var class-string */
private $name;
/**
* A collection of methods belonging
* to the parsed class.
*
* @var ReflectionMethod[]
*/
private $methods;
/** @var int */
private $parentsCount;
/**
* @param class-string $name
* @param ReflectionMethod[] $methods
*/
public function __construct(string $name, array $methods, int $parentsCount)
{
$this->name = $name;
$this->methods = $methods;
$this->parentsCount = $parentsCount;
}
/**
* Get the name of a parsed object.
*
* @return class-string
*/
public function getName(): string
{
return $this->name;
}
/**
* Return the methods of this parsed class
* optionally filtering on annotations present
* on a method.
*
* @return ReflectionMethod[]
*/
public function getMethods(): array
{
return $this->methods;
}
public function getParentsCount(): int
{
return $this->parentsCount;
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace ParaTest\Parser;
use InvalidArgumentException;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use PHPUnit\Runner\Exception;
use PHPUnit\Runner\StandardTestSuiteLoader;
use PHPUnit\Util\Test as TestUtil;
use ReflectionClass;
use ReflectionMethod;
use function array_diff;
use function assert;
use function get_declared_classes;
use function is_file;
use function realpath;
/** @internal */
final class Parser
{
/** @var ReflectionClass<TestCase> */
private $refl;
/** @var ReflectionClass<TestCase>[] */
private static $alreadyLoadedSources = [];
/** @var class-string[] */
private static $externalClassesFound = [];
public function __construct(string $srcPath)
{
if (! is_file($srcPath)) {
throw new InvalidArgumentException('file not found: ' . $srcPath);
}
$srcPath = realpath($srcPath);
assert($srcPath !== false);
if (! isset(self::$alreadyLoadedSources[$srcPath])) {
$declaredClasses = get_declared_classes();
try {
$refClass = (new StandardTestSuiteLoader())->load($srcPath);
if (! $refClass->isSubclassOf(TestCase::class)) {
throw new NoClassInFileException($srcPath);
}
self::$alreadyLoadedSources[$srcPath] = $refClass;
self::$externalClassesFound += array_diff(
get_declared_classes(),
$declaredClasses,
[self::$alreadyLoadedSources[$srcPath]->getName()],
);
} catch (Exception $exception) {
self::$externalClassesFound += array_diff(get_declared_classes(), $declaredClasses);
$reflFound = null;
foreach (self::$externalClassesFound as $newClass) {
$refClass = new ReflectionClass($newClass);
if ($refClass->getFileName() !== $srcPath) {
continue;
}
$reflFound = $refClass;
break;
}
if ($reflFound === null || ! $reflFound->isSubclassOf(TestCase::class) || $reflFound->isAbstract()) {
throw new NoClassInFileException($srcPath, 0, $exception);
}
self::$alreadyLoadedSources[$srcPath] = $reflFound;
}
}
$this->refl = self::$alreadyLoadedSources[$srcPath];
}
/**
* Returns the fully constructed class
* with methods or null if the class is abstract.
*/
public function getClass(): ParsedClass
{
$parentsCount = 0;
$class = $this->refl;
while (($parent = $class->getParentClass()) !== false) {
++$parentsCount;
$class = $parent;
}
return new ParsedClass(
$this->refl->getName(),
$this->getMethods(),
$parentsCount,
);
}
/**
* Return all test methods present in the file.
*
* @return ReflectionMethod[]
* @psalm-return list<ReflectionMethod>
*/
private function getMethods(): array
{
$methods = [];
// @see \PHPUnit\Framework\TestSuite::__construct
foreach ($this->refl->getMethods() as $method) {
if ($method->getDeclaringClass()->getName() === Assert::class) {
continue;
}
if ($method->getDeclaringClass()->getName() === TestCase::class) {
continue;
}
if (! TestUtil::isTestMethod($method)) {
continue;
}
$methods[] = $method;
}
return $methods;
}
}

View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\Coverage\CoverageReporter;
use ParaTest\Logging\JUnit\Writer;
use ParaTest\Logging\LogInterpreter;
use SebastianBergmann\Timer\Timer;
use Symfony\Component\Console\Output\OutputInterface;
use function array_reverse;
use function assert;
use function file_put_contents;
use function mt_srand;
use function shuffle;
use function sprintf;
/** @internal */
abstract class BaseRunner implements RunnerInterface
{
protected const CYCLE_SLEEP = 10000;
/** @var Options */
protected $options;
/** @var ResultPrinter */
protected $printer;
/**
* A collection of pending ExecutableTest objects that have
* yet to run.
*
* @var ExecutableTest[]
*/
protected $pending = [];
/**
* A tallied exit code that returns the highest exit
* code returned out of the entire collection of tests.
*
* @var int
*/
protected $exitcode = 0;
/** @var OutputInterface */
protected $output;
/** @var LogInterpreter */
private $interpreter;
/**
* CoverageMerger to hold track of the accumulated coverage.
*
* @var CoverageMerger|null
*/
private $coverage = null;
public function __construct(Options $options, OutputInterface $output)
{
$this->options = $options;
$this->output = $output;
$this->interpreter = new LogInterpreter();
$this->printer = new ResultPrinter($this->interpreter, $output, $options);
if (! $this->options->hasCoverage()) {
return;
}
$this->coverage = new CoverageMerger($this->options->coverageTestLimit());
}
final public function run(): void
{
$this->load(new SuiteLoader($this->options, $this->output));
$this->printer->start();
$this->doRun();
$this->complete();
}
abstract protected function doRun(): void;
/**
* Builds the collection of pending ExecutableTest objects
* to run. If functional mode is enabled $this->pending will
* contain a collection of TestMethod objects instead of Suite
* objects.
*/
private function load(SuiteLoader $loader): void
{
$this->beforeLoadChecks();
$loader->load();
$this->pending = $this->options->functional()
? $loader->getTestMethods()
: $loader->getSuites();
$this->sortPending();
foreach ($this->pending as $pending) {
$this->printer->addTest($pending);
}
}
private function sortPending(): void
{
if ($this->options->orderBy() === Options::ORDER_RANDOM) {
mt_srand($this->options->randomOrderSeed());
shuffle($this->pending);
}
if ($this->options->orderBy() !== Options::ORDER_REVERSE) {
return;
}
$this->pending = array_reverse($this->pending);
}
abstract protected function beforeLoadChecks(): void;
/**
* Finalizes the run process. This method
* prints all results, rewinds the log interpreter,
* logs any results to JUnit, and cleans up temporary
* files.
*/
private function complete(): void
{
$this->printer->printResults();
$this->log();
$this->logCoverage();
$readers = $this->interpreter->getReaders();
foreach ($readers as $reader) {
$reader->removeLog();
}
}
/**
* Returns the highest exit code encountered
* throughout the course of test execution.
*/
final public function getExitCode(): int
{
return $this->exitcode;
}
/**
* Write output to JUnit format if requested.
*/
final protected function log(): void
{
if (($logJunit = $this->options->logJunit()) === null) {
return;
}
$name = $this->options->path() ?? '';
$writer = new Writer($this->interpreter, $name);
$writer->write($logJunit);
}
/**
* Write coverage to file if requested.
*/
final protected function logCoverage(): void
{
if (! $this->hasCoverage()) {
return;
}
$coverageMerger = $this->getCoverage();
assert($coverageMerger !== null);
$codeCoverage = $coverageMerger->getCodeCoverageObject();
assert($codeCoverage !== null);
$codeCoverageConfiguration = null;
if (($configuration = $this->options->configuration()) !== null) {
$codeCoverageConfiguration = $configuration->codeCoverage();
}
$reporter = new CoverageReporter($codeCoverage, $codeCoverageConfiguration);
$output = $this->output;
$timer = new Timer();
$start = static function (string $format) use ($output, $timer): void {
$output->write(sprintf("\nGenerating code coverage report in %s format ... ", $format));
$timer->start();
};
$stop = static function () use ($output, $timer): void {
$output->write(sprintf("done [%s]\n", $timer->stop()->asString()));
};
if (($coverageClover = $this->options->coverageClover()) !== null) {
$start('Clover XML');
$reporter->clover($coverageClover);
$stop();
}
if (($coverageCobertura = $this->options->coverageCobertura()) !== null) {
$start('Cobertura XML');
$reporter->cobertura($coverageCobertura);
$stop();
}
if (($coverageCrap4j = $this->options->coverageCrap4j()) !== null) {
$start('Crap4J XML');
$reporter->crap4j($coverageCrap4j);
$stop();
}
if (($coverageHtml = $this->options->coverageHtml()) !== null) {
$start('HTML');
$reporter->html($coverageHtml);
$stop();
}
if (($coveragePhp = $this->options->coveragePhp()) !== null) {
$start('PHP');
$reporter->php($coveragePhp);
$stop();
}
if (($coverageText = $this->options->coverageText()) !== null) {
if ($coverageText === '') {
$this->output->write($reporter->text($this->options->colors()));
} else {
file_put_contents($coverageText, $reporter->text($this->options->colors()));
}
}
if (($coverageXml = $this->options->coverageXml()) === null) {
return;
}
$start('PHPUnit XML');
$reporter->xml($coverageXml);
$stop();
}
final protected function hasCoverage(): bool
{
return $this->options->hasCoverage();
}
final protected function getCoverage(): ?CoverageMerger
{
return $this->coverage;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use RuntimeException;
/** @internal */
final class EmptyLogFileException extends RuntimeException
{
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use ParaTest\Runners\PHPUnit\Worker\NullPhpunitPrinter;
use function array_map;
use function array_merge;
use function assert;
use function tempnam;
use function unlink;
abstract class ExecutableTest
{
/**
* The path to the test to run.
*
* @var string
*/
private $path;
/**
* A path to the temp JUnit file created
* for this test.
*
* @var string|null
*/
private $tempJUnit;
/**
* Path where the coveragereport is stored.
*
* @var string|null
*/
private $coverageFileName;
/**
* A path to the temp Teamcity format file created
* for this test.
*
* @var string|null
*/
private $tempTeamcity;
/**
* Last executed process command.
*
* @var string
*/
private $lastCommand = '';
/** @var bool */
private $needsCoverage;
/** @var bool */
private $needsTeamcity;
/** @var string */
private $tmpDir;
public function __construct(string $path, bool $needsCoverage, bool $needsTeamcity, string $tmpDir)
{
$this->path = $path;
$this->needsCoverage = $needsCoverage;
$this->needsTeamcity = $needsTeamcity;
$this->tmpDir = $tmpDir;
}
/**
* Get the expected count of tests to be executed.
*/
abstract public function getTestCount(): int;
/**
* Get the path to the test being executed.
*/
final public function getPath(): string
{
return $this->path;
}
private function touchTempFile(?string &$tempName, string $prefix): string
{
if ($tempName === null) {
$newFile = tempnam($this->tmpDir, $prefix);
assert($newFile !== false);
$tempName = $newFile;
}
return $tempName;
}
private function unlinkTempFile(?string &$tempName): void
{
if ($tempName === null) {
return;
}
unlink($tempName);
$tempName = null;
}
/**
* Returns the path to this test's JUnit temp file.
* If the temp file does not exist, it will be
* created.
*/
final public function getTempFile(): string
{
return $this->touchTempFile($this->tempJUnit, 'PT_');
}
/**
* Removes the test file.
*/
final public function deleteTempFiles(): void
{
$this->unlinkTempFile($this->tempJUnit);
$this->unlinkTempFile($this->tempTeamcity);
$this->unlinkTempFile($this->coverageFileName);
}
/**
* Return the last process command.
*/
final public function getLastCommand(): string
{
return $this->lastCommand;
}
/**
* Set the last process command.
*/
final public function setLastCommand(string $command): void
{
$this->lastCommand = $command;
}
/**
* Generate command line arguments with passed options suitable to handle through paratest.
*
* @param string $binary executable binary name
* @param array<string, string|null> $options command line options
* @param string[]|null $passthru
*
* @return string[] command line arguments
* @psalm-return array<string>
*/
final public function commandArguments(string $binary, array $options, ?array $passthru): array
{
$options = $this->prepareOptions($options);
$options['no-logging'] = null;
$options['no-coverage'] = null;
$options['printer'] = NullPhpunitPrinter::class;
$options['log-junit'] = $this->getTempFile();
if ($this->needsTeamcity) {
$options['log-teamcity'] = $this->getTeamcityTempFile();
}
if ($this->needsCoverage) {
$options['coverage-php'] = $this->getCoverageFileName();
}
$arguments = [$binary];
if ($passthru !== null) {
$arguments = array_merge($arguments, $passthru);
}
foreach ($options as $key => $value) {
$arguments[] = "--{$key}";
if ($value === null) {
continue;
}
$arguments[] = $value;
}
$arguments[] = $this->getPath();
$arguments = array_map('strval', $arguments);
return $arguments;
}
/**
* Get coverage filename.
*/
final public function getCoverageFileName(): string
{
return $this->touchTempFile($this->coverageFileName, 'CV_');
}
/**
* Returns the path to this test's Teamcity format temp file.
* If the temp file does not exist, it will be
* created.
*/
final public function getTeamcityTempFile(): string
{
return $this->touchTempFile($this->tempTeamcity, 'TF_');
}
/**
* A template method that can be overridden to add necessary options for a test.
*
* @param array<string, string|null> $options
*
* @return array<string, string|null>
*/
abstract protected function prepareOptions(array $options): array;
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use function array_merge;
/** @internal */
final class FullSuite extends ExecutableTest
{
/** @var string */
private $suiteName;
public function __construct(string $suiteName, bool $needsCoverage, bool $needsTeamcity, string $tmpDir)
{
parent::__construct('', $needsCoverage, $needsTeamcity, $tmpDir);
$this->suiteName = $suiteName;
}
/** @inheritDoc */
protected function prepareOptions(array $options): array
{
return array_merge(
$options,
['testsuite' => $this->suiteName],
);
}
/** @psalm-return 1 */
public function getTestCount(): int
{
return 1; //There is no simple way of knowing this
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,745 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use InvalidArgumentException;
use ParaTest\Logging\JUnit\ErrorTestCase;
use ParaTest\Logging\JUnit\FailureTestCase;
use ParaTest\Logging\JUnit\Reader;
use ParaTest\Logging\JUnit\RiskyTestCase;
use ParaTest\Logging\JUnit\SkippedTestCase;
use ParaTest\Logging\JUnit\SuccessTestCase;
use ParaTest\Logging\JUnit\TestCaseWithMessage;
use ParaTest\Logging\JUnit\TestSuite;
use ParaTest\Logging\JUnit\WarningTestCase;
use ParaTest\Logging\LogInterpreter;
use PHPUnit\Framework\TestCase;
use PHPUnit\Util\Color;
use PHPUnit\Util\TestDox\NamePrettifier;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\Timer\ResourceUsageFormatter;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;
use function array_filter;
use function array_map;
use function assert;
use function class_exists;
use function count;
use function explode;
use function fclose;
use function file_get_contents;
use function filesize;
use function floor;
use function fopen;
use function fwrite;
use function get_class;
use function implode;
use function is_array;
use function is_string;
use function max;
use function preg_split;
use function rtrim;
use function sprintf;
use function str_pad;
use function str_repeat;
use function strlen;
use const DIRECTORY_SEPARATOR;
use const PHP_EOL;
use const PHP_SAPI;
use const PHP_VERSION;
/**
* Used for outputting ParaTest results
*
* @internal
*/
final class ResultPrinter
{
private const TESTDOX_SYMBOLS = [
SuccessTestCase::class => '✔',
ErrorTestCase::class => '✘',
FailureTestCase::class => '✘',
SkippedTestCase::class => '↩',
RiskyTestCase::class => '☢',
WarningTestCase::class => '⚠',
];
/** @var LogInterpreter */
private $results;
/**
* The number of tests results currently printed.
* Used to determine when to tally current results
* and start a new row.
*
* @var int
*/
private $numTestsWidth = 0;
/**
* Used for formatting results to a given width.
*
* @var int
*/
private $maxColumn = 0;
/**
* The total number of cases to be run.
*
* @var int
*/
private $totalCases = 0;
/**
* The current column being printed to.
*
* @var int
*/
private $column = 0;
/**
* The total number of cases printed so far.
*
* @var int
*/
private $casesProcessed = 0;
/**
* Number of columns.
*
* @var int
*/
private $numberOfColumns = 80;
/**
* Number of skipped or incomplete tests.
*
* @var int
*/
private $totalSkippedOrIncomplete = 0;
/**
* Do we need to try to process skipped/incompleted tests.
*
* @var bool
*/
private $processSkipped = false;
/** @var OutputInterface */
private $output;
/** @var Options */
private $options;
/** @var bool */
private $needsTeamcity;
/** @var bool */
private $printsTeamcity;
/** @var resource|null */
private $teamcityLogFileHandle;
public function __construct(LogInterpreter $results, OutputInterface $output, Options $options)
{
$this->results = $results;
$this->output = $output;
$this->options = $options;
$this->printsTeamcity = $this->options->teamcity();
$this->needsTeamcity = $this->options->needsTeamcity();
if (($teamcityLogFile = $this->options->logTeamcity()) === null) {
return;
}
$teamcityLogFileHandle = fopen($teamcityLogFile, 'ab+');
assert($teamcityLogFileHandle !== false);
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
}
/**
* Adds an ExecutableTest to the tracked results.
*/
public function addTest(ExecutableTest $suite): void
{
$this->totalCases += $suite->getTestCount();
}
/**
* Initializes printing constraints, prints header
* information and starts the test timer.
*/
public function start(): void
{
$this->numTestsWidth = strlen((string) $this->totalCases);
$this->maxColumn = $this->numberOfColumns
+ (DIRECTORY_SEPARATOR === '\\' ? -1 : 0) // fix windows blank lines
- strlen($this->getProgress());
if ($this->options->verbose()) {
// @see \PHPUnit\TextUI\TestRunner::writeMessage()
$output = $this->output;
$write = static function (string $type, string $message) use ($output): void {
$output->write(sprintf("%-15s%s\n", $type . ':', $message));
};
// @see \PHPUnit\TextUI\TestRunner::run()
$write('Processes', $this->options->processes() . ($this->options->functional() ? '. Functional mode is ON.' : ''));
$configuration = $this->options->configuration();
if (PHP_SAPI === 'phpdbg') {
$write('Runtime', 'PHPDBG ' . PHP_VERSION); // @codeCoverageIgnore
} else {
$runtime = 'PHP ' . PHP_VERSION;
if ($this->options->hasCoverage()) {
$filter = new Filter();
if ($configuration !== null && $configuration->codeCoverage()->pathCoverage()) {
$codeCoverageDriver = (new Selector())->forLineAndPathCoverage($filter); // @codeCoverageIgnore
} else {
$codeCoverageDriver = (new Selector())->forLineCoverage($filter);
}
$runtime .= ' with ' . $codeCoverageDriver->nameAndVersion();
}
$write('Runtime', $runtime);
}
if ($configuration !== null) {
$write('Configuration', $configuration->filename());
}
if ($this->options->orderBy() === Options::ORDER_RANDOM) {
$write('Random Seed', (string) $this->options->randomOrderSeed());
}
$output->write("\n");
}
$this->processSkipped = $this->isSkippedIncompleTestCanBeTracked($this->options);
}
public function println(string $string = ''): void
{
$this->column = 0;
$this->output->write($string . "\n");
}
/**
* Prints all results and removes any log files
* used for aggregating results.
*/
public function printResults(): void
{
$toFilter = [
$this->getErrors(),
$this->getWarnings(),
$this->getFailures(),
$this->getRisky(),
];
if ($this->options->verbose()) {
$toFilter[] = $this->getSkipped();
}
$failures = array_filter($toFilter);
$escapedFailures = array_map(static function (string $failure): string {
return OutputFormatter::escape($failure);
}, $failures);
$this->output->write($this->getHeader());
$this->output->write(implode("---\n\n", $escapedFailures));
$this->output->write($this->getFooter());
if ($this->teamcityLogFileHandle === null) {
return;
}
$resource = $this->teamcityLogFileHandle;
$this->teamcityLogFileHandle = null;
fclose($resource);
}
/**
* Prints the individual "quick" feedback for run
* tests, that is the ".EF" items.
*/
public function printFeedback(ExecutableTest $test): Reader
{
try {
$reader = new Reader($test->getTempFile());
} catch (InvalidArgumentException $invalidArgumentException) {
throw new EmptyLogFileException(
$invalidArgumentException->getMessage(),
0,
$invalidArgumentException,
);
}
$teamcityContent = null;
if ($this->needsTeamcity) {
$teamcityLogFile = $test->getTeamcityTempFile();
if (filesize($teamcityLogFile) === 0) {
throw new EmptyLogFileException("Teamcity format file {$teamcityLogFile} is empty");
}
$teamcityContent = file_get_contents($teamcityLogFile);
assert($teamcityContent !== false);
if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityContent);
}
}
$this->results->addReader($reader);
if ($teamcityContent !== null) {
$this->output->write(OutputFormatter::escape($teamcityContent));
} elseif ($this->options->testdox()) {
$this->processTestdoxReader($reader->getSuite());
} else {
$this->processReaderFeedback($reader, $test->getTestCount());
}
return $reader;
}
/**
* Returns the header containing resource usage.
*/
public function getHeader(): string
{
$resourceUsage = (new ResourceUsageFormatter())->resourceUsageSinceStartOfRequest();
return "\n" . $resourceUsage . "\n\n";
}
/**
* Return the footer information reporting success
* or failure.
*/
public function getFooter(): string
{
if ($this->results->isSuccessful()) {
if ($this->results->getTotalWarnings() === 0) {
$footer = $this->getSuccessFooter();
} else {
$footer = $this->getWarningFooter();
}
} else {
$footer = $this->getFailedFooter();
}
return "{$footer}\n";
}
/**
* Returns error messages.
*/
public function getErrors(): string
{
$errors = $this->results->getErrors();
return $this->getDefects($errors, 'error');
}
/**
* Returns warning messages as a string.
*/
public function getWarnings(): string
{
$warnings = $this->results->getWarnings();
return $this->getDefects($warnings, 'warning');
}
/**
* Returns the failure messages.
*/
public function getFailures(): string
{
$failures = $this->results->getFailures();
return $this->getDefects($failures, 'failure');
}
/**
* Returns the risky messages.
*/
public function getRisky(): string
{
$risky = $this->results->getRisky();
return $this->getDefects($risky, 'risky');
}
/**
* Returns the skipped messages.
*/
public function getSkipped(): string
{
$risky = $this->results->getSkipped();
return $this->getDefects($risky, 'skipped');
}
/**
* Returns the total cases being printed.
*/
public function getTotalCases(): int
{
return $this->totalCases;
}
/**
* Process reader feedback and print it.
*/
private function processReaderFeedback(Reader $reader, int $expectedTestCount): void
{
$feedbackItems = $reader->getFeedback();
$actualTestCount = strlen($feedbackItems);
$this->processTestOverhead($actualTestCount, $expectedTestCount);
for ($index = 0; $index < $actualTestCount; ++$index) {
$item = $feedbackItems[$index];
$this->printFeedbackItem($item);
if ($item !== 'S') {
continue;
}
++$this->totalSkippedOrIncomplete;
}
if (! $this->processSkipped) {
return;
}
$this->printSkippedAndIncomplete($actualTestCount, $expectedTestCount);
}
/**
* Is skipped/incomplete amount can be properly processed.
*
* @todo Skipped/Incomplete test tracking available only in functional mode for now
* or in regular mode but without group/exclude-group filters.
*/
private function isSkippedIncompleTestCanBeTracked(Options $options): bool
{
return $options->functional()
|| (count($options->group()) === 0 && count($options->excludeGroup()) === 0);
}
/**
* Process test overhead.
*
* In some situations phpunit can return more tests then we expect and in that case
* this method correct total amount of tests so paratest progress will be auto corrected.
*
* @todo May be we need to throw Exception here instead of silent correction.
*/
private function processTestOverhead(int $actualTestCount, int $expectedTestCount): void
{
$overhead = $actualTestCount - $expectedTestCount;
if ($this->processSkipped) {
if ($overhead > 0) {
$this->totalCases += $overhead;
} else {
$this->totalSkippedOrIncomplete += -$overhead;
}
} else {
$this->totalCases += $overhead;
}
}
/**
* Prints S for skipped and incomplete tests.
*
* If for some reason process return less tests than expected then we threat all remaining
* as skipped or incomplete and print them as skipped (S letter)
*/
private function printSkippedAndIncomplete(int $actualTestCount, int $expectedTestCount): void
{
$overhead = $expectedTestCount - $actualTestCount;
if ($overhead <= 0) {
return;
}
for ($i = 0; $i < $overhead; ++$i) {
$this->printFeedbackItem('S');
}
}
/**
* Prints a single "quick" feedback item and increments
* the total number of processed cases and the column
* position.
*/
private function printFeedbackItem(string $item): void
{
$this->printFeedbackItemColor($item);
++$this->column;
++$this->casesProcessed;
if ($this->column !== $this->maxColumn && $this->casesProcessed < $this->totalCases) {
return;
}
if (
$this->casesProcessed > 0
&& $this->casesProcessed === $this->totalCases
&& ($pad = $this->maxColumn - $this->column) > 0
) {
$this->output->write(str_repeat(' ', $pad));
}
$this->output->write($this->getProgress());
$this->println();
}
private function printFeedbackItemColor(string $item): void
{
$buffer = $item;
switch ($item) {
case 'E':
$buffer = $this->colorizeTextBox('fg-red, bold', $item);
break;
case 'F':
$buffer = $this->colorizeTextBox('bg-red, fg-white', $item);
break;
case 'W':
case 'I':
case 'R':
$buffer = $this->colorizeTextBox('fg-yellow, bold', $item);
break;
case 'S':
$buffer = $this->colorizeTextBox('fg-cyan, bold', $item);
break;
}
$this->output->write($buffer);
}
/**
* Method that returns a formatted string
* for a collection of errors or failures.
*
* @param string[] $defects
*/
private function getDefects(array $defects, string $type): string
{
$count = count($defects);
if ($count === 0) {
return '';
}
$output = sprintf(
"There %s %d %s%s:\n",
$count === 1 ? 'was' : 'were',
$count,
$type,
$count === 1 ? '' : 's',
);
for ($i = 1; $i <= count($defects); ++$i) {
$output .= sprintf("\n%d) %s\n", $i, $defects[$i - 1]);
}
$output .= "\n";
return $output;
}
/**
* Prints progress for large test collections.
*/
private function getProgress(): string
{
return sprintf(
' %' . $this->numTestsWidth . 'd / %' . $this->numTestsWidth . 'd (%3s%%)',
$this->casesProcessed,
$this->totalCases,
floor(($this->totalCases > 0 ? $this->casesProcessed / $this->totalCases : 0) * 100),
);
}
/**
* Get the footer for a test collection that had tests with
* failures or errors.
*/
private function getFailedFooter(): string
{
$formatString = "FAILURES!\n%s";
return $this->colorizeTextBox(
'fg-white, bg-red',
sprintf(
$formatString,
$this->getFooterCounts(),
),
);
}
/**
* Get the footer for a test collection containing all successful
* tests.
*/
private function getSuccessFooter(): string
{
if ($this->totalSkippedOrIncomplete === 0) {
$tests = $this->totalCases;
$asserts = $this->results->getTotalAssertions();
return $this->colorizeTextBox(
'fg-black, bg-green',
sprintf(
'OK (%d test%s, %d assertion%s)',
$tests,
$tests === 1 ? '' : 's',
$asserts,
$asserts === 1 ? '' : 's',
),
);
}
return $this->colorizeTextBox(
'fg-black, bg-yellow',
sprintf(
"OK, but incomplete, skipped, or risky tests!\n"
. '%s',
$this->getFooterCounts(),
),
);
}
private function getWarningFooter(): string
{
$formatString = "WARNINGS!\n%s";
return $this->colorizeTextBox(
'fg-black, bg-yellow',
sprintf(
$formatString,
$this->getFooterCounts(),
),
);
}
private function getFooterCounts(): string
{
$counts = [
'Tests' => $this->results->getTotalTests(),
'Assertions' => $this->results->getTotalAssertions(),
] + array_filter([
'Errors' => $this->results->getTotalErrors(),
'Failures' => $this->results->getTotalFailures(),
'Warnings' => $this->results->getTotalWarnings(),
'Skipped' => $this->results->getTotalSkipped(),
]);
$output = '';
foreach ($counts as $label => $count) {
$output .= sprintf('%s: %s, ', $label, $count);
}
return rtrim($output, ', ') . '.';
}
/** @see \PHPUnit\TextUI\DefaultResultPrinter::colorizeTextBox */
private function colorizeTextBox(string $color, string $buffer): string
{
if (! $this->options->colors()) {
return $buffer;
}
$lines = preg_split('/\r\n|\r|\n/', $buffer);
assert(is_array($lines));
$padding = max(array_map('\\strlen', $lines));
$styledLines = [];
foreach ($lines as $line) {
$styledLines[] = Color::colorize($color, str_pad($line, $padding));
}
return implode(PHP_EOL, $styledLines);
}
private function processTestdoxReader(TestSuite $testSuite): void
{
foreach ($testSuite->suites as $suite) {
$this->processTestdoxReader($suite);
}
if ($testSuite->cases === []) {
return;
}
$prettifier = new NamePrettifier(false);
$separator = PHP_EOL;
foreach ($testSuite->cases as $index => $case) {
if ($index === 0) {
$class = $case->class;
assert(class_exists($class));
$this->output->writeln($prettifier->prettifyTestClass($class));
}
assert(isset($class) && is_string($class) && class_exists($class));
$separator = PHP_EOL;
$testCase = new $class($case->name);
assert($testCase instanceof TestCase);
$time = '';
if ($this->options->verbose()) {
$time = sprintf(' [%.2f ms]', $case->time * 1000);
}
$testName = $prettifier->prettifyTestCase($testCase);
$this->output->writeln(sprintf(
' %s %s%s',
self::TESTDOX_SYMBOLS[get_class($case)],
$testName,
$time,
));
$failingCase = $case instanceof FailureTestCase || $case instanceof ErrorTestCase || $case instanceof WarningTestCase;
if (! $this->options->verbose() && ! $failingCase) {
continue;
}
if (! $case instanceof TestCaseWithMessage) {
continue;
}
$lines = explode("\n", $case->text);
$lines[0] = '';
if ($case instanceof SkippedTestCase) {
unset($lines[0]);
}
$lines[] = '';
foreach ($lines as $index => $line) {
$lines[$index] = ' │' . ($line !== '' ? ' ' . $line : '');
}
$this->output->writeln(implode(PHP_EOL, $lines) . PHP_EOL);
$separator = '';
}
$this->output->write($separator);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use Exception;
use ParaTest\Runners\PHPUnit\Worker\RunnerWorker;
use PHPUnit\TextUI\TestRunner;
use function array_shift;
use function assert;
use function count;
use function max;
use function range;
use function usleep;
/** @internal */
final class Runner extends BaseRunner
{
/**
* A collection of ExecutableTest objects that have processes
* currently running.
*
* @var RunnerWorker[]
*/
private $running = [];
/**
* The money maker. Runs all ExecutableTest objects in separate processes.
*/
protected function doRun(): void
{
$availableTokens = range(1, $this->options->processes());
while (count($this->running) > 0 || count($this->pending) > 0) {
$this->fillRunQueue($availableTokens);
usleep(self::CYCLE_SLEEP);
$availableTokens = [];
foreach ($this->running as $token => $test) {
if ($this->testIsStillRunning($test)) {
continue;
}
unset($this->running[$token]);
$availableTokens[] = $token;
}
}
}
/**
* This method removes ExecutableTest objects from the pending collection
* and adds them to the running collection. It is also in charge of recycling and
* acquiring available test tokens for use.
*
* @param int[] $availableTokens
*/
private function fillRunQueue(array $availableTokens): void
{
while (
count($this->pending) > 0
&& count($this->running) < $this->options->processes()
&& ($token = array_shift($availableTokens)) !== null
) {
$executableTest = array_shift($this->pending);
$this->running[$token] = new RunnerWorker($executableTest, $this->options, $token);
$this->running[$token]->run();
if (! $this->options->debug()) {
continue;
}
$cmd = $this->running[$token];
$this->output->write("\nExecuting test via: {$cmd->getExecutableTest()->getLastCommand()}\n");
}
}
/**
* Returns whether or not a test has finished being
* executed. If it has, this method also halts a test process - optionally
* throwing an exception if a fatal error has occurred -
* prints feedback, and updates the overall exit code.
*
* @throws Exception
*/
private function testIsStillRunning(RunnerWorker $worker): bool
{
if ($worker->isRunning()) {
return true;
}
$this->exitcode = max($this->exitcode, (int) $worker->stop());
if (($this->options->stopOnFailure() || $this->options->stopOnError()) && $this->exitcode > 0) {
$this->pending = [];
}
if (
$this->exitcode > 0
&& $this->exitcode !== TestRunner::FAILURE_EXIT
&& $this->exitcode !== TestRunner::EXCEPTION_EXIT
) {
throw $worker->getWorkerCrashedException();
}
$executableTest = $worker->getExecutableTest();
try {
$this->printer->printFeedback($executableTest);
} catch (EmptyLogFileException $emptyLogFileException) {
throw $worker->getWorkerCrashedException($emptyLogFileException);
}
if ($this->hasCoverage()) {
$coverageMerger = $this->getCoverage();
assert($coverageMerger !== null);
$coverageMerger->addCoverageFromFile($executableTest->getCoverageFileName());
}
return false;
}
protected function beforeLoadChecks(): void
{
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
interface RunnerInterface
{
public function run(): void;
public function getExitCode(): int;
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use function array_map;
use function array_sum;
/**
* A suite represents an entire PHPUnit Test Suite
* object - this class is essentially used for running
* entire test classes in parallel
*
* @internal
*/
final class Suite extends ExecutableTest
{
/**
* A collection of test methods.
*
* @var TestMethod[]
*/
private $functions;
/** @param TestMethod[] $functions */
public function __construct(string $path, array $functions, bool $needsCoverage, bool $needsTeamcity, string $tmpDir)
{
parent::__construct($path, $needsCoverage, $needsTeamcity, $tmpDir);
$this->functions = $functions;
}
/**
* Return the collection of test methods.
*
* @return TestMethod[]
*/
public function getFunctions(): array
{
return $this->functions;
}
/**
* Get the expected count of tests to be executed.
*
* @psalm-return int
*/
public function getTestCount(): int
{
return array_sum(array_map(static function (TestMethod $method): int {
return $method->getTestCount();
}, $this->functions));
}
/** @inheritDoc */
protected function prepareOptions(array $options): array
{
return $options;
}
}

View File

@@ -0,0 +1,522 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use ParaTest\Parser\NoClassInFileException;
use ParaTest\Parser\ParsedClass;
use ParaTest\Parser\Parser;
use PHPUnit\Framework\ExecutionOrderDependency;
use PHPUnit\TextUI\XmlConfiguration\CodeCoverage\FilterMapper;
use PHPUnit\TextUI\XmlConfiguration\Configuration;
use PHPUnit\TextUI\XmlConfiguration\PhpHandler;
use PHPUnit\TextUI\XmlConfiguration\TestSuite;
use PHPUnit\Util\FileLoader;
use PHPUnit\Util\Test;
use ReflectionMethod;
use RuntimeException;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\StaticAnalysis\CacheWarmer;
use SebastianBergmann\Environment\Runtime;
use SebastianBergmann\FileIterator\Facade;
use SebastianBergmann\Timer\Timer;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function array_filter;
use function array_intersect;
use function array_keys;
use function array_map;
use function array_merge;
use function array_unique;
use function array_values;
use function assert;
use function count;
use function in_array;
use function is_array;
use function is_int;
use function ksort;
use function preg_match;
use function realpath;
use function sprintf;
use function strpos;
use function strrpos;
use function substr;
use function trim;
use function version_compare;
use const PHP_VERSION;
/** @internal */
final class SuiteLoader
{
/**
* The collection of loaded files.
*
* @var string[]
*/
private $files = [];
/** @var string[]|null */
private $suitesName = null;
/**
* The collection of parsed test classes.
*
* @var array<string, ExecutableTest>
*/
private $loadedSuites = [];
/**
* The configuration.
*
* @var Configuration|null
*/
private $configuration;
/** @var Options */
private $options;
/** @var OutputInterface */
private $output;
public function __construct(Options $options, OutputInterface $output)
{
$this->options = $options;
$this->configuration = $options->configuration();
$this->output = $output;
}
/**
* Returns all parsed suite objects as ExecutableTest
* instances.
*
* @return ExecutableTest[]
* @psalm-return list<ExecutableTest>
*/
public function getSuites(): array
{
return array_values($this->loadedSuites);
}
/**
* Returns a collection of TestMethod objects
* for all loaded ExecutableTest instances.
*
* @return TestMethod[]
*/
public function getTestMethods(): array
{
$methods = [];
foreach ($this->loadedSuites as $suite) {
assert($suite instanceof Suite);
$methods = array_merge($methods, $suite->getFunctions());
}
return $methods;
}
/**
* Populates the loaded suite collection. Will load suites
* based off a phpunit xml configuration or a specified path.
*
* @throws RuntimeException
*/
public function load(): void
{
$this->loadConfiguration();
$testSuiteCollection = null;
if (($path = $this->options->path()) !== null) {
if (realpath($path) === false) {
throw new RuntimeException("Invalid path {$path} provided");
}
$this->files = array_merge(
$this->files,
(new Facade())->getFilesAsArray($path, ['Test.php']),
);
} elseif (
$this->options->parallelSuite()
&& $this->configuration !== null
&& ! $this->configuration->testSuite()->isEmpty()
) {
$testSuiteCollection = $this->configuration->testSuite()->asArray();
$this->suitesName = array_map(static function (TestSuite $testSuite): string {
return $testSuite->name();
}, $testSuiteCollection);
} elseif (
$this->configuration !== null
&& ! $this->configuration->testSuite()->isEmpty()
) {
$testSuiteCollection = array_filter(
$this->configuration->testSuite()->asArray(),
function (TestSuite $testSuite): bool {
return $this->options->testsuite() === [] ||
in_array($testSuite->name(), $this->options->testsuite(), true);
},
);
foreach ($testSuiteCollection as $testSuite) {
$this->loadFilesFromTestSuite($testSuite);
}
}
if ($path === null && $testSuiteCollection === null) {
throw new RuntimeException('No path or configuration provided (tests must end with Test.php)');
}
$this->files = array_unique($this->files); // remove duplicates
$this->initSuites();
$this->warmCoverageCache();
}
/**
* Called after all files are loaded. Parses loaded files into
* ExecutableTest objects - either Suite or TestMethod or FullSuite.
*/
private function initSuites(): void
{
if (is_array($this->suitesName)) {
foreach ($this->suitesName as $suiteName) {
$this->loadedSuites[$suiteName] = $this->createFullSuite($suiteName);
}
} else {
// The $class->getParentsCount() + array_merge(...$loadedSuites) stuff
// are needed to run test with child tests early, because PHPUnit autoloading
// of such classes in WrapperRunner environments fails (Runner is fine)
$loadedSuites = [];
foreach ($this->files as $path) {
try {
$class = (new Parser($path))->getClass();
$suite = $this->createSuite($path, $class);
if (count($suite->getFunctions()) > 0) {
$loadedSuites[$class->getParentsCount()][$path] = $suite;
}
} catch (NoClassInFileException $e) {
continue;
}
}
foreach (array_keys($loadedSuites) as $key) {
ksort($loadedSuites[$key]);
}
ksort($loadedSuites);
foreach ($loadedSuites as $loadedSuite) {
$this->loadedSuites = array_merge($this->loadedSuites, $loadedSuite);
}
}
}
/**
* @return TestMethod[]
* @psalm-return list<TestMethod>
*/
private function executableTests(string $path, ParsedClass $class): array
{
$executableTests = [];
$methodBatches = $this->getMethodBatches($class);
foreach ($methodBatches as $methodBatch) {
$executableTests[] = new TestMethod(
$path,
$methodBatch,
$this->options->hasCoverage(),
$this->options->needsTeamcity(),
$this->options->tmpDir(),
);
}
return $executableTests;
}
/**
* Get method batches.
*
* Identify method dependencies, and group dependents and dependees on a single methodBatch.
* Use max batch size to fill batches.
*
* @return string[][] of MethodBatches. Each MethodBatch has an array of method names
*/
private function getMethodBatches(ParsedClass $class): array
{
$classMethods = $class->getMethods();
$maxBatchSize = $this->options->functional() ? $this->options->maxBatchSize() : 0;
assert($maxBatchSize !== null);
$batches = [];
foreach ($classMethods as $method) {
$tests = $this->getMethodTests($class, $method);
// if filter passed to paratest then method tests can be blank if not match to filter
if (count($tests) === 0) {
continue;
}
$dependencies = Test::getDependencies($class->getName(), $method->getName());
if (count($dependencies) !== 0) {
$this->addDependentTestsToBatchSet($batches, $dependencies, $tests);
} else {
$this->addTestsToBatchSet($batches, $tests, $maxBatchSize);
}
}
return $batches;
}
/**
* @param string[][] $batches
* @param ExecutionOrderDependency[] $dependencies
* @param string[] $tests
*/
private function addDependentTestsToBatchSet(array &$batches, array $dependencies, array $tests): void
{
$dependencies = array_map(static function (ExecutionOrderDependency $dependency): string {
return substr($dependency->getTarget(), (int) strrpos($dependency->getTarget(), ':') + 1);
}, $dependencies);
foreach ($batches as $key => $batch) {
foreach ($batch as $methodName) {
if (in_array($methodName, $dependencies, true)) {
$batches[$key] = array_merge($batches[$key], $tests);
continue;
}
}
}
}
/**
* @param string[][] $batches
* @param string[] $tests
*/
private function addTestsToBatchSet(array &$batches, array $tests, int $maxBatchSize): void
{
foreach ($tests as $test) {
$lastIndex = count($batches) - 1;
if (
$lastIndex !== -1
&& count($batches[$lastIndex]) < $maxBatchSize
) {
$batches[$lastIndex][] = $test;
} else {
$batches[] = [$test];
}
}
}
/**
* Get method all available tests.
*
* With empty filter this method returns single test if doesn't have data provider or
* data provider is not used and return all test if has data provider and data provider is used.
*
* @return string[] array of test names
* @psalm-return list<string>
*/
private function getMethodTests(ParsedClass $class, ReflectionMethod $method): array
{
$result = [];
/** @var string[] $groups */
$groups = Test::getGroups($class->getName(), $method->getName());
if ($this->containsOnlyVirtualGroups($groups)) {
$groups[] = 'default';
}
if (! $this->testMatchGroupOptions($groups)) {
return $result;
}
try {
$providedData = Test::getProvidedData($class->getName(), $method->getName());
} catch (Throwable $throwable) {
$providedData = null;
}
if ($providedData !== null) {
foreach (array_keys($providedData) as $key) {
$test = sprintf(
'%s with data set %s',
$method->getName(),
is_int($key) ? '#' . $key : '"' . $key . '"',
);
if (! $this->testMatchFilterOptions($class->getName(), $test)) {
continue;
}
$result[] = $test;
}
} elseif ($this->testMatchFilterOptions($class->getName(), $method->getName())) {
$result = [$method->getName()];
}
return $result;
}
/** @param string[] $groups */
private function testMatchGroupOptions(array $groups): bool
{
if ($this->options->group() === [] && $this->options->excludeGroup() === []) {
return true;
}
$matchGroupIncluded = (
$this->options->group() !== []
&& array_intersect($groups, $this->options->group()) !== []
);
$matchGroupNotExcluded = (
$this->options->excludeGroup() !== []
&& array_intersect($groups, $this->options->excludeGroup()) === []
);
return $matchGroupIncluded || $matchGroupNotExcluded;
}
private function testMatchFilterOptions(string $className, string $name): bool
{
if (($filter = $this->options->filter()) === null) {
return true;
}
$re = '/' . trim($filter, '/') . '/';
$fullName = $className . '::' . $name;
return preg_match($re, $fullName) === 1;
}
private function createSuite(string $path, ParsedClass $class): Suite
{
return new Suite(
$path,
$this->executableTests(
$path,
$class,
),
$this->options->hasCoverage(),
$this->options->needsTeamcity(),
$this->options->tmpDir(),
);
}
private function createFullSuite(string $suiteName): FullSuite
{
return new FullSuite(
$suiteName,
$this->options->hasCoverage(),
$this->options->needsTeamcity(),
$this->options->tmpDir(),
);
}
/** @see \PHPUnit\TextUI\XmlConfiguration\TestSuiteMapper::map */
private function loadFilesFromTestSuite(TestSuite $testSuiteCollection): void
{
foreach ($testSuiteCollection->directories() as $directory) {
if (
! version_compare(
PHP_VERSION,
$directory->phpVersion(),
$directory->phpVersionOperator()->asString(),
)
) {
continue; // @codeCoverageIgnore
}
$exclude = [];
foreach ($testSuiteCollection->exclude()->asArray() as $file) {
$exclude[] = $file->path();
}
$this->files = array_merge($this->files, (new Facade())->getFilesAsArray(
$directory->path(),
$directory->suffix(),
$directory->prefix(),
$exclude,
));
}
foreach ($testSuiteCollection->files() as $file) {
if (
! version_compare(
PHP_VERSION,
$file->phpVersion(),
$file->phpVersionOperator()->asString(),
)
) {
continue; // @codeCoverageIgnore
}
$this->files[] = $file->path();
}
}
private function loadConfiguration(): void
{
if ($this->configuration !== null) {
(new PhpHandler())->handle($this->configuration->php());
}
$bootstrap = null;
if ($this->options->bootstrap() !== null) {
$bootstrap = $this->options->bootstrap();
} elseif ($this->configuration !== null && $this->configuration->phpunit()->hasBootstrap()) {
$bootstrap = $this->configuration->phpunit()->bootstrap();
}
if ($bootstrap === null) {
return;
}
FileLoader::checkAndLoad($bootstrap);
}
private function warmCoverageCache(): void
{
if (
! $this->options->hasCoverage()
|| ! (new Runtime())->canCollectCodeCoverage()
|| ($configuration = $this->options->configuration()) === null
|| ! $configuration->codeCoverage()->hasCacheDirectory()
) {
return;
}
$filter = new Filter();
(new FilterMapper())->map(
$filter,
$configuration->codeCoverage(),
);
$timer = new Timer();
$timer->start();
$this->output->write('Warming cache for static analysis ... ');
(new CacheWarmer())->warmCache(
$configuration->codeCoverage()->cacheDirectory()->path(),
! $configuration->codeCoverage()->disableCodeCoverageIgnore(),
$configuration->codeCoverage()->ignoreDeprecatedCodeUnits(),
$filter,
);
$this->output->write(sprintf("done [%s]\n\n", $timer->stop()->asString()));
}
/**
* @see \PHPUnit\Framework\TestSuite::containsOnlyVirtualGroups
*
* @param string[] $groups
*/
private function containsOnlyVirtualGroups(array $groups): bool
{
foreach ($groups as $group) {
if (strpos($group, '__phpunit_') !== 0) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use function array_reduce;
use function count;
use function implode;
use function preg_quote;
use function strpos;
/**
* Represents a set of tests grouped in batch which can be passed to a single phpunit process.
* Batch limited to run tests only from one php test case file.
* Used for running ParaTest in functional mode.
*
* @internal
*
* @todo Rename to Batch
*/
final class TestMethod extends ExecutableTest
{
/**
* A set of filters for test, they are merged into phpunit's --filter option.
*
* @var string[]
*/
private $filters;
/**
* Passed filters must be unescaped and must represent test name, optionally including
* dataset name (numeric or named).
*
* @param string $testPath path to phpunit test case file
* @param string[] $filters array of filters or single filter
*/
public function __construct(string $testPath, array $filters, bool $needsCoverage, bool $needsTeamcity, string $tmpDir)
{
parent::__construct($testPath, $needsCoverage, $needsTeamcity, $tmpDir);
// for compatibility with other code (tests), which can pass string (one filter)
// instead of array of filters
$this->filters = $filters;
}
/**
* Returns the test method's name.
*
* This method will join all filters via pipe character and return as string.
*/
public function getName(): string
{
return implode('|', $this->filters);
}
/**
* Additional processing for options being passed to PHPUnit.
*
* This sets up the --filter switch used to run a single PHPUnit test method.
* This method also provide escaping for method name to be used as filter regexp.
*
* @param array<string, string|null> $options
*
* @return array<string, string|null>
*/
protected function prepareOptions(array $options): array
{
$re = array_reduce($this->filters, static function (?string $r, string $v): string {
$isDataSet = strpos($v, ' with data set ') !== false;
return ($r !== null ? $r . '|' : '') . preg_quote($v, '/') . ($isDataSet ? '$' : '(?:\s|$)');
});
$options['filter'] = '/' . $re . '/';
return $options;
}
/**
* Get the expected count of tests to be executed.
*
* @psalm-return 0|positive-int
*/
public function getTestCount(): int
{
return count($this->filters);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit\Worker;
use PHPUnit\Framework\TestListenerDefaultImplementation;
use PHPUnit\Framework\TestResult;
use PHPUnit\TextUI\ResultPrinter;
/**
* @internal
*
* @codeCoverageIgnore
*/
final class NullPhpunitPrinter implements ResultPrinter
{
use TestListenerDefaultImplementation;
public function printResult(TestResult $result): void
{
}
public function write(string $buffer): void
{
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit\Worker;
use ParaTest\Runners\PHPUnit\ExecutableTest;
use ParaTest\Runners\PHPUnit\Options;
use ParaTest\Runners\PHPUnit\WorkerCrashedException;
use RuntimeException;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Throwable;
use function array_merge;
use function assert;
use function strlen;
use const DIRECTORY_SEPARATOR;
/** @internal */
final class RunnerWorker
{
/** @var ExecutableTest */
private $executableTest;
/** @var Process */
private $process;
public function __construct(ExecutableTest $executableTest, Options $options, int $token)
{
$this->executableTest = $executableTest;
$phpFinder = new PhpExecutableFinder();
$phpBin = $phpFinder->find(false);
assert($phpBin !== false);
$args = [$phpBin];
$args = array_merge($args, $phpFinder->findArguments());
if (($passthruPhp = $options->passthruPhp()) !== null) {
$args = array_merge($args, $passthruPhp);
}
$args = array_merge(
$args,
$this->executableTest->commandArguments(
$options->phpunit(),
$options->filtered(),
$options->passthru(),
),
);
$this->process = new Process($args, $options->cwd(), $options->fillEnvWithTokens($token));
$cmd = $this->process->getCommandLine();
$this->assertValidCommandLineLength($cmd);
$this->executableTest->setLastCommand($cmd);
}
public function getExecutableTest(): ExecutableTest
{
return $this->executableTest;
}
/**
* Executes the test by creating a separate process.
*/
public function run(): void
{
$this->process->start();
}
/**
* Check if the process has terminated.
*/
public function isRunning(): bool
{
return $this->process->isRunning();
}
/**
* Stop the process and return it's
* exit code.
*/
public function stop(): ?int
{
return $this->process->stop();
}
/**
* Assert that command line length is valid.
*
* In some situations process command line can became too long when combining different test
* cases in single --filter arguments so it's better to show error regarding that to user
* and propose him to decrease max batch size.
*
* @param string $cmd Command line
*
* @throws RuntimeException on too long command line.
*
* @codeCoverageIgnore
*/
private function assertValidCommandLineLength(string $cmd): void
{
if (DIRECTORY_SEPARATOR !== '\\') {
return;
}
// symfony's process wrapper
$cmd = 'cmd /V:ON /E:ON /C "(' . $cmd . ')';
if (strlen($cmd) > 32767) {
throw new RuntimeException('Command line is too long, try to decrease max batch size');
}
}
public function getWorkerCrashedException(?Throwable $previousException = null): WorkerCrashedException
{
return WorkerCrashedException::fromProcess(
$this->process,
$this->process->getCommandLine(),
$previousException,
);
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit\Worker;
use ParaTest\Logging\JUnit\Reader;
use ParaTest\Runners\PHPUnit\EmptyLogFileException;
use ParaTest\Runners\PHPUnit\ExecutableTest;
use ParaTest\Runners\PHPUnit\Options;
use ParaTest\Runners\PHPUnit\ResultPrinter;
use ParaTest\Runners\PHPUnit\WorkerCrashedException;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\InputStream;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Throwable;
use function array_map;
use function array_merge;
use function assert;
use function clearstatcache;
use function dirname;
use function end;
use function filesize;
use function implode;
use function realpath;
use function serialize;
use function sprintf;
use function touch;
use function uniqid;
use function unlink;
use const DIRECTORY_SEPARATOR;
/** @internal */
final class WrapperWorker
{
/**
* It must be a 1 byte string to ensure
* filesize() is equal to the number of tests executed
*/
public const TEST_EXECUTED_MARKER = '1';
public const COMMAND_EXIT = "EXIT\n";
/** @var ExecutableTest|null */
private $currentlyExecuting;
/** @var Process */
private $process;
/** @var int */
private $inExecution = 0;
/** @var OutputInterface */
private $output;
/** @var string[] */
private $commands = [];
/** @var string */
private $writeToPathname;
/** @var InputStream */
private $input;
public function __construct(OutputInterface $output, Options $options, int $token)
{
$wrapper = realpath(
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit-wrapper.php',
);
assert($wrapper !== false);
$this->output = $output;
$this->writeToPathname = sprintf(
'%s%sworker_%s_stdout_%s',
$options->tmpDir(),
DIRECTORY_SEPARATOR,
$token,
uniqid(),
);
touch($this->writeToPathname);
$phpFinder = new PhpExecutableFinder();
$phpBin = $phpFinder->find(false);
assert($phpBin !== false);
$parameters = [$phpBin];
$parameters = array_merge($parameters, $phpFinder->findArguments());
if (($passthruPhp = $options->passthruPhp()) !== null) {
$parameters = array_merge($parameters, $passthruPhp);
}
$parameters[] = $wrapper;
$parameters[] = '--write-to';
$parameters[] = $this->writeToPathname;
if ($options->debug()) {
$this->output->write(sprintf(
"Starting WrapperWorker via: %s\n",
implode(' ', array_map('\escapeshellarg', $parameters)),
));
}
$this->input = new InputStream();
$this->process = new Process(
$parameters,
$options->cwd(),
$options->fillEnvWithTokens($token),
$this->input,
null,
);
}
public function __destruct()
{
@unlink($this->writeToPathname);
}
public function start(): void
{
$this->process->start();
}
public function getWorkerCrashedException(?Throwable $previousException = null): WorkerCrashedException
{
$command = end($this->commands);
assert($command !== false);
return WorkerCrashedException::fromProcess($this->process, $command, $previousException);
}
/** @param array<string, string|null> $phpunitOptions */
public function assign(ExecutableTest $test, string $phpunit, array $phpunitOptions, Options $options): void
{
assert($this->currentlyExecuting === null);
$commandArguments = $test->commandArguments($phpunit, $phpunitOptions, $options->passthru());
$command = implode(' ', array_map('\\escapeshellarg', $commandArguments));
if ($options->debug()) {
$this->output->write("\nExecuting test via: {$command}\n");
}
$this->input->write(serialize($commandArguments) . "\n");
$this->currentlyExecuting = $test;
$test->setLastCommand($command);
$this->commands[] = $command;
++$this->inExecution;
}
public function printFeedback(ResultPrinter $printer): ?Reader
{
if ($this->currentlyExecuting === null) {
return null;
}
try {
$reader = $printer->printFeedback($this->currentlyExecuting);
} catch (EmptyLogFileException $emptyLogException) {
throw $this->getWorkerCrashedException($emptyLogException);
}
return $reader;
}
public function reset(): void
{
$this->currentlyExecuting = null;
}
public function stop(): void
{
$this->input->write(self::COMMAND_EXIT);
}
public function getCoverageFileName(): ?string
{
if ($this->currentlyExecuting !== null) {
return $this->currentlyExecuting->getCoverageFileName();
}
return null;
}
public function isFree(): bool
{
clearstatcache(true, $this->writeToPathname);
return $this->inExecution === filesize($this->writeToPathname);
}
public function isRunning(): bool
{
return $this->process->isRunning();
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use RuntimeException;
use Symfony\Component\Process\Process;
use Throwable;
use function escapeshellarg;
use function sprintf;
/** @internal */
final class WorkerCrashedException extends RuntimeException
{
public static function fromProcess(Process $process, string $command, ?Throwable $previousException = null): self
{
$envs = '';
foreach ($process->getEnv() as $key => $value) {
$envs .= sprintf('%s=%s ', $key, escapeshellarg((string) $value));
}
$error = sprintf(
'The command "%s%s" failed.' . "\n\nExit Code: %s(%s)\n\nWorking directory: %s",
$envs,
$command,
(string) $process->getExitCode(),
(string) $process->getExitCodeText(),
(string) $process->getWorkingDirectory(),
);
if (! $process->isOutputDisabled()) {
$error .= sprintf(
"\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s",
$process->getOutput(),
$process->getErrorOutput(),
);
}
return new self($error, 0, $previousException);
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace ParaTest\Runners\PHPUnit;
use InvalidArgumentException;
use ParaTest\Runners\PHPUnit\Worker\WrapperWorker;
use PHPUnit\TextUI\TestRunner;
use function array_shift;
use function assert;
use function count;
use function max;
use function usleep;
/** @internal */
final class WrapperRunner extends BaseRunner
{
/** @var array<int,WrapperWorker> */
private $workers = [];
/** @var array<int,int> */
private $batches = [];
protected function beforeLoadChecks(): void
{
if ($this->options->functional()) {
throw new InvalidArgumentException(
'The `functional` option is not supported yet in the WrapperRunner. Only full classes can be run due ' .
'to the current PHPUnit commands causing classloading issues.',
);
}
}
protected function doRun(): void
{
$this->startWorkers();
$this->assignAllPendingTests();
$this->waitForAllToFinish();
}
private function startWorkers(): void
{
for ($token = 1; $token <= $this->options->processes(); ++$token) {
$this->startWorker($token);
}
}
private function assignAllPendingTests(): void
{
$phpunit = $this->options->phpunit();
$phpunitOptions = $this->options->filtered();
$batchSize = $this->options->maxBatchSize();
while (count($this->pending) > 0 && count($this->workers) > 0) {
foreach ($this->workers as $token => $worker) {
if (! $worker->isRunning()) {
throw $worker->getWorkerCrashedException();
}
if (! $worker->isFree()) {
continue;
}
$this->flushWorker($worker);
if ($batchSize !== null && $batchSize !== 0 && $this->batches[$token] === $batchSize) {
$this->destroyWorker($token);
$worker = $this->startWorker($token);
}
if ($this->exitcode > 0 && ($this->options->stopOnFailure() || $this->options->stopOnError())) {
$this->pending = [];
} elseif (($pending = array_shift($this->pending)) !== null) {
$worker->assign($pending, $phpunit, $phpunitOptions, $this->options);
$this->batches[$token]++;
}
}
usleep(self::CYCLE_SLEEP);
}
}
private function flushWorker(WrapperWorker $worker): void
{
$reader = $worker->printFeedback($this->printer);
if ($this->hasCoverage()) {
$coverageMerger = $this->getCoverage();
assert($coverageMerger !== null);
if (($coverageFileName = $worker->getCoverageFileName()) !== null) {
$coverageMerger->addCoverageFromFile($coverageFileName);
}
}
$worker->reset();
if ($reader === null) {
return;
}
$exitCode = TestRunner::SUCCESS_EXIT;
if ($reader->getTotalErrors() > 0) {
$exitCode = TestRunner::EXCEPTION_EXIT;
} elseif ($reader->getTotalFailures() > 0 || $reader->getTotalWarnings() > 0) {
$exitCode = TestRunner::FAILURE_EXIT;
}
$this->exitcode = max($this->exitcode, $exitCode);
}
private function waitForAllToFinish(): void
{
$stopped = [];
while (count($this->workers) > 0) {
foreach ($this->workers as $index => $worker) {
if ($worker->isRunning()) {
if (! isset($stopped[$index]) && $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
{
$this->workers[$token] = new WrapperWorker($this->output, $this->options, $token);
$this->workers[$token]->start();
$this->batches[$token] = 0;
return $this->workers[$token];
}
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]);
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace ParaTest\Util;
use RuntimeException;
use function array_search;
use function array_unshift;
use function in_array;
use function strlen;
use function substr_compare;
/** @internal */
final class PhpstormHelper
{
/** @param array<int, string> $argv */
public static function handleArgvFromPhpstorm(array &$argv, string $paratestBinary): string
{
$phpunitKey = self::getArgvKeyFor($argv, '/phpunit');
if (! in_array('--filter', $argv, true)) {
$coverageArgKey = self::getCoverageArgvKey($argv);
if ($coverageArgKey !== false) {
unset($argv[$coverageArgKey]);
}
unset($argv[$phpunitKey]);
return $paratestBinary;
}
unset($argv[self::getArgvKeyFor($argv, '/paratest_for_phpstorm')]);
$phpunitBinary = $argv[$phpunitKey];
foreach ($argv as $index => $value) {
if ($value === '--configuration' || $value === '--bootstrap') {
break;
}
unset($argv[$index]);
}
array_unshift($argv, $phpunitBinary);
return $phpunitBinary;
}
/** @param array<int, string> $argv */
private static function getArgvKeyFor(array $argv, string $searchFor): int
{
foreach ($argv as $key => $arg) {
if (self::strEndsWith($arg, $searchFor)) {
return $key;
}
}
throw new RuntimeException("Missing path to '$searchFor'");
}
/**
* Polyfill from PHP 8.0, drop when 7.4 support ends
*/
public static function strEndsWith(string $haystack, string $needle): bool
{
if ($needle === '' || $needle === $haystack) {
return true;
}
if ($haystack === '') {
return false;
}
$needleLength = strlen($needle);
return $needleLength <= strlen($haystack) && substr_compare($haystack, $needle, -$needleLength) === 0;
}
/**
* @param array<int, string> $argv
*
* @return int|false
*/
private static function getCoverageArgvKey(array $argv)
{
$coverageOptions = [
'-dpcov.enabled=1',
'-dxdebug.mode=coverage',
];
foreach ($coverageOptions as $coverageOption) {
$key = array_search($coverageOption, $argv, true);
if ($key !== false) {
return $key;
}
}
return false;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ParaTest\Util;
use function assert;
use function explode;
use function trim;
/** @internal */
final class Str
{
/**
* Split $string on $delimiter and trim the individual parts.
*
* @return string[]
* @psalm-return list<string>
*/
public static function explodeWithCleanup(string $delimiter, string $string): array
{
assert($delimiter !== '');
$stringValues = explode($delimiter, $string);
$parsedValues = [];
foreach ($stringValues as $value) {
$value = trim($value);
if ($value === '') {
continue;
}
$parsedValues[] = $value;
}
return $parsedValues;
}
}