├── LICENSE ├── README.md ├── bin ├── paratest ├── paratest_for_phpstorm └── phpunit-wrapper.php ├── composer.json ├── renovate.json └── src ├── Coverage └── CoverageMerger.php ├── JUnit ├── LogMerger.php ├── MessageType.php ├── TestCase.php ├── TestCaseWithMessage.php ├── TestSuite.php └── Writer.php ├── Options.php ├── ParaTestCommand.php ├── RunnerInterface.php ├── Util └── PhpstormHelper.php └── WrapperRunner ├── ApplicationForWrapperWorker.php ├── ProgressPrinterOutput.php ├── ResultPrinter.php ├── SuiteLoader.php ├── WorkerCrashedException.php ├── WrapperRunner.php └── WrapperWorker.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Brian Scaturro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ParaTest 2 | ======== 3 | 4 | [![Latest Stable Version](https://img.shields.io/packagist/v/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest) 5 | [![Downloads](https://img.shields.io/packagist/dt/brianium/paratest.svg)](https://packagist.org/packages/brianium/paratest) 6 | [![Integrate](https://github.com/paratestphp/paratest/workflows/CI/badge.svg)](https://github.com/paratestphp/paratest/actions) 7 | [![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) 8 | 9 | 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 10 | start using it with no additional bootstrap or configurations! 11 | 12 | Benefits: 13 | 14 | * 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! 15 | * Code Coverage report combining. Run your tests in N parallel processes and all the code coverage output will be combined into one report. 16 | 17 | # Installation 18 | 19 | To install with composer run the following command: 20 | 21 | composer require --dev brianium/paratest 22 | 23 | # Versions 24 | 25 | Only the latest version of PHPUnit is supported, and thus only the latest version of ParaTest is actively maintained. 26 | 27 | This is because of the following reasons: 28 | 29 | 1. To reduce bugs, code duplication and incompatibilities with PHPUnit, from version 5 ParaTest heavily relies on PHPUnit `@internal` classes 30 | 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 31 | 32 | # Usage 33 | 34 | After installation, the binary can be found at `vendor/bin/paratest`. Run it 35 | with `--help` option to see a complete list of the available options. 36 | 37 | ## Test token 38 | 39 | The `TEST_TOKEN` environment variable is guaranteed to have a value that is different 40 | from every other currently running test. This is useful to e.g. use a different database 41 | for each test: 42 | 43 | ```php 44 | if (getenv('TEST_TOKEN') !== false) { // Using ParaTest 45 | $dbname = 'testdb_' . getenv('TEST_TOKEN'); 46 | } else { 47 | $dbname = 'testdb'; 48 | } 49 | ``` 50 | 51 | A `UNIQUE_TEST_TOKEN` environment variable is also available and guaranteed to have a value that is unique both 52 | per run and per process. 53 | 54 | ## Code coverage 55 | 56 | The cache is always warmed up by ParaTest before executing the test suite. 57 | 58 | ### PCOV 59 | 60 | If you have installed `pcov` but need to enable it only while running tests, you have to pass thru the needed PHP binary 61 | option: 62 | 63 | ``` 64 | php -d pcov.enabled=1 vendor/bin/paratest --passthru-php="'-d' 'pcov.enabled=1'" 65 | ``` 66 | 67 | ### xDebug 68 | 69 | If you have `xDebug` installed, activating it by the environment variable is enough to have it running even in the subprocesses: 70 | 71 | ``` 72 | XDEBUG_MODE=coverage vendor/bin/paratest 73 | ``` 74 | 75 | ## Initial setup for all tests 76 | 77 | Because ParaTest runs multiple processes in parallel, each with their own instance of the PHP interpreter, 78 | techniques used to perform an initialization step exactly once for each test work different from PHPUnit. 79 | The following pattern will not work as expected - run the initialization exactly once - and instead run the 80 | initialization once per process: 81 | 82 | ```php 83 | private static bool $initialized = false; 84 | 85 | public function setUp(): void 86 | { 87 | if (! self::$initialized) { 88 | self::initialize(); 89 | self::$initialized = true; 90 | } 91 | } 92 | ``` 93 | 94 | This is because static variables persist during the execution of a single process. 95 | In parallel testing each process has a separate instance of `$initialized`. 96 | You can use the following pattern to ensure your initialization runs exactly once for the entire test invocation: 97 | 98 | ```php 99 | static bool $initialized = false; 100 | 101 | public function setUp(): void 102 | { 103 | if (! self::$initialized) { 104 | // We utilize the filesystem as shared mutable state to coordinate between processes 105 | touch('/tmp/test-initialization-lock-file'); 106 | $lockFile = fopen('/tmp/test-initialization-lock-file', 'r'); 107 | 108 | // Attempt to get an exclusive lock - first process wins 109 | if (flock($lockFile, LOCK_EX | LOCK_NB)) { 110 | // Since we are the single process that has an exclusive lock, we run the initialization 111 | self::initialize(); 112 | } else { 113 | // If no exclusive lock is available, block until the first process is done with initialization 114 | flock($lockFile, LOCK_SH); 115 | } 116 | 117 | self::$initialized = true; 118 | } 119 | } 120 | ``` 121 | 122 | ## Troubleshooting 123 | 124 | If you run into problems with `paratest`, try to get more information about the issue by enabling debug output via 125 | `--verbose --debug`. 126 | 127 | When a sub-process fails, the originating command is given in the output and can then be copy-pasted in the terminal 128 | to be run and debugged. All internal commands run with `--printer [...]\NullPhpunitPrinter` which silence the original 129 | PHPUnit output: during a debugging run remove that option to restore the output and see what PHPUnit is doing. 130 | 131 | ## Caveats 132 | 133 | 1. Constants, static methods, static variables and everything exposed by test classes consumed by other test classes 134 | (including Reflection) are not supported. This is due to a limitation of the current implementation of `WrapperRunner` 135 | and how PHPUnit searches for classes. The fix is to put shared code into classes which are not tests _themselves_. 136 | 137 | ## Integration with PHPStorm 138 | 139 | ParaTest provides a dedicated binary to work with PHPStorm; follow these steps to have ParaTest working within it: 140 | 141 | 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 142 | 2. Go to `Run` -> `Edit configurations...` 143 | 3. Select `Add new Configuration`, select the `PHPUnit` type and name it `ParaTest` 144 | 4. In the `Command Line` -> `Interpreter options` add `./vendor/bin/paratest_for_phpstorm` 145 | 5. Any additional ParaTest options you want to pass to ParaTest should go within the `Test runner` -> `Test runner options` section 146 | 147 | You should now have a `ParaTest` run within your configurations list. 148 | It should natively work with the `Rerun failed tests` and `Toggle auto-test` buttons of the `Run` overlay. 149 | 150 | ### Run with Coverage 151 | 152 | 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) 153 | and working when running tests sequentially in order for the helper binary to correctly handle code coverage 154 | 155 | # For Contributors: testing ParaTest itself 156 | 157 | Before creating a Pull Request be sure to run all the necessary checks with `make` command. 158 | -------------------------------------------------------------------------------- /bin/paratest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); 38 | -------------------------------------------------------------------------------- /bin/paratest_for_phpstorm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | false]); 52 | assert(is_array($phpunitArgv)); 53 | 54 | $application = new ApplicationForWrapperWorker( 55 | $phpunitArgv, 56 | $getopt['progress-file'], 57 | $getopt['unexpected-output-file'], 58 | $getopt['test-result-file'], 59 | $getopt['result-cache-file'] ?? null, 60 | $getopt['teamcity-file'] ?? null, 61 | $getopt['testdox-file'] ?? null, 62 | isset($getopt['testdox-color']), 63 | isset($getopt['testdox-columns']) ? (int) $getopt['testdox-columns'] : null, 64 | isset($getopt['testdox-summary']), 65 | ); 66 | 67 | while (true) { 68 | if (feof(STDIN)) { 69 | $application->end(); 70 | exit; 71 | } 72 | 73 | $testPath = fgets(STDIN); 74 | if ($testPath === false || $testPath === WrapperWorker::COMMAND_EXIT) { 75 | $application->end(); 76 | exit; 77 | } 78 | 79 | // It must be a 1 byte string to ensure filesize() is equal to the number of tests executed 80 | $exitCode = $application->runTest(trim($testPath, "\n")); 81 | 82 | fwrite($statusFile, (string) $exitCode); 83 | fflush($statusFile); 84 | } 85 | })(); 86 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brianium/paratest", 3 | "description": "Parallel testing for PHP", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "testing", 8 | "PHPUnit", 9 | "concurrent", 10 | "parallel" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Brian Scaturro", 15 | "email": "scaturrob@gmail.com", 16 | "role": "Developer" 17 | }, 18 | { 19 | "name": "Filippo Tessarotto", 20 | "email": "zoeslam@gmail.com", 21 | "role": "Developer" 22 | } 23 | ], 24 | "homepage": "https://github.com/paratestphp/paratest", 25 | "funding": [ 26 | { 27 | "type": "github", 28 | "url": "https://github.com/sponsors/Slamdunk" 29 | }, 30 | { 31 | "type": "paypal", 32 | "url": "https://paypal.me/filippotessarotto" 33 | } 34 | ], 35 | "require": { 36 | "php": "~8.3.0 || ~8.4.0", 37 | "ext-dom": "*", 38 | "ext-pcre": "*", 39 | "ext-reflection": "*", 40 | "ext-simplexml": "*", 41 | "fidry/cpu-core-counter": "^1.2.0", 42 | "jean85/pretty-package-versions": "^2.1.1", 43 | "phpunit/php-code-coverage": "^12.3.0", 44 | "phpunit/php-file-iterator": "^6", 45 | "phpunit/php-timer": "^8", 46 | "phpunit/phpunit": "^12.1.6", 47 | "sebastian/environment": "^8", 48 | "symfony/console": "^6.4.20 || ^7.2.6", 49 | "symfony/process": "^6.4.20 || ^7.2.5" 50 | }, 51 | "require-dev": { 52 | "ext-pcntl": "*", 53 | "ext-pcov": "*", 54 | "ext-posix": "*", 55 | "doctrine/coding-standard": "^13.0.1", 56 | "phpstan/phpstan": "^2.1.17", 57 | "phpstan/phpstan-deprecation-rules": "^2.0.3", 58 | "phpstan/phpstan-phpunit": "^2.0.6", 59 | "phpstan/phpstan-strict-rules": "^2.0.4", 60 | "squizlabs/php_codesniffer": "^3.13.0", 61 | "symfony/filesystem": "^6.4.13 || ^7.2.0" 62 | }, 63 | "autoload": { 64 | "psr-4": { 65 | "ParaTest\\": [ 66 | "src/" 67 | ] 68 | } 69 | }, 70 | "autoload-dev": { 71 | "psr-4": { 72 | "ParaTest\\Tests\\": "test/" 73 | } 74 | }, 75 | "bin": [ 76 | "bin/paratest", 77 | "bin/paratest_for_phpstorm" 78 | ], 79 | "config": { 80 | "allow-plugins": { 81 | "composer/package-versions-deprecated": true, 82 | "dealerdirect/phpcodesniffer-composer-installer": true, 83 | "infection/extension-installer": true 84 | }, 85 | "sort-packages": true 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>Slamdunk/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/Coverage/CoverageMerger.php: -------------------------------------------------------------------------------- 1 | isFile() || $coverageFile->getSize() === 0) { 23 | return; 24 | } 25 | 26 | /** @psalm-suppress UnresolvableInclude **/ 27 | $coverage = include $coverageFile->getPathname(); 28 | assert($coverage instanceof CodeCoverage); 29 | 30 | $this->coverage->merge($coverage); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/JUnit/LogMerger.php: -------------------------------------------------------------------------------- 1 | $junitFiles */ 21 | public function merge(array $junitFiles): ?TestSuite 22 | { 23 | $mainSuite = null; 24 | foreach ($junitFiles as $junitFile) { 25 | if (! $junitFile->isFile()) { 26 | continue; 27 | } 28 | 29 | $otherSuite = TestSuite::fromFile($junitFile); 30 | if ($mainSuite === null) { 31 | $mainSuite = $otherSuite; 32 | continue; 33 | } 34 | 35 | if ($mainSuite->name !== $otherSuite->name) { 36 | if ($mainSuite->name !== '') { 37 | $mainSuite = new TestSuite( 38 | '', 39 | $mainSuite->tests, 40 | $mainSuite->assertions, 41 | $mainSuite->failures, 42 | $mainSuite->errors, 43 | $mainSuite->skipped, 44 | $mainSuite->time, 45 | '', 46 | [$mainSuite->name => $mainSuite], 47 | [], 48 | ); 49 | } 50 | 51 | if ($otherSuite->name !== '') { 52 | $otherSuite = new TestSuite( 53 | '', 54 | $otherSuite->tests, 55 | $otherSuite->assertions, 56 | $otherSuite->failures, 57 | $otherSuite->errors, 58 | $otherSuite->skipped, 59 | $otherSuite->time, 60 | '', 61 | [$otherSuite->name => $otherSuite], 62 | [], 63 | ); 64 | } 65 | } 66 | 67 | $mainSuite = $this->mergeSuites($mainSuite, $otherSuite); 68 | } 69 | 70 | return $mainSuite; 71 | } 72 | 73 | private function mergeSuites(TestSuite $suite1, TestSuite $suite2): TestSuite 74 | { 75 | assert($suite1->name === $suite2->name); 76 | 77 | $suites = $suite1->suites; 78 | foreach ($suite2->suites as $suite2suiteName => $suite2suite) { 79 | if (! isset($suites[$suite2suiteName])) { 80 | $suites[$suite2suiteName] = $suite2suite; 81 | continue; 82 | } 83 | 84 | $suites[$suite2suiteName] = $this->mergeSuites( 85 | $suites[$suite2suiteName], 86 | $suite2suite, 87 | ); 88 | } 89 | 90 | ksort($suites); 91 | 92 | return new TestSuite( 93 | $suite1->name, 94 | $suite1->tests + $suite2->tests, 95 | $suite1->assertions + $suite2->assertions, 96 | $suite1->failures + $suite2->failures, 97 | $suite1->errors + $suite2->errors, 98 | $suite1->skipped + $suite2->skipped, 99 | $suite1->time + $suite2->time, 100 | $suite1->file, 101 | $suites, 102 | array_merge($suite1->cases, $suite2->cases), 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/JUnit/MessageType.php: -------------------------------------------------------------------------------- 1 | 'error', 18 | self::failure => 'failure', 19 | self::skipped => 'skipped', 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/JUnit/TestCase.php: -------------------------------------------------------------------------------- 1 | attributes(); 43 | assert($element !== null); 44 | $attributes = iterator_to_array($element); 45 | assert($attributes !== []); 46 | 47 | return (string) $attributes['type']; 48 | }; 49 | 50 | if (($errors = $node->xpath('error')) !== []) { 51 | $error = $getFirstNode($errors); 52 | $type = $getType($error); 53 | $text = (string) $error; 54 | 55 | return new TestCaseWithMessage( 56 | (string) $node['name'], 57 | (string) $node['class'], 58 | (string) $node['file'], 59 | (int) $node['line'], 60 | (int) $node['assertions'], 61 | (float) $node['time'], 62 | $type, 63 | $text, 64 | MessageType::error, 65 | ); 66 | } 67 | 68 | if (($failures = $node->xpath('failure')) !== []) { 69 | $failure = $getFirstNode($failures); 70 | $type = $getType($failure); 71 | $text = (string) $failure; 72 | 73 | return new TestCaseWithMessage( 74 | (string) $node['name'], 75 | (string) $node['class'], 76 | (string) $node['file'], 77 | (int) $node['line'], 78 | (int) $node['assertions'], 79 | (float) $node['time'], 80 | $type, 81 | $text, 82 | MessageType::failure, 83 | ); 84 | } 85 | 86 | if ($node->xpath('skipped') !== []) { 87 | $text = (string) $node['name']; 88 | if ((string) $node['class'] !== '') { 89 | $text = sprintf( 90 | "%s::%s\n\n%s:%s", 91 | $node['class'], 92 | $node['name'], 93 | $node['file'], 94 | $node['line'], 95 | ); 96 | } 97 | 98 | return new TestCaseWithMessage( 99 | (string) $node['name'], 100 | (string) $node['class'], 101 | (string) $node['file'], 102 | (int) $node['line'], 103 | (int) $node['assertions'], 104 | (float) $node['time'], 105 | null, 106 | $text, 107 | MessageType::skipped, 108 | ); 109 | } 110 | 111 | return new self( 112 | (string) $node['name'], 113 | (string) $node['class'], 114 | (string) $node['file'], 115 | (int) $node['line'], 116 | (int) $node['assertions'], 117 | (float) $node['time'], 118 | ); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/JUnit/TestCaseWithMessage.php: -------------------------------------------------------------------------------- 1 | $suites 23 | * @param list $cases 24 | */ 25 | public function __construct( 26 | public string $name, 27 | public int $tests, 28 | public int $assertions, 29 | public int $failures, 30 | public int $errors, 31 | public int $skipped, 32 | public float $time, 33 | public string $file, 34 | public array $suites, 35 | public array $cases 36 | ) { 37 | } 38 | 39 | public static function fromFile(SplFileInfo $logFile): self 40 | { 41 | assert($logFile->isFile() && 0 < (int) $logFile->getSize()); 42 | 43 | $logFileContents = file_get_contents($logFile->getPathname()); 44 | assert($logFileContents !== false); 45 | 46 | return self::parseTestSuite( 47 | new SimpleXMLElement($logFileContents), 48 | true, 49 | ); 50 | } 51 | 52 | private static function parseTestSuite(SimpleXMLElement $node, bool $isRootSuite): self 53 | { 54 | if ($isRootSuite) { 55 | $tests = 0; 56 | $assertions = 0; 57 | $failures = 0; 58 | $errors = 0; 59 | $skipped = 0; 60 | $time = 0; 61 | } else { 62 | $tests = (int) $node['tests']; 63 | $assertions = (int) $node['assertions']; 64 | $failures = (int) $node['failures']; 65 | $errors = (int) $node['errors']; 66 | $skipped = (int) $node['skipped']; 67 | $time = (float) $node['time']; 68 | } 69 | 70 | $count = count($node->testsuite); 71 | $suites = []; 72 | foreach ($node->testsuite as $singleTestSuiteXml) { 73 | $testSuite = self::parseTestSuite($singleTestSuiteXml, false); 74 | if ($isRootSuite && $count === 1) { 75 | return $testSuite; 76 | } 77 | 78 | $suites[$testSuite->name] = $testSuite; 79 | 80 | if (! $isRootSuite) { 81 | continue; 82 | } 83 | 84 | $tests += $testSuite->tests; 85 | $assertions += $testSuite->assertions; 86 | $failures += $testSuite->failures; 87 | $errors += $testSuite->errors; 88 | $skipped += $testSuite->skipped; 89 | $time += $testSuite->time; 90 | } 91 | 92 | $cases = []; 93 | foreach ($node->testcase as $singleTestCase) { 94 | $cases[] = TestCase::caseFromNode($singleTestCase); 95 | } 96 | 97 | return new self( 98 | (string) $node['name'], 99 | $tests, 100 | $assertions, 101 | $failures, 102 | $errors, 103 | $skipped, 104 | $time, 105 | (string) $node['file'], 106 | $suites, 107 | $cases, 108 | ); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/JUnit/Writer.php: -------------------------------------------------------------------------------- 1 | document = new DOMDocument('1.0', 'UTF-8'); 32 | $this->document->formatOutput = true; 33 | } 34 | 35 | public function write(TestSuite $testSuite, string $path): void 36 | { 37 | $dir = dirname($path); 38 | if (! is_dir($dir)) { 39 | mkdir($dir, 0777, true); 40 | } 41 | 42 | $result = file_put_contents($path, $this->getXml($testSuite)); 43 | assert(is_int($result) && 0 < $result); 44 | } 45 | 46 | /** @return non-empty-string */ 47 | private function getXml(TestSuite $testSuite): string 48 | { 49 | $xmlTestsuites = $this->document->createElement('testsuites'); 50 | $xmlTestsuites->appendChild($this->createSuiteNode($testSuite)); 51 | $xmlTestsuites->setAttribute('name', self::TESTSUITES_NAME); 52 | $this->document->appendChild($xmlTestsuites); 53 | 54 | $xml = $this->document->saveXML(); 55 | assert(is_string($xml) && $xml !== ''); 56 | 57 | return $xml; 58 | } 59 | 60 | private function createSuiteNode(TestSuite $parentSuite): DOMElement 61 | { 62 | $suiteNode = $this->document->createElement('testsuite'); 63 | $suiteNode->setAttribute('name', $parentSuite->name !== self::TESTSUITES_NAME ? $parentSuite->name : ''); 64 | if ($parentSuite->file !== '') { 65 | $suiteNode->setAttribute('file', $parentSuite->file); 66 | } 67 | 68 | $suiteNode->setAttribute('tests', (string) $parentSuite->tests); 69 | $suiteNode->setAttribute('assertions', (string) $parentSuite->assertions); 70 | $suiteNode->setAttribute('errors', (string) $parentSuite->errors); 71 | $suiteNode->setAttribute('failures', (string) $parentSuite->failures); 72 | $suiteNode->setAttribute('skipped', (string) $parentSuite->skipped); 73 | $suiteNode->setAttribute('time', (string) $parentSuite->time); 74 | 75 | foreach ($parentSuite->suites as $suite) { 76 | $suiteNode->appendChild($this->createSuiteNode($suite)); 77 | } 78 | 79 | foreach ($parentSuite->cases as $case) { 80 | $suiteNode->appendChild($this->createCaseNode($case)); 81 | } 82 | 83 | return $suiteNode; 84 | } 85 | 86 | private function createCaseNode(TestCase $case): DOMElement 87 | { 88 | $caseNode = $this->document->createElement('testcase'); 89 | 90 | $caseNode->setAttribute('name', $case->name); 91 | $caseNode->setAttribute('class', $case->class); 92 | $caseNode->setAttribute('classname', str_replace('\\', '.', $case->class)); 93 | $caseNode->setAttribute('file', $case->file); 94 | $caseNode->setAttribute('line', (string) $case->line); 95 | $caseNode->setAttribute('assertions', (string) $case->assertions); 96 | $caseNode->setAttribute('time', sprintf('%F', $case->time)); 97 | 98 | if ($case instanceof TestCaseWithMessage) { 99 | if ($case->xmlTagName === MessageType::skipped) { 100 | $defectNode = $this->document->createElement($case->xmlTagName->toString()); 101 | } else { 102 | $defectNode = $this->document->createElement($case->xmlTagName->toString(), htmlspecialchars($case->text, ENT_XML1)); 103 | $type = $case->type; 104 | if ($type !== null) { 105 | $defectNode->setAttribute('type', $type); 106 | } 107 | } 108 | 109 | $caseNode->appendChild($defectNode); 110 | } 111 | 112 | return $caseNode; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Options.php: -------------------------------------------------------------------------------- 1 | true, 54 | 'cache-directory' => true, 55 | 'configuration' => true, 56 | 'coverage-filter' => true, 57 | 'dont-report-useless-tests' => true, 58 | 'exclude-group' => true, 59 | 'fail-on-incomplete' => true, 60 | 'fail-on-risky' => true, 61 | 'fail-on-skipped' => true, 62 | 'fail-on-warning' => true, 63 | 'fail-on-deprecation' => true, 64 | 'filter' => true, 65 | 'group' => true, 66 | 'no-configuration' => true, 67 | 'order-by' => true, 68 | 'process-isolation' => true, 69 | 'random-order-seed' => true, 70 | 'stop-on-defect' => true, 71 | 'stop-on-error' => true, 72 | 'stop-on-warning' => true, 73 | 'stop-on-risky' => true, 74 | 'stop-on-skipped' => true, 75 | 'stop-on-incomplete' => true, 76 | 'strict-coverage' => true, 77 | 'strict-global-state' => true, 78 | 'disallow-test-output' => true, 79 | 'enforce-time-limit' => true, 80 | 'default-time-limit' => true, 81 | ]; 82 | 83 | public readonly bool $needsTeamcity; 84 | 85 | /** 86 | * @param non-empty-string $phpunit 87 | * @param non-empty-string $cwd 88 | * @param list|null $passthruPhp 89 | * @param array> $phpunitOptions 90 | * @param non-empty-string $runner 91 | * @param non-empty-string $tmpDir 92 | */ 93 | public function __construct( 94 | public Configuration $configuration, 95 | public string $phpunit, 96 | public string $cwd, 97 | public int $maxBatchSize, 98 | public bool $noTestTokens, 99 | public ?array $passthruPhp, 100 | public array $phpunitOptions, 101 | public int $processes, 102 | public string $runner, 103 | public string $tmpDir, 104 | public bool $verbose, 105 | public bool $functional, 106 | ) { 107 | $this->needsTeamcity = $configuration->outputIsTeamCity() || $configuration->hasLogfileTeamcity(); 108 | } 109 | 110 | /** @param non-empty-string $cwd */ 111 | public static function fromConsoleInput(InputInterface $input, string $cwd): self 112 | { 113 | $options = $input->getOptions(); 114 | 115 | $maxBatchSize = (int) $options['max-batch-size']; 116 | unset($options['max-batch-size']); 117 | 118 | assert(is_bool($options['no-test-tokens'])); 119 | $noTestTokens = $options['no-test-tokens']; 120 | unset($options['no-test-tokens']); 121 | 122 | assert($options['passthru-php'] === null || is_string($options['passthru-php'])); 123 | $passthruPhp = self::parsePassthru($options['passthru-php']); 124 | unset($options['passthru-php']); 125 | 126 | assert(is_string($options['processes'])); 127 | $processes = is_numeric($options['processes']) 128 | ? (int) $options['processes'] 129 | : self::getNumberOfCPUCores(); 130 | unset($options['processes']); 131 | 132 | assert(is_string($options['runner']) && $options['runner'] !== ''); 133 | $runner = $options['runner']; 134 | unset($options['runner']); 135 | 136 | assert(is_string($options['tmp-dir']) && $options['tmp-dir'] !== ''); 137 | $tmpDir = $options['tmp-dir']; 138 | unset($options['tmp-dir']); 139 | 140 | assert(is_bool($options['verbose'])); 141 | $verbose = $options['verbose']; 142 | unset($options['verbose']); 143 | 144 | assert(is_bool($options['functional'])); 145 | $functional = $options['functional']; 146 | unset($options['functional']); 147 | 148 | assert(array_key_exists('colors', $options)); 149 | if ($options['colors'] === Configuration::COLOR_DEFAULT) { 150 | unset($options['colors']); 151 | } elseif ($options['colors'] === null) { 152 | $options['colors'] = Configuration::COLOR_AUTO; 153 | } 154 | 155 | assert(array_key_exists('coverage-text', $options)); 156 | if ($options['coverage-text'] === null) { 157 | $options['coverage-text'] = 'php://stdout'; 158 | } 159 | 160 | // Must be a static non-customizable reference because ParaTest code 161 | // is strictly coupled with PHPUnit pinned version 162 | $phpunit = self::getPhpunitBinary(); 163 | if (str_starts_with($phpunit, $cwd)) { 164 | $phpunit = substr($phpunit, 1 + strlen($cwd)); 165 | } 166 | 167 | $phpunitArgv = [$phpunit]; 168 | foreach ($options as $key => $value) { 169 | if ($value === null || $value === false) { 170 | continue; 171 | } 172 | 173 | if ($value === true) { 174 | $phpunitArgv[] = "--{$key}"; 175 | continue; 176 | } 177 | 178 | if (! is_array($value)) { 179 | $value = [$value]; 180 | } 181 | 182 | foreach ($value as $innerValue) { 183 | $phpunitArgv[] = "--{$key}={$innerValue}"; 184 | } 185 | } 186 | 187 | if (($path = $input->getArgument('path')) !== null) { 188 | assert(is_string($path)); 189 | $phpunitArgv[] = '--'; 190 | $phpunitArgv[] = $path; 191 | } 192 | 193 | $phpunitOptions = array_intersect_key($options, self::OPTIONS_TO_KEEP_FOR_PHPUNIT_IN_WORKER); 194 | $phpunitOptions = array_filter($phpunitOptions); 195 | 196 | $configuration = (new Builder())->build($phpunitArgv); 197 | 198 | return new self( 199 | $configuration, 200 | $phpunit, 201 | $cwd, 202 | $maxBatchSize, 203 | $noTestTokens, 204 | $passthruPhp, 205 | $phpunitOptions, 206 | $processes, 207 | $runner, 208 | $tmpDir, 209 | $verbose, 210 | $functional, 211 | ); 212 | } 213 | 214 | public static function setInputDefinition(InputDefinition $inputDefinition): void 215 | { 216 | $inputDefinition->setDefinition([ 217 | // Arguments 218 | new InputArgument( 219 | 'path', 220 | InputArgument::OPTIONAL, 221 | 'The path to a directory or file containing tests.', 222 | ), 223 | 224 | // ParaTest options 225 | new InputOption( 226 | 'functional', 227 | null, 228 | InputOption::VALUE_NONE, 229 | 'Whether to enable functional testing, for unit and dataset parallelization', 230 | ), 231 | new InputOption( 232 | 'max-batch-size', 233 | 'm', 234 | InputOption::VALUE_REQUIRED, 235 | 'Max batch size.', 236 | '0', 237 | ), 238 | new InputOption( 239 | 'no-test-tokens', 240 | null, 241 | InputOption::VALUE_NONE, 242 | 'Disable TEST_TOKEN environment variables.', 243 | ), 244 | new InputOption( 245 | 'passthru-php', 246 | null, 247 | InputOption::VALUE_REQUIRED, 248 | 'Pass the given arguments verbatim to the underlying php process. Example: --passthru-php="\'-d\' ' . 249 | '\'pcov.enabled=1\'"', 250 | ), 251 | new InputOption( 252 | 'processes', 253 | 'p', 254 | InputOption::VALUE_REQUIRED, 255 | 'The number of test processes to run.', 256 | 'auto', 257 | ), 258 | new InputOption( 259 | 'runner', 260 | null, 261 | InputOption::VALUE_REQUIRED, 262 | sprintf('A %s.', RunnerInterface::class), 263 | 'WrapperRunner', 264 | ), 265 | new InputOption( 266 | 'tmp-dir', 267 | null, 268 | InputOption::VALUE_REQUIRED, 269 | 'Temporary directory for internal ParaTest files', 270 | sys_get_temp_dir(), 271 | ), 272 | new InputOption( 273 | 'verbose', 274 | 'v', 275 | InputOption::VALUE_NONE, 276 | 'Output more verbose information', 277 | ), 278 | 279 | // PHPUnit options 280 | new InputOption( 281 | 'bootstrap', 282 | null, 283 | InputOption::VALUE_REQUIRED, 284 | '@see PHPUnit guide, chapter: ' . $chapter = 'Configuration', 285 | ), 286 | new InputOption( 287 | 'configuration', 288 | 'c', 289 | InputOption::VALUE_REQUIRED, 290 | '@see PHPUnit guide, chapter: ' . $chapter, 291 | ), 292 | new InputOption( 293 | 'no-configuration', 294 | null, 295 | InputOption::VALUE_NONE, 296 | '@see PHPUnit guide, chapter: ' . $chapter, 297 | ), 298 | new InputOption( 299 | 'cache-directory', 300 | null, 301 | InputOption::VALUE_REQUIRED, 302 | '@see PHPUnit guide, chapter: ' . $chapter, 303 | ), 304 | new InputOption( 305 | 'testsuite', 306 | null, 307 | InputOption::VALUE_REQUIRED, 308 | '@see PHPUnit guide, chapter: ' . $chapter = 'Selection', 309 | ), 310 | new InputOption( 311 | 'exclude-testsuite', 312 | null, 313 | InputOption::VALUE_REQUIRED, 314 | '@see PHPUnit guide, chapter: ' . $chapter, 315 | ), 316 | new InputOption( 317 | 'group', 318 | null, 319 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 320 | '@see PHPUnit guide, chapter: ' . $chapter, 321 | ), 322 | new InputOption( 323 | 'exclude-group', 324 | null, 325 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 326 | '@see PHPUnit guide, chapter: ' . $chapter, 327 | ), 328 | new InputOption( 329 | 'filter', 330 | null, 331 | InputOption::VALUE_REQUIRED, 332 | '@see PHPUnit guide, chapter: ' . $chapter, 333 | ), 334 | new InputOption( 335 | 'process-isolation', 336 | null, 337 | InputOption::VALUE_NONE, 338 | '@see PHPUnit guide, chapter: ' . $chapter = 'Execution', 339 | ), 340 | new InputOption( 341 | 'strict-coverage', 342 | null, 343 | InputOption::VALUE_NONE, 344 | '@see PHPUnit guide, chapter: ' . $chapter, 345 | ), 346 | new InputOption( 347 | 'strict-global-state', 348 | null, 349 | InputOption::VALUE_NONE, 350 | '@see PHPUnit guide, chapter: ' . $chapter, 351 | ), 352 | new InputOption( 353 | 'disallow-test-output', 354 | null, 355 | InputOption::VALUE_NONE, 356 | '@see PHPUnit guide, chapter: ' . $chapter, 357 | ), 358 | new InputOption( 359 | 'enforce-time-limit', 360 | null, 361 | InputOption::VALUE_NONE, 362 | '@see PHPUnit guide, chapter: ' . $chapter, 363 | ), 364 | new InputOption( 365 | 'default-time-limit', 366 | null, 367 | InputOption::VALUE_REQUIRED, 368 | '@see PHPUnit guide, chapter: ' . $chapter, 369 | '0', 370 | ), 371 | new InputOption( 372 | 'dont-report-useless-tests', 373 | null, 374 | InputOption::VALUE_NONE, 375 | '@see PHPUnit guide, chapter: ' . $chapter, 376 | ), 377 | new InputOption( 378 | 'stop-on-defect', 379 | null, 380 | InputOption::VALUE_NONE, 381 | '@see PHPUnit guide, chapter: ' . $chapter, 382 | ), 383 | new InputOption( 384 | 'stop-on-error', 385 | null, 386 | InputOption::VALUE_NONE, 387 | '@see PHPUnit guide, chapter: ' . $chapter, 388 | ), 389 | new InputOption( 390 | 'stop-on-failure', 391 | null, 392 | InputOption::VALUE_NONE, 393 | '@see PHPUnit guide, chapter: ' . $chapter, 394 | ), 395 | new InputOption( 396 | 'stop-on-warning', 397 | null, 398 | InputOption::VALUE_NONE, 399 | '@see PHPUnit guide, chapter: ' . $chapter, 400 | ), 401 | new InputOption( 402 | 'stop-on-risky', 403 | null, 404 | InputOption::VALUE_NONE, 405 | '@see PHPUnit guide, chapter: ' . $chapter, 406 | ), 407 | new InputOption( 408 | 'stop-on-skipped', 409 | null, 410 | InputOption::VALUE_NONE, 411 | '@see PHPUnit guide, chapter: ' . $chapter, 412 | ), 413 | new InputOption( 414 | 'stop-on-incomplete', 415 | null, 416 | InputOption::VALUE_NONE, 417 | '@see PHPUnit guide, chapter: ' . $chapter, 418 | ), 419 | new InputOption( 420 | 'fail-on-incomplete', 421 | null, 422 | InputOption::VALUE_NONE, 423 | '@see PHPUnit guide, chapter: ' . $chapter, 424 | ), 425 | new InputOption( 426 | 'fail-on-risky', 427 | null, 428 | InputOption::VALUE_NONE, 429 | '@see PHPUnit guide, chapter: ' . $chapter, 430 | ), 431 | new InputOption( 432 | 'fail-on-skipped', 433 | null, 434 | InputOption::VALUE_NONE, 435 | '@see PHPUnit guide, chapter: ' . $chapter, 436 | ), 437 | new InputOption( 438 | 'fail-on-warning', 439 | null, 440 | InputOption::VALUE_NONE, 441 | '@see PHPUnit guide, chapter: ' . $chapter, 442 | ), 443 | new InputOption( 444 | 'fail-on-deprecation', 445 | null, 446 | InputOption::VALUE_NONE, 447 | '@see PHPUnit guide, chapter: ' . $chapter, 448 | ), 449 | new InputOption( 450 | 'order-by', 451 | null, 452 | InputOption::VALUE_REQUIRED, 453 | '@see PHPUnit guide, chapter: ' . $chapter, 454 | ), 455 | new InputOption( 456 | 'random-order-seed', 457 | null, 458 | InputOption::VALUE_REQUIRED, 459 | '@see PHPUnit guide, chapter: ' . $chapter, 460 | ), 461 | new InputOption( 462 | 'colors', 463 | null, 464 | InputOption::VALUE_OPTIONAL, 465 | '@see PHPUnit guide, chapter: ' . $chapter = 'Reporting', 466 | Configuration::COLOR_DEFAULT, 467 | ), 468 | new InputOption( 469 | 'no-progress', 470 | null, 471 | InputOption::VALUE_NONE, 472 | '@see PHPUnit guide, chapter: ' . $chapter, 473 | ), 474 | new InputOption( 475 | 'display-incomplete', 476 | null, 477 | InputOption::VALUE_NONE, 478 | '@see PHPUnit guide, chapter: ' . $chapter, 479 | ), 480 | new InputOption( 481 | 'display-skipped', 482 | null, 483 | InputOption::VALUE_NONE, 484 | '@see PHPUnit guide, chapter: ' . $chapter, 485 | ), 486 | new InputOption( 487 | 'display-deprecations', 488 | null, 489 | InputOption::VALUE_NONE, 490 | '@see PHPUnit guide, chapter: ' . $chapter, 491 | ), 492 | new InputOption( 493 | 'display-errors', 494 | null, 495 | InputOption::VALUE_NONE, 496 | '@see PHPUnit guide, chapter: ' . $chapter, 497 | ), 498 | new InputOption( 499 | 'display-notices', 500 | null, 501 | InputOption::VALUE_NONE, 502 | '@see PHPUnit guide, chapter: ' . $chapter, 503 | ), 504 | new InputOption( 505 | 'display-warnings', 506 | null, 507 | InputOption::VALUE_NONE, 508 | '@see PHPUnit guide, chapter: ' . $chapter, 509 | ), 510 | new InputOption( 511 | 'teamcity', 512 | null, 513 | InputOption::VALUE_NONE, 514 | '@see PHPUnit guide, chapter: ' . $chapter, 515 | ), 516 | new InputOption( 517 | 'testdox', 518 | null, 519 | InputOption::VALUE_NONE, 520 | '@see PHPUnit guide, chapter: ' . $chapter, 521 | ), 522 | new InputOption( 523 | 'log-junit', 524 | null, 525 | InputOption::VALUE_REQUIRED, 526 | '@see PHPUnit guide, chapter: ' . $chapter = 'Logging', 527 | ), 528 | new InputOption( 529 | 'log-teamcity', 530 | null, 531 | InputOption::VALUE_REQUIRED, 532 | '@see PHPUnit guide, chapter: ' . $chapter, 533 | ), 534 | new InputOption( 535 | 'coverage-clover', 536 | null, 537 | InputOption::VALUE_REQUIRED, 538 | '@see PHPUnit guide, chapter: ' . $chapter = 'Code Coverage', 539 | ), 540 | new InputOption( 541 | 'coverage-cobertura', 542 | null, 543 | InputOption::VALUE_REQUIRED, 544 | '@see PHPUnit guide, chapter: ' . $chapter, 545 | ), 546 | new InputOption( 547 | 'coverage-crap4j', 548 | null, 549 | InputOption::VALUE_REQUIRED, 550 | '@see PHPUnit guide, chapter: ' . $chapter, 551 | ), 552 | new InputOption( 553 | 'coverage-html', 554 | null, 555 | InputOption::VALUE_REQUIRED, 556 | '@see PHPUnit guide, chapter: ' . $chapter, 557 | ), 558 | new InputOption( 559 | 'coverage-php', 560 | null, 561 | InputOption::VALUE_REQUIRED, 562 | '@see PHPUnit guide, chapter: ' . $chapter, 563 | ), 564 | new InputOption( 565 | 'coverage-text', 566 | null, 567 | InputOption::VALUE_OPTIONAL, 568 | '@see PHPUnit guide, chapter: ' . $chapter, 569 | false, 570 | ), 571 | new InputOption( 572 | 'coverage-xml', 573 | null, 574 | InputOption::VALUE_REQUIRED, 575 | '@see PHPUnit guide, chapter: ' . $chapter, 576 | ), 577 | new InputOption( 578 | 'coverage-filter', 579 | null, 580 | InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 581 | '@see PHPUnit guide, chapter: ' . $chapter, 582 | ), 583 | new InputOption( 584 | 'no-coverage', 585 | null, 586 | InputOption::VALUE_NONE, 587 | '@see PHPUnit guide, chapter: ' . $chapter, 588 | ), 589 | ]); 590 | } 591 | 592 | /** @return non-empty-string $phpunit the path to phpunit */ 593 | private static function getPhpunitBinary(): string 594 | { 595 | $tryPaths = [ 596 | dirname(__DIR__, 3) . '/bin/phpunit', 597 | dirname(__DIR__, 3) . '/phpunit/phpunit/phpunit', 598 | dirname(__DIR__) . '/vendor/phpunit/phpunit/phpunit', 599 | ]; 600 | 601 | foreach ($tryPaths as $path) { 602 | if (($realPath = realpath($path)) !== false && file_exists($realPath)) { 603 | return $realPath; 604 | } 605 | } 606 | 607 | throw new RuntimeException('PHPUnit not found'); // @codeCoverageIgnore 608 | } 609 | 610 | public static function getNumberOfCPUCores(): int 611 | { 612 | try { 613 | return (new CpuCoreCounter())->getCount(); 614 | } catch (NumberOfCpuCoreNotFound) { 615 | return 2; 616 | } 617 | } 618 | 619 | /** @return list|null */ 620 | private static function parsePassthru(?string $param): ?array 621 | { 622 | if ($param === null) { 623 | return null; 624 | } 625 | 626 | $stringToArgumentProcess = Process::fromShellCommandline( 627 | sprintf( 628 | '%s -r %s -- %s', 629 | escapeshellarg(PHP_BINARY), 630 | escapeshellarg('echo serialize($argv);'), 631 | $param, 632 | ), 633 | ); 634 | $stringToArgumentProcess->mustRun(); 635 | 636 | $passthruAsArguments = unserialize($stringToArgumentProcess->getOutput()); 637 | assert(is_array($passthruAsArguments)); 638 | array_shift($passthruAsArguments); 639 | 640 | if (count($passthruAsArguments) === 0) { 641 | return null; 642 | } 643 | 644 | return $passthruAsArguments; 645 | } 646 | 647 | /** @return array{PARATEST: int, TEST_TOKEN?: int, UNIQUE_TEST_TOKEN?: non-empty-string} */ 648 | public function fillEnvWithTokens(int $inc): array 649 | { 650 | $env = ['PARATEST' => 1]; 651 | if (! $this->noTestTokens) { 652 | $env[self::ENV_KEY_TOKEN] = $inc; 653 | $env[self::ENV_KEY_UNIQUE_TOKEN] = uniqid($inc . '_'); 654 | } 655 | 656 | return $env; 657 | } 658 | } 659 | -------------------------------------------------------------------------------- /src/ParaTestCommand.php: -------------------------------------------------------------------------------- 1 | WrapperRunner::class, 30 | ]; 31 | 32 | /** @param non-empty-string $cwd */ 33 | public function __construct( 34 | private readonly string $cwd, 35 | ?string $name = null 36 | ) { 37 | parent::__construct($name); 38 | } 39 | 40 | /** @param non-empty-string $cwd */ 41 | public static function applicationFactory(string $cwd): Application 42 | { 43 | $application = new Application(); 44 | $command = new self($cwd, self::COMMAND_NAME); 45 | 46 | $application->setName('ParaTest'); 47 | $application->setVersion(PrettyVersions::getVersion('brianium/paratest')->getPrettyVersion()); 48 | $application->add($command); 49 | $commandName = $command->getName(); 50 | assert($commandName !== null); 51 | $application->setDefaultCommand($commandName, true); 52 | 53 | return $application; 54 | } 55 | 56 | protected function configure(): void 57 | { 58 | Options::setInputDefinition($this->getDefinition()); 59 | } 60 | 61 | /** 62 | * {@inheritDoc} 63 | */ 64 | public function mergeApplicationDefinition($mergeArgs = true): void 65 | { 66 | } 67 | 68 | protected function execute(InputInterface $input, OutputInterface $output): int 69 | { 70 | $application = $this->getApplication(); 71 | assert($application !== null); 72 | 73 | $output->write(sprintf( 74 | "%s upon %s\n\n", 75 | $application->getLongVersion(), 76 | Version::getVersionString(), 77 | )); 78 | 79 | $options = Options::fromConsoleInput( 80 | $input, 81 | $this->cwd, 82 | ); 83 | if (! $options->configuration->hasConfigurationFile() && ! $options->configuration->hasCliArguments()) { 84 | return $this->displayHelp($output); 85 | } 86 | 87 | $runnerClass = $this->getRunnerClass($input); 88 | 89 | return (new $runnerClass($options, $output))->run(); 90 | } 91 | 92 | private function displayHelp(OutputInterface $output): int 93 | { 94 | $app = $this->getApplication(); 95 | assert($app !== null); 96 | $help = $app->find('help'); 97 | $input = new ArrayInput(['command_name' => $this->getName()]); 98 | 99 | return $help->run($input, $output); 100 | } 101 | 102 | /** @return class-string */ 103 | private function getRunnerClass(InputInterface $input): string 104 | { 105 | $runnerClass = $input->getOption('runner'); 106 | assert(is_string($runnerClass)); 107 | $runnerClass = self::KNOWN_RUNNERS[$runnerClass] ?? $runnerClass; 108 | 109 | if (! class_exists($runnerClass) || ! is_subclass_of($runnerClass, RunnerInterface::class)) { 110 | throw new InvalidArgumentException(sprintf( 111 | 'Selected runner class "%s" does not exist or does not implement %s', 112 | $runnerClass, 113 | RunnerInterface::class, 114 | )); 115 | } 116 | 117 | return $runnerClass; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/RunnerInterface.php: -------------------------------------------------------------------------------- 1 | $argv */ 18 | public static function handleArgvFromPhpstorm(array &$argv, string $paratestBinary): string 19 | { 20 | $phpunitKey = self::getArgvKeyFor($argv, '/phpunit'); 21 | 22 | if (! in_array('--filter', $argv, true)) { 23 | $coverageArgKey = self::getCoverageArgvKey($argv); 24 | if ($coverageArgKey !== false) { 25 | unset($argv[$coverageArgKey]); 26 | } 27 | 28 | unset($argv[$phpunitKey]); 29 | 30 | return $paratestBinary; 31 | } 32 | 33 | unset($argv[self::getArgvKeyFor($argv, '/paratest_for_phpstorm')]); 34 | $phpunitBinary = $argv[$phpunitKey]; 35 | foreach ($argv as $index => $value) { 36 | if ($value === '--configuration' || $value === '--bootstrap') { 37 | break; 38 | } 39 | 40 | unset($argv[$index]); 41 | } 42 | 43 | array_unshift($argv, $phpunitBinary); 44 | 45 | return $phpunitBinary; 46 | } 47 | 48 | /** @param array $argv */ 49 | private static function getArgvKeyFor(array $argv, string $searchFor): int 50 | { 51 | foreach ($argv as $key => $arg) { 52 | if (str_ends_with($arg, $searchFor)) { 53 | return $key; 54 | } 55 | } 56 | 57 | throw new RuntimeException("Missing path to '$searchFor'"); 58 | } 59 | 60 | /** 61 | * @param array $argv 62 | * 63 | * @return int|false 64 | */ 65 | private static function getCoverageArgvKey(array $argv) 66 | { 67 | $coverageOptions = [ 68 | '-dpcov.enabled=1', 69 | '-dxdebug.mode=coverage', 70 | ]; 71 | 72 | foreach ($coverageOptions as $coverageOption) { 73 | $key = array_search($coverageOption, $argv, true); 74 | if ($key !== false) { 75 | return $key; 76 | } 77 | } 78 | 79 | return false; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/WrapperRunner/ApplicationForWrapperWorker.php: -------------------------------------------------------------------------------- 1 | $argv */ 62 | public function __construct( 63 | private readonly array $argv, 64 | private readonly string $progressFile, 65 | private readonly string $unexpectedOutputFile, 66 | private readonly string $testResultFile, 67 | private readonly ?string $resultCacheFile, 68 | private readonly ?string $teamcityFile, 69 | private readonly ?string $testdoxFile, 70 | private readonly bool $testdoxColor, 71 | private readonly ?int $testdoxColumns, 72 | ) { 73 | } 74 | 75 | public function runTest(string $testPath): int 76 | { 77 | $null = strpos($testPath, "\0"); 78 | $filter = null; 79 | if ($null !== false) { 80 | $filter = new Factory(); 81 | $name = substr($testPath, $null + 1); 82 | assert($name !== ''); 83 | $filter->addIncludeNameFilter($name); 84 | 85 | $testPath = substr($testPath, 0, $null); 86 | } 87 | 88 | $this->bootstrap(); 89 | 90 | if (is_file($testPath) && str_ends_with($testPath, '.phpt')) { 91 | $testSuite = TestSuite::empty($testPath); 92 | $testSuite->addTestFile($testPath); 93 | } else { 94 | $testSuiteRefl = (new TestSuiteLoader())->load($testPath); 95 | $testSuite = TestSuite::fromClassReflector($testSuiteRefl); 96 | } 97 | 98 | EventFacade::emitter()->testSuiteLoaded( 99 | TestSuiteBuilder::from($testSuite), 100 | ); 101 | 102 | (new TestSuiteFilterProcessor())->process($this->configuration, $testSuite); 103 | 104 | if ($filter !== null) { 105 | $testSuite->injectFilter($filter); 106 | 107 | EventFacade::emitter()->testSuiteFiltered( 108 | TestSuiteBuilder::from($testSuite), 109 | ); 110 | } 111 | 112 | EventFacade::emitter()->testRunnerExecutionStarted( 113 | TestSuiteBuilder::from($testSuite), 114 | ); 115 | 116 | $testSuite->run(); 117 | 118 | return TestResultFacade::result()->wasSuccessfulIgnoringPhpunitWarnings() 119 | ? RunnerInterface::SUCCESS_EXIT 120 | : RunnerInterface::FAILURE_EXIT; 121 | } 122 | 123 | private function bootstrap(): void 124 | { 125 | if ($this->hasBeenBootstrapped) { 126 | return; 127 | } 128 | 129 | ExcludeList::addDirectory(__DIR__); 130 | EventFacade::emitter()->applicationStarted(); 131 | 132 | $this->configuration = (new Builder())->build($this->argv); 133 | 134 | (new PhpHandler())->handle($this->configuration->php()); 135 | 136 | if ($this->configuration->hasBootstrap()) { 137 | $bootstrapFilename = $this->configuration->bootstrap(); 138 | include_once $bootstrapFilename; 139 | EventFacade::emitter()->testRunnerBootstrapFinished($bootstrapFilename); 140 | } 141 | 142 | $extensionRequiresCodeCoverageCollection = false; 143 | if (! $this->configuration->noExtensions()) { 144 | if ($this->configuration->hasPharExtensionDirectory()) { 145 | (new PharLoader())->loadPharExtensionsInDirectory( 146 | $this->configuration->pharExtensionDirectory(), 147 | ); 148 | } 149 | 150 | $extensionFacade = new ExtensionFacade(); 151 | $extensionBootstrapper = new ExtensionBootstrapper( 152 | $this->configuration, 153 | $extensionFacade, 154 | ); 155 | 156 | foreach ($this->configuration->extensionBootstrappers() as $bootstrapper) { 157 | $extensionBootstrapper->bootstrap( 158 | $bootstrapper['className'], 159 | $bootstrapper['parameters'], 160 | ); 161 | } 162 | 163 | $extensionRequiresCodeCoverageCollection = $extensionFacade->requiresCodeCoverageCollection(); 164 | } 165 | 166 | if ($this->configuration->hasLogfileJunit()) { 167 | new JunitXmlLogger( 168 | DefaultPrinter::from($this->configuration->logfileJunit()), 169 | EventFacade::instance(), 170 | ); 171 | } 172 | 173 | $printer = new ProgressPrinterOutput( 174 | DefaultPrinter::from($this->progressFile), 175 | DefaultPrinter::from($this->unexpectedOutputFile), 176 | ); 177 | 178 | new UnexpectedOutputPrinter($printer, EventFacade::instance()); 179 | new ProgressPrinter( 180 | $printer, 181 | EventFacade::instance(), 182 | false, 183 | 99999, 184 | $this->configuration->source(), 185 | ); 186 | 187 | if (isset($this->teamcityFile)) { 188 | new TeamCityLogger( 189 | DefaultPrinter::from($this->teamcityFile), 190 | EventFacade::instance(), 191 | ); 192 | } 193 | 194 | if (isset($this->testdoxFile)) { 195 | $this->testdoxResultCollector = new TestResultCollector( 196 | EventFacade::instance(), 197 | new IssueFilter($this->configuration->source()), 198 | ); 199 | } 200 | 201 | TestResultFacade::init(); 202 | DeprecationCollector::init(); 203 | 204 | if (isset($this->resultCacheFile)) { 205 | new ResultCacheHandler( 206 | new DefaultResultCache($this->resultCacheFile), 207 | EventFacade::instance(), 208 | ); 209 | } 210 | 211 | if ($this->configuration->source()->useBaseline()) { 212 | $baselineFile = $this->configuration->source()->baseline(); 213 | $baseline = null; 214 | 215 | try { 216 | $baseline = (new Reader())->read($baselineFile); 217 | } catch (CannotLoadBaselineException $e) { 218 | EventFacade::emitter()->testRunnerTriggeredPhpunitWarning($e->getMessage()); 219 | } 220 | 221 | if ($baseline !== null) { 222 | ErrorHandler::instance()->useBaseline($baseline); 223 | } 224 | } 225 | 226 | EventFacade::instance()->seal(); 227 | 228 | CodeCoverage::instance()->init( 229 | $this->configuration, 230 | CodeCoverageFilterRegistry::instance(), 231 | $extensionRequiresCodeCoverageCollection, 232 | ); 233 | 234 | EventFacade::emitter()->testRunnerStarted(); 235 | 236 | if ($this->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) { 237 | mt_srand($this->configuration->randomOrderSeed()); 238 | } 239 | 240 | $this->hasBeenBootstrapped = true; 241 | } 242 | 243 | public function end(): void 244 | { 245 | if (! $this->hasBeenBootstrapped) { 246 | return; 247 | } 248 | 249 | EventFacade::emitter()->testRunnerExecutionFinished(); 250 | EventFacade::emitter()->testRunnerFinished(); 251 | 252 | CodeCoverage::instance()->generateReports(new NullPrinter(), $this->configuration); 253 | 254 | $result = TestResultFacade::result(); 255 | if (isset($this->testdoxResultCollector)) { 256 | assert(isset($this->testdoxFile)); 257 | assert(isset($this->testdoxColumns)); 258 | 259 | (new TestDoxResultPrinter(DefaultPrinter::from($this->testdoxFile), $this->testdoxColor, $this->testdoxColumns, false))->print( 260 | $result, 261 | $this->testdoxResultCollector->testMethodsGroupedByClass(), 262 | ); 263 | } 264 | 265 | file_put_contents($this->testResultFile, serialize($result)); 266 | 267 | EventFacade::emitter()->applicationFinished(0); 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/WrapperRunner/ProgressPrinterOutput.php: -------------------------------------------------------------------------------- 1 | $this->progressPrinter->print($buffer), 33 | default => $this->outputPrinter->print($buffer), 34 | }; 35 | } 36 | 37 | public function flush(): void 38 | { 39 | $this->progressPrinter->flush(); 40 | $this->outputPrinter->flush(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/WrapperRunner/ResultPrinter.php: -------------------------------------------------------------------------------- 1 | */ 52 | private array $tailPositions; 53 | 54 | public function __construct( 55 | private readonly OutputInterface $output, 56 | private readonly Options $options 57 | ) { 58 | $this->printer = new class ($this->output) implements Printer { 59 | public function __construct( 60 | private readonly OutputInterface $output, 61 | ) { 62 | } 63 | 64 | public function print(string $buffer): void 65 | { 66 | $this->output->write(OutputFormatter::escape($buffer)); 67 | } 68 | 69 | public function flush(): void 70 | { 71 | } 72 | }; 73 | 74 | $this->numberOfColumns = $this->options->configuration->columns(); 75 | 76 | if (! $this->options->configuration->hasLogfileTeamcity()) { 77 | return; 78 | } 79 | 80 | $teamcityLogFileHandle = fopen($this->options->configuration->logfileTeamcity(), 'ab+'); 81 | assert($teamcityLogFileHandle !== false); 82 | $this->teamcityLogFileHandle = $teamcityLogFileHandle; 83 | } 84 | 85 | public function setTestCount(int $testCount): void 86 | { 87 | $this->totalCases = $testCount; 88 | } 89 | 90 | public function start(): void 91 | { 92 | $this->numTestsWidth = strlen((string) $this->totalCases); 93 | $this->maxColumn = $this->numberOfColumns 94 | + (DIRECTORY_SEPARATOR === '\\' ? -1 : 0) // fix windows blank lines 95 | - strlen($this->getProgress()); 96 | 97 | // @see \PHPUnit\TextUI\TestRunner::writeMessage() 98 | $output = $this->output; 99 | $write = static function (string $type, string $message) use ($output): void { 100 | $output->write(sprintf("%-15s%s\n", $type . ':', $message)); 101 | }; 102 | 103 | // @see \PHPUnit\TextUI\Application::writeRuntimeInformation() 104 | $write('Processes', (string) $this->options->processes); 105 | 106 | $runtime = 'PHP ' . PHP_VERSION; 107 | 108 | if ($this->options->configuration->hasCoverageReport()) { 109 | $filter = new Filter(); 110 | if ($this->options->configuration->pathCoverage()) { 111 | $codeCoverageDriver = (new Selector())->forLineAndPathCoverage($filter); // @codeCoverageIgnore 112 | } else { 113 | $codeCoverageDriver = (new Selector())->forLineCoverage($filter); 114 | } 115 | 116 | $runtime .= ' with ' . $codeCoverageDriver->nameAndVersion(); 117 | } 118 | 119 | $write('Runtime', $runtime); 120 | 121 | if ($this->options->configuration->hasConfigurationFile()) { 122 | $write('Configuration', $this->options->configuration->configurationFile()); 123 | } 124 | 125 | if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) { 126 | $write('Random Seed', (string) $this->options->configuration->randomOrderSeed()); 127 | } 128 | 129 | $output->write("\n"); 130 | } 131 | 132 | public function printFeedback( 133 | SplFileInfo $progressFile, 134 | SplFileInfo $outputFile, 135 | SplFileInfo|null $teamcityFile 136 | ): void { 137 | if ($this->options->needsTeamcity && $teamcityFile !== null) { 138 | $teamcityProgress = $this->tailMultiple([$teamcityFile]); 139 | 140 | if ($this->teamcityLogFileHandle !== null) { 141 | fwrite($this->teamcityLogFileHandle, $teamcityProgress); 142 | } 143 | } 144 | 145 | if ($this->options->configuration->outputIsTeamCity()) { 146 | assert(isset($teamcityProgress)); 147 | $this->output->write($teamcityProgress); 148 | 149 | return; 150 | } 151 | 152 | if ($this->options->configuration->noProgress()) { 153 | return; 154 | } 155 | 156 | $unexpectedOutput = $this->tail($outputFile); 157 | if ($unexpectedOutput !== '') { 158 | $this->output->write($unexpectedOutput); 159 | } 160 | 161 | $feedbackItems = $this->tail($progressFile); 162 | if ($feedbackItems === '') { 163 | return; 164 | } 165 | 166 | $actualTestCount = strlen($feedbackItems); 167 | for ($index = 0; $index < $actualTestCount; ++$index) { 168 | $this->printFeedbackItem($feedbackItems[$index]); 169 | } 170 | } 171 | 172 | /** 173 | * @param list $teamcityFiles 174 | * @param list $testdoxFiles 175 | */ 176 | public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles): void 177 | { 178 | if ($this->options->needsTeamcity) { 179 | $teamcityProgress = $this->tailMultiple($teamcityFiles); 180 | 181 | if ($this->teamcityLogFileHandle !== null) { 182 | fwrite($this->teamcityLogFileHandle, $teamcityProgress); 183 | $resource = $this->teamcityLogFileHandle; 184 | $this->teamcityLogFileHandle = null; 185 | fclose($resource); 186 | } 187 | } 188 | 189 | if ($this->options->configuration->outputIsTeamCity()) { 190 | assert(isset($teamcityProgress)); 191 | $this->output->write($teamcityProgress); 192 | 193 | return; 194 | } 195 | 196 | $this->printer->print(PHP_EOL . (new ResourceUsageFormatter())->resourceUsageSinceStartOfRequest() . PHP_EOL . PHP_EOL); 197 | 198 | $defaultResultPrinter = new DefaultResultPrinter( 199 | $this->printer, 200 | $this->options->configuration->displayDetailsOnPhpunitDeprecations(), 201 | true, 202 | true, 203 | true, 204 | true, 205 | true, 206 | true, 207 | $this->options->configuration->displayDetailsOnIncompleteTests(), 208 | $this->options->configuration->displayDetailsOnSkippedTests(), 209 | $this->options->configuration->displayDetailsOnTestsThatTriggerDeprecations(), 210 | $this->options->configuration->displayDetailsOnTestsThatTriggerErrors(), 211 | $this->options->configuration->displayDetailsOnTestsThatTriggerNotices(), 212 | $this->options->configuration->displayDetailsOnTestsThatTriggerWarnings(), 213 | false, 214 | ); 215 | 216 | if ($this->options->configuration->outputIsTestDox()) { 217 | $this->output->write($this->tailMultiple($testdoxFiles)); 218 | 219 | $defaultResultPrinter = new DefaultResultPrinter( 220 | $this->printer, 221 | $this->options->configuration->displayDetailsOnPhpunitDeprecations(), 222 | true, 223 | true, 224 | true, 225 | false, 226 | false, 227 | false, 228 | false, 229 | false, 230 | false, 231 | false, 232 | false, 233 | false, 234 | false, 235 | ); 236 | } 237 | 238 | $defaultResultPrinter->print($testResult); 239 | 240 | (new SummaryPrinter( 241 | $this->printer, 242 | $this->options->configuration->colors(), 243 | ))->print($testResult); 244 | } 245 | 246 | private function printFeedbackItem(string $item): void 247 | { 248 | $this->printFeedbackItemColor($item); 249 | ++$this->column; 250 | ++$this->casesProcessed; 251 | if ($this->column !== $this->maxColumn && $this->casesProcessed < $this->totalCases) { 252 | return; 253 | } 254 | 255 | if ( 256 | $this->casesProcessed > 0 257 | && $this->casesProcessed === $this->totalCases 258 | && ($pad = $this->maxColumn - $this->column) > 0 259 | ) { 260 | $this->output->write(str_repeat(' ', $pad)); 261 | } 262 | 263 | $this->output->write($this->getProgress() . "\n"); 264 | $this->column = 0; 265 | } 266 | 267 | private function printFeedbackItemColor(string $item): void 268 | { 269 | $buffer = match ($item) { 270 | 'E' => $this->colorizeTextBox('fg-red, bold', $item), 271 | 'F' => $this->colorizeTextBox('bg-red, fg-white', $item), 272 | 'I', 'N', 'D', 'R', 'W' => $this->colorizeTextBox('fg-yellow, bold', $item), 273 | 'S' => $this->colorizeTextBox('fg-cyan, bold', $item), 274 | '.' => $item, 275 | }; 276 | $this->output->write($buffer); 277 | } 278 | 279 | private function getProgress(): string 280 | { 281 | return sprintf( 282 | ' %' . $this->numTestsWidth . 'd / %' . $this->numTestsWidth . 'd (%3s%%)', 283 | $this->casesProcessed, 284 | $this->totalCases, 285 | floor(($this->totalCases > 0 ? $this->casesProcessed / $this->totalCases : 0) * 100), 286 | ); 287 | } 288 | 289 | private function colorizeTextBox(string $color, string $buffer): string 290 | { 291 | if (! $this->options->configuration->colors()) { 292 | return $buffer; 293 | } 294 | 295 | return Color::colorizeTextBox($color, $buffer); 296 | } 297 | 298 | /** @param list $files */ 299 | private function tailMultiple(array $files): string 300 | { 301 | $content = ''; 302 | foreach ($files as $file) { 303 | if (! $file->isFile()) { 304 | continue; 305 | } 306 | 307 | $content .= $this->tail($file); 308 | } 309 | 310 | return $content; 311 | } 312 | 313 | private function tail(SplFileInfo $file): string 314 | { 315 | $path = $file->getPathname(); 316 | assert($path !== ''); 317 | $handle = fopen($path, 'r'); 318 | assert($handle !== false); 319 | $fseek = fseek($handle, $this->tailPositions[$path] ?? 0); 320 | assert($fseek === 0); 321 | 322 | $contents = ''; 323 | while (! feof($handle)) { 324 | $fread = fread($handle, 8192); 325 | assert($fread !== false); 326 | $contents .= $fread; 327 | } 328 | 329 | $ftell = ftell($handle); 330 | assert($ftell !== false); 331 | $this->tailPositions[$path] = $ftell; 332 | fclose($handle); 333 | 334 | return $contents; 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /src/WrapperRunner/SuiteLoader.php: -------------------------------------------------------------------------------- 1 | */ 49 | public array $tests; 50 | 51 | public function __construct( 52 | private Options $options, 53 | OutputInterface $output, 54 | CodeCoverageFilterRegistry $codeCoverageFilterRegistry, 55 | ) { 56 | (new PhpHandler())->handle($this->options->configuration->php()); 57 | 58 | if ($this->options->configuration->hasBootstrap()) { 59 | $bootstrapFilename = $this->options->configuration->bootstrap(); 60 | include_once $bootstrapFilename; 61 | EventFacade::emitter()->testRunnerBootstrapFinished($bootstrapFilename); 62 | } 63 | 64 | if (! $this->options->configuration->noExtensions()) { 65 | if ($this->options->configuration->hasPharExtensionDirectory()) { 66 | (new PharLoader())->loadPharExtensionsInDirectory( 67 | $this->options->configuration->pharExtensionDirectory(), 68 | ); 69 | } 70 | 71 | $extensionFacade = new ExtensionFacade(); 72 | $extensionBootstrapper = new ExtensionBootstrapper( 73 | $this->options->configuration, 74 | $extensionFacade, 75 | ); 76 | 77 | foreach ($this->options->configuration->extensionBootstrappers() as $bootstrapper) { 78 | $extensionBootstrapper->bootstrap( 79 | $bootstrapper['className'], 80 | $bootstrapper['parameters'], 81 | ); 82 | } 83 | } 84 | 85 | TestResultFacade::init(); 86 | EventFacade::instance()->seal(); 87 | 88 | $testSuite = (new TestSuiteBuilder())->build($this->options->configuration); 89 | 90 | if ($this->options->configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) { 91 | mt_srand($this->options->configuration->randomOrderSeed()); 92 | } 93 | 94 | if ( 95 | $this->options->configuration->executionOrder() !== TestSuiteSorter::ORDER_DEFAULT || 96 | $this->options->configuration->executionOrderDefects() !== TestSuiteSorter::ORDER_DEFAULT || 97 | $this->options->configuration->resolveDependencies() 98 | ) { 99 | $resultCache = new NullResultCache(); 100 | if ($this->options->configuration->cacheResult()) { 101 | $resultCache = new DefaultResultCache($this->options->configuration->testResultCacheFile()); 102 | $resultCache->load(); 103 | } 104 | 105 | (new TestSuiteSorter($resultCache))->reorderTestsInSuite( 106 | $testSuite, 107 | $this->options->configuration->executionOrder(), 108 | $this->options->configuration->resolveDependencies(), 109 | $this->options->configuration->executionOrderDefects(), 110 | ); 111 | } 112 | 113 | (new TestSuiteFilterProcessor())->process($this->options->configuration, $testSuite); 114 | 115 | $this->testCount = count($testSuite); 116 | 117 | $files = []; 118 | $tests = []; 119 | foreach ($this->loadFiles($testSuite) as $file => $test) { 120 | $files[$file] = null; 121 | 122 | if ($test instanceof PhptTestCase) { 123 | $tests[] = $file; 124 | } else { 125 | $name = $test->name(); 126 | if ($test->providedData() !== []) { 127 | $dataName = $test->dataName(); 128 | if ($this->options->functional) { 129 | $name = sprintf('/%s%s$/', preg_quote($name, '/'), preg_quote($test->dataSetAsString(), '/')); 130 | } else { 131 | if (is_int($dataName)) { 132 | $name .= '#' . $dataName; 133 | } else { 134 | $name .= '@' . $dataName; 135 | } 136 | } 137 | } else { 138 | $name = sprintf('/%s$/', $name); 139 | } 140 | 141 | $tests[] = "$file\0$name"; 142 | } 143 | } 144 | 145 | $this->tests = $this->options->functional 146 | ? $tests 147 | : array_keys($files); 148 | 149 | if (! $this->options->configuration->hasCoverageReport()) { 150 | return; 151 | } 152 | 153 | ob_start(); 154 | $result = (new WarmCodeCoverageCacheCommand( 155 | $this->options->configuration, 156 | $codeCoverageFilterRegistry, 157 | ))->execute(); 158 | $ob_get_clean = ob_get_clean(); 159 | assert($ob_get_clean !== false); 160 | $output->write($ob_get_clean); 161 | $output->write($result->output()); 162 | if ($result->shellExitCode() !== Result::SUCCESS) { 163 | exit($result->shellExitCode()); 164 | } 165 | } 166 | 167 | /** @return Generator */ 168 | private function loadFiles(TestSuite $testSuite): Generator 169 | { 170 | foreach ($testSuite as $test) { 171 | if ($test instanceof TestSuite) { 172 | yield from $this->loadFiles($test); 173 | 174 | continue; 175 | } 176 | 177 | if ($test instanceof PhptTestCase) { 178 | $refProperty = new ReflectionProperty(PhptTestCase::class, 'filename'); 179 | $filename = $refProperty->getValue($test); 180 | assert(is_string($filename) && $filename !== ''); 181 | $filename = $this->stripCwd($filename); 182 | 183 | yield $filename => $test; 184 | 185 | continue; 186 | } 187 | 188 | if ($test instanceof TestCase) { 189 | $refClass = new ReflectionClass($test); 190 | $filename = $refClass->getFileName(); 191 | assert(is_string($filename)); 192 | $filename = $this->stripCwd($filename); 193 | 194 | yield $filename => $test; 195 | 196 | continue; 197 | } 198 | } 199 | } 200 | 201 | /** 202 | * @param non-empty-string $filename 203 | * 204 | * @return non-empty-string 205 | */ 206 | private function stripCwd(string $filename): string 207 | { 208 | if (! str_starts_with($filename, $this->options->cwd)) { 209 | return $filename; 210 | } 211 | 212 | $substr = substr($filename, 1 + strlen($this->options->cwd)); 213 | assert($substr !== ''); 214 | 215 | return $substr; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/WrapperRunner/WorkerCrashedException.php: -------------------------------------------------------------------------------- 1 | getEnv() as $key => $value) { 21 | $envs .= sprintf('%s=%s ', $key, escapeshellarg((string) $value)); 22 | } 23 | 24 | $error = sprintf( 25 | 'The test "%s%s" failed.' . "\n\nExit Code: %s(%s)\n\nWorking directory: %s", 26 | $envs, 27 | $test, 28 | (string) $process->getExitCode(), 29 | (string) $process->getExitCodeText(), 30 | (string) $process->getWorkingDirectory(), 31 | ); 32 | 33 | if (! $process->isOutputDisabled()) { 34 | $error .= sprintf( 35 | "\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s", 36 | $process->getOutput(), 37 | $process->getErrorOutput(), 38 | ); 39 | } 40 | 41 | return new self($error, 0, $previousException); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/WrapperRunner/WrapperRunner.php: -------------------------------------------------------------------------------- 1 | */ 45 | private array $pending = []; 46 | private int $exitcode = -1; 47 | /** @var array */ 48 | private array $workers = []; 49 | /** @var array */ 50 | private array $batches = []; 51 | 52 | /** @var list */ 53 | private array $statusFiles = []; 54 | /** @var list */ 55 | private array $progressFiles = []; 56 | /** @var list */ 57 | private array $unexpectedOutputFiles = []; 58 | /** @var list */ 59 | private array $resultCacheFiles = []; 60 | /** @var list */ 61 | private array $testResultFiles = []; 62 | /** @var list */ 63 | private array $coverageFiles = []; 64 | /** @var list */ 65 | private array $junitFiles = []; 66 | /** @var list */ 67 | private array $teamcityFiles = []; 68 | /** @var list */ 69 | private array $testdoxFiles = []; 70 | /** @var array */ 71 | private readonly array $parameters; 72 | private CodeCoverageFilterRegistry $codeCoverageFilterRegistry; 73 | 74 | public function __construct( 75 | private readonly Options $options, 76 | private readonly OutputInterface $output 77 | ) { 78 | $this->printer = new ResultPrinter($output, $options); 79 | 80 | $wrapper = realpath( 81 | dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpunit-wrapper.php', 82 | ); 83 | assert($wrapper !== false); 84 | $phpFinder = new PhpExecutableFinder(); 85 | $phpBin = $phpFinder->find(false); 86 | assert($phpBin !== false); 87 | assert($phpBin !== ''); 88 | $parameters = [$phpBin]; 89 | /** @var array $arguments */ 90 | $arguments = $phpFinder->findArguments(); 91 | $parameters = array_merge($parameters, $arguments); 92 | 93 | if ($options->passthruPhp !== null) { 94 | $parameters = array_merge($parameters, $options->passthruPhp); 95 | } 96 | 97 | $parameters[] = $wrapper; 98 | 99 | $this->parameters = $parameters; 100 | $this->codeCoverageFilterRegistry = new CodeCoverageFilterRegistry(); 101 | } 102 | 103 | public function run(): int 104 | { 105 | $directory = dirname(__DIR__); 106 | assert($directory !== ''); 107 | ExcludeList::addDirectory($directory); 108 | $suiteLoader = new SuiteLoader( 109 | $this->options, 110 | $this->output, 111 | $this->codeCoverageFilterRegistry, 112 | ); 113 | $result = TestResultFacade::result(); 114 | 115 | $this->pending = $suiteLoader->tests; 116 | $this->printer->setTestCount($suiteLoader->testCount); 117 | $this->printer->start(); 118 | $this->startWorkers(); 119 | $this->assignAllPendingTests(); 120 | $this->waitForAllToFinish(); 121 | 122 | return $this->complete($result); 123 | } 124 | 125 | private function startWorkers(): void 126 | { 127 | for ($token = 1; $token <= $this->options->processes; ++$token) { 128 | $this->startWorker($token); 129 | } 130 | } 131 | 132 | private function assignAllPendingTests(): void 133 | { 134 | $batchSize = $this->options->maxBatchSize; 135 | 136 | while (count($this->pending) > 0 && count($this->workers) > 0) { 137 | foreach ($this->workers as $token => $worker) { 138 | if (! $worker->isRunning()) { 139 | throw $worker->getWorkerCrashedException(); 140 | } 141 | 142 | if (! $worker->isFree()) { 143 | continue; 144 | } 145 | 146 | $this->flushWorker($worker); 147 | 148 | if ($batchSize !== 0 && $this->batches[$token] === $batchSize) { 149 | $this->destroyWorker($token); 150 | $worker = $this->startWorker($token); 151 | } 152 | 153 | if ( 154 | $this->exitcode > 0 155 | && $this->options->configuration->stopOnFailure() 156 | ) { 157 | $this->pending = []; 158 | } elseif (($pending = array_shift($this->pending)) !== null) { 159 | $worker->assign($pending); 160 | $this->batches[$token]++; 161 | } 162 | } 163 | 164 | usleep(self::CYCLE_SLEEP); 165 | } 166 | } 167 | 168 | private function flushWorker(WrapperWorker $worker): void 169 | { 170 | $this->exitcode = max($this->exitcode, $worker->getExitCode()); 171 | $this->printer->printFeedback( 172 | $worker->progressFile, 173 | $worker->unexpectedOutputFile, 174 | $worker->teamcityFile ?? null, 175 | ); 176 | $worker->reset(); 177 | } 178 | 179 | private function waitForAllToFinish(): void 180 | { 181 | $stopped = []; 182 | while (count($this->workers) > 0) { 183 | foreach ($this->workers as $index => $worker) { 184 | if ($worker->isRunning()) { 185 | if (! isset($stopped[$index]) && $worker->isFree()) { 186 | $worker->stop(); 187 | $stopped[$index] = true; 188 | } 189 | 190 | continue; 191 | } 192 | 193 | if (! $worker->isFree()) { 194 | throw $worker->getWorkerCrashedException(); 195 | } 196 | 197 | $this->flushWorker($worker); 198 | unset($this->workers[$index]); 199 | } 200 | 201 | usleep(self::CYCLE_SLEEP); 202 | } 203 | } 204 | 205 | /** @param positive-int $token */ 206 | private function startWorker(int $token): WrapperWorker 207 | { 208 | $worker = new WrapperWorker( 209 | $this->output, 210 | $this->options, 211 | $this->parameters, 212 | $token, 213 | ); 214 | $worker->start(); 215 | $this->batches[$token] = 0; 216 | 217 | $this->statusFiles[] = $worker->statusFile; 218 | $this->progressFiles[] = $worker->progressFile; 219 | $this->unexpectedOutputFiles[] = $worker->unexpectedOutputFile; 220 | $this->testResultFiles[] = $worker->testResultFile; 221 | 222 | if (isset($worker->resultCacheFile)) { 223 | $this->resultCacheFiles[] = $worker->resultCacheFile; 224 | } 225 | 226 | if (isset($worker->junitFile)) { 227 | $this->junitFiles[] = $worker->junitFile; 228 | } 229 | 230 | if (isset($worker->coverageFile)) { 231 | $this->coverageFiles[] = $worker->coverageFile; 232 | } 233 | 234 | if (isset($worker->teamcityFile)) { 235 | $this->teamcityFiles[] = $worker->teamcityFile; 236 | } 237 | 238 | if (isset($worker->testdoxFile)) { 239 | $this->testdoxFiles[] = $worker->testdoxFile; 240 | } 241 | 242 | return $this->workers[$token] = $worker; 243 | } 244 | 245 | private function destroyWorker(int $token): void 246 | { 247 | $this->workers[$token]->stop(); 248 | // We need to wait for ApplicationForWrapperWorker::end to end 249 | while ($this->workers[$token]->isRunning()) { 250 | usleep(self::CYCLE_SLEEP); 251 | } 252 | 253 | unset($this->workers[$token]); 254 | } 255 | 256 | private function complete(TestResult $testResultSum): int 257 | { 258 | foreach ($this->testResultFiles as $testresultFile) { 259 | if (! $testresultFile->isFile()) { 260 | continue; 261 | } 262 | 263 | $contents = file_get_contents($testresultFile->getPathname()); 264 | assert($contents !== false); 265 | $testResult = unserialize($contents); 266 | assert($testResult instanceof TestResult); 267 | 268 | $testResultSum = new TestResult( 269 | (int) $testResultSum->hasTests() + (int) $testResult->hasTests(), 270 | $testResultSum->numberOfTestsRun() + $testResult->numberOfTestsRun(), 271 | $testResultSum->numberOfAssertions() + $testResult->numberOfAssertions(), 272 | array_merge_recursive($testResultSum->testErroredEvents(), $testResult->testErroredEvents()), 273 | array_merge_recursive($testResultSum->testFailedEvents(), $testResult->testFailedEvents()), 274 | array_merge_recursive($testResultSum->testConsideredRiskyEvents(), $testResult->testConsideredRiskyEvents()), 275 | array_merge_recursive($testResultSum->testSuiteSkippedEvents(), $testResult->testSuiteSkippedEvents()), 276 | array_merge_recursive($testResultSum->testSkippedEvents(), $testResult->testSkippedEvents()), 277 | array_merge_recursive($testResultSum->testMarkedIncompleteEvents(), $testResult->testMarkedIncompleteEvents()), 278 | array_merge_recursive($testResultSum->testTriggeredPhpunitDeprecationEvents(), $testResult->testTriggeredPhpunitDeprecationEvents()), 279 | array_merge_recursive($testResultSum->testTriggeredPhpunitErrorEvents(), $testResult->testTriggeredPhpunitErrorEvents()), 280 | array_merge_recursive($testResultSum->testTriggeredPhpunitNoticeEvents(), $testResult->testTriggeredPhpunitNoticeEvents()), 281 | array_merge_recursive($testResultSum->testTriggeredPhpunitWarningEvents(), $testResult->testTriggeredPhpunitWarningEvents()), 282 | array_merge_recursive($testResultSum->testRunnerTriggeredDeprecationEvents(), $testResult->testRunnerTriggeredDeprecationEvents()), 283 | array_merge_recursive($testResultSum->testRunnerTriggeredNoticeEvents(), $testResult->testRunnerTriggeredNoticeEvents()), 284 | array_merge_recursive($testResultSum->testRunnerTriggeredWarningEvents(), $testResult->testRunnerTriggeredWarningEvents()), 285 | array_merge_recursive($testResultSum->errors(), $testResult->errors()), 286 | array_merge_recursive($testResultSum->deprecations(), $testResult->deprecations()), 287 | array_merge_recursive($testResultSum->notices(), $testResult->notices()), 288 | array_merge_recursive($testResultSum->warnings(), $testResult->warnings()), 289 | array_merge_recursive($testResultSum->phpDeprecations(), $testResult->phpDeprecations()), 290 | array_merge_recursive($testResultSum->phpNotices(), $testResult->phpNotices()), 291 | array_merge_recursive($testResultSum->phpWarnings(), $testResult->phpWarnings()), 292 | $testResultSum->numberOfIssuesIgnoredByBaseline() + $testResult->numberOfIssuesIgnoredByBaseline(), 293 | ); 294 | } 295 | 296 | if ($this->options->configuration->cacheResult()) { 297 | $resultCacheSum = new DefaultResultCache($this->options->configuration->testResultCacheFile()); 298 | foreach ($this->resultCacheFiles as $resultCacheFile) { 299 | $resultCache = new DefaultResultCache($resultCacheFile->getPathname()); 300 | $resultCache->load(); 301 | 302 | $resultCacheSum->mergeWith($resultCache); 303 | } 304 | 305 | $resultCacheSum->persist(); 306 | } 307 | 308 | $this->printer->printResults( 309 | $testResultSum, 310 | $this->teamcityFiles, 311 | $this->testdoxFiles, 312 | ); 313 | $this->generateCodeCoverageReports(); 314 | $this->generateLogs(); 315 | 316 | $exitcode = (new ShellExitCodeCalculator())->calculate( 317 | $this->options->configuration->failOnDeprecation(), 318 | $this->options->configuration->failOnPhpunitDeprecation(), 319 | $this->options->configuration->failOnPhpunitNotice(), 320 | $this->options->configuration->failOnEmptyTestSuite(), 321 | $this->options->configuration->failOnIncomplete(), 322 | $this->options->configuration->failOnNotice(), 323 | $this->options->configuration->failOnRisky(), 324 | $this->options->configuration->failOnSkipped(), 325 | $this->options->configuration->failOnWarning(), 326 | $testResultSum, 327 | ); 328 | 329 | $this->clearFiles($this->statusFiles); 330 | $this->clearFiles($this->progressFiles); 331 | $this->clearFiles($this->unexpectedOutputFiles); 332 | $this->clearFiles($this->testResultFiles); 333 | $this->clearFiles($this->resultCacheFiles); 334 | $this->clearFiles($this->coverageFiles); 335 | $this->clearFiles($this->junitFiles); 336 | $this->clearFiles($this->teamcityFiles); 337 | $this->clearFiles($this->testdoxFiles); 338 | 339 | return $exitcode; 340 | } 341 | 342 | protected function generateCodeCoverageReports(): void 343 | { 344 | if ($this->coverageFiles === []) { 345 | return; 346 | } 347 | 348 | $coverageManager = new CodeCoverage(); 349 | $coverageManager->init( 350 | $this->options->configuration, 351 | $this->codeCoverageFilterRegistry, 352 | false, 353 | ); 354 | $coverageMerger = new CoverageMerger($coverageManager->codeCoverage()); 355 | foreach ($this->coverageFiles as $coverageFile) { 356 | $coverageMerger->addCoverageFromFile($coverageFile); 357 | } 358 | 359 | $coverageManager->generateReports( 360 | $this->printer->printer, 361 | $this->options->configuration, 362 | ); 363 | } 364 | 365 | private function generateLogs(): void 366 | { 367 | if ($this->junitFiles === []) { 368 | return; 369 | } 370 | 371 | $testSuite = (new LogMerger())->merge($this->junitFiles); 372 | if ($testSuite === null) { 373 | return; 374 | } 375 | 376 | (new Writer())->write( 377 | $testSuite, 378 | $this->options->configuration->logfileJunit(), 379 | ); 380 | } 381 | 382 | /** @param list $files */ 383 | private function clearFiles(array $files): void 384 | { 385 | foreach ($files as $file) { 386 | if (! $file->isFile()) { 387 | continue; 388 | } 389 | 390 | unlink($file->getPathname()); 391 | } 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/WrapperRunner/WrapperWorker.php: -------------------------------------------------------------------------------- 1 | tmpDir, 60 | DIRECTORY_SEPARATOR, 61 | $token, 62 | uniqid(), 63 | ); 64 | $this->statusFile = new SplFileInfo($commonTmpFilePath . 'status'); 65 | touch($this->statusFile->getPathname()); 66 | $this->progressFile = new SplFileInfo($commonTmpFilePath . 'progress'); 67 | touch($this->progressFile->getPathname()); 68 | $this->unexpectedOutputFile = new SplFileInfo($commonTmpFilePath . 'unexpected_output'); 69 | touch($this->unexpectedOutputFile->getPathname()); 70 | $this->testResultFile = new SplFileInfo($commonTmpFilePath . 'test_result'); 71 | 72 | if ($this->options->configuration->cacheResult()) { 73 | $this->resultCacheFile = new SplFileInfo($commonTmpFilePath . 'result_cache'); 74 | } 75 | 76 | if ($options->configuration->hasLogfileJunit()) { 77 | $this->junitFile = new SplFileInfo($commonTmpFilePath . 'junit'); 78 | } 79 | 80 | if ($options->configuration->hasCoverageReport()) { 81 | $this->coverageFile = new SplFileInfo($commonTmpFilePath . 'coverage'); 82 | } 83 | 84 | if ($options->needsTeamcity) { 85 | $this->teamcityFile = new SplFileInfo($commonTmpFilePath . 'teamcity'); 86 | } 87 | 88 | if ($options->configuration->outputIsTestDox()) { 89 | $this->testdoxFile = new SplFileInfo($commonTmpFilePath . 'testdox'); 90 | } 91 | 92 | $parameters[] = '--status-file'; 93 | $parameters[] = $this->statusFile->getPathname(); 94 | $parameters[] = '--progress-file'; 95 | $parameters[] = $this->progressFile->getPathname(); 96 | $parameters[] = '--unexpected-output-file'; 97 | $parameters[] = $this->unexpectedOutputFile->getPathname(); 98 | $parameters[] = '--test-result-file'; 99 | $parameters[] = $this->testResultFile->getPathname(); 100 | 101 | if (isset($this->resultCacheFile)) { 102 | $parameters[] = '--result-cache-file'; 103 | $parameters[] = $this->resultCacheFile->getPathname(); 104 | } 105 | 106 | if (isset($this->teamcityFile)) { 107 | $parameters[] = '--teamcity-file'; 108 | $parameters[] = $this->teamcityFile->getPathname(); 109 | } 110 | 111 | if (isset($this->testdoxFile)) { 112 | $parameters[] = '--testdox-file'; 113 | $parameters[] = $this->testdoxFile->getPathname(); 114 | if ($options->configuration->colors()) { 115 | $parameters[] = '--testdox-color'; 116 | } 117 | 118 | $parameters[] = '--testdox-columns'; 119 | $parameters[] = (string) $options->configuration->columns(); 120 | } 121 | 122 | $phpunitArguments = [$options->phpunit]; 123 | foreach ($options->phpunitOptions as $key => $value) { 124 | if ($options->functional && $key === 'filter') { 125 | continue; 126 | } 127 | 128 | if ($value === true) { 129 | $phpunitArguments[] = "--{$key}"; 130 | continue; 131 | } 132 | 133 | if (! is_array($value)) { 134 | $value = [$value]; 135 | } 136 | 137 | foreach ($value as $innerValue) { 138 | $phpunitArguments[] = "--{$key}"; 139 | $phpunitArguments[] = $innerValue; 140 | } 141 | } 142 | 143 | $phpunitArguments[] = '--do-not-cache-result'; 144 | $phpunitArguments[] = '--no-logging'; 145 | $phpunitArguments[] = '--no-coverage'; 146 | $phpunitArguments[] = '--no-output'; 147 | if (isset($this->junitFile)) { 148 | $phpunitArguments[] = '--log-junit'; 149 | $phpunitArguments[] = $this->junitFile->getPathname(); 150 | } 151 | 152 | if (isset($this->coverageFile)) { 153 | $phpunitArguments[] = '--coverage-php'; 154 | $phpunitArguments[] = $this->coverageFile->getPathname(); 155 | } 156 | 157 | $parameters[] = '--phpunit-argv'; 158 | $parameters[] = serialize($phpunitArguments); 159 | 160 | if ($options->verbose) { 161 | $output->write(sprintf( 162 | "Starting process {$this->token}: %s\n", 163 | implode(' ', array_map('\\escapeshellarg', $parameters)), 164 | )); 165 | } 166 | 167 | $this->input = new InputStream(); 168 | $this->process = new Process( 169 | $parameters, 170 | $options->cwd, 171 | $options->fillEnvWithTokens($token), 172 | $this->input, 173 | null, 174 | ); 175 | } 176 | 177 | public function start(): void 178 | { 179 | $this->process->start(); 180 | } 181 | 182 | public function getWorkerCrashedException(?Throwable $previousException = null): WorkerCrashedException 183 | { 184 | return WorkerCrashedException::fromProcess( 185 | $this->process, 186 | $this->currentlyExecuting ?? 'N.A.', 187 | $previousException, 188 | ); 189 | } 190 | 191 | public function assign(string $test): void 192 | { 193 | assert($this->currentlyExecuting === null); 194 | 195 | if ($this->options->verbose) { 196 | $this->output->write("Process {$this->token} executing: {$test}\n"); 197 | } 198 | 199 | $this->input->write($test . "\n"); 200 | $this->currentlyExecuting = $test; 201 | ++$this->inExecution; 202 | } 203 | 204 | public function reset(): void 205 | { 206 | $this->currentlyExecuting = null; 207 | } 208 | 209 | public function stop(): void 210 | { 211 | $this->input->write(self::COMMAND_EXIT); 212 | } 213 | 214 | public function isFree(): bool 215 | { 216 | $statusFilepath = $this->statusFile->getPathname(); 217 | clearstatcache(true, $statusFilepath); 218 | 219 | $isFree = $this->inExecution === filesize($statusFilepath); 220 | 221 | if ($isFree && $this->inExecution > 0) { 222 | $exitCodes = file_get_contents($statusFilepath); 223 | assert(is_string($exitCodes) && $exitCodes !== ''); 224 | $this->exitCode = (int) $exitCodes[-1]; 225 | } 226 | 227 | return $isFree; 228 | } 229 | 230 | public function getExitCode(): int 231 | { 232 | return $this->exitCode; 233 | } 234 | 235 | public function isRunning(): bool 236 | { 237 | return $this->process->isRunning(); 238 | } 239 | } 240 | --------------------------------------------------------------------------------