vendor and env first commit

This commit is contained in:
2025-03-28 08:52:46 +01:00
parent f8388bc81b
commit 8f26283832
10976 changed files with 1349952 additions and 2 deletions
+19
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.
+170
View File
@@ -0,0 +1,170 @@
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/CI/badge.svg)](https://github.com/paratestphp/paratest/actions)
[![Infection MSI](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fparatestphp%2Fparatest%2F7.x)](https://dashboard.stryker-mutator.io/reports/github.com/paratestphp/paratest/7.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` to parallelize by TestCase or `vendor/bin/paratest --functional` to parallelize by Test. 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.
# 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.
## 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
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
```
## 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
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) && '' !== $cwd);
ParaTest\ParaTestCommand::applicationFactory($cwd)->run();
+3
View File
@@ -0,0 +1,3 @@
@ECHO OFF
SET BIN_TARGET=%~dp0\"../bin"\paratest
php "%BIN_TARGET%" %*
+10
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');
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker;
(static function (): void {
$getopt = getopt('', [
'status-file:',
'progress-file:',
'unexpected-output-file:',
'testresult-file:',
'teamcity-file:',
'testdox-file:',
'testdox-color',
'testdox-columns:',
'phpunit-argv:',
]);
$composerAutoloadFiles = [
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'autoload.php',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php',
dirname(__DIR__) . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php',
];
foreach ($composerAutoloadFiles as $file) {
if (file_exists($file)) {
define('PHPUNIT_COMPOSER_INSTALL', $file);
require_once $file;
break;
}
}
assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile));
assert(isset($getopt['progress-file']) && is_string($getopt['progress-file']));
assert(isset($getopt['unexpected-output-file']) && is_string($getopt['unexpected-output-file']));
assert(isset($getopt['testresult-file']) && is_string($getopt['testresult-file']));
assert(!isset($getopt['teamcity-file']) || is_string($getopt['teamcity-file']));
assert(!isset($getopt['testdox-file']) || is_string($getopt['testdox-file']));
assert(!isset($getopt['testdox-columns']) || $getopt['testdox-columns'] === (string) (int) $getopt['testdox-columns']);
assert(isset($getopt['phpunit-argv']) && is_string($getopt['phpunit-argv']));
$phpunitArgv = unserialize($getopt['phpunit-argv'], ['allowed_classes' => false]);
assert(is_array($phpunitArgv));
$application = new ApplicationForWrapperWorker(
$phpunitArgv,
$getopt['progress-file'],
$getopt['unexpected-output-file'],
$getopt['testresult-file'],
$getopt['teamcity-file'] ?? null,
$getopt['testdox-file'] ?? null,
isset($getopt['testdox-color']),
isset($getopt['testdox-columns']) ? (int) $getopt['testdox-columns'] : null,
);
while (true) {
if (feof(STDIN)) {
$application->end();
exit;
}
$testPath = fgets(STDIN);
if ($testPath === false || $testPath === WrapperWorker::COMMAND_EXIT) {
$application->end();
exit;
}
// It must be a 1 byte string to ensure filesize() is equal to the number of tests executed
$exitCode = $application->runTest(trim($testPath, "\n"));
fwrite($statusFile, (string) $exitCode);
fflush($statusFile);
}
})();
+87
View File
@@ -0,0 +1,87 @@
{
"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": "~8.2.0 || ~8.3.0",
"ext-dom": "*",
"ext-pcre": "*",
"ext-reflection": "*",
"ext-simplexml": "*",
"fidry/cpu-core-counter": "^1.1.0",
"jean85/pretty-package-versions": "^2.0.6",
"phpunit/php-code-coverage": "^10.1.14 || ^11.0.3",
"phpunit/php-file-iterator": "^4.1.0 || ^5.0.0",
"phpunit/php-timer": "^6.0.0 || ^7.0.0",
"phpunit/phpunit": "^10.5.20 || ^11.1.3",
"sebastian/environment": "^6.1.0 || ^7.1.0",
"symfony/console": "^6.4.7 || ^7.1.0",
"symfony/process": "^6.4.7 || ^7.1.0"
},
"require-dev": {
"ext-pcov": "*",
"ext-posix": "*",
"doctrine/coding-standard": "^12.0.0",
"phpstan/phpstan": "^1.11.2",
"phpstan/phpstan-deprecation-rules": "^1.2.0",
"phpstan/phpstan-phpunit": "^1.4.0",
"phpstan/phpstan-strict-rules": "^1.6.0",
"squizlabs/php_codesniffer": "^3.10.1",
"symfony/filesystem": "^6.4.3 || ^7.1.0"
},
"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
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"local>Slamdunk/.github:renovate-config"
]
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace ParaTest\Coverage;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SplFileInfo;
use function assert;
/** @internal */
final class CoverageMerger
{
public function __construct(
private readonly CodeCoverage $coverage
) {
}
public function addCoverageFromFile(SplFileInfo $coverageFile): void
{
if (! $coverageFile->isFile() || $coverageFile->getSize() === 0) {
return;
}
/** @psalm-suppress UnresolvableInclude **/
$coverage = include $coverageFile->getPathname();
assert($coverage instanceof CodeCoverage);
$this->coverage->merge($coverage);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use SplFileInfo;
use function array_merge;
use function assert;
use function ksort;
/**
* @internal
*
* @immutable
*/
final class LogMerger
{
/** @param list<SplFileInfo> $junitFiles */
public function merge(array $junitFiles): TestSuite
{
$mainSuite = null;
foreach ($junitFiles as $junitFile) {
if (! $junitFile->isFile()) {
continue;
}
$otherSuite = TestSuite::fromFile($junitFile);
if ($mainSuite === null) {
$mainSuite = $otherSuite;
continue;
}
if ($mainSuite->name !== $otherSuite->name) {
if ($mainSuite->name !== '') {
$mainSuite = new TestSuite(
'',
$mainSuite->tests,
$mainSuite->assertions,
$mainSuite->failures,
$mainSuite->errors,
$mainSuite->skipped,
$mainSuite->time,
'',
[$mainSuite->name => $mainSuite],
[],
);
}
if ($otherSuite->name !== '') {
$otherSuite = new TestSuite(
'',
$otherSuite->tests,
$otherSuite->assertions,
$otherSuite->failures,
$otherSuite->errors,
$otherSuite->skipped,
$otherSuite->time,
'',
[$otherSuite->name => $otherSuite],
[],
);
}
}
$mainSuite = $this->mergeSuites($mainSuite, $otherSuite);
}
assert($mainSuite !== null);
return $mainSuite;
}
private function mergeSuites(TestSuite $suite1, TestSuite $suite2): TestSuite
{
assert($suite1->name === $suite2->name);
$suites = $suite1->suites;
foreach ($suite2->suites as $suite2suiteName => $suite2suite) {
if (! isset($suites[$suite2suiteName])) {
$suites[$suite2suiteName] = $suite2suite;
continue;
}
$suites[$suite2suiteName] = $this->mergeSuites(
$suites[$suite2suiteName],
$suite2suite,
);
}
ksort($suites);
return new TestSuite(
$suite1->name,
$suite1->tests + $suite2->tests,
$suite1->assertions + $suite2->assertions,
$suite1->failures + $suite2->failures,
$suite1->errors + $suite2->errors,
$suite1->skipped + $suite2->skipped,
$suite1->time + $suite2->time,
$suite1->file,
$suites,
array_merge($suite1->cases, $suite2->cases),
);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
/** @internal */
enum MessageType
{
case error;
case failure;
case skipped;
public function toString(): string
{
return match ($this) {
self::error => 'error',
self::failure => 'failure',
self::skipped => 'skipped',
};
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use SimpleXMLElement;
use function assert;
use function count;
use function current;
use function iterator_to_array;
use function sprintf;
/**
* @internal
*
* @immutable
*/
class TestCase
{
public function __construct(
public readonly string $name,
public readonly string $class,
public readonly string $file,
public readonly int $line,
public readonly int $assertions,
public readonly float $time
) {
}
final public static function caseFromNode(SimpleXMLElement $node): self
{
$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')) !== []) {
$error = $getFirstNode($errors);
$type = $getType($error);
$text = (string) $error;
return new TestCaseWithMessage(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
MessageType::error,
);
}
if (($failures = $node->xpath('failure')) !== []) {
$failure = $getFirstNode($failures);
$type = $getType($failure);
$text = (string) $failure;
return new TestCaseWithMessage(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
$type,
$text,
MessageType::failure,
);
}
if ($node->xpath('skipped') !== []) {
$text = (string) $node['name'];
if ((string) $node['class'] !== '') {
$text = sprintf(
"%s::%s\n\n%s:%s",
$node['class'],
$node['name'],
$node['file'],
$node['line'],
);
}
return new TestCaseWithMessage(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
null,
$text,
MessageType::skipped,
);
}
return new self(
(string) $node['name'],
(string) $node['class'],
(string) $node['file'],
(int) $node['line'],
(int) $node['assertions'],
(float) $node['time'],
);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
/**
* @internal
*
* @immutable
*/
final class TestCaseWithMessage extends TestCase
{
public function __construct(
string $name,
string $class,
string $file,
int $line,
int $assertions,
float $time,
public readonly ?string $type,
public readonly string $text,
public readonly MessageType $xmlTagName
) {
parent::__construct($name, $class, $file, $line, $assertions, $time);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use SimpleXMLElement;
use SplFileInfo;
use function assert;
use function count;
use function file_get_contents;
/**
* @internal
*
* @immutable
*/
final class TestSuite
{
/**
* @param array<string, TestSuite> $suites
* @param list<TestCase> $cases
*/
public function __construct(
public readonly string $name,
public readonly int $tests,
public readonly int $assertions,
public readonly int $failures,
public readonly int $errors,
public readonly int $skipped,
public readonly float $time,
public readonly string $file,
public readonly array $suites,
public readonly array $cases
) {
}
public static function fromFile(SplFileInfo $logFile): self
{
assert($logFile->isFile() && 0 < (int) $logFile->getSize());
$logFileContents = file_get_contents($logFile->getPathname());
assert($logFileContents !== false);
return self::parseTestSuite(
new SimpleXMLElement($logFileContents),
true,
);
}
private static function parseTestSuite(SimpleXMLElement $node, bool $isRootSuite): self
{
if ($isRootSuite) {
$tests = 0;
$assertions = 0;
$failures = 0;
$errors = 0;
$skipped = 0;
$time = 0;
} else {
$tests = (int) $node['tests'];
$assertions = (int) $node['assertions'];
$failures = (int) $node['failures'];
$errors = (int) $node['errors'];
$skipped = (int) $node['skipped'];
$time = (float) $node['time'];
}
$count = count($node->testsuite);
$suites = [];
foreach ($node->testsuite as $singleTestSuiteXml) {
$testSuite = self::parseTestSuite($singleTestSuiteXml, false);
if ($isRootSuite && $count === 1) {
return $testSuite;
}
$suites[$testSuite->name] = $testSuite;
if (! $isRootSuite) {
continue;
}
$tests += $testSuite->tests;
$assertions += $testSuite->assertions;
$failures += $testSuite->failures;
$errors += $testSuite->errors;
$skipped += $testSuite->skipped;
$time += $testSuite->time;
}
$cases = [];
foreach ($node->testcase as $singleTestCase) {
$cases[] = TestCase::caseFromNode($singleTestCase);
}
return new self(
(string) $node['name'],
$tests,
$assertions,
$failures,
$errors,
$skipped,
$time,
(string) $node['file'],
$suites,
$cases,
);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace ParaTest\JUnit;
use DOMDocument;
use DOMElement;
use function assert;
use function dirname;
use function file_put_contents;
use function htmlspecialchars;
use function is_dir;
use function is_int;
use function is_string;
use function mkdir;
use function sprintf;
use function str_replace;
use const ENT_XML1;
/** @internal */
final class Writer
{
private readonly DOMDocument $document;
public function __construct()
{
$this->document = new DOMDocument('1.0', 'UTF-8');
$this->document->formatOutput = true;
}
public function write(TestSuite $testSuite, string $path): void
{
$dir = dirname($path);
if (! is_dir($dir)) {
mkdir($dir, 0777, true);
}
$result = file_put_contents($path, $this->getXml($testSuite));
assert(is_int($result) && 0 < $result);
}
/** @return non-empty-string */
private function getXml(TestSuite $testSuite): string
{
$xmlTestsuites = $this->document->createElement('testsuites');
$xmlTestsuites->appendChild($this->createSuiteNode($testSuite));
$this->document->appendChild($xmlTestsuites);
$xml = $this->document->saveXML();
assert(is_string($xml) && $xml !== '');
return $xml;
}
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);
$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;
}
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->xmlTagName === MessageType::skipped) {
$defectNode = $this->document->createElement($case->xmlTagName->toString());
} else {
$defectNode = $this->document->createElement($case->xmlTagName->toString(), htmlspecialchars($case->text, ENT_XML1));
$type = $case->type;
if ($type !== null) {
$defectNode->setAttribute('type', $type);
}
}
$caseNode->appendChild($defectNode);
}
return $caseNode;
}
}
+629
View File
@@ -0,0 +1,629 @@
<?php
declare(strict_types=1);
namespace ParaTest;
use Fidry\CpuCoreCounter\CpuCoreCounter;
use Fidry\CpuCoreCounter\NumberOfCpuCoreNotFound;
use PHPUnit\TextUI\Configuration\Builder;
use PHPUnit\TextUI\Configuration\Configuration;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Process\Process;
use function array_filter;
use function array_intersect_key;
use function array_key_exists;
use function array_shift;
use function assert;
use function count;
use function dirname;
use function escapeshellarg;
use function file_exists;
use function is_array;
use function is_bool;
use function is_numeric;
use function is_string;
use function realpath;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;
use function sys_get_temp_dir;
use function uniqid;
use function unserialize;
use const PHP_BINARY;
/**
* @internal
*
* @immutable
*/
final class Options
{
public const ENV_KEY_TOKEN = 'TEST_TOKEN';
public const ENV_KEY_UNIQUE_TOKEN = 'UNIQUE_TEST_TOKEN';
private const OPTIONS_TO_KEEP_FOR_PHPUNIT_IN_WORKER = [
'bootstrap' => true,
'cache-directory' => true,
'configuration' => true,
'coverage-filter' => true,
'dont-report-useless-tests' => true,
'exclude-group' => true,
'fail-on-incomplete' => true,
'fail-on-risky' => true,
'fail-on-skipped' => true,
'fail-on-warning' => true,
'filter' => true,
'group' => true,
'no-configuration' => true,
'order-by' => true,
'process-isolation' => true,
'random-order-seed' => true,
'stop-on-defect' => true,
'stop-on-error' => true,
'stop-on-warning' => true,
'stop-on-risky' => true,
'stop-on-skipped' => true,
'stop-on-incomplete' => true,
'strict-coverage' => true,
'strict-global-state' => true,
'disallow-test-output' => true,
];
public readonly bool $needsTeamcity;
/**
* @param non-empty-string $phpunit
* @param non-empty-string $cwd
* @param list<non-empty-string>|null $passthruPhp
* @param array<non-empty-string, non-empty-string|true> $phpunitOptions
* @param non-empty-string $runner
* @param non-empty-string $tmpDir
*/
private function __construct(
public readonly Configuration $configuration,
public readonly string $phpunit,
public readonly string $cwd,
public readonly int $maxBatchSize,
public readonly bool $noTestTokens,
public readonly ?array $passthruPhp,
public readonly array $phpunitOptions,
public readonly int $processes,
public readonly string $runner,
public readonly string $tmpDir,
public readonly bool $verbose,
public readonly bool $functional,
) {
$this->needsTeamcity = $configuration->outputIsTeamCity() || $configuration->hasLogfileTeamcity();
}
/** @param non-empty-string $cwd */
public static function fromConsoleInput(InputInterface $input, string $cwd): self
{
$options = $input->getOptions();
$maxBatchSize = (int) $options['max-batch-size'];
unset($options['max-batch-size']);
assert(is_bool($options['no-test-tokens']));
$noTestTokens = $options['no-test-tokens'];
unset($options['no-test-tokens']);
assert($options['passthru-php'] === null || is_string($options['passthru-php']));
$passthruPhp = self::parsePassthru($options['passthru-php']);
unset($options['passthru-php']);
assert(is_string($options['processes']));
$processes = is_numeric($options['processes'])
? (int) $options['processes']
: self::getNumberOfCPUCores();
unset($options['processes']);
assert(is_string($options['runner']) && $options['runner'] !== '');
$runner = $options['runner'];
unset($options['runner']);
assert(is_string($options['tmp-dir']) && $options['tmp-dir'] !== '');
$tmpDir = $options['tmp-dir'];
unset($options['tmp-dir']);
assert(is_bool($options['verbose']));
$verbose = $options['verbose'];
unset($options['verbose']);
assert(is_bool($options['functional']));
$functional = $options['functional'];
unset($options['functional']);
assert(array_key_exists('colors', $options));
if ($options['colors'] === Configuration::COLOR_DEFAULT) {
unset($options['colors']);
} elseif ($options['colors'] === null) {
$options['colors'] = Configuration::COLOR_AUTO;
}
assert(array_key_exists('coverage-text', $options));
if ($options['coverage-text'] === null) {
$options['coverage-text'] = 'php://stdout';
}
// Must be a static non-customizable reference because ParaTest code
// is strictly coupled with PHPUnit pinned version
$phpunit = self::getPhpunitBinary();
if (str_starts_with($phpunit, $cwd)) {
$phpunit = substr($phpunit, 1 + strlen($cwd));
}
$phpunitArgv = [$phpunit];
foreach ($options as $key => $value) {
if ($value === null || $value === false) {
continue;
}
if ($value === true) {
$phpunitArgv[] = "--{$key}";
continue;
}
$phpunitArgv[] = "--{$key}={$value}";
}
if (($path = $input->getArgument('path')) !== null) {
$phpunitArgv[] = '--';
$phpunitArgv[] = $path;
}
$phpunitOptions = array_intersect_key($options, self::OPTIONS_TO_KEEP_FOR_PHPUNIT_IN_WORKER);
$phpunitOptions = array_filter($phpunitOptions);
$configuration = (new Builder())->build($phpunitArgv);
return new self(
$configuration,
$phpunit,
$cwd,
$maxBatchSize,
$noTestTokens,
$passthruPhp,
$phpunitOptions,
$processes,
$runner,
$tmpDir,
$verbose,
$functional,
);
}
public static function setInputDefinition(InputDefinition $inputDefinition): void
{
$inputDefinition->setDefinition([
// Arguments
new InputArgument(
'path',
InputArgument::OPTIONAL,
'The path to a directory or file containing tests.',
),
// ParaTest options
new InputOption(
'functional',
null,
InputOption::VALUE_NONE,
'Whether to enable functional testing, for unit and dataset parallelization',
),
new InputOption(
'max-batch-size',
'm',
InputOption::VALUE_REQUIRED,
'Max batch size.',
'0',
),
new InputOption(
'no-test-tokens',
null,
InputOption::VALUE_NONE,
'Disable TEST_TOKEN environment variables.',
),
new InputOption(
'passthru-php',
null,
InputOption::VALUE_REQUIRED,
'Pass the given arguments verbatim to the underlying php process. Example: --passthru-php="\'-d\' ' .
'\'pcov.enabled=1\'"',
),
new InputOption(
'processes',
'p',
InputOption::VALUE_REQUIRED,
'The number of test processes to run.',
'auto',
),
new InputOption(
'runner',
null,
InputOption::VALUE_REQUIRED,
sprintf('A %s.', RunnerInterface::class),
'WrapperRunner',
),
new InputOption(
'tmp-dir',
null,
InputOption::VALUE_REQUIRED,
'Temporary directory for internal ParaTest files',
sys_get_temp_dir(),
),
new InputOption(
'verbose',
'v',
InputOption::VALUE_NONE,
'Output more verbose information',
),
// PHPUnit options
new InputOption(
'bootstrap',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Configuration',
),
new InputOption(
'configuration',
'c',
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'no-configuration',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'cache-directory',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'testsuite',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Selection',
),
new InputOption(
'exclude-testsuite',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'group',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'exclude-group',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'filter',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'process-isolation',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter = 'Execution',
),
new InputOption(
'strict-coverage',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'strict-global-state',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'disallow-test-output',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'dont-report-useless-tests',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-defect',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-error',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-failure',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-warning',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-risky',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-skipped',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'stop-on-incomplete',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-incomplete',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-risky',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-skipped',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'fail-on-warning',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'order-by',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'random-order-seed',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'colors',
null,
InputOption::VALUE_OPTIONAL,
'@see PHPUnit guide, chapter: ' . $chapter = 'Reporting',
Configuration::COLOR_DEFAULT,
),
new InputOption(
'no-progress',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-incomplete',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-skipped',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-deprecations',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-errors',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-notices',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'display-warnings',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'teamcity',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'testdox',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'log-junit',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Logging',
),
new InputOption(
'log-teamcity',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-clover',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter = 'Code Coverage',
),
new InputOption(
'coverage-cobertura',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-crap4j',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-html',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-php',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-text',
null,
InputOption::VALUE_OPTIONAL,
'@see PHPUnit guide, chapter: ' . $chapter,
false,
),
new InputOption(
'coverage-xml',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'coverage-filter',
null,
InputOption::VALUE_REQUIRED,
'@see PHPUnit guide, chapter: ' . $chapter,
),
new InputOption(
'no-coverage',
null,
InputOption::VALUE_NONE,
'@see PHPUnit guide, chapter: ' . $chapter,
),
]);
}
/** @return non-empty-string $phpunit the path to phpunit */
private static function getPhpunitBinary(): string
{
$tryPaths = [
dirname(__DIR__, 3) . '/bin/phpunit',
dirname(__DIR__, 3) . '/phpunit/phpunit/phpunit',
dirname(__DIR__) . '/vendor/phpunit/phpunit/phpunit',
];
foreach ($tryPaths as $path) {
if (($realPath = realpath($path)) !== false && file_exists($realPath)) {
return $realPath;
}
}
throw new RuntimeException('PHPUnit not found'); // @codeCoverageIgnore
}
public static function getNumberOfCPUCores(): int
{
try {
return (new CpuCoreCounter())->getCount();
} catch (NumberOfCpuCoreNotFound) {
return 2;
}
}
/** @return list<non-empty-string>|null */
private static function parsePassthru(?string $param): ?array
{
if ($param === null) {
return null;
}
$stringToArgumentProcess = Process::fromShellCommandline(
sprintf(
'%s -r %s -- %s',
escapeshellarg(PHP_BINARY),
escapeshellarg('echo serialize($argv);'),
$param,
),
);
$stringToArgumentProcess->mustRun();
$passthruAsArguments = unserialize($stringToArgumentProcess->getOutput());
assert(is_array($passthruAsArguments));
array_shift($passthruAsArguments);
if (count($passthruAsArguments) === 0) {
return null;
}
return $passthruAsArguments;
}
/** @return array{PARATEST: int, TEST_TOKEN?: int, UNIQUE_TEST_TOKEN?: non-empty-string} */
public function fillEnvWithTokens(int $inc): array
{
$env = ['PARATEST' => 1];
if (! $this->noTestTokens) {
$env[self::ENV_KEY_TOKEN] = $inc;
$env[self::ENV_KEY_UNIQUE_TOKEN] = uniqid($inc . '_');
}
return $env;
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace ParaTest;
use InvalidArgumentException;
use Jean85\PrettyVersions;
use ParaTest\WrapperRunner\WrapperRunner;
use PHPUnit\Runner\Version;
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 = [
'WrapperRunner' => WrapperRunner::class,
];
/** @param non-empty-string $cwd */
public function __construct(
private readonly string $cwd,
?string $name = null
) {
parent::__construct($name);
}
/** @param non-empty-string $cwd */
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);
$commandName = $command->getName();
assert($commandName !== null);
$application->setDefaultCommand($commandName, true);
return $application;
}
protected function configure(): void
{
Options::setInputDefinition($this->getDefinition());
}
/**
* {@inheritDoc}
*/
public function mergeApplicationDefinition($mergeArgs = true): void
{
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$application = $this->getApplication();
assert($application !== null);
$output->write(sprintf(
"%s upon %s\n\n",
$application->getLongVersion(),
Version::getVersionString(),
));
$options = Options::fromConsoleInput(
$input,
$this->cwd,
);
if (! $options->configuration->hasConfigurationFile() && ! $options->configuration->hasCliArguments()) {
return $this->displayHelp($output);
}
$runnerClass = $this->getRunnerClass($input);
return (new $runnerClass($options, $output))->run();
}
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;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ParaTest;
interface RunnerInterface
{
public const SUCCESS_EXIT = 0;
public const FAILURE_EXIT = 1;
public const EXCEPTION_EXIT = 2;
public function run(): int;
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace ParaTest\Util;
use RuntimeException;
use function array_search;
use function array_unshift;
use function in_array;
use function str_ends_with;
/** @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 (str_ends_with($arg, $searchFor)) {
return $key;
}
}
throw new RuntimeException("Missing path to '$searchFor'");
}
/**
* @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;
}
}
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\RunnerInterface;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Event\TestSuite\TestSuiteBuilder;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Logging\JUnit\JunitXmlLogger;
use PHPUnit\Logging\TeamCity\TeamCityLogger;
use PHPUnit\Logging\TestDox\TestResultCollector;
use PHPUnit\Metadata\Api\CodeCoverage as CodeCoverageMetadataApi;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\Runner\Extension\ExtensionBootstrapper;
use PHPUnit\Runner\Extension\Facade as ExtensionFacade;
use PHPUnit\Runner\Extension\PharLoader;
use PHPUnit\Runner\Filter\Factory;
use PHPUnit\Runner\TestSuiteLoader;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\Runner\Version;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TextUI\Configuration\Builder;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\Configuration\Configuration;
use PHPUnit\TextUI\Configuration\PhpHandler;
use PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter;
use PHPUnit\TextUI\Output\Default\UnexpectedOutputPrinter;
use PHPUnit\TextUI\Output\DefaultPrinter;
use PHPUnit\TextUI\Output\NullPrinter;
use PHPUnit\TextUI\Output\TestDox\ResultPrinter as TestDoxResultPrinter;
use PHPUnit\TextUI\TestSuiteFilterProcessor;
use PHPUnit\Util\ExcludeList;
use function assert;
use function file_put_contents;
use function is_file;
use function mt_srand;
use function serialize;
use function str_ends_with;
use function strpos;
use function substr;
use function version_compare;
/**
* @internal
*
* @codeCoverageIgnore
*/
final class ApplicationForWrapperWorker
{
private bool $hasBeenBootstrapped = false;
private Configuration $configuration;
private TestResultCollector $testdoxResultCollector;
/** @param list<string> $argv */
public function __construct(
private readonly array $argv,
private readonly string $progressFile,
private readonly string $unexpectedOutputFile,
private readonly string $testresultFile,
private readonly ?string $teamcityFile,
private readonly ?string $testdoxFile,
private readonly bool $testdoxColor,
private readonly ?int $testdoxColumns,
) {
}
public function runTest(string $testPath): int
{
$null = strpos($testPath, "\0");
$filter = null;
if ($null !== false) {
$filter = new Factory();
$name = substr($testPath, $null + 1);
assert($name !== '');
if (version_compare(Version::id(), '11.0.0') >= 0) {
$filter->addIncludeNameFilter($name);
} else {
$filter->addNameFilter($name);
}
$testPath = substr($testPath, 0, $null);
}
$this->bootstrap();
if (is_file($testPath) && str_ends_with($testPath, '.phpt')) {
$testSuite = TestSuite::empty($testPath);
$testSuite->addTestFile($testPath);
} else {
$testSuiteRefl = (new TestSuiteLoader())->load($testPath);
$testSuite = TestSuite::fromClassReflector($testSuiteRefl);
}
if (version_compare(Version::id(), '11.0.0') < 0) {
if (CodeCoverage::instance()->isActive()) {
CodeCoverage::instance()->ignoreLines(
(new CodeCoverageMetadataApi())->linesToBeIgnored($testSuite),
);
}
}
(new TestSuiteFilterProcessor())->process($this->configuration, $testSuite);
if ($filter !== null) {
$testSuite->injectFilter($filter);
EventFacade::emitter()->testSuiteFiltered(
TestSuiteBuilder::from($testSuite),
);
}
EventFacade::emitter()->testRunnerExecutionStarted(
TestSuiteBuilder::from($testSuite),
);
$testSuite->run();
return TestResultFacade::result()->wasSuccessfulIgnoringPhpunitWarnings()
? RunnerInterface::SUCCESS_EXIT
: RunnerInterface::FAILURE_EXIT;
}
private function bootstrap(): void
{
if ($this->hasBeenBootstrapped) {
return;
}
ExcludeList::addDirectory(__DIR__);
EventFacade::emitter()->applicationStarted();
$this->configuration = (new Builder())->build($this->argv);
(new PhpHandler())->handle($this->configuration->php());
if ($this->configuration->hasBootstrap()) {
$bootstrapFilename = $this->configuration->bootstrap();
include_once $bootstrapFilename;
EventFacade::emitter()->testRunnerBootstrapFinished($bootstrapFilename);
}
$extensionRequiresCodeCoverageCollection = false;
if (! $this->configuration->noExtensions()) {
if ($this->configuration->hasPharExtensionDirectory()) {
(new PharLoader())->loadPharExtensionsInDirectory(
$this->configuration->pharExtensionDirectory(),
);
}
$extensionFacade = new ExtensionFacade();
$extensionBootstrapper = new ExtensionBootstrapper(
$this->configuration,
$extensionFacade,
);
foreach ($this->configuration->extensionBootstrappers() as $bootstrapper) {
$extensionBootstrapper->bootstrap(
$bootstrapper['className'],
$bootstrapper['parameters'],
);
}
$extensionRequiresCodeCoverageCollection = $extensionFacade->requiresCodeCoverageCollection();
}
CodeCoverage::instance()->init(
$this->configuration,
CodeCoverageFilterRegistry::instance(),
$extensionRequiresCodeCoverageCollection,
);
if ($this->configuration->hasLogfileJunit()) {
new JunitXmlLogger(
DefaultPrinter::from($this->configuration->logfileJunit()),
EventFacade::instance(),
);
}
$printer = new ProgressPrinterOutput(
DefaultPrinter::from($this->progressFile),
DefaultPrinter::from($this->unexpectedOutputFile),
);
new UnexpectedOutputPrinter($printer, EventFacade::instance());
new ProgressPrinter(
$printer,
EventFacade::instance(),
false,
99999,
$this->configuration->source(),
);
if (isset($this->teamcityFile)) {
new TeamCityLogger(
DefaultPrinter::from($this->teamcityFile),
EventFacade::instance(),
);
}
if (isset($this->testdoxFile)) {
$this->testdoxResultCollector = new TestResultCollector(EventFacade::instance());
}
TestResultFacade::init();
EventFacade::instance()->seal();
EventFacade::emitter()->testRunnerStarted();
if ($this->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
mt_srand($this->configuration->randomOrderSeed());
}
$this->hasBeenBootstrapped = true;
}
public function end(): void
{
if (! $this->hasBeenBootstrapped) {
return;
}
EventFacade::emitter()->testRunnerExecutionFinished();
EventFacade::emitter()->testRunnerFinished();
CodeCoverage::instance()->generateReports(new NullPrinter(), $this->configuration);
$result = TestResultFacade::result();
if (isset($this->testdoxResultCollector)) {
assert(isset($this->testdoxFile));
assert(isset($this->testdoxColumns));
(new TestDoxResultPrinter(DefaultPrinter::from($this->testdoxFile), $this->testdoxColor, $this->testdoxColumns))->print(
$this->testdoxResultCollector->testMethodsGroupedByClass(),
);
}
file_put_contents($this->testresultFile, serialize($result));
EventFacade::emitter()->applicationFinished(0);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use PHPUnit\TextUI\Output\Printer;
use function preg_match;
/** @internal */
final class ProgressPrinterOutput implements Printer
{
public function __construct(
private readonly Printer $progressPrinter,
private readonly Printer $outputPrinter,
) {
}
public function print(string $buffer): void
{
// Skip anything in \PHPUnit\TextUI\Output\Default\ProgressPrinter\ProgressPrinter::printProgress except $progress
if (
$buffer === "\n"
|| preg_match('/^ +$/', $buffer) === 1
|| preg_match('/^ \\d+ \\/ \\d+ \\(...%\\)$/', $buffer) === 1
) {
return;
}
match ($buffer) {
'E', 'F', 'I', 'N', 'D', 'R', 'W', 'S', '.' => $this->progressPrinter->print($buffer),
default => $this->outputPrinter->print($buffer),
};
}
public function flush(): void
{
$this->progressPrinter->flush();
$this->outputPrinter->flush();
}
}
@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\Options;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Output\Default\ResultPrinter as DefaultResultPrinter;
use PHPUnit\TextUI\Output\Printer;
use PHPUnit\TextUI\Output\SummaryPrinter;
use PHPUnit\Util\Color;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\Timer\ResourceUsageFormatter;
use SplFileInfo;
use Symfony\Component\Console\Formatter\OutputFormatter;
use Symfony\Component\Console\Output\OutputInterface;
use function assert;
use function fclose;
use function feof;
use function floor;
use function fopen;
use function fread;
use function fseek;
use function ftell;
use function fwrite;
use function sprintf;
use function str_repeat;
use function strlen;
use const DIRECTORY_SEPARATOR;
use const PHP_EOL;
use const PHP_VERSION;
/** @internal */
final class ResultPrinter
{
public readonly Printer $printer;
private int $numTestsWidth = 0;
private int $maxColumn = 0;
private int $totalCases = 0;
private int $column = 0;
private int $casesProcessed = 0;
private int $numberOfColumns;
/** @var resource|null */
private $teamcityLogFileHandle;
/** @var array<non-empty-string, int> */
private array $tailPositions;
public function __construct(
private readonly OutputInterface $output,
private readonly Options $options
) {
$this->printer = new class ($this->output) implements Printer {
public function __construct(
private readonly OutputInterface $output,
) {
}
public function print(string $buffer): void
{
$this->output->write(OutputFormatter::escape($buffer));
}
public function flush(): void
{
}
};
$this->numberOfColumns = $this->options->configuration->columns();
if (! $this->options->configuration->hasLogfileTeamcity()) {
return;
}
$teamcityLogFileHandle = fopen($this->options->configuration->logfileTeamcity(), 'ab+');
assert($teamcityLogFileHandle !== false);
$this->teamcityLogFileHandle = $teamcityLogFileHandle;
}
public function setTestCount(int $testCount): void
{
$this->totalCases = $testCount;
}
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());
// @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\Application::writeRuntimeInformation()
$write('Processes', (string) $this->options->processes);
$runtime = 'PHP ' . PHP_VERSION;
if ($this->options->configuration->hasCoverageReport()) {
$filter = new Filter();
if ($this->options->configuration->pathCoverage()) {
$codeCoverageDriver = (new Selector())->forLineAndPathCoverage($filter); // @codeCoverageIgnore
} else {
$codeCoverageDriver = (new Selector())->forLineCoverage($filter);
}
$runtime .= ' with ' . $codeCoverageDriver->nameAndVersion();
}
$write('Runtime', $runtime);
if ($this->options->configuration->hasConfigurationFile()) {
$write('Configuration', $this->options->configuration->configurationFile());
}
if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
$write('Random Seed', (string) $this->options->configuration->randomOrderSeed());
}
$output->write("\n");
}
public function printFeedback(
SplFileInfo $progressFile,
SplFileInfo $outputFile,
SplFileInfo|null $teamcityFile
): void {
if ($this->options->needsTeamcity && $teamcityFile !== null) {
$teamcityProgress = $this->tailMultiple([$teamcityFile]);
if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
}
}
if ($this->options->configuration->outputIsTeamCity()) {
assert(isset($teamcityProgress));
$this->output->write($teamcityProgress);
return;
}
if ($this->options->configuration->noProgress()) {
return;
}
$unexpectedOutput = $this->tail($outputFile);
if ($unexpectedOutput !== '') {
$this->output->write($unexpectedOutput);
}
$feedbackItems = $this->tail($progressFile);
if ($feedbackItems === '') {
return;
}
$actualTestCount = strlen($feedbackItems);
for ($index = 0; $index < $actualTestCount; ++$index) {
$this->printFeedbackItem($feedbackItems[$index]);
}
}
/**
* @param list<SplFileInfo> $teamcityFiles
* @param list<SplFileInfo> $testdoxFiles
*/
public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles): void
{
if ($this->options->needsTeamcity) {
$teamcityProgress = $this->tailMultiple($teamcityFiles);
if ($this->teamcityLogFileHandle !== null) {
fwrite($this->teamcityLogFileHandle, $teamcityProgress);
$resource = $this->teamcityLogFileHandle;
$this->teamcityLogFileHandle = null;
fclose($resource);
}
}
if ($this->options->configuration->outputIsTeamCity()) {
assert(isset($teamcityProgress));
$this->output->write($teamcityProgress);
return;
}
$this->printer->print(PHP_EOL . (new ResourceUsageFormatter())->resourceUsageSinceStartOfRequest() . PHP_EOL . PHP_EOL);
$defaultResultPrinter = new DefaultResultPrinter(
$this->printer,
true,
true,
true,
true,
true,
true,
$this->options->configuration->displayDetailsOnIncompleteTests(),
$this->options->configuration->displayDetailsOnSkippedTests(),
$this->options->configuration->displayDetailsOnTestsThatTriggerDeprecations(),
$this->options->configuration->displayDetailsOnTestsThatTriggerErrors(),
$this->options->configuration->displayDetailsOnTestsThatTriggerNotices(),
$this->options->configuration->displayDetailsOnTestsThatTriggerWarnings(),
false,
);
if ($this->options->configuration->outputIsTestDox()) {
$this->output->write($this->tailMultiple($testdoxFiles));
$defaultResultPrinter = new DefaultResultPrinter(
$this->printer,
true,
true,
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
);
}
$defaultResultPrinter->print($testResult);
(new SummaryPrinter(
$this->printer,
$this->options->configuration->colors(),
))->print($testResult);
}
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() . "\n");
$this->column = 0;
}
private function printFeedbackItemColor(string $item): void
{
$buffer = match ($item) {
'E' => $this->colorizeTextBox('fg-red, bold', $item),
'F' => $this->colorizeTextBox('bg-red, fg-white', $item),
'I', 'N', 'D', 'R', 'W' => $this->colorizeTextBox('fg-yellow, bold', $item),
'S' => $this->colorizeTextBox('fg-cyan, bold', $item),
'.' => $item,
};
$this->output->write($buffer);
}
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),
);
}
private function colorizeTextBox(string $color, string $buffer): string
{
if (! $this->options->configuration->colors()) {
return $buffer;
}
return Color::colorizeTextBox($color, $buffer);
}
/** @param list<SplFileInfo> $files */
private function tailMultiple(array $files): string
{
$content = '';
foreach ($files as $file) {
if (! $file->isFile()) {
continue;
}
$content .= $this->tail($file);
}
return $content;
}
private function tail(SplFileInfo $file): string
{
$path = $file->getPathname();
assert($path !== '');
$handle = fopen($path, 'r');
assert($handle !== false);
$fseek = fseek($handle, $this->tailPositions[$path] ?? 0);
assert($fseek === 0);
$contents = '';
while (! feof($handle)) {
$fread = fread($handle, 8192);
assert($fread !== false);
$contents .= $fread;
}
$ftell = ftell($handle);
assert($ftell !== false);
$this->tailPositions[$path] = $ftell;
fclose($handle);
return $contents;
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use Generator;
use ParaTest\Options;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\TestSuite;
use PHPUnit\Runner\PhptTestCase;
use PHPUnit\Runner\ResultCache\NullResultCache;
use PHPUnit\Runner\TestSuiteSorter;
use PHPUnit\TextUI\Command\Result;
use PHPUnit\TextUI\Command\WarmCodeCoverageCacheCommand;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\Configuration\PhpHandler;
use PHPUnit\TextUI\Configuration\TestSuiteBuilder;
use PHPUnit\TextUI\TestSuiteFilterProcessor;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function assert;
use function count;
use function is_int;
use function is_string;
use function mt_srand;
use function ob_get_clean;
use function ob_start;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;
/** @internal */
final class SuiteLoader
{
public readonly int $testCount;
/** @var list<non-empty-string> */
public readonly array $tests;
public function __construct(
private readonly Options $options,
OutputInterface $output,
CodeCoverageFilterRegistry $codeCoverageFilterRegistry,
) {
(new PhpHandler())->handle($this->options->configuration->php());
if ($this->options->configuration->hasBootstrap()) {
include_once $this->options->configuration->bootstrap();
}
$testSuite = (new TestSuiteBuilder())->build($this->options->configuration);
if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) {
mt_srand($this->options->configuration->randomOrderSeed());
}
if (
$this->options->configuration->executionOrder() !== TestSuiteSorter::ORDER_DEFAULT ||
$this->options->configuration->executionOrderDefects() !== TestSuiteSorter::ORDER_DEFAULT ||
$this->options->configuration->resolveDependencies()
) {
(new TestSuiteSorter(new NullResultCache()))->reorderTestsInSuite(
$testSuite,
$this->options->configuration->executionOrder(),
$this->options->configuration->resolveDependencies(),
$this->options->configuration->executionOrderDefects(),
);
}
(new TestSuiteFilterProcessor())->process($this->options->configuration, $testSuite);
$this->testCount = count($testSuite);
$files = [];
$tests = [];
foreach ($this->loadFiles($testSuite) as $file => $test) {
$files[$file] = null;
if ($test instanceof PhptTestCase) {
$tests[] = $file;
} else {
$name = $test->name();
if ($test->providedData() !== []) {
$dataName = $test->dataName();
if ($this->options->functional) {
$name = sprintf('/%s\s.*%s.*$/', $name, $dataName);
} else {
if (is_int($dataName)) {
$name .= '#' . $dataName;
} else {
$name .= '@' . $dataName;
}
}
} else {
$name = sprintf('/%s$/', $name);
}
$tests[] = "$file\0$name";
}
}
$this->tests = $this->options->functional
? $tests
: array_keys($files);
if (! $this->options->configuration->hasCoverageReport()) {
return;
}
ob_start();
$result = (new WarmCodeCoverageCacheCommand(
$this->options->configuration,
$codeCoverageFilterRegistry,
))->execute();
$ob_get_clean = ob_get_clean();
assert($ob_get_clean !== false);
$output->write($ob_get_clean);
$output->write($result->output());
if ($result->shellExitCode() !== Result::SUCCESS) {
exit($result->shellExitCode());
}
}
/** @return Generator<non-empty-string, (PhptTestCase|TestCase)> */
private function loadFiles(TestSuite $testSuite): Generator
{
foreach ($testSuite as $test) {
if ($test instanceof TestSuite) {
yield from $this->loadFiles($test);
continue;
}
if ($test instanceof PhptTestCase) {
$refProperty = new ReflectionProperty(PhptTestCase::class, 'filename');
$filename = $refProperty->getValue($test);
assert(is_string($filename) && $filename !== '');
$filename = $this->stripCwd($filename);
yield $filename => $test;
continue;
}
if ($test instanceof TestCase) {
$refClass = new ReflectionClass($test);
$filename = $refClass->getFileName();
assert(is_string($filename) && $filename !== '');
$filename = $this->stripCwd($filename);
yield $filename => $test;
continue;
}
}
}
/**
* @param non-empty-string $filename
*
* @return non-empty-string
*/
private function stripCwd(string $filename): string
{
if (! str_starts_with($filename, $this->options->cwd)) {
return $filename;
}
$substr = substr($filename, 1 + strlen($this->options->cwd));
assert($substr !== '');
return $substr;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
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 $test, ?Throwable $previousException = null): self
{
$envs = '';
foreach ($process->getEnv() as $key => $value) {
$envs .= sprintf('%s=%s ', $key, escapeshellarg((string) $value));
}
$error = sprintf(
'The test "%s%s" failed.' . "\n\nExit Code: %s(%s)\n\nWorking directory: %s",
$envs,
$test,
(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);
}
}
@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer;
use ParaTest\Options;
use ParaTest\RunnerInterface;
use PHPUnit\Event\Facade as EventFacade;
use PHPUnit\Runner\CodeCoverage;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\TextUI\ShellExitCodeCalculator;
use PHPUnit\Util\ExcludeList;
use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use function array_merge;
use function array_merge_recursive;
use function array_shift;
use function assert;
use function count;
use function dirname;
use function file_get_contents;
use function max;
use function realpath;
use function unlink;
use function unserialize;
use function usleep;
use const DIRECTORY_SEPARATOR;
/** @internal */
final class WrapperRunner implements RunnerInterface
{
private const CYCLE_SLEEP = 10000;
private readonly ResultPrinter $printer;
/** @var list<non-empty-string> */
private array $pending = [];
private int $exitcode = -1;
/** @var array<positive-int,WrapperWorker> */
private array $workers = [];
/** @var array<int,int> */
private array $batches = [];
/** @var list<SplFileInfo> */
private array $statusFiles = [];
/** @var list<SplFileInfo> */
private array $progressFiles = [];
/** @var list<SplFileInfo> */
private array $unexpectedOutputFiles = [];
/** @var list<SplFileInfo> */
private array $testresultFiles = [];
/** @var list<SplFileInfo> */
private array $coverageFiles = [];
/** @var list<SplFileInfo> */
private array $junitFiles = [];
/** @var list<SplFileInfo> */
private array $teamcityFiles = [];
/** @var list<SplFileInfo> */
private array $testdoxFiles = [];
/** @var non-empty-string[] */
private readonly array $parameters;
private CodeCoverageFilterRegistry $codeCoverageFilterRegistry;
public function __construct(
private readonly Options $options,
private readonly OutputInterface $output
) {
$this->printer = new ResultPrinter($output, $options);
$wrapper = realpath(
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit-wrapper.php',
);
assert($wrapper !== false);
$phpFinder = new PhpExecutableFinder();
$phpBin = $phpFinder->find(false);
assert($phpBin !== false);
$parameters = [$phpBin];
$parameters = array_merge($parameters, $phpFinder->findArguments());
if ($options->passthruPhp !== null) {
$parameters = array_merge($parameters, $options->passthruPhp);
}
$parameters[] = $wrapper;
$this->parameters = $parameters;
$this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry();
}
public function run(): int
{
$directory = dirname(__DIR__);
assert($directory !== '');
ExcludeList::addDirectory($directory);
TestResultFacade::init();
EventFacade::instance()->seal();
$suiteLoader = new SuiteLoader(
$this->options,
$this->output,
$this->codeCoverageFilterRegistry,
);
$result = TestResultFacade::result();
$this->pending = $suiteLoader->tests;
$this->printer->setTestCount($suiteLoader->testCount);
$this->printer->start();
$this->startWorkers();
$this->assignAllPendingTests();
$this->waitForAllToFinish();
return $this->complete($result);
}
private function startWorkers(): void
{
for ($token = 1; $token <= $this->options->processes; ++$token) {
$this->startWorker($token);
}
}
private function assignAllPendingTests(): void
{
$batchSize = $this->options->maxBatchSize;
while (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 !== 0 && $this->batches[$token] === $batchSize) {
$this->destroyWorker($token);
$worker = $this->startWorker($token);
}
if (
$this->exitcode > 0
&& $this->options->configuration->stopOnFailure()
) {
$this->pending = [];
} elseif (($pending = array_shift($this->pending)) !== null) {
$worker->assign($pending);
$this->batches[$token]++;
}
}
usleep(self::CYCLE_SLEEP);
}
}
private function flushWorker(WrapperWorker $worker): void
{
$this->exitcode = max($this->exitcode, $worker->getExitCode());
$this->printer->printFeedback(
$worker->progressFile,
$worker->unexpectedOutputFile,
$worker->teamcityFile ?? null,
);
$worker->reset();
}
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);
}
}
/** @param positive-int $token */
private function startWorker(int $token): WrapperWorker
{
$worker = new WrapperWorker(
$this->output,
$this->options,
$this->parameters,
$token,
);
$worker->start();
$this->batches[$token] = 0;
$this->statusFiles[] = $worker->statusFile;
$this->progressFiles[] = $worker->progressFile;
$this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile;
$this->testresultFiles[] = $worker->testresultFile;
if (isset($worker->junitFile)) {
$this->junitFiles[] = $worker->junitFile;
}
if (isset($worker->coverageFile)) {
$this->coverageFiles[] = $worker->coverageFile;
}
if (isset($worker->teamcityFile)) {
$this->teamcityFiles[] = $worker->teamcityFile;
}
if (isset($worker->testdoxFile)) {
$this->testdoxFiles[] = $worker->testdoxFile;
}
return $this->workers[$token] = $worker;
}
private function destroyWorker(int $token): void
{
$this->workers[$token]->stop();
// We need to wait for ApplicationForWrapperWorker::end to end
while ($this->workers[$token]->isRunning()) {
usleep(self::CYCLE_SLEEP);
}
unset($this->workers[$token]);
}
private function complete(TestResult $testResultSum): int
{
foreach ($this->testresultFiles as $testresultFile) {
if (! $testresultFile->isFile()) {
continue;
}
$contents = file_get_contents($testresultFile->getPathname());
assert($contents !== false);
$testResult = unserialize($contents);
assert($testResult instanceof TestResult);
$testResultSum = new TestResult(
(int) $testResultSum->hasTests() + (int) $testResult->hasTests(),
$testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(),
$testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(),
array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()),
array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()),
array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()),
array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()),
array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()),
array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()),
array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()),
array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()),
array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()),
array_merge_recursive($testResultSum->errors(), $testResult->errors()),
array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()),
array_merge_recursive($testResultSum->notices(), $testResult->notices()),
array_merge_recursive($testResultSum->warnings(), $testResult->warnings()),
array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()),
array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()),
array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()),
$testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(),
);
}
$this->printer->printResults(
$testResultSum,
$this->teamcityFiles,
$this->testdoxFiles,
);
$this->generateCodeCoverageReports();
$this->generateLogs();
$exitcode = (new ShellExitCodeCalculator())->calculate(
$this->options->configuration->failOnDeprecation(),
$this->options->configuration->failOnEmptyTestSuite(),
$this->options->configuration->failOnIncomplete(),
$this->options->configuration->failOnNotice(),
$this->options->configuration->failOnRisky(),
$this->options->configuration->failOnSkipped(),
$this->options->configuration->failOnWarning(),
$testResultSum,
);
$this->clearFiles($this->statusFiles);
$this->clearFiles($this->progressFiles);
$this->clearFiles($this->unexpectedOutputFiles);
$this->clearFiles($this->testresultFiles);
$this->clearFiles($this->coverageFiles);
$this->clearFiles($this->junitFiles);
$this->clearFiles($this->teamcityFiles);
$this->clearFiles($this->testdoxFiles);
return $exitcode;
}
protected function generateCodeCoverageReports(): void
{
if ($this->coverageFiles === []) {
return;
}
$coverageManager = new CodeCoverage();
$coverageManager->init(
$this->options->configuration,
$this->codeCoverageFilterRegistry,
false,
);
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
foreach ($this->coverageFiles as $coverageFile) {
$coverageMerger->addCoverageFromFile($coverageFile);
}
$coverageManager->generateReports(
$this->printer->printer,
$this->options->configuration,
);
}
private function generateLogs(): void
{
if ($this->junitFiles === []) {
return;
}
$testSuite = (new LogMerger())->merge($this->junitFiles);
(new Writer())->write(
$testSuite,
$this->options->configuration->logfileJunit(),
);
}
/** @param list<SplFileInfo> $files */
private function clearFiles(array $files): void
{
foreach ($files as $file) {
if (! $file->isFile()) {
continue;
}
unlink($file->getPathname());
}
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace ParaTest\WrapperRunner;
use ParaTest\Options;
use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\InputStream;
use Symfony\Component\Process\Process;
use Throwable;
use function array_map;
use function assert;
use function clearstatcache;
use function file_get_contents;
use function filesize;
use function implode;
use function is_string;
use function serialize;
use function sprintf;
use function touch;
use function uniqid;
use const DIRECTORY_SEPARATOR;
/** @internal */
final class WrapperWorker
{
public const COMMAND_EXIT = "EXIT\n";
public readonly SplFileInfo $statusFile;
public readonly SplFileInfo $progressFile;
public readonly SplFileInfo $unexpectedOutputFile;
public readonly SplFileInfo $testresultFile;
public readonly SplFileInfo $junitFile;
public readonly SplFileInfo $coverageFile;
public readonly SplFileInfo $teamcityFile;
public readonly SplFileInfo $testdoxFile;
private ?string $currentlyExecuting = null;
private Process $process;
private int $inExecution = 0;
private InputStream $input;
private int $exitCode = -1;
/** @param non-empty-string[] $parameters */
public function __construct(
private readonly OutputInterface $output,
private readonly Options $options,
array $parameters,
private readonly int $token
) {
$commonTmpFilePath = sprintf(
'%s%sworker_%02s_stdout_%s_',
$options->tmpDir,
DIRECTORY_SEPARATOR,
$token,
uniqid(),
);
$this->statusFile = new SplFileInfo($commonTmpFilePath . 'status');
touch($this->statusFile->getPathname());
$this->progressFile = new SplFileInfo($commonTmpFilePath . 'progress');
touch($this->progressFile->getPathname());
$this->unexpectedOutputFile = new SplFileInfo($commonTmpFilePath . 'unexpected_output');
touch($this->unexpectedOutputFile->getPathname());
$this->testresultFile = new SplFileInfo($commonTmpFilePath . 'testresult');
if ($options->configuration->hasLogfileJunit()) {
$this->junitFile = new SplFileInfo($commonTmpFilePath . 'junit');
}
if ($options->configuration->hasCoverageReport()) {
$this->coverageFile = new SplFileInfo($commonTmpFilePath . 'coverage');
}
if ($options->needsTeamcity) {
$this->teamcityFile = new SplFileInfo($commonTmpFilePath . 'teamcity');
}
if ($options->configuration->outputIsTestDox()) {
$this->testdoxFile = new SplFileInfo($commonTmpFilePath . 'testdox');
}
$parameters[] = '--status-file';
$parameters[] = $this->statusFile->getPathname();
$parameters[] = '--progress-file';
$parameters[] = $this->progressFile->getPathname();
$parameters[] = '--unexpected-output-file';
$parameters[] = $this->unexpectedOutputFile->getPathname();
$parameters[] = '--testresult-file';
$parameters[] = $this->testresultFile->getPathname();
if (isset($this->teamcityFile)) {
$parameters[] = '--teamcity-file';
$parameters[] = $this->teamcityFile->getPathname();
}
if (isset($this->testdoxFile)) {
$parameters[] = '--testdox-file';
$parameters[] = $this->testdoxFile->getPathname();
if ($options->configuration->colors()) {
$parameters[] = '--testdox-color';
}
$parameters[] = '--testdox-columns';
$parameters[] = (string) $options->configuration->columns();
}
$phpunitArguments = [$options->phpunit];
foreach ($options->phpunitOptions as $key => $value) {
if ($options->functional && $key === 'filter') {
continue;
}
$phpunitArguments[] = "--{$key}";
if ($value === true) {
continue;
}
$phpunitArguments[] = $value;
}
$phpunitArguments[] = '--do-not-cache-result';
$phpunitArguments[] = '--no-logging';
$phpunitArguments[] = '--no-coverage';
$phpunitArguments[] = '--no-output';
if (isset($this->junitFile)) {
$phpunitArguments[] = '--log-junit';
$phpunitArguments[] = $this->junitFile->getPathname();
}
if (isset($this->coverageFile)) {
$phpunitArguments[] = '--coverage-php';
$phpunitArguments[] = $this->coverageFile->getPathname();
}
$parameters[] = '--phpunit-argv';
$parameters[] = serialize($phpunitArguments);
if ($options->verbose) {
$output->write(sprintf(
"Starting process {$this->token}: %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 start(): void
{
$this->process->start();
}
public function getWorkerCrashedException(?Throwable $previousException = null): WorkerCrashedException
{
return WorkerCrashedException::fromProcess(
$this->process,
$this->currentlyExecuting ?? 'N.A.',
$previousException,
);
}
public function assign(string $test): void
{
assert($this->currentlyExecuting === null);
if ($this->options->verbose) {
$this->output->write("Process {$this->token} executing: {$test}\n");
}
$this->input->write($test . "\n");
$this->currentlyExecuting = $test;
++$this->inExecution;
}
public function reset(): void
{
$this->currentlyExecuting = null;
}
public function stop(): void
{
$this->input->write(self::COMMAND_EXIT);
}
public function isFree(): bool
{
$statusFilepath = $this->statusFile->getPathname();
clearstatcache(true, $statusFilepath);
$isFree = $this->inExecution === filesize($statusFilepath);
if ($isFree && $this->inExecution > 0) {
$exitCodes = file_get_contents($statusFilepath);
assert(is_string($exitCodes) && $exitCodes !== '');
$this->exitCode = (int) $exitCodes[-1];
}
return $isFree;
}
public function getExitCode(): int
{
return $this->exitCode;
}
public function isRunning(): bool
{
return $this->process->isRunning();
}
}