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