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
@@ -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();
}
}