Compare commits

..

52 Commits
v4.7.3 ... 5.x

Author SHA1 Message Date
3c8bae5f05 chore: bumps dependencies 2026-06-12 20:30:22 +01:00
b2998bc69e chore: updates snapshots 2026-06-12 20:30:16 +01:00
3876093cd2 fix: missing array values 2026-06-12 20:22:47 +01:00
932f8bcc07 consistent sharding logic when no shards file (#1710) 2026-06-12 20:19:30 +01:00
d393799d2a Optimize buildFilterArgument in Shard plugin for compact regex generation and add comprehensive tests (#1675) 2026-06-12 20:06:50 +01:00
0d7814ca16 chore: update snapshots 2026-06-12 18:02:06 +01:00
8467c64c22 fix: popArgument drops duplicate arguments breaking --parallel --exclude-gropup= (#1674)
* fix: popArgument drops duplicate arguments breaking --parallel multi-exclude-group

Fixes #1437

* fix: ensure popArgument handles duplicate arguments

* fix: update expected test results and snapshots after rebase

---------

Signed-off-by: nuno maduro <enunomaduro@gmail.com>
Co-authored-by: nuno maduro <enunomaduro@gmail.com>
2026-06-12 17:58:37 +01:00
97714a7088 Enforce filter length validation and add tests for Shard plugin (#1673) 2026-06-12 17:56:32 +01:00
afb582616d chore: points to 5.x 2026-06-12 07:29:53 +01:00
15e9b6a507 chore: snapshots 2026-06-12 07:22:13 +01:00
520ce29376 Merge branch '4.x' into 5.x 2026-06-12 07:22:01 +01:00
774a340400 feat: add toBeUlid assertion and isUlid validation method (#1726) 2026-06-11 10:14:00 +01:00
3d5bba93f8 Bump shivammathur/setup-php in the github-actions group (#1706)
Bumps the github-actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.0 to 2.37.1
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](accd6127cb...7c071dfe9d)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-04 17:55:13 +01:00
79bc7a8257 Merge branch '4.x' into 5.x 2026-06-01 07:09:57 +01:00
fc48c1bd1e Merge branch '4.x' into 5.x 2026-06-01 06:33:35 +01:00
da726beffc chore: merges 4.x 2026-06-01 06:28:44 +01:00
4ef12b9aac Merge branch '4.x' into 5.x 2026-06-01 06:25:56 +01:00
4d550cecfd Merge branch '4.x' into 5.x 2026-05-13 12:20:46 +01:00
34695843b3 chore: pin GitHub Actions to commit SHAs (#1699)
* chore: pin GitHub Actions to commit SHAs

* chore: pin GitHub Actions to commit SHAs
2026-05-11 22:12:04 -03:00
d17be9decd types 2026-05-04 08:02:09 -03:00
b828ddcec7 chore: style 2026-05-04 07:38:50 -03:00
f859bb179d Merge branch '4.x' into 5.x 2026-05-04 07:38:40 -03:00
18bbca748f Merge branch '4.x' into 5.x 2026-04-18 07:03:46 -07:00
f142aad8ad Merge branch '4.x' into 5.x 2026-04-17 19:35:53 -07:00
74a28d4f5e fix: wrapper runner 2026-04-17 07:29:03 -07:00
6053e15d00 Merge branch '4.x' into 5.x 2026-04-17 06:07:14 -07:00
2d649d765f chore: adjusts tests 2026-04-11 01:54:13 +01:00
4fb4908570 Merge branch '4.x' into 5.x 2026-04-10 22:37:24 +01:00
e63a886f98 Merge pull request #1661 from Avnsh1111/fix/opposite-expectation-truncated-message
fix: preserve full error message in not() expectation failures
2026-04-10 11:48:24 +01:00
8dd650fd05 Merge branch '4.x' into 5.x 2026-04-09 21:39:15 +01:00
fbca346d7c fix: types 2026-04-07 14:40:55 +01:00
3f13bca0f7 just in case 2026-04-07 14:37:13 +01:00
d3acb1c56a fix: coverage 2026-04-07 14:33:41 +01:00
e601e6df31 fix: preserve full error message in not() expectation failures
When using not() expectations with custom error messages, the message
was truncated because throwExpectationFailedException() passed all
arguments through shortenedExport() which limits strings to ~40 chars.

Uses the full export() method for arguments instead of shortenedExport()
so custom error messages are displayed in their entirety.

Fixes #1533
2026-04-07 18:12:54 +05:30
6fdbca1226 fix: parallel testing 2026-04-06 23:37:49 +01:00
54359b895f Merge branch '4.x' into 5.x 2026-04-06 21:57:41 +01:00
44c04bfce1 chore: bumps paratest 2026-04-06 14:41:38 +01:00
271c680d3c Merge branch '4.x' into 5.x 2026-04-06 11:24:05 +01:00
4a1d8d27b8 chore: bumps dependencies 2026-04-03 12:12:27 +01:00
0f6924984c Merge branch '4.x' into 5.x 2026-04-03 12:02:36 +01:00
668ca9f5de feat: adds pao 2026-04-02 15:45:13 +01:00
f659a45311 Merge branch '4.x' into 5.x 2026-03-21 13:20:25 +00:00
12c1da29ee Merge branch '4.x' into 5.x 2026-03-10 21:21:24 +00:00
fa27c8daef chore: version 2026-02-17 17:52:40 +00:00
f0a08f0503 chore: missing types 2026-02-17 17:52:00 +00:00
2c040c5b1f chore: style 2026-02-17 17:45:50 +00:00
a9ce1fd739 chore: code refactor 2026-02-17 17:45:34 +00:00
3533356262 chore: updates snapshots 2026-02-17 17:44:56 +00:00
4aa41d0b14 chore: bumps dependencies 2026-02-17 17:41:38 +00:00
e4ed60085c chore: bumps dependencies 2026-02-17 17:18:45 +00:00
e2b119655d chore: point pestphp dependencies to ^5.0.0 2026-02-17 17:13:36 +00:00
fcf5baf0a9 chore: start preparing for pest 5.x 2026-02-17 16:55:03 +00:00
41 changed files with 655 additions and 173 deletions

View File

@ -2,7 +2,7 @@ name: Static Analysis
on:
push:
branches: [4.x]
branches: [5.x]
pull_request:
schedule:
- cron: '0 9 * * *'
@ -33,7 +33,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: 8.3
php-version: 8.4
tools: composer:v2
coverage: none
extensions: sockets
@ -47,14 +47,14 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json', '**/composer.lock') }}
restore-keys: |
static-php-8.3-${{ matrix.dependency-version }}-composer-
static-php-8.3-composer-
static-php-8.4-${{ matrix.dependency-version }}-composer-
static-php-8.4-composer-
- name: Install Dependencies
env:
COMPOSER_ROOT_VERSION: 4.x-dev
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi
- name: Profanity Check

View File

@ -2,7 +2,7 @@ name: Tests
on:
push:
branches: [4.x]
branches: [5.x]
pull_request:
schedule:
- cron: '0 9 * * *'
@ -24,12 +24,9 @@ jobs:
fail-fast: true
matrix:
os: [ubuntu-latest, macos-latest] # windows-latest
symfony: ['7.4', '8.0']
php: ['8.3', '8.4', '8.5']
symfony: ['8.0']
php: ['8.4', '8.5']
dependency_version: [prefer-stable]
exclude:
- php: '8.3'
symfony: '8.0'
name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }}
@ -67,7 +64,7 @@ jobs:
- name: Install PHP dependencies
shell: bash
env:
COMPOSER_ROOT_VERSION: 4.x-dev
COMPOSER_ROOT_VERSION: 5.x-dev
run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}"
- name: Unit Tests

View File

@ -1,7 +1,7 @@
<p align="center">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v4/social.png" width="600" alt="PEST">
<img src="https://raw.githubusercontent.com/pestphp/art/master/v5/social.png" width="600" alt="PEST">
<p align="center">
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (4.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=4.x&label=Tests%204.x"></a>
<a href="https://github.com/pestphp/pest/actions"><img alt="GitHub Workflow Status (5.x)" src="https://img.shields.io/github/actions/workflow/status/pestphp/pest/tests.yml?branch=5.x&label=Tests%205.x"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Total Downloads" src="https://img.shields.io/packagist/dt/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="Latest Version" src="https://img.shields.io/packagist/v/pestphp/pest"></a>
<a href="https://packagist.org/packages/pestphp/pest"><img alt="License" src="https://img.shields.io/packagist/l/pestphp/pest"></a>
@ -12,7 +12,7 @@
------
> Pest v4 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v4-is-here-now-with-browser-testing)**.
> Pest v5 Now Available: **[Read the announcement »](https://pestphp.com/docs/pest-v5-is-here)**.
**Pest** is an elegant PHP testing Framework with a focus on simplicity, meticulously designed to bring back the joy of testing in PHP.

View File

@ -2,10 +2,10 @@
When releasing a new version of Pest there are some checks and updates that need to be done:
> **For Pest v3 you should use the `3.x` branch instead.**
> **For Pest v4 you should use the `4.x` branch instead.**
- Clear your local repository with: `git add . && git reset --hard && git checkout 4.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...4.x](https://github.com/pestphp/pest/compare/{latest_version}...4.x)
- Clear your local repository with: `git add . && git reset --hard && git checkout 5.x`
- On the GitHub repository, check the contents of [github.com/pestphp/pest/compare/{latest_version}...5.x](https://github.com/pestphp/pest/compare/{latest_version}...5.x)
- Update the version number in [src/Pest.php](src/Pest.php)
- Run the tests locally using: `composer test`
- Commit the Pest file with the message: `git commit -m "release: vX.X.X"`

View File

@ -17,21 +17,20 @@
}
],
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"composer/xdebug-handler": "^3.0.5",
"php": "^8.4",
"brianium/paratest": "^7.22.4",
"nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"phpunit/phpunit": "^12.5.29",
"symfony/process": "^7.4.13|^8.1.0"
"pestphp/pest-plugin": "^5.0.0",
"pestphp/pest-plugin-arch": "^5.0.0",
"pestphp/pest-plugin-mutate": "^5.0.0",
"pestphp/pest-plugin-profanity": "^5.0.0",
"phpunit/phpunit": "^13.1.8",
"symfony/process": "^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.29",
"phpunit/phpunit": ">13.1.8",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@ -60,9 +59,10 @@
},
"require-dev": {
"mrpunyapal/peststan": "^0.2.10",
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
"laravel/pao": "^1.1.1",
"pestphp/pest-dev-tools": "^5.0.0",
"pestphp/pest-plugin-browser": "^5.0.0",
"pestphp/pest-plugin-type-coverage": "^5.0.0",
"psy/psysh": "^0.12.23"
},
"minimum-stability": "dev",

View File

@ -7,7 +7,7 @@
<div>
<span class="text-gray mr-1">- </span>
<span>composer require pestphp/pest-plugin-browser:^4.0 --dev</span>
<span>composer require pestphp/pest-plugin-browser:^5.0 --dev</span>
</div>
<div>

View File

@ -33,7 +33,7 @@ final readonly class Configuration
*/
public function in(string ...$targets): UsesCall
{
return (new UsesCall($this->filename, []))->in(...$targets);
return new UsesCall($this->filename, [])->in(...$targets);
}
/**
@ -60,7 +60,7 @@ final readonly class Configuration
*/
public function group(string ...$groups): UsesCall
{
return (new UsesCall($this->filename, []))->group(...$groups);
return new UsesCall($this->filename, [])->group(...$groups);
}
/**
@ -68,7 +68,7 @@ final readonly class Configuration
*/
public function only(): void
{
(new BeforeEachCall(TestSuite::getInstance(), $this->filename))->only();
new BeforeEachCall(TestSuite::getInstance(), $this->filename)->only();
}
/**

View File

@ -244,7 +244,7 @@ final class Expectation
if ($callbacks[$index] instanceof Closure) {
$callbacks[$index](new self($value), new self($key));
} else {
(new self($value))->toEqual($callbacks[$index]);
new self($value)->toEqual($callbacks[$index]);
}
$index = isset($callbacks[$index + 1]) ? $index + 1 : 0;
@ -921,15 +921,7 @@ final class Expectation
return Targeted::make(
$this,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (! isset($object->reflectionClass) || ! $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)),
"to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -1144,8 +1136,8 @@ final class Expectation
$this,
fn (ObjectDescription $object): bool => isset($object->reflectionClass)
&& $object->reflectionClass->isEnum()
&& (new ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
&& (string) (new ReflectionEnum($object->name))->getBackingType() === $backingType, // @phpstan-ignore-line
&& new ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
&& (string) new ReflectionEnum($object->name)->getBackingType() === $backingType, // @phpstan-ignore-line
'to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);

View File

@ -576,15 +576,7 @@ final readonly class OppositeExpectation
return Targeted::make(
$original,
function (ObjectDescription $object) use ($traits): bool {
foreach ($traits as $trait) {
if (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true)) {
return false;
}
}
return true;
},
fn (ObjectDescription $object): bool => array_all($traits, fn (string $trait): bool => ! (isset($object->reflectionClass) && in_array($trait, $object->reflectionClass->getTraitNames(), true))),
"not to use traits '".implode("', '", $traits)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -604,15 +596,7 @@ final readonly class OppositeExpectation
return Targeted::make(
$original,
function (ObjectDescription $object) use ($interfaces): bool {
foreach ($interfaces as $interface) {
if (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface)) {
return false;
}
}
return true;
},
fn (ObjectDescription $object): bool => array_all($interfaces, fn (string $interface): bool => ! (isset($object->reflectionClass) && $object->reflectionClass->implementsInterface($interface))),
"not to implement '".implode("', '", $interfaces)."'",
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);
@ -814,13 +798,11 @@ final readonly class OppositeExpectation
$exporter = Exporter::default();
$toString = fn (mixed $argument): string => $exporter->shortenedExport($argument);
throw new ExpectationFailedException(sprintf(
'Expecting %s not %s %s.',
$toString($this->original->value),
$exporter->shortenedExport($this->original->value),
strtolower((string) preg_replace('/(?<!\ )[A-Z]/', ' $0', $name)),
implode(' ', array_map(fn (mixed $argument): string => $toString($argument), $arguments)),
implode(' ', array_map(fn (mixed $argument): string => $exporter->export($argument), $arguments)),
));
}
@ -852,8 +834,8 @@ final readonly class OppositeExpectation
$original,
fn (ObjectDescription $object): bool => isset($object->reflectionClass) === false
|| ! $object->reflectionClass->isEnum()
|| ! (new \ReflectionEnum($object->name))->isBacked() // @phpstan-ignore-line
|| (string) (new \ReflectionEnum($object->name))->getBackingType() !== $backingType, // @phpstan-ignore-line
|| ! new \ReflectionEnum($object->name)->isBacked() // @phpstan-ignore-line
|| (string) new \ReflectionEnum($object->name)->getBackingType() !== $backingType, // @phpstan-ignore-line
'not to be '.$backingType.' backed enum',
FileLineFinder::where(fn (string $line): bool => str_contains($line, 'class')),
);

View File

@ -197,7 +197,7 @@ final class TestCaseFactory
if (
$method->closure instanceof \Closure &&
(new \ReflectionFunction($method->closure))->isStatic()
new \ReflectionFunction($method->closure)->isStatic()
) {
throw new TestClosureMustNotBeStatic($method);

View File

@ -936,7 +936,7 @@ final class Expectation
if ($exception instanceof Closure) {
$callback = $exception;
$parameters = (new ReflectionFunction($exception))->getParameters();
$parameters = new ReflectionFunction($exception)->getParameters();
if (count($parameters) !== 1) {
throw new InvalidArgumentException('The given closure must have a single parameter type-hinted as the class string.');
@ -1142,6 +1142,22 @@ final class Expectation
return $this;
}
/**
* Asserts that the value is a ULID.
*
* @return self<TValue>
*/
public function toBeUlid(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue(Str::isUlid($this->value), $message);
return $this;
}
/**
* Asserts that the value is between 2 specified values
*

View File

@ -37,7 +37,7 @@ final readonly class HigherOrderExpectationTypeExtension implements ExpressionTy
$varType = $scope->getType($expr->var);
if (! (new ObjectType(HigherOrderExpectation::class))->isSuperTypeOf($varType)->yes()) {
if (! new ObjectType(HigherOrderExpectation::class)->isSuperTypeOf($varType)->yes()) {
return null;
}

View File

@ -53,9 +53,7 @@ final class UsesCall
$this->targets = [$filename];
}
/**
* @deprecated Use `pest()->printer()->compact()` instead.
*/
#[\Deprecated(message: 'Use `pest()->printer()->compact()` instead.')]
public function compact(): self
{
DefaultPrinter::compact(true);

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.7.3';
return '5.0.0-rc.10';
}
function testDirectory(string $file = ''): string

View File

@ -50,11 +50,14 @@ trait HandleArguments
*/
public function popArgument(string $argument, array $arguments): array
{
$arguments = array_flip($arguments);
$key = array_search($argument, $arguments, true);
unset($arguments[$argument]);
while ($key !== false) {
unset($arguments[$key]);
$key = array_search($argument, $arguments, true);
}
return array_values(array_flip($arguments));
return array_values($arguments);
}
/**

View File

@ -17,6 +17,8 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
final class Coverage implements AddsOutput, HandlesArguments
{
use Concerns\HandleArguments;
private const string COVERAGE_OPTION = 'coverage';
private const string MIN_OPTION = 'min';
@ -77,11 +79,9 @@ final class Coverage implements AddsOutput, HandlesArguments
return false;
}))];
$originals = array_flip($originals);
foreach ($arguments as $argument) {
unset($originals[$argument]);
$originals = $this->popArgument($argument, $originals);
}
$originals = array_flip($originals);
$inputs = [];
$inputs[] = new InputOption(self::COVERAGE_OPTION, null, InputOption::VALUE_NONE);

View File

@ -178,13 +178,7 @@ final class Parallel implements HandlesArguments
{
$arguments = new ArgvInput;
foreach (self::UNSUPPORTED_ARGUMENTS as $unsupportedArgument) {
if ($arguments->hasParameterOption($unsupportedArgument)) {
return true;
}
}
return false;
return array_any(self::UNSUPPORTED_ARGUMENTS, fn (string|array $unsupportedArgument): bool => $arguments->hasParameterOption($unsupportedArgument));
}
/**

View File

@ -7,7 +7,6 @@ namespace Pest\Plugins\Parallel\Paratest;
use const DIRECTORY_SEPARATOR;
use NunoMaduro\Collision\Adapters\Phpunit\Support\ResultReflection;
use ParaTest\Coverage\CoverageMerger;
use ParaTest\JUnit\LogMerger;
use ParaTest\JUnit\Writer;
use ParaTest\Options;
@ -25,11 +24,17 @@ use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use PHPUnit\TestRunner\TestResult\TestResult;
use PHPUnit\TextUI\Configuration\CodeCoverageFilterRegistry;
use PHPUnit\Util\ExcludeList;
use ReflectionProperty;
use SebastianBergmann\CodeCoverage\Node\Builder;
use SebastianBergmann\CodeCoverage\Serialization\Merger;
use SebastianBergmann\CodeCoverage\StaticAnalysis\FileAnalyser;
use SebastianBergmann\CodeCoverage\StaticAnalysis\ParsingSourceAnalyser;
use SebastianBergmann\Timer\Timer;
use SplFileInfo;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use function array_filter;
use function array_merge;
use function array_merge_recursive;
use function array_shift;
@ -447,10 +452,33 @@ final class WrapperRunner implements RunnerInterface
return;
}
$coverageMerger = new CoverageMerger($coverageManager->codeCoverage());
foreach ($this->coverageFiles as $coverageFile) {
$coverageMerger->addCoverageFromFile($coverageFile);
$coverageFiles = [];
foreach ($this->coverageFiles as $fileInfo) {
$realPath = $fileInfo->getRealPath();
if ($realPath !== false && $realPath !== '') {
$coverageFiles[] = $realPath;
}
}
$serializedCoverage = (new Merger)->merge($coverageFiles);
$report = (new Builder(new FileAnalyser(new ParsingSourceAnalyser, false, false)))->build(
$serializedCoverage['codeCoverage'],
$serializedCoverage['testResults'],
$serializedCoverage['basePath'],
);
$codeCoverage = $coverageManager->codeCoverage();
$codeCoverage->excludeUncoveredFiles();
$mergedData = $serializedCoverage['codeCoverage'];
$basePath = $serializedCoverage['basePath'];
if ($basePath !== '') {
foreach ($mergedData->coveredFiles() as $relativePath) {
$mergedData->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
}
}
$codeCoverage->setData($mergedData);
$codeCoverage->setTests($serializedCoverage['testResults']);
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
$coverageManager->generateReports(
$this->printer->printer,

View File

@ -27,6 +27,13 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
private const string SHARD_OPTION = 'shard';
/**
* The maximum length allowed for the filter argument.
* While ARG_MAX can be 2MB, individual arguments are often limited to 128KB (MAX_ARG_STRLEN).
* Practical limits in CI environments (like Docker or pipeline runners) can be even lower.
*/
private const int MAX_FILTER_LENGTH = 32768;
/**
* The shard index and total number of shards.
*
@ -132,7 +139,8 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
self::$timeBalanced = true;
self::$shardsOutdated = $newTests !== [];
} else {
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
$isInCurrentShard = fn (int $key): bool => $key % $total === ($index - 1);
$testsToRun = array_values(array_filter($tests, $isInCurrentShard, ARRAY_FILTER_USE_KEY));
}
self::$shard = [
@ -146,7 +154,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
return $arguments;
}
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
$filter = $this->buildFilterArgument($testsToRun);
$this->ensureFilterLengthIsSafe($filter);
return [...$arguments, '--filter', $filter];
}
/**
@ -187,11 +199,11 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/
private function allTests(array $arguments): array
{
$output = (new Process([
$output = new Process([
'php',
...$this->removeParallelArguments($arguments),
'--list-tests',
]))->setTimeout(120)->mustRun()->getOutput();
])->setTimeout(120)->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
@ -209,10 +221,63 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
/**
* Builds the filter argument for the given tests to run.
*
* @param array<int, string> $testsToRun
*/
private function buildFilterArgument(mixed $testsToRun): string
private function buildFilterArgument(array $testsToRun): string
{
return addslashes(implode('|', $testsToRun));
if ($testsToRun === []) {
return '';
}
/** @var array<string, mixed> $tree */
$tree = [];
foreach ($testsToRun as $class) {
$parts = explode('\\', $class);
$current = &$tree;
foreach ($parts as $part) {
if (! isset($current[$part])) {
$current[$part] = [];
}
$current = &$current[$part];
}
}
$buildRegex = function (array $tree) use (&$buildRegex): string {
$parts = [];
foreach ($tree as $key => $sub) {
$subRegex = $buildRegex($sub);
if ($subRegex === '') {
$parts[] = preg_quote($key, '/');
} else {
$parts[] = preg_quote($key, '/').'\\\\'.(count($sub) > 1 ? '('.$subRegex.')' : $subRegex);
}
}
return implode('|', $parts);
};
return $buildRegex($tree);
}
/**
* Ensures that the filter length is safe for the current environment.
*
* @throws InvalidOption
*/
private function ensureFilterLengthIsSafe(string $filter): void
{
$maxLength = (int) (getenv('PEST_SHARD_MAX_FILTER_LENGTH') ?: self::MAX_FILTER_LENGTH);
if (strlen($filter) > $maxLength) {
throw new InvalidOption(sprintf(
'The generated filter for this shard is too long (%d characters). '.
'This can cause issues with some environments (limit is %d characters). '.
'Please increase the number of shards (e.g., use 1/4 instead of 1/2) to reduce the filter length.',
strlen($filter),
$maxLength
));
}
}
/**

View File

@ -624,7 +624,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
{
$this->watchPatterns->useDefaults($projectRoot);
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
$fingerprint = Fingerprint::compute($projectRoot);
$this->startFingerprint = $fingerprint;
@ -687,7 +687,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
{
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$this->branch = new ChangedFiles($projectRoot)->currentBranch() ?? 'main';
if ($replayingGlobal) {
$this->installWorkerReplay($projectRoot);

View File

@ -1289,13 +1289,7 @@ final class Graph
/** @param array<string, array<int, string>> $edges */
private function anyTestUses(array $edges, string $component): bool
{
foreach ($edges as $components) {
if (in_array($component, $components, true)) {
return true;
}
}
return false;
return array_any($edges, fn (array $components): bool => in_array($component, $components, true));
}
public function pruneMissingTests(): void

View File

@ -386,12 +386,6 @@ final class JsModuleGraph
private static function hasViteConfig(string $projectRoot): bool
{
foreach (self::VITE_CONFIG_NAMES as $name) {
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
}
}
return false;
return array_any(self::VITE_CONFIG_NAMES, fn (string $name): bool => is_file($projectRoot.DIRECTORY_SEPARATOR.$name));
}
}

View File

@ -23,15 +23,7 @@ final class TableExtractor
}
$prefix = strtolower(substr($trimmed, 0, 6));
$matched = false;
foreach (self::DML_PREFIXES as $dml) {
if (str_starts_with($prefix, $dml)) {
$matched = true;
break;
}
}
$matched = array_any(self::DML_PREFIXES, fn (string $dml): bool => str_starts_with($prefix, $dml));
if (! $matched) {
return [];

View File

@ -94,15 +94,7 @@ final readonly class TestPaths
if (in_array($relativePath, $this->files, true)) {
return true;
}
$matchesSuffix = false;
foreach ($this->suffixes as $suffix) {
if (str_ends_with($relativePath, $suffix)) {
$matchesSuffix = true;
break;
}
}
$matchesSuffix = array_any($this->suffixes, fn (string $suffix): bool => str_ends_with($relativePath, $suffix));
if (! $matchesSuffix) {
return false;

View File

@ -253,35 +253,17 @@ final class WatchPatterns
private function patternTargetsDotfiles(string $pattern): bool
{
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
return array_any(explode('/', str_replace('\\', '/', $pattern)), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function touchesVcs(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if (in_array($segment, self::VCS_DIRS, true)) {
return true;
}
}
return false;
return array_any(explode('/', $file), fn (string $segment): bool => in_array($segment, self::VCS_DIRS, true));
}
private function touchesDotfile(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
return array_any(explode('/', $file), fn (string $segment): bool => $segment !== '' && $segment[0] === '.');
}
private function excludeMatches(string $exclude, string $file): bool

View File

@ -37,7 +37,7 @@ final class XdebugRestarter implements Restarter
return;
}
(new XdebugHandler('pest'))->check();
new XdebugHandler('pest')->check();
}
private function xdebugIsCoverageOnly(): bool

View File

@ -9,6 +9,7 @@ use Pest\Plugins\Tia\CoverageMerger;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Node\Directory;
use SebastianBergmann\CodeCoverage\Node\File;
use SebastianBergmann\CodeCoverage\Report\Facade;
use SebastianBergmann\Environment\Runtime;
use Symfony\Component\Console\Output\OutputInterface;
@ -95,10 +96,18 @@ final class Coverage
$codeCoverage = require $reportPath;
unlink($reportPath);
$totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines();
// @phpstan-ignore-next-line
if (is_array($codeCoverage)) {
$facade = Facade::fromSerializedData($codeCoverage);
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
/** @var Directory<File|Directory> $report */
$report = (fn (): Directory => $this->report)->call($facade);
} else {
/** @var Directory<File|Directory> $report */
$report = $codeCoverage->getReport();
}
$totalCoverage = $report->percentageOfExecutedLines();
foreach ($report->getIterator() as $file) {
if (! $file instanceof File) {

View File

@ -86,4 +86,17 @@ final readonly class Exporter
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->shortenedExport($value));
}
/**
* Exports a value into a full single-line string without truncation.
*/
public function export(mixed $value): string
{
$map = [
'#\\\n\s*#' => '',
'# Object \(\.{3}\)#' => '',
];
return (string) preg_replace(array_keys($map), array_values($map), $this->exporter->export($value));
}
}

View File

@ -50,7 +50,7 @@ final class HigherOrderMessage
}
if ($this->hasHigherOrderCallable()) {
return (new HigherOrderCallables($target))->{$this->name}(...$this->arguments);
return new HigherOrderCallables($target)->{$this->name}(...$this->arguments);
}
try {

View File

@ -31,7 +31,7 @@ final class HigherOrderMessageCollection
*/
public function addWhen(callable $condition, string $filename, int $line, string $name, ?array $arguments): void
{
$this->messages[] = (new HigherOrderMessage($filename, $line, $name, $arguments))->when($condition);
$this->messages[] = new HigherOrderMessage($filename, $line, $name, $arguments)->when($condition);
}
/**

View File

@ -38,7 +38,7 @@ final class HigherOrderTapProxy
return $this->target->{$property};
}
$className = (new ReflectionClass($this->target))->getName();
$className = new ReflectionClass($this->target)->getName();
if (str_starts_with($className, 'P\\')) {
$className = substr($className, 2);
@ -60,7 +60,7 @@ final class HigherOrderTapProxy
$filename = Backtrace::file();
$line = Backtrace::line();
return (new HigherOrderMessage($filename, $line, $methodName, $arguments))
return new HigherOrderMessage($filename, $line, $methodName, $arguments)
->call($this->target);
}
}

View File

@ -181,7 +181,7 @@ final class Reflection
*/
public static function getFunctionArguments(Closure $function): array
{
$parameters = (new ReflectionFunction($function))->getParameters();
$parameters = new ReflectionFunction($function)->getParameters();
$arguments = [];
foreach ($parameters as $parameter) {
@ -207,7 +207,7 @@ final class Reflection
public static function getFunctionVariable(Closure $function, string $key): mixed
{
return (new ReflectionFunction($function))->getStaticVariables()[$key] ?? null;
return new ReflectionFunction($function)->getStaticVariables()[$key] ?? null;
}
/**

View File

@ -98,6 +98,14 @@ final class Str
return preg_match('/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/iD', $value) > 0;
}
/**
* Determine if a given value is a valid ULID.
*/
public static function isUlid(string $value): bool
{
return preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', $value) > 0;
}
/**
* Creates a describe block as `$describeDescription` → `$testDescription` format.
*

View File

@ -1,5 +1,5 @@
Pest Testing Framework 4.7.3.
Pest Testing Framework 5.0.0-rc.10.
USAGE: pest <file> [options]
@ -45,6 +45,7 @@
--filter [pattern] ............................... Filter which tests to run
--exclude-filter [pattern] .. Exclude tests for the specified filter pattern
--test-suffix [suffixes] Only search for test in files with specified suffix(es). Default: Test.php,.phpt
--test-files-file [file] Only run test files listed in file (one file by line)
EXECUTION OPTIONS:
--parallel ........................................... Run tests in parallel
@ -125,12 +126,12 @@
LOGGING OPTIONS:
--log-junit [file] .......... Write test results in JUnit XML format to file
--log-otr [file] Write test results in Open Test Reporting XML format to file
--include-git-information Include Git information in Open Test Reporting XML logfile
--log-teamcity [file] ........ Write test results in TeamCity format to file
--testdox-html [file] .. Write test results in TestDox format (HTML) to file
--testdox-text [file] Write test results in TestDox format (plain text) to file
--log-events-text [file] ............... Stream events as plain text to file
--log-events-verbose-text [file] Stream events as plain text with extended information to file
--include-git-information ..... Include Git information in supported formats
--no-logging ....... Ignore logging configured in the XML configuration file
CODE COVERAGE OPTIONS:

View File

@ -1,3 +1,3 @@
Pest Testing Framework 4.7.3.
Pest Testing Framework 5.0.0-rc.10.

View File

@ -752,6 +752,13 @@
✓ passes as not truthy with ('0')
✓ failures
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeUlid
✓ failures with wrong type
✓ pass
✓ failures
✓ failures with message
✓ not failures
PASS Tests\Features\Expect\toBeUppercase
@ -1696,6 +1703,8 @@
PASS Tests\Unit\Expectations\OppositeExpectation
✓ it throw expectation failed exception with string argument
✓ it throw expectation failed exception with array argument
✓ it does not truncate long string arguments in error message
✓ it does not truncate custom error message when using not()
PASS Tests\Unit\Overrides\ThrowableBuilder
✓ collision editor can be added to the stack trace
@ -1707,6 +1716,8 @@
✓ method hasArgument with ('someValue', true)
✓ method hasArgument with ('--a', false)
✓ method hasArgument with ('--undefined-argument', false)
✓ popArgument preserves duplicate values when removing a missing argument
✓ popArgument preserves duplicate values when removing an existing argument
PASS Tests\Unit\Plugins\Environment
✓ environment is set to CI when --ci option is used
@ -1715,6 +1726,40 @@
PASS Tests\Unit\Plugins\Retry
✓ it orders by defects and stop on defects if when --retry is used
PASS Tests\Unit\Plugins\Shard
✓ getShard → it parses valid shard format with ('1/2', 1, 2)
✓ getShard → it parses valid shard format with ('2/2', 2, 2)
✓ getShard → it parses valid shard format with ('1/4', 1, 4)
✓ getShard → it parses valid shard format with ('4/4', 4, 4)
✓ getShard → it parses valid shard format with ('1/10', 1, 10)
✓ getShard → it parses valid shard format with ('10/10', 10, 10)
✓ getShard → it parses valid shard format with ('5/100', 5, 100)
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'invalid'])
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1'])
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1/'])
✓ getShard → it throws exception for invalid format with (['test', '--shard', '/2'])
✓ getShard → it throws exception for invalid format with (['test', '--shard', 'a/b'])
✓ getShard → it throws exception for invalid format with (['test', '--shard', '1.5/2'])
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '0/2'])
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '1/0'])
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '3/2'])
✓ getShard → it throws exception for invalid index or total values with (['test', '--shard', '5/4'])
✓ buildFilterArgument → it generates compact filter for single test
✓ buildFilterArgument → it generates compact filter for multiple tests with common prefix
✓ buildFilterArgument → it generates compact filter for tests with different namespaces
✓ buildFilterArgument → it returns empty string for empty test list
✓ buildFilterArgument → it generates compact filter for deeply nested namespaces
✓ buildFilterArgument → it handles mix of nested and flat namespaces
✓ ensureFilterLengthIsSafe → it accepts filter within length limit
✓ ensureFilterLengthIsSafe → it throws exception when filter exceeds default limit
✓ ensureFilterLengthIsSafe → it respects custom limit from environment variable
✓ ensureFilterLengthIsSafe → it accepts filter within custom limit
✓ handleArguments → it returns original arguments when shard option is not present
✓ handleArguments → it removes parallel arguments from test discovery
✓ addOutput → it displays shard information after test execution
✓ addOutput → it uses singular form for single test file
✓ addOutput → it returns original exit code when shard is not set
PASS Tests\Unit\Plugins\Tia\ContentHash
✓ of() → it returns false when file does not exist
✓ of() → it hashes an existing file
@ -1904,6 +1949,7 @@
✓ parallel
✓ a parallel test can extend another test with same name
✓ parallel reports invalid datasets as failures
✓ parallel can have multiple exclude-groups
PASS Tests\Visual\ParallelNestedDatasets
✓ parallel loads nested datasets from nested directories
@ -1937,4 +1983,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, 1328 passed (3008 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1370 passed (3068 assertions)

View File

@ -0,0 +1,26 @@
<?php
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('failures with wrong type', function () {
expect([])->toBeUlid();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('pass', function () {
expect('01ARZ3NDEKTSV4RRFFQ69G5FAV')->toBeUlid();
expect('01BX5ZZKBKACTAV9WEVGEMMVRE')->toBeUlid();
expect('7ZZZZZZZZZ0000000000000000')->toBeUlid();
});
test('failures', function () {
expect('foo')->toBeUlid();
})->throws(ExpectationFailedException::class);
test('failures with message', function () {
expect('bar')->toBeUlid('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function () {
expect('foo')->not->toBeUlid();
});

View File

@ -14,3 +14,17 @@ it('throw expectation failed exception with array argument', function (): void {
$expectation->throwExpectationFailedException('toBe', ['bar']);
})->throws(ExpectationFailedException::class, "Expecting 'foo' not to be 'bar'.");
it('does not truncate long string arguments in error message', function (): void {
$expectation = new OppositeExpectation(expect('foo'));
$longMessage = 'Very long error message. Very long error message. Very long error message.';
$expectation->throwExpectationFailedException('toBe', [$longMessage]);
})->throws(ExpectationFailedException::class, 'Very long error message. Very long error message. Very long error message.');
it('does not truncate custom error message when using not()', function (): void {
$longMessage = 'This is a very detailed custom error message that should not be truncated in the output.';
expect(true)->not()->toBeTrue($longMessage);
})->throws(ExpectationFailedException::class, 'This is a very detailed custom error message that should not be truncated in the output.');

View File

@ -24,3 +24,27 @@ test('method hasArgument', function (string $argument, bool $expectedResult) {
['--a', false],
['--undefined-argument', false],
]);
test('popArgument preserves duplicate values when removing a missing argument', function () {
$obj = new class
{
use HandleArguments;
};
$arguments = ['--verbose', '--exclude-group', 'firstGroup', '--exclude-group', 'secondGroup', '--filter=MyTest'];
$result = $obj->popArgument('--missingitem', $arguments);
expect($result)->toBe($arguments);
});
test('popArgument preserves duplicate values when removing an existing argument', function () {
$obj = new class
{
use HandleArguments;
};
$arguments = ['--verbose', '--exclude-group', 'firstGroup', '--exclude-group', 'secondGroup', '--filter=MyTest'];
$result = $obj->popArgument('--verbose', $arguments);
expect($result)->toBe(['--exclude-group', 'firstGroup', '--exclude-group', 'secondGroup', '--filter=MyTest']);
});

View File

@ -0,0 +1,301 @@
<?php
use Pest\Exceptions\InvalidOption;
use Pest\Plugins\Shard;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\BufferedOutput;
describe('getShard', function () {
it('parses valid shard format', function (string $format, int $expectedIndex, int $expectedTotal) {
$input = new ArgvInput(['test', '--shard', $format]);
$result = Shard::getShard($input);
expect($result)->toBe([
'index' => $expectedIndex,
'total' => $expectedTotal,
]);
})->with([
['1/2', 1, 2],
['2/2', 2, 2],
['1/4', 1, 4],
['4/4', 4, 4],
['1/10', 1, 10],
['10/10', 10, 10],
['5/100', 5, 100],
]);
it('throws exception for invalid format', function (array $arguments) {
$input = new ArgvInput($arguments);
Shard::getShard($input);
})->with([
[['test', '--shard', 'invalid']],
[['test', '--shard', '1']],
[['test', '--shard', '1/']],
[['test', '--shard', '/2']],
[['test', '--shard', 'a/b']],
[['test', '--shard', '1.5/2']],
])->throws(InvalidOption::class);
it('throws exception for invalid index or total values', function (array $arguments) {
$input = new ArgvInput($arguments);
Shard::getShard($input);
})->with([
[['test', '--shard', '0/2']],
[['test', '--shard', '1/0']],
[['test', '--shard', '3/2']],
[['test', '--shard', '5/4']],
])->throws(InvalidOption::class);
});
describe('buildFilterArgument', function () {
it('generates compact filter for single test', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('buildFilterArgument');
$filter = $method->invoke($shard, ['Tests\\Unit\\ExampleTest']);
expect($filter)->toBe('Tests\\\\Unit\\\\ExampleTest');
});
it('generates compact filter for multiple tests with common prefix', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('buildFilterArgument');
$filter = $method->invoke($shard, [
'Tests\\Unit\\Foo\\BarTest',
'Tests\\Unit\\Foo\\BazTest',
]);
expect($filter)->toBe('Tests\\\\Unit\\\\Foo\\\\(BarTest|BazTest)');
});
it('generates compact filter for tests with different namespaces', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('buildFilterArgument');
$filter = $method->invoke($shard, [
'Tests\\Unit\\FooTest',
'Tests\\Feature\\BarTest',
]);
expect($filter)->toBe('Tests\\\\(Unit\\\\FooTest|Feature\\\\BarTest)');
});
it('returns empty string for empty test list', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('buildFilterArgument');
$filter = $method->invoke($shard, []);
expect($filter)->toBe('');
});
it('generates compact filter for deeply nested namespaces', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('buildFilterArgument');
$filter = $method->invoke($shard, [
'Tests\\Unit\\Plugins\\Concerns\\Foo',
'Tests\\Unit\\Plugins\\Concerns\\Bar',
'Tests\\Unit\\Plugins\\Concerns\\Baz',
]);
expect($filter)->toBe('Tests\\\\Unit\\\\Plugins\\\\Concerns\\\\(Foo|Bar|Baz)');
});
it('handles mix of nested and flat namespaces', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('buildFilterArgument');
$tests = [
'Tests\\Unit\\SimpleTest',
'Tests\\Unit\\Plugins\\Concerns\\HandleArguments',
'Tests\\Unit\\Plugins\\Concerns\\Validation',
'Tests\\Unit\\Another\\Deep\\Nested\\Test',
];
$filter = $method->invoke($shard, $tests);
expect($filter)
->toBe(addslashes('Tests\\Unit\\(SimpleTest|Plugins\\Concerns\\(HandleArguments|Validation)|Another\\Deep\\Nested\\Test)'));
});
});
describe('ensureFilterLengthIsSafe', function () {
it('accepts filter within length limit', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('ensureFilterLengthIsSafe');
$filter = str_repeat('a', 1000);
$method->invoke($shard, $filter);
expect(true)->toBeTrue();
});
it('throws exception when filter exceeds default limit', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('ensureFilterLengthIsSafe');
$filter = str_repeat('a', 32769);
$method->invoke($shard, $filter);
})->throws(InvalidOption::class, 'The generated filter for this shard is too long');
it('respects custom limit from environment variable', function () {
putenv('PEST_SHARD_MAX_FILTER_LENGTH=1000');
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('ensureFilterLengthIsSafe');
$filter = str_repeat('a', 1001);
try {
$method->invoke($shard, $filter);
expect(false)->toBeTrue('Should have thrown exception');
} catch (InvalidOption $e) {
expect($e->getMessage())->toContain('1001 characters')
->and($e->getMessage())->toContain('limit is 1000 characters');
} finally {
putenv('PEST_SHARD_MAX_FILTER_LENGTH');
}
});
it('accepts filter within custom limit', function () {
putenv('PEST_SHARD_MAX_FILTER_LENGTH=1000');
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('ensureFilterLengthIsSafe');
$filter = str_repeat('a', 999);
try {
$method->invoke($shard, $filter);
expect(true)->toBeTrue();
} catch (InvalidOption) {
expect(false)->toBeTrue('Should not have thrown exception');
} finally {
putenv('PEST_SHARD_MAX_FILTER_LENGTH');
}
});
});
describe('handleArguments', function () {
it('returns original arguments when shard option is not present', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$arguments = ['bin/pest', 'tests/', '--parallel'];
$result = $shard->handleArguments($arguments);
expect($result)->toBe($arguments);
});
it('removes parallel arguments from test discovery', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$method = $reflection->getMethod('removeParallelArguments');
$arguments = ['bin/pest', '--parallel', 'tests/', '-p'];
$result = $method->invoke($shard, $arguments);
expect($result)->toBe([0 => 'bin/pest', 2 => 'tests/']);
});
});
describe('addOutput', function () {
it('displays shard information after test execution', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$property = $reflection->getProperty('shard');
$property->setValue(null, [
'index' => 2,
'total' => 4,
'testsRan' => 25,
'testsCount' => 100,
]);
$exitCode = $shard->addOutput(0);
$outputText = $output->fetch();
expect($exitCode)->toBe(0)
->and($outputText)->toContain('Shard:')
->and($outputText)->toContain('2 of 4')
->and($outputText)->toContain('25 files ran')
->and($outputText)->toContain('out of 100');
});
it('uses singular form for single test file', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$property = $reflection->getProperty('shard');
$property->setValue(null, [
'index' => 1,
'total' => 4,
'testsRan' => 1,
'testsCount' => 100,
]);
$shard->addOutput(0);
$outputText = $output->fetch();
expect($outputText)->toContain('1 file ran')
->and($outputText)->not->toContain('1 files');
});
it('returns original exit code when shard is not set', function () {
$output = new BufferedOutput;
$shard = new Shard($output);
$reflection = new ReflectionClass($shard);
$property = $reflection->getProperty('shard');
$property->setValue(null, null);
$exitCode = $shard->addOutput(1);
$outputText = $output->fetch();
expect($exitCode)->toBe(1)
->and($outputText)->not->toContain('Shard:');
});
});

View File

@ -24,13 +24,13 @@ test('parallel', function () use ($run) {
$file = file_get_contents(__FILE__);
$file = preg_replace(
'/\$expected = \'.*?\';/',
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1312 passed (2957 assertions)';",
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1353 passed (3015 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1312 passed (2957 assertions)';
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1353 passed (3015 assertions)';
expect($output)
->toContain("Tests: {$expected}")
@ -47,3 +47,14 @@ test('parallel reports invalid datasets as failures', function () use ($run) {
->toContain('Tests: 1 failed, 1 passed (1 assertions)')
->toContain('Parallel: 3 processes');
})->skipOnWindows();
test('parallel can have multiple exclude-groups', function () use ($run) {
$singleExclude = $run('--exclude-group=integration');
$doubleExclude = $run('--exclude-group=integration', '--exclude-group=container');
preg_match('/(\d+) passed/', $singleExclude, $singleMatch);
preg_match('/(\d+) passed/', $doubleExclude, $doubleMatch);
expect((int) $doubleMatch[1])->toBeLessThan((int) $singleMatch[1]);
expect($doubleExclude)->toContain('Parallel: 3 processes');
})->skipOnWindows();