From 3100b6497e860bc0b700a79be3214d4b057e407d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksandr=20=C5=A0t=C5=A1epelin?= Date: Thu, 23 Apr 2026 11:09:59 +0300 Subject: [PATCH] fix: honor all stop-on-* flags in parallel mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously --stop-on-failure was the only flag honored in parallel mode, and it only cleared pending tests when the exit code was non-zero. The rest (--stop-on-defect, --stop-on-error, --stop-on-risky, --stop-on-incomplete, --stop-on-warning, --stop-on-notice, --stop-on-deprecation, --stop-on-skipped) were silently ignored. WrapperRunner now precomputes the set of progress-file characters that should trigger a stop (F, E, R, I, W, N, D, S) based on the configured flags, and scans each worker's progress file for them after every cycle. Once any matching outcome is seen, the pending queue is cleared. Up to N-1 in-flight tests (where N is --processes) can still complete after the first stop-worthy outcome — stopping them mid-run would risk corrupting shared state. Closes #1463. --- .../Parallel/Paratest/WrapperRunner.php | 87 ++++++++++++++++++- tests/.snapshots/success.txt | 19 +++- .../ParallelStopOn/HasDeprecationTest.php | 7 ++ tests/.tests/ParallelStopOn/HasErrorTest.php | 5 ++ .../.tests/ParallelStopOn/HasFailureTest.php | 3 + .../ParallelStopOn/HasIncompleteTest.php | 5 ++ tests/.tests/ParallelStopOn/HasNoticeTest.php | 7 ++ tests/.tests/ParallelStopOn/HasRiskyTest.php | 5 ++ .../.tests/ParallelStopOn/HasSkippedTest.php | 3 + .../.tests/ParallelStopOn/HasWarningTest.php | 7 ++ .../ParallelStopOn/Passing/Passing01Test.php | 3 + .../ParallelStopOn/Passing/Passing02Test.php | 3 + .../ParallelStopOn/Passing/Passing03Test.php | 3 + .../ParallelStopOn/Passing/Passing04Test.php | 3 + .../ParallelStopOn/Passing/Passing05Test.php | 3 + .../ParallelStopOn/Passing/Passing06Test.php | 3 + .../ParallelStopOn/Passing/Passing07Test.php | 3 + .../ParallelStopOn/Passing/Passing08Test.php | 3 + .../ParallelStopOn/Passing/Passing09Test.php | 3 + .../ParallelStopOn/Passing/Passing10Test.php | 3 + tests/Visual/Parallel.php | 30 +++++++ 21 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 tests/.tests/ParallelStopOn/HasDeprecationTest.php create mode 100644 tests/.tests/ParallelStopOn/HasErrorTest.php create mode 100644 tests/.tests/ParallelStopOn/HasFailureTest.php create mode 100644 tests/.tests/ParallelStopOn/HasIncompleteTest.php create mode 100644 tests/.tests/ParallelStopOn/HasNoticeTest.php create mode 100644 tests/.tests/ParallelStopOn/HasRiskyTest.php create mode 100644 tests/.tests/ParallelStopOn/HasSkippedTest.php create mode 100644 tests/.tests/ParallelStopOn/HasWarningTest.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing01Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing02Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing03Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing04Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing05Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing06Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing07Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing08Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing09Test.php create mode 100644 tests/.tests/ParallelStopOn/Passing/Passing10Test.php diff --git a/src/Plugins/Parallel/Paratest/WrapperRunner.php b/src/Plugins/Parallel/Paratest/WrapperRunner.php index 19ce3d1a4..2e817aaa3 100644 --- a/src/Plugins/Parallel/Paratest/WrapperRunner.php +++ b/src/Plugins/Parallel/Paratest/WrapperRunner.php @@ -33,12 +33,15 @@ use function array_merge; use function array_merge_recursive; use function array_shift; +use function array_unique; +use function array_values; use function assert; use function count; use function dirname; use function file_get_contents; use function max; use function realpath; +use function str_contains; use function str_starts_with; use function unlink; use function unserialize; @@ -107,6 +110,11 @@ final class WrapperRunner implements RunnerInterface /** @var non-empty-string[] */ private readonly array $parameters; + /** @var list|null */ + private ?array $stopOutcomeChars = null; + + private bool $stopRequested = false; + /** * The code coverage filter registry. */ @@ -210,10 +218,7 @@ private function assignAllPendingTests(): void $worker = $this->startWorker($token); } - if ( - $this->exitcode > 0 - && $this->options->configuration->stopOnFailure() - ) { + if ($this->shouldStop()) { $this->pending = []; } elseif (($pending = array_shift($this->pending)) !== null) { $worker->assign($pending); @@ -225,6 +230,80 @@ private function assignAllPendingTests(): void } } + private function shouldStop(): bool + { + if ($this->stopRequested) { + return true; + } + + $outcomes = $this->stopOutcomeChars(); + if ($outcomes === []) { + return false; + } + + foreach ($this->workers as $worker) { + $progress = file_get_contents($worker->progressFile->getPathname()); + if ($progress === false || $progress === '') { + continue; + } + + foreach ($outcomes as $outcome) { + if (str_contains($progress, $outcome)) { + return $this->stopRequested = true; + } + } + } + + return false; + } + + /** + * @return list + */ + private function stopOutcomeChars(): array + { + if ($this->stopOutcomeChars !== null) { + return $this->stopOutcomeChars; + } + + $configuration = $this->options->configuration; + $outcomes = []; + + if ($configuration->stopOnFailure() || $configuration->stopOnDefect()) { + $outcomes[] = 'F'; + } + + if ($configuration->stopOnError() || $configuration->stopOnDefect()) { + $outcomes[] = 'E'; + } + + if ($configuration->stopOnRisky() || $configuration->stopOnDefect()) { + $outcomes[] = 'R'; + } + + if ($configuration->stopOnIncomplete() || $configuration->stopOnDefect()) { + $outcomes[] = 'I'; + } + + if ($configuration->stopOnWarning()) { + $outcomes[] = 'W'; + } + + if ($configuration->stopOnNotice()) { + $outcomes[] = 'N'; + } + + if ($configuration->stopOnDeprecation()) { + $outcomes[] = 'D'; + } + + if ($configuration->stopOnSkipped()) { + $outcomes[] = 'S'; + } + + return $this->stopOutcomeChars = array_values(array_unique($outcomes)); + } + private function flushWorker(WrapperWorker $worker): void { $this->exitcode = max($this->exitcode, $worker->getExitCode()); diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 0e38f50f0..68bf017b6 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1908,6 +1908,23 @@ ✓ parallel ✓ a parallel test can extend another test with same name ✓ parallel reports invalid datasets as failures + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('fails') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('errors') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('is risky') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('is incomplete') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('warns') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('notices') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('deprecates') + ✓ parallel stop-on-* fixture runs every case without a stop flag with ('is skipped') + ✓ parallel honors --stop-on-* with dataset "failure" + ✓ parallel honors --stop-on-* with dataset "defect" + ✓ parallel honors --stop-on-* with dataset "error" + ✓ parallel honors --stop-on-* with dataset "risky" + ✓ parallel honors --stop-on-* with dataset "incomplete" + ✓ parallel honors --stop-on-* with dataset "warning" + ✓ parallel honors --stop-on-* with dataset "notice" + ✓ parallel honors --stop-on-* with dataset "deprecation" + ✓ parallel honors --stop-on-* with dataset "skipped" PASS Tests\Visual\ParallelNestedDatasets ✓ parallel loads nested datasets from nested directories @@ -1941,4 +1958,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1330 passed (3010 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1347 passed (3027 assertions) \ No newline at end of file diff --git a/tests/.tests/ParallelStopOn/HasDeprecationTest.php b/tests/.tests/ParallelStopOn/HasDeprecationTest.php new file mode 100644 index 000000000..47e031b06 --- /dev/null +++ b/tests/.tests/ParallelStopOn/HasDeprecationTest.php @@ -0,0 +1,7 @@ +toBeTrue(); +}); diff --git a/tests/.tests/ParallelStopOn/HasErrorTest.php b/tests/.tests/ParallelStopOn/HasErrorTest.php new file mode 100644 index 000000000..7c9d0ed5d --- /dev/null +++ b/tests/.tests/ParallelStopOn/HasErrorTest.php @@ -0,0 +1,5 @@ +assertTrue(false); diff --git a/tests/.tests/ParallelStopOn/HasIncompleteTest.php b/tests/.tests/ParallelStopOn/HasIncompleteTest.php new file mode 100644 index 000000000..9c9fff147 --- /dev/null +++ b/tests/.tests/ParallelStopOn/HasIncompleteTest.php @@ -0,0 +1,5 @@ +markTestIncomplete('work in progress'); +}); diff --git a/tests/.tests/ParallelStopOn/HasNoticeTest.php b/tests/.tests/ParallelStopOn/HasNoticeTest.php new file mode 100644 index 000000000..552f11b23 --- /dev/null +++ b/tests/.tests/ParallelStopOn/HasNoticeTest.php @@ -0,0 +1,7 @@ +toBeTrue(); +}); diff --git a/tests/.tests/ParallelStopOn/HasRiskyTest.php b/tests/.tests/ParallelStopOn/HasRiskyTest.php new file mode 100644 index 000000000..5519b1ad0 --- /dev/null +++ b/tests/.tests/ParallelStopOn/HasRiskyTest.php @@ -0,0 +1,5 @@ +skip('skipping'); diff --git a/tests/.tests/ParallelStopOn/HasWarningTest.php b/tests/.tests/ParallelStopOn/HasWarningTest.php new file mode 100644 index 000000000..8fae1020d --- /dev/null +++ b/tests/.tests/ParallelStopOn/HasWarningTest.php @@ -0,0 +1,7 @@ +toBeTrue(); +}); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing01Test.php b/tests/.tests/ParallelStopOn/Passing/Passing01Test.php new file mode 100644 index 000000000..8dc2e1fb5 --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing01Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing02Test.php b/tests/.tests/ParallelStopOn/Passing/Passing02Test.php new file mode 100644 index 000000000..86045d01e --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing02Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing03Test.php b/tests/.tests/ParallelStopOn/Passing/Passing03Test.php new file mode 100644 index 000000000..057c6070f --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing03Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing04Test.php b/tests/.tests/ParallelStopOn/Passing/Passing04Test.php new file mode 100644 index 000000000..883200ba6 --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing04Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing05Test.php b/tests/.tests/ParallelStopOn/Passing/Passing05Test.php new file mode 100644 index 000000000..8a050a111 --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing05Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing06Test.php b/tests/.tests/ParallelStopOn/Passing/Passing06Test.php new file mode 100644 index 000000000..ceef2a2fb --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing06Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing07Test.php b/tests/.tests/ParallelStopOn/Passing/Passing07Test.php new file mode 100644 index 000000000..1f0a6b0f6 --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing07Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing08Test.php b/tests/.tests/ParallelStopOn/Passing/Passing08Test.php new file mode 100644 index 000000000..bae06622c --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing08Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing09Test.php b/tests/.tests/ParallelStopOn/Passing/Passing09Test.php new file mode 100644 index 000000000..bcc6b5e14 --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing09Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/.tests/ParallelStopOn/Passing/Passing10Test.php b/tests/.tests/ParallelStopOn/Passing/Passing10Test.php new file mode 100644 index 000000000..edc06d046 --- /dev/null +++ b/tests/.tests/ParallelStopOn/Passing/Passing10Test.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index d497eef3c..1fb444168 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -47,3 +47,33 @@ ->toContain('Tests: 1 failed, 1 passed (1 assertions)') ->toContain('Parallel: 3 processes'); })->skipOnWindows(); + +test('parallel stop-on-* fixture runs every case without a stop flag', function (string $testName) use ($run) { + $output = $run('--filter', "($testName|passes)", 'tests/.tests/ParallelStopOn'); + expect($output)->toContain('10 passed'); +})->with([ + 'fails', + 'errors', + 'is risky', + 'is incomplete', + 'warns', + 'notices', + 'deprecates', + 'is skipped', +])->skipOnWindows(); + +test('parallel honors --stop-on-*', function (string $flag, string $testName) use ($run) { + $output = $run('--filter', "($testName|passes)", $flag, 'tests/.tests/ParallelStopOn'); + preg_match('/(\d+) passed/', $output, $matches); + expect((int) ($matches[1] ?? 0))->toBeLessThan(10); +})->with([ + 'failure' => ['--stop-on-failure', 'fails'], + 'defect' => ['--stop-on-defect', 'fails'], + 'error' => ['--stop-on-error', 'errors'], + 'risky' => ['--stop-on-risky', 'is risky'], + 'incomplete' => ['--stop-on-incomplete', 'is incomplete'], + 'warning' => ['--stop-on-warning', 'warns'], + 'notice' => ['--stop-on-notice', 'notices'], + 'deprecation' => ['--stop-on-deprecation', 'deprecates'], + 'skipped' => ['--stop-on-skipped', 'is skipped'], +])->skipOnWindows();