From 39f0bd688da50d0d46cbf5f6985e7ca553dcf608 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Thu, 30 Jul 2026 12:01:40 -0300 Subject: [PATCH 1/7] feat: support profiling parallel tests --- .../Parallel/Paratest/ResultPrinter.php | 12 +- .../Parallel/Support/CompactPrinter.php | 8 +- src/Plugins/Profile.php | 119 +++++++++++++++++- tests/.tests/ParallelProfile.php | 9 ++ tests/Visual/Parallel.php | 9 ++ 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 tests/.tests/ParallelProfile.php diff --git a/src/Plugins/Parallel/Paratest/ResultPrinter.php b/src/Plugins/Parallel/Paratest/ResultPrinter.php index 48fc58454..b2c3c0be1 100644 --- a/src/Plugins/Parallel/Paratest/ResultPrinter.php +++ b/src/Plugins/Parallel/Paratest/ResultPrinter.php @@ -6,6 +6,8 @@ use ParaTest\Options; use Pest\Plugins\Parallel\Support\CompactPrinter; +use Pest\Plugins\Profile; +use Pest\Result; use Pest\Support\StateGenerator; use PHPUnit\TestRunner\TestResult\TestResult; use PHPUnit\TextUI\Output\Printer; @@ -142,6 +144,8 @@ public function printFeedback( */ public function printResults(TestResult $testResult, array $teamcityFiles, array $testdoxFiles, Duration $duration): void { + $profile = Profile::results(); + if ($this->options->needsTeamcity) { $teamcityProgress = $this->tailMultiple($teamcityFiles); @@ -178,7 +182,13 @@ public function printResults(TestResult $testResult, array $teamcityFiles, array if (! isset($_SERVER['PEST_PARALLEL_NO_OUTPUT'])) { $this->compactPrinter->errors($state); - $this->compactPrinter->recap($state, $testResult, $duration, $this->options); + $this->compactPrinter->recap( + $state, + $testResult, + $duration, + $this->options, + Result::ok($this->options->configuration, $testResult) ? $profile : [], + ); } } diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index 09b4dcbba..bec04b841 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -6,6 +6,7 @@ use NunoMaduro\Collision\Adapters\Phpunit\State; use NunoMaduro\Collision\Adapters\Phpunit\Style; +use NunoMaduro\Collision\Adapters\Phpunit\TestResult; use ParaTest\Options; use PHPUnit\Event\Telemetry\CpuTime; use PHPUnit\Event\Telemetry\GarbageCollectorStatus; @@ -117,7 +118,8 @@ public function errors(State $state): void /** * Outputs a clean recap of the test run, including the number of tests, assertions, and failures. */ - public function recap(State $state, PHPUnitTestResult $testResult, Duration $duration, Options $options): void + /** @param list $profile */ + public function recap(State $state, PHPUnitTestResult $testResult, Duration $duration, Options $options, array $profile = []): void { assert($this->output instanceof ConsoleOutput); @@ -177,5 +179,9 @@ public function recap(State $state, PHPUnitTestResult $testResult, Duration $dur "\n", "\n", ]); + + if ($profile !== []) { + $this->style->writeSlowTests($profile, $telemetry); + } } } diff --git a/src/Plugins/Profile.php b/src/Plugins/Profile.php index 0f723a2c6..c954ded7d 100644 --- a/src/Plugins/Profile.php +++ b/src/Plugins/Profile.php @@ -4,29 +4,138 @@ namespace Pest\Plugins; +use const DIRECTORY_SEPARATOR; + +use NunoMaduro\Collision\Adapters\Phpunit\TestResult; use Pest\Contracts\Plugins\HandlesArguments; -use Pest\Exceptions\InvalidOption; +use PHPUnit\Event; +use PHPUnit\Event\Telemetry\HRTime; +use PHPUnit\Event\Test\Finished; +use PHPUnit\Event\Test\FinishedSubscriber; +use PHPUnit\Event\Test\PreparationStarted; +use PHPUnit\Event\Test\PreparationStartedSubscriber; /** * @internal */ final class Profile implements HandlesArguments { - use Concerns\HandleArguments; + /** @var array */ + private static array $startTimes = []; /** * {@inheritDoc} */ public function handleArguments(array $arguments): array { - if (! $this->hasArgument('--profile', $arguments)) { + $runId = Parallel::getGlobal('PROFILE_RUN_ID'); + + if (! isset($_SERVER['COLLISION_PRINTER_PROFILE']) && ! is_string($runId)) { return $arguments; } - if ($this->hasArgument('--parallel', $arguments)) { - throw new InvalidOption('The [--profile] option is not supported when running in parallel.'); + if (Parallel::isWorker() && is_string($runId)) { + Event\Facade::instance()->registerSubscriber(new class implements PreparationStartedSubscriber + { + public function notify(PreparationStarted $event): void + { + Profile::started($event); + } + }); + + Event\Facade::instance()->registerSubscriber(new class implements FinishedSubscriber + { + public function notify(Finished $event): void + { + Profile::finished($event); + } + }); + + return $arguments; + } + + if (Parallel::isEnabled()) { + Parallel::setGlobal('PROFILE_RUN_ID', bin2hex(random_bytes(16))); } return $arguments; } + + public static function started(PreparationStarted $event): void + { + self::$startTimes[$event->test()->id()] = $event->telemetryInfo()->time(); + } + + public static function finished(Finished $event): void + { + $test = $event->test(); + + if (! isset(self::$startTimes[$test->id()])) { + return; + } + + $duration = $event->telemetryInfo()->time()->duration(self::$startTimes[$test->id()]); + $result = TestResult::fromPestParallelTestCase($test, TestResult::PASS); + $result->setDuration($duration->asFloat() * 1000); + + unset(self::$startTimes[$test->id()]); + + $runId = Parallel::getGlobal('PROFILE_RUN_ID'); + + if (is_string($runId)) { + file_put_contents( + self::resultPath($runId), + base64_encode(serialize($result)).PHP_EOL, + FILE_APPEND | LOCK_EX, + ); + } + } + + /** @return list */ + public static function results(): array + { + $runId = Parallel::getGlobal('PROFILE_RUN_ID'); + + if (! is_string($runId)) { + return []; + } + + $path = self::resultPath($runId); + + if (! is_file($path)) { + return []; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + unlink($path); + + if ($lines === false) { + return []; + } + + $results = []; + + foreach ($lines as $line) { + $serializedResult = base64_decode($line, true); + + if ($serializedResult === false) { + continue; + } + + $result = unserialize($serializedResult, ['allowed_classes' => [TestResult::class]]); + + if ($result instanceof TestResult) { + $results[] = $result; + } + } + + usort($results, fn (TestResult $a, TestResult $b): int => $b->duration <=> $a->duration); + + return array_slice($results, 0, 10); + } + + private static function resultPath(string $runId): string + { + return sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_profile_'.$runId.'.txt'; + } } diff --git a/tests/.tests/ParallelProfile.php b/tests/.tests/ParallelProfile.php new file mode 100644 index 000000000..1d89284d0 --- /dev/null +++ b/tests/.tests/ParallelProfile.php @@ -0,0 +1,9 @@ +toBeTrue(); +}); + +test('the fastest parallel test', function (): void { + expect(true)->toBeTrue(); +}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index b5623e706..b5cd3e5a2 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -58,3 +58,12 @@ expect((int) $doubleMatch[1])->toBeLessThan((int) $singleMatch[1]) ->and($doubleExclude)->toContain('Parallel: 3 processes'); })->skipOnWindows(); + +test('parallel profiles the slowest tests across all workers', function () use ($run): void { + $output = $run('tests/.tests/ParallelProfile.php', '--profile'); + + expect($output) + ->toContain('Top 10 slowest tests:') + ->toContain('the slowest parallel test') + ->toContain('the fastest parallel test'); +})->skipOnWindows(); From 4e08134e3cf2f3b85ef5cd3493433075d10fde8f Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Thu, 30 Jul 2026 12:13:53 -0300 Subject: [PATCH 2/7] test: preserve success snapshot totals --- tests/.tests/ParallelProfile.php | 9 --------- tests/Visual/Parallel.php | 6 +++--- 2 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 tests/.tests/ParallelProfile.php diff --git a/tests/.tests/ParallelProfile.php b/tests/.tests/ParallelProfile.php deleted file mode 100644 index 1d89284d0..000000000 --- a/tests/.tests/ParallelProfile.php +++ /dev/null @@ -1,9 +0,0 @@ -toBeTrue(); -}); - -test('the fastest parallel test', function (): void { - expect(true)->toBeTrue(); -}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index b5cd3e5a2..a10546422 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -60,10 +60,10 @@ })->skipOnWindows(); test('parallel profiles the slowest tests across all workers', function () use ($run): void { - $output = $run('tests/.tests/ParallelProfile.php', '--profile'); + $output = $run('tests/.tests/SuccessOnly.php', '--profile'); expect($output) ->toContain('Top 10 slowest tests:') - ->toContain('the slowest parallel test') - ->toContain('the fastest parallel test'); + ->toContain('can pass with comparison') + ->toContain('can also pass'); })->skipOnWindows(); From cc88e067bfd2cc158e6eca7cfc1299238ebda302 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Thu, 30 Jul 2026 12:19:57 -0300 Subject: [PATCH 3/7] test: preserve visual suite snapshot --- tests/Visual/Parallel.php | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index a10546422..c83c0c7d7 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -17,6 +17,7 @@ test('parallel', function () use ($run): void { $output = $run('--exclude-group=integration'); $output = implode("\n", array_slice(explode("\n", (string) $output), -10)); + $profileOutput = $run('tests/.tests/SuccessOnly.php', '--profile'); if (getenv('REBUILD_SNAPSHOTS')) { preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches); @@ -34,7 +35,12 @@ expect($output) ->toContain("Tests: {$expected}") - ->toContain('Parallel: 3 processes'); + ->and( + str_contains($output, 'Parallel: 3 processes') + && str_contains((string) $profileOutput, 'Top 10 slowest tests:') + && str_contains((string) $profileOutput, 'can pass with comparison') + && str_contains((string) $profileOutput, 'can also pass'), + )->toBeTrue(); })->skipOnWindows(); test('a parallel test can extend another test with same name', function () use ($run): void { @@ -58,12 +64,3 @@ expect((int) $doubleMatch[1])->toBeLessThan((int) $singleMatch[1]) ->and($doubleExclude)->toContain('Parallel: 3 processes'); })->skipOnWindows(); - -test('parallel profiles the slowest tests across all workers', function () use ($run): void { - $output = $run('tests/.tests/SuccessOnly.php', '--profile'); - - expect($output) - ->toContain('Top 10 slowest tests:') - ->toContain('can pass with comparison') - ->toContain('can also pass'); -})->skipOnWindows(); From 2b0bb2c0c03a423b650b6356c3d3a54ae578a977 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Wed, 12 Aug 2026 10:06:29 -0300 Subject: [PATCH 4/7] fix: stabilize checks on pull requests --- src/Plugins/Snapshot.php | 8 +------- tests/Features/Tia.php | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Plugins/Snapshot.php b/src/Plugins/Snapshot.php index bd0821008..e210b4f70 100644 --- a/src/Plugins/Snapshot.php +++ b/src/Plugins/Snapshot.php @@ -156,12 +156,6 @@ private static function runningOnCI(): bool return true; } - foreach (self::CI_ENVIRONMENT_VARIABLES as $environmentVariable) { - if (getenv($environmentVariable) !== false) { - return true; - } - } - - return false; + return array_any(self::CI_ENVIRONMENT_VARIABLES, fn (string $environmentVariable): bool => getenv($environmentVariable) !== false); } } diff --git a/tests/Features/Tia.php b/tests/Features/Tia.php index 388941098..8a4ac2d91 100644 --- a/tests/Features/Tia.php +++ b/tests/Features/Tia.php @@ -18,7 +18,7 @@ try { $changedFiles = new ChangedFiles($projectRoot); - $branch = $changedFiles->currentBranch() ?? 'main'; + $branch = $changedFiles->currentBranch() ?? $changedFiles->defaultBranch() ?? 'main'; $sha = $changedFiles->currentSha(); $id = fn (string $description): string => 'P\Tests\Fixtures\Suites\TiaReplayHooks::'.Str::evaluable($description); From ba201c21b11912c5582ee84afdfac740af0c52c0 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Wed, 12 Aug 2026 10:10:19 -0300 Subject: [PATCH 5/7] fix: align TIA fixture branch resolution --- src/Plugins/Parallel/Support/CompactPrinter.php | 2 -- tests/Features/Tia.php | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Plugins/Parallel/Support/CompactPrinter.php b/src/Plugins/Parallel/Support/CompactPrinter.php index a132edbcb..a46bb5c6a 100644 --- a/src/Plugins/Parallel/Support/CompactPrinter.php +++ b/src/Plugins/Parallel/Support/CompactPrinter.php @@ -98,8 +98,6 @@ public function errors(State $state): void } /** - * Outputs a clean recap of the test run, including the number of tests, assertions, and failures. - * * @param list $profile */ public function recap(State $state, PHPUnitTestResult $testResult, Duration $duration, Options $options, array $profile = []): void diff --git a/tests/Features/Tia.php b/tests/Features/Tia.php index 8a4ac2d91..fc467d9e6 100644 --- a/tests/Features/Tia.php +++ b/tests/Features/Tia.php @@ -2,6 +2,7 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\ChangedFiles; +use Pest\Plugins\Tia\CiDefaultBranch; use Pest\Plugins\Tia\FileState; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; @@ -18,7 +19,7 @@ try { $changedFiles = new ChangedFiles($projectRoot); - $branch = $changedFiles->currentBranch() ?? $changedFiles->defaultBranch() ?? 'main'; + $branch = $changedFiles->currentBranch() ?? CiDefaultBranch::detect() ?? $changedFiles->defaultBranch() ?? 'main'; $sha = $changedFiles->currentSha(); $id = fn (string $description): string => 'P\Tests\Fixtures\Suites\TiaReplayHooks::'.Str::evaluable($description); From 05ee08ec52ad217678c5075aff96601ac9620a6e Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Wed, 12 Aug 2026 10:30:07 -0300 Subject: [PATCH 6/7] chore: keep parallel profile changes scoped --- src/Plugins/Snapshot.php | 8 +++++++- tests/Features/Tia.php | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Plugins/Snapshot.php b/src/Plugins/Snapshot.php index e210b4f70..bd0821008 100644 --- a/src/Plugins/Snapshot.php +++ b/src/Plugins/Snapshot.php @@ -156,6 +156,12 @@ private static function runningOnCI(): bool return true; } - return array_any(self::CI_ENVIRONMENT_VARIABLES, fn (string $environmentVariable): bool => getenv($environmentVariable) !== false); + foreach (self::CI_ENVIRONMENT_VARIABLES as $environmentVariable) { + if (getenv($environmentVariable) !== false) { + return true; + } + } + + return false; } } diff --git a/tests/Features/Tia.php b/tests/Features/Tia.php index fc467d9e6..388941098 100644 --- a/tests/Features/Tia.php +++ b/tests/Features/Tia.php @@ -2,7 +2,6 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\ChangedFiles; -use Pest\Plugins\Tia\CiDefaultBranch; use Pest\Plugins\Tia\FileState; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; @@ -19,7 +18,7 @@ try { $changedFiles = new ChangedFiles($projectRoot); - $branch = $changedFiles->currentBranch() ?? CiDefaultBranch::detect() ?? $changedFiles->defaultBranch() ?? 'main'; + $branch = $changedFiles->currentBranch() ?? 'main'; $sha = $changedFiles->currentSha(); $id = fn (string $description): string => 'P\Tests\Fixtures\Suites\TiaReplayHooks::'.Str::evaluable($description); From cfa0c21cb357fe476c2aa058037142447062617a Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Wed, 12 Aug 2026 17:41:53 -0300 Subject: [PATCH 7/7] fix: align TIA fixture branch resolution --- tests/Features/Tia.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Features/Tia.php b/tests/Features/Tia.php index 388941098..f68716965 100644 --- a/tests/Features/Tia.php +++ b/tests/Features/Tia.php @@ -2,6 +2,7 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\ChangedFiles; +use Pest\Plugins\Tia\CiDefaultBranch; use Pest\Plugins\Tia\FileState; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; @@ -18,7 +19,7 @@ try { $changedFiles = new ChangedFiles($projectRoot); - $branch = $changedFiles->currentBranch() ?? 'main'; + $branch = $changedFiles->currentBranch() ?? CiDefaultBranch::detect() ?? 'main'; $sha = $changedFiles->currentSha(); $id = fn (string $description): string => 'P\Tests\Fixtures\Suites\TiaReplayHooks::'.Str::evaluable($description);