Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bin/worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Plugins\Parallel\Paratest\StopConditions;
use Pest\Support\Container;
use Pest\TestSuite;
use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
Expand Down Expand Up @@ -99,6 +101,8 @@
(int) ($getopt['testdox-columns'] ?? null),
);

$stopConditions = null;

while (true) {
if (feof(STDIN)) {
$application->end();
Expand All @@ -113,6 +117,9 @@

$exitCode = $application->runTest(realpath(trim($testPath)));

$stopConditions ??= new StopConditions(ConfigurationRegistry::get());
$stopConditions->write($getopt['test-result-file'].'.stop');

fwrite($statusFile, (string) $exitCode);
fflush($statusFile);
}
Expand Down
3 changes: 3 additions & 0 deletions src/Plugins/Parallel.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class Parallel implements HandlesArguments
Parallel\Handlers\Parallel::class,
Parallel\Handlers\Pest::class,
Parallel\Handlers\Laravel::class,
Parallel\Handlers\StopOn::class,
];

/**
Expand Down Expand Up @@ -148,6 +149,8 @@ private function paratestCommand(): Application
$command->setName('Pest');
$command->setVersion(version());

Parallel\Handlers\StopOn::configureOptions($command->find('paratest')->getDefinition());

return $command;
}

Expand Down
76 changes: 76 additions & 0 deletions src/Plugins/Parallel/Handlers/StopOn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Pest\Plugins\Parallel\Handlers;

use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Plugins\Parallel\Contracts\HandlersWorkerArguments;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;

final class StopOn implements HandlersWorkerArguments, HandlesArguments
{
private const array OPTIONS = [
'--stop-on-defect',
'--stop-on-error',
'--stop-on-failure',
'--stop-on-warning',
'--stop-on-risky',
'--stop-on-deprecation',
'--stop-on-notice',
'--stop-on-skipped',
'--stop-on-incomplete',
];

public function handleArguments(array $arguments): array
{
foreach ($arguments as $index => $argument) {
[$option, $value] = array_pad(explode('=', $argument, 2), 2, '');

if (in_array($option, self::OPTIONS, true)
&& in_array($value, ['', '0'], true)) {
$arguments[$index] = $option.'=1';
}
}

return $arguments;
}

public function handleWorkerArguments(array $arguments): array
{
$arguments = array_values($arguments);

foreach ($arguments as $index => $argument) {
if (! in_array($argument, self::OPTIONS, true)
|| ! isset($arguments[$index + 1])
|| str_starts_with($arguments[$index + 1], '--')) {
continue;
}

$arguments[$index] .= '='.$arguments[$index + 1];
unset($arguments[$index + 1]);
}

return array_values($arguments);
}

public static function configureOptions(InputDefinition $definition): void
{
$options = $definition->getOptions();

foreach (self::OPTIONS as $argument) {
$name = substr($argument, 2);
$option = $options[$name];
$options[$name] = new InputOption(
$name,
$option->getShortcut(),
InputOption::VALUE_OPTIONAL,
$option->getDescription(),
false,
);
}

$definition->setOptions($options);
}
}
216 changes: 216 additions & 0 deletions src/Plugins/Parallel/Paratest/StopConditions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<?php

declare(strict_types=1);

namespace Pest\Plugins\Parallel\Paratest;

use PHPUnit\Runner\DeprecationCollector\Facade as DeprecationCollector;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\Issues\Issue;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\Configuration;
use RuntimeException;

final class StopConditions
{
/**
* @var array<string, positive-int>
*/
private readonly array $thresholds;

/**
* @var array<string, array<string, non-negative-int>>
*/
private array $workerCounts = [];

/**
* @var array<string, non-negative-int>
*/
private array $totals = [];

/**
* @var array<string, array<string, true>>
*/
private array $issueIds = [];

public function __construct(private readonly Configuration $configuration)
{
$this->thresholds = array_filter([
'defect' => $configuration->stopOnDefectThreshold(),
'error' => $configuration->stopOnErrorThreshold(),
'failure' => $configuration->stopOnFailureThreshold(),
'warning' => $configuration->stopOnWarningThreshold(),
'risky' => $configuration->stopOnRiskyThreshold(),
'deprecation' => $configuration->stopOnDeprecationThreshold(),
'notice' => $configuration->stopOnNoticeThreshold(),
'incomplete' => $configuration->stopOnIncompleteThreshold(),
'skipped' => $configuration->stopOnSkippedThreshold(),
], static fn (int $threshold): bool => $threshold > 0);
}

