From 4702358b2f3520ac6e4f5c0b974ec2912527329f Mon Sep 17 00:00:00 2001 From: Kevin Ullyott Date: Mon, 18 May 2026 17:14:14 -0400 Subject: [PATCH 1/6] Fix the regex for sharding Signed-off-by: Kevin Ullyott --- src/Plugins/Shard.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index 814125f20..610ce8949 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -193,7 +193,7 @@ private function allTests(array $arguments): array '--list-tests', ]))->setTimeout(120)->mustRun()->getOutput(); - preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches); + preg_match_all('/ - (?:P\\\\)?([^:]+)::/', $output, $matches); return array_values(array_unique($matches[1])); } From e35a8eedfac92ae1de53b1beff0618616b120409 Mon Sep 17 00:00:00 2001 From: Kevin Ullyott Date: Mon, 18 May 2026 17:14:43 -0400 Subject: [PATCH 2/6] fix: instantiate subscribers for shard timing events Signed-off-by: Kevin Ullyott --- src/Plugins/Shard.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index 610ce8949..c30b5b286 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -94,8 +94,8 @@ public function handleArguments(array $arguments): array if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) { self::$updateShards = true; - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted); - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted()); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished()); return $arguments; } @@ -172,8 +172,8 @@ private function handleUpdateShards(array $arguments): array Parallel::setGlobal('UPDATE_SHARDS', true); Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true)); } else { - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted); - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted()); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished()); } return $arguments; From a7a00c58340ccc201eeafe7e53e8b89d792e2737 Mon Sep 17 00:00:00 2001 From: Kevin Ullyott Date: Mon, 18 May 2026 17:14:56 -0400 Subject: [PATCH 3/6] First pass at a test for the regex change Signed-off-by: Kevin Ullyott --- tests/Plugins/Shard.php | 121 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/Plugins/Shard.php diff --git a/tests/Plugins/Shard.php b/tests/Plugins/Shard.php new file mode 100644 index 000000000..6bf6b2ad0 --- /dev/null +++ b/tests/Plugins/Shard.php @@ -0,0 +1,121 @@ +toBe([ + 'Tests\Unit\ExampleTest', + 'Tests\Feature\AuthTest', + ]); +}); + +test('allTests regex captures non-standard directory identifiers', function () use ($extractTests) { + $output = <<<'OUTPUT' +Available test(s): + - P\Appmodules\Billing\tests\InvoiceTest::it_creates_invoice + - P\Appmodules\Billing\tests\InvoiceTest::it_sends_invoice + - P\Modules\Auth\tests\LoginTest::it_authenticates +OUTPUT; + + $tests = $extractTests($output); + + expect($tests)->toBe([ + 'Appmodules\Billing\tests\InvoiceTest', + 'Modules\Auth\tests\LoginTest', + ]); +}); + +test('allTests regex captures identifiers without P prefix', function () use ($extractTests) { + $output = <<<'OUTPUT' +Available test(s): + - Tests\Feature\BarTest::test_bar + - App\Tests\BazTest::test_baz +OUTPUT; + + $tests = $extractTests($output); + + expect($tests)->toBe([ + 'Tests\Feature\BarTest', + 'App\Tests\BazTest', + ]); +}); + +test('allTests regex handles mixed standard and non-standard identifiers', function () use ($extractTests) { + $output = <<<'OUTPUT' +Available test(s): + - P\Tests\Unit\ExampleTest::it_works + - P\Appmodules\Foo\tests\FooTest::it_works + - Tests\Feature\BarTest::test_bar + - P\Modules\Core\tests\CoreTest::it_boots +OUTPUT; + + $tests = $extractTests($output); + + expect($tests)->toBe([ + 'Tests\Unit\ExampleTest', + 'Appmodules\Foo\tests\FooTest', + 'Tests\Feature\BarTest', + 'Modules\Core\tests\CoreTest', + ]); +}); + +test('allTests regex does not match non-test lines', function () use ($extractTests) { + $output = <<<'OUTPUT' +Available test(s): + + - P\Tests\Unit\ExampleTest::it_works +Some random output line +OUTPUT; + + $tests = $extractTests($output); + + expect($tests)->toBe([ + 'Tests\Unit\ExampleTest', + ]); +}); + +test('buildFilterArgument correctly escapes non-standard identifiers', function () use ($buildFilter) { + $tests = [ + 'Appmodules\Billing\tests\InvoiceTest', + 'Modules\Auth\tests\LoginTest', + ]; + + $filter = $buildFilter($tests); + + expect($filter)->toBe('Appmodules\\\\Billing\\\\tests\\\\InvoiceTest|Modules\\\\Auth\\\\tests\\\\LoginTest'); +}); + +test('sharding distributes non-standard identifiers across shards', function () use ($extractTests) { + $output = <<<'OUTPUT' +Available test(s): + - P\Tests\Unit\ATest::it_works + - P\Appmodules\Foo\tests\BTest::it_works + - P\Modules\Bar\tests\CTest::it_works + - P\Tests\Feature\DTest::it_works +OUTPUT; + + $tests = $extractTests($output); + $total = 2; + $chunks = array_chunk($tests, max(1, (int) ceil(count($tests) / $total))); + + expect($chunks)->toHaveCount(2) + ->and($chunks[0])->toBe(['Tests\Unit\ATest', 'Appmodules\Foo\tests\BTest']) + ->and($chunks[1])->toBe(['Modules\Bar\tests\CTest', 'Tests\Feature\DTest']); +}); From a8c4f2adc9705fb4ed55e46fb0d9af37924eaa2e Mon Sep 17 00:00:00 2001 From: Kevin Ullyott Date: Mon, 18 May 2026 17:36:06 -0400 Subject: [PATCH 4/6] Switch tests to actually run the code with fixtures Signed-off-by: Kevin Ullyott --- .../Shard/modules/billing/InvoiceTest.php | 5 + tests-external/Shard/standard/UnitTest.php | 3 + tests/Plugins/Shard.php | 152 +++++++----------- 3 files changed, 62 insertions(+), 98 deletions(-) create mode 100644 tests-external/Shard/modules/billing/InvoiceTest.php create mode 100644 tests-external/Shard/standard/UnitTest.php diff --git a/tests-external/Shard/modules/billing/InvoiceTest.php b/tests-external/Shard/modules/billing/InvoiceTest.php new file mode 100644 index 000000000..4d565ed6d --- /dev/null +++ b/tests-external/Shard/modules/billing/InvoiceTest.php @@ -0,0 +1,5 @@ +assertTrue(true); + +it('sends invoice')->assertTrue(true); diff --git a/tests-external/Shard/standard/UnitTest.php b/tests-external/Shard/standard/UnitTest.php new file mode 100644 index 000000000..915336ab3 --- /dev/null +++ b/tests-external/Shard/standard/UnitTest.php @@ -0,0 +1,3 @@ +assertTrue(true); diff --git a/tests/Plugins/Shard.php b/tests/Plugins/Shard.php index 6bf6b2ad0..4f1b91fe8 100644 --- a/tests/Plugins/Shard.php +++ b/tests/Plugins/Shard.php @@ -1,121 +1,77 @@ run(); -$buildFilter = function (array $testsToRun): string { - return addslashes(implode('|', $testsToRun)); + return $process; }; -test('allTests regex captures standard Tests-namespaced identifiers', function () use ($extractTests) { - $output = <<<'OUTPUT' -Available test(s): - - P\Tests\Unit\ExampleTest::it_works - - P\Tests\Feature\AuthTest::it_logs_in - - P\Tests\Unit\ExampleTest::it_does_something_else -OUTPUT; - - $tests = $extractTests($output); +$shardsPath = dirname(__DIR__).'/.pest/shards.json'; - expect($tests)->toBe([ - 'Tests\Unit\ExampleTest', - 'Tests\Feature\AuthTest', - ]); +afterAll(function () use ($shardsPath) { + if (file_exists($shardsPath)) { + unlink($shardsPath); + } }); -test('allTests regex captures non-standard directory identifiers', function () use ($extractTests) { - $output = <<<'OUTPUT' -Available test(s): - - P\Appmodules\Billing\tests\InvoiceTest::it_creates_invoice - - P\Appmodules\Billing\tests\InvoiceTest::it_sends_invoice - - P\Modules\Auth\tests\LoginTest::it_authenticates -OUTPUT; +test('shard discovers tests in non-standard directories', function () use ($pest) { + $process = $pest(['--list-tests']); - $tests = $extractTests($output); + expect($process->getExitCode())->toBe(0); - expect($tests)->toBe([ - 'Appmodules\Billing\tests\InvoiceTest', - 'Modules\Auth\tests\LoginTest', - ]); -}); + $output = $process->getOutput(); -test('allTests regex captures identifiers without P prefix', function () use ($extractTests) { - $output = <<<'OUTPUT' -Available test(s): - - Tests\Feature\BarTest::test_bar - - App\Tests\BazTest::test_baz -OUTPUT; + // Identifiers must NOT start with Tests\ since fixtures are in tests-external/ + expect($output)->toContain('InvoiceTest::') + ->toContain('UnitTest::') + ->not->toContain(' - P\Tests\\'); +})->skipOnWindows(); - $tests = $extractTests($output); +test('shard includes non-standard directory tests in shard count', function () use ($pest) { + $process = $pest(['--shard=1/1']); - expect($tests)->toBe([ - 'Tests\Feature\BarTest', - 'App\Tests\BazTest', - ]); -}); + expect($process->getExitCode())->toBe(0); -test('allTests regex handles mixed standard and non-standard identifiers', function () use ($extractTests) { - $output = <<<'OUTPUT' -Available test(s): - - P\Tests\Unit\ExampleTest::it_works - - P\Appmodules\Foo\tests\FooTest::it_works - - Tests\Feature\BarTest::test_bar - - P\Modules\Core\tests\CoreTest::it_boots -OUTPUT; - - $tests = $extractTests($output); - - expect($tests)->toBe([ - 'Tests\Unit\ExampleTest', - 'Appmodules\Foo\tests\FooTest', - 'Tests\Feature\BarTest', - 'Modules\Core\tests\CoreTest', - ]); -}); + $output = $process->getOutput(); -test('allTests regex does not match non-test lines', function () use ($extractTests) { - $output = <<<'OUTPUT' -Available test(s): + // Both test files must be discovered and sharded + expect($output)->toContain('2 files ran, out of 2'); +})->skipOnWindows(); - - P\Tests\Unit\ExampleTest::it_works -Some random output line -OUTPUT; +test('shard distributes non-standard directory tests across shards', function () use ($pest) { + $process1 = $pest(['--shard=1/2']); + $process2 = $pest(['--shard=2/2']); - $tests = $extractTests($output); + expect($process1->getExitCode())->toBe(0) + ->and($process2->getExitCode())->toBe(0); - expect($tests)->toBe([ - 'Tests\Unit\ExampleTest', - ]); -}); + $output1 = $process1->getOutput(); + $output2 = $process2->getOutput(); -test('buildFilterArgument correctly escapes non-standard identifiers', function () use ($buildFilter) { - $tests = [ - 'Appmodules\Billing\tests\InvoiceTest', - 'Modules\Auth\tests\LoginTest', - ]; + // Each shard gets 1 file, total is 2 + expect($output1)->toContain('1 file ran, out of 2') + ->and($output2)->toContain('1 file ran, out of 2'); +})->skipOnWindows(); - $filter = $buildFilter($tests); +test('update-shards records timings for non-standard directory tests', function () use ($pest, $shardsPath) { + $process = $pest(['--update-shards']); - expect($filter)->toBe('Appmodules\\\\Billing\\\\tests\\\\InvoiceTest|Modules\\\\Auth\\\\tests\\\\LoginTest'); -}); + expect($process->getExitCode())->toBe(0); -test('sharding distributes non-standard identifiers across shards', function () use ($extractTests) { - $output = <<<'OUTPUT' -Available test(s): - - P\Tests\Unit\ATest::it_works - - P\Appmodules\Foo\tests\BTest::it_works - - P\Modules\Bar\tests\CTest::it_works - - P\Tests\Feature\DTest::it_works -OUTPUT; - - $tests = $extractTests($output); - $total = 2; - $chunks = array_chunk($tests, max(1, (int) ceil(count($tests) / $total))); - - expect($chunks)->toHaveCount(2) - ->and($chunks[0])->toBe(['Tests\Unit\ATest', 'Appmodules\Foo\tests\BTest']) - ->and($chunks[1])->toBe(['Modules\Bar\tests\CTest', 'Tests\Feature\DTest']); -}); + $output = $process->getOutput(); + + expect($output)->toContain('shards.json updated with timings for 2 test class'); + + // Verify the shards.json contains the non-standard identifiers + expect($shardsPath)->toBeFile(); + $data = json_decode(file_get_contents($shardsPath), true); + $keys = array_keys($data['timings']); + + expect($keys)->each->not->toStartWith('Tests\\'); +})->skipOnWindows(); From 28af8f909016bfac58eda3bbf1e680948cd097bd Mon Sep 17 00:00:00 2001 From: Kevin Ullyott Date: Mon, 18 May 2026 17:45:13 -0400 Subject: [PATCH 5/6] Test standard directory as well Signed-off-by: Kevin Ullyott --- tests/Plugins/Shard.php | 66 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/tests/Plugins/Shard.php b/tests/Plugins/Shard.php index 4f1b91fe8..bd6ff2048 100644 --- a/tests/Plugins/Shard.php +++ b/tests/Plugins/Shard.php @@ -2,9 +2,9 @@ use Symfony\Component\Process\Process; -$pest = function (array $extraArgs = []): Process { +$pest = function (string $path, array $extraArgs = []): Process { $process = new Process( - ['php', 'bin/pest', 'tests-external/Shard/', ...$extraArgs], + ['php', 'bin/pest', $path, ...$extraArgs], dirname(__DIR__, 2), ); $process->run(); @@ -20,8 +20,60 @@ } }); +test('shard discovers tests in standard directories', function () use ($pest) { + $process = $pest('tests/Fixtures/DirectoryWithTests/', ['--list-tests']); + + expect($process->getExitCode())->toBe(0); + + $output = $process->getOutput(); + + expect($output)->toContain('ExampleTest::') + ->toContain(' - P\Tests\\'); +})->skipOnWindows(); + +test('shard includes standard directory tests in shard count', function () use ($pest) { + $process = $pest('tests/Fixtures/DirectoryWithTests/', ['--shard=1/1']); + + expect($process->getExitCode())->toBe(0); + + $output = $process->getOutput(); + + expect($output)->toContain('1 file ran, out of 1'); +})->skipOnWindows(); + +test('shard distributes standard directory tests across shards', function () use ($pest) { + $process1 = $pest('tests/Fixtures/DirectoryWithTests/', ['--shard=1/2']); + $process2 = $pest('tests/Fixtures/DirectoryWithTests/', ['--shard=2/2']); + + expect($process1->getExitCode())->toBe(0) + ->and($process2->getExitCode())->toBe(0); + + $output1 = $process1->getOutput(); + $output2 = $process2->getOutput(); + + // 1 file total — first shard gets it, second gets 0 + expect($output1)->toContain('1 file ran, out of 1') + ->and($output2)->toContain('0 files ran, out of 1'); +})->skipOnWindows(); + +test('update-shards records timings for standard directory tests', function () use ($pest, $shardsPath) { + $process = $pest('tests/Fixtures/DirectoryWithTests/', ['--update-shards']); + + expect($process->getExitCode())->toBe(0); + + $output = $process->getOutput(); + + expect($output)->toContain('shards.json updated with timings for 1 test class'); + + expect($shardsPath)->toBeFile(); + $data = json_decode(file_get_contents($shardsPath), true); + $keys = array_keys($data['timings']); + + expect($keys)->each->toStartWith('Tests\\'); +})->skipOnWindows(); + test('shard discovers tests in non-standard directories', function () use ($pest) { - $process = $pest(['--list-tests']); + $process = $pest('tests-external/Shard/', ['--list-tests']); expect($process->getExitCode())->toBe(0); @@ -34,7 +86,7 @@ })->skipOnWindows(); test('shard includes non-standard directory tests in shard count', function () use ($pest) { - $process = $pest(['--shard=1/1']); + $process = $pest('tests-external/Shard/', ['--shard=1/1']); expect($process->getExitCode())->toBe(0); @@ -45,8 +97,8 @@ })->skipOnWindows(); test('shard distributes non-standard directory tests across shards', function () use ($pest) { - $process1 = $pest(['--shard=1/2']); - $process2 = $pest(['--shard=2/2']); + $process1 = $pest('tests-external/Shard/', ['--shard=1/2']); + $process2 = $pest('tests-external/Shard/', ['--shard=2/2']); expect($process1->getExitCode())->toBe(0) ->and($process2->getExitCode())->toBe(0); @@ -60,7 +112,7 @@ })->skipOnWindows(); test('update-shards records timings for non-standard directory tests', function () use ($pest, $shardsPath) { - $process = $pest(['--update-shards']); + $process = $pest('tests-external/Shard/', ['--update-shards']); expect($process->getExitCode())->toBe(0); From 71017bb0cc828b0b24cd624b749669758cbd4af0 Mon Sep 17 00:00:00 2001 From: Kevin Ullyott Date: Mon, 18 May 2026 17:49:01 -0400 Subject: [PATCH 6/6] Adjust formatting Signed-off-by: Kevin Ullyott --- src/Plugins/Shard.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Plugins/Shard.php b/src/Plugins/Shard.php index c30b5b286..610ce8949 100644 --- a/src/Plugins/Shard.php +++ b/src/Plugins/Shard.php @@ -94,8 +94,8 @@ public function handleArguments(array $arguments): array if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) { self::$updateShards = true; - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted()); - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished()); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished); return $arguments; } @@ -172,8 +172,8 @@ private function handleUpdateShards(array $arguments): array Parallel::setGlobal('UPDATE_SHARDS', true); Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true)); } else { - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted()); - Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished()); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted); + Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished); } return $arguments;