public function write(string $filename): void
{
if ($this->thresholds === []) {
return;
}

$result = TestResultFacade::result();
$state = json_encode([
'counts' => $this->counts($result),
'issues' => $this->issues($result),
], JSON_THROW_ON_ERROR);

if (file_put_contents($filename.'.tmp', $state) === false
|| ! rename($filename.'.tmp', $filename)) {
throw new RuntimeException(sprintf('Unable to write the parallel stop counts to [%s].', $filename));
}
}

public function read(string $filename): void
{
if ($this->thresholds === []) {
return;
}

$contents = file_get_contents($filename);

if ($contents === false) {
throw new RuntimeException(sprintf('Unable to read the parallel stop counts from [%s].', $filename));
}

$state = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);

if (! is_array($state)
|| ! isset($state['counts'], $state['issues'])
|| ! is_array($state['counts'])
|| ! is_array($state['issues'])) {
throw new RuntimeException(sprintf('Invalid parallel stop counts in [%s].', $filename));
}

$this->addCounts($filename, $state['counts']);
$this->addIssues($filename, $state['issues']);
}

public function shouldStop(): bool
{
return array_any($this->thresholds, function (int $threshold, string $outcome): bool {
$issueType = $outcome === 'defect' ? 'warning' : $outcome;
$count = ($this->totals[$outcome] ?? 0) + count($this->issueIds[$issueType] ?? []);

return $count >= $threshold;
});
}

/**
* @return array<string, int>
*/
private function counts(TestResult $result): array
{
$counts = [
'error' => $result->numberOfTestErroredEvents(),
'failure' => $result->numberOfTestFailedEvents(),
'warning' => $result->numberOfPhpunitWarnings(),
'risky' => $result->numberOfTestsWithTestConsideredRiskyEvents(),
'deprecation' => $this->numberOfDeprecations(),
'notice' => 0,
'incomplete' => $result->numberOfTestMarkedIncompleteEvents(),
'skipped' => $result->numberOfTestSkippedEvents(),
];
$counts['defect'] = $counts['error'] + $counts['failure'] + $counts['warning'] + $counts['risky'];

return $counts;
}

/**
* @return array<'warning'|'notice', list<non-empty-string>>
*/
private function issues(TestResult $result): array
{
$issues = [];

if (isset($this->thresholds['warning']) || isset($this->thresholds['defect'])) {
$issues['warning'] = array_merge(
$this->issueKeys($result->warnings(), 'user'),
$this->issueKeys($result->phpWarnings(), 'php'),
);
}

if (isset($this->thresholds['notice'])) {
$issues['notice'] = array_merge(
$this->issueKeys($result->notices(), 'user'),
$this->issueKeys($result->phpNotices(), 'php'),
);
}

return $issues;
}

/**
* @return non-negative-int
*/
private function numberOfDeprecations(): int
{
$deprecations = DeprecationCollector::filteredDeprecations();

if ($this->configuration->hasSpecificDeprecationToStopOn()) {
$message = $this->configuration->specificDeprecationToStopOn();
$deprecations = array_filter($deprecations, static fn (string $deprecation): bool => str_contains($deprecation, $message));
}

return count($deprecations);
}

/**
* @param array<array-key, mixed> $counts
*/
private function addCounts(string $filename, array $counts): void
{
foreach (array_keys($this->thresholds) as $outcome) {
if (! isset($counts[$outcome]) || ! is_int($counts[$outcome]) || $counts[$outcome] < 0) {
throw new RuntimeException(sprintf('Invalid parallel stop counts in [%s].', $filename));
}

$previous = $this->workerCounts[$filename][$outcome] ?? 0;
$increase = $counts[$outcome] - $previous;

if ($increase < 0) {
throw new RuntimeException(sprintf('Parallel stop counts decreased in [%s].', $filename));
}

$this->totals[$outcome] = ($this->totals[$outcome] ?? 0) + $increase;
$this->workerCounts[$filename][$outcome] = $counts[$outcome];
}
}

/**
* @param array<array-key, mixed> $issues
*/
private function addIssues(string $filename, array $issues): void
{
foreach ($issues as $outcome => $ids) {
if (! in_array($outcome, ['warning', 'notice'], true) || ! is_array($ids)) {
throw new RuntimeException(sprintf('Invalid parallel stop issues in [%s].', $filename));
}

foreach ($ids as $id) {
if (! is_string($id)) {
throw new RuntimeException(sprintf('Invalid parallel stop issue in [%s].', $filename));
}

$this->issueIds[$outcome][$id] = true;
}
}
}

/**
* @param list<Issue> $issues
* @return list<non-empty-string>
*/
private function issueKeys(array $issues, string $origin): array
{
return array_map(
static fn (Issue $issue): string => hash('sha256', implode(':', [$origin, $issue->file(), $issue->line(), $issue->description()])),
$issues,
);
}
}
Loading