Compare commits

..

26 Commits

Author SHA1 Message Date
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
31 changed files with 125 additions and 579 deletions

View File

@ -2,7 +2,7 @@ name: Static Analysis
on:
push:
branches: [4.x]
branches: [5.x]
pull_request:
schedule:
- cron: '0 9 * * *'
@ -30,7 +30,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
php-version: 8.4
tools: composer:v2
coverage: none
extensions: sockets
@ -44,10 +44,10 @@ jobs:
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: static-php-8.3-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
key: static-php-8.4-${{ matrix.dependency-version }}-composer-${{ hashFiles('**/composer.json') }}
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
run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi

View File

@ -2,7 +2,7 @@ name: Tests
on:
push:
branches: [4.x]
branches: [5.x]
pull_request:
schedule:
- cron: '0 9 * * *'
@ -21,12 +21,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 }}

View File

@ -17,20 +17,20 @@
}
],
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"php": "^8.4",
"brianium/paratest": "^7.22.1",
"nunomaduro/collision": "^8.9.3",
"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.16",
"symfony/process": "^7.4.8|^8.0.8"
"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.0",
"symfony/process": "^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.16",
"phpunit/phpunit": ">13.1.0",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
@ -59,9 +59,10 @@
},
"require-dev": {
"mrpunyapal/peststan": "^0.2.5",
"pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4",
"nunomaduro/pao": "0.x-dev",
"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.22"
},
"minimum-stability": "dev",

View File

@ -176,5 +176,9 @@ final class Laravel extends AbstractPreset
->toImplement('Illuminate\Contracts\Container\ContextualAttribute')
->toHaveAttribute('Attribute')
->toHaveMethod('resolve');
$this->expectations[] = expect('App\Rules')
->classes()
->toImplement('Illuminate\Contracts\Validation\ValidationRule');
}
}

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

@ -238,7 +238,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;
@ -915,15 +915,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')),
);
@ -1138,8 +1130,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

@ -922,7 +922,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.');

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.6.0';
return '5.0.0-rc.3';
}
function testDirectory(string $file = ''): string

View File

@ -123,10 +123,6 @@ final readonly class Help implements HandlesArguments
'arg' => '--update-snapshots',
'desc' => 'Update snapshots for tests using the "toMatchSnapshot" expectation',
],
[
'arg' => '--update-shards',
'desc' => 'Update shards.json with test timing data for time-balanced sharding',
],
], ...$content['Execution']];
$content['Selection'] = [[

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;
@ -448,10 +453,23 @@ 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->setTests($serializedCoverage['testResults']);
(new ReflectionProperty(\SebastianBergmann\CodeCoverage\CodeCoverage::class, 'cachedReport'))->setValue($codeCoverage, $report);
$coverageManager->generateReports(
$this->printer->printer,

View File

@ -6,13 +6,7 @@ namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption;
use Pest\Subscribers\EnsureShardTimingFinished;
use Pest\Subscribers\EnsureShardTimingsAreCollected;
use Pest\Subscribers\EnsureShardTimingStarted;
use Pest\TestSuite;
use PHPUnit\Event;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
@ -21,7 +15,7 @@ use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Shard implements AddsOutput, HandlesArguments, Terminable
final class Shard implements AddsOutput, HandlesArguments
{
use Concerns\HandleArguments;
@ -39,40 +33,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/
private static ?array $shard = null;
/**
* Whether to update the shards.json file.
*/
private static bool $updateShards = false;
/**
* Whether time-balanced sharding was used.
*/
private static bool $timeBalanced = false;
/**
* Whether the shards.json file is outdated.
*/
private static bool $shardsOutdated = false;
/**
* Whether the test suite passed.
*/
private static bool $passed = false;
/**
* Collected timings from workers or subscribers.
*
* @var array<string, float>|null
*/
private static ?array $collectedTimings = null;
/**
* The canonical list of test classes from --list-tests.
*
* @var list<string>|null
*/
private static ?array $knownTests = null;
/**
* Creates a new Plugin instance.
*/
@ -87,19 +47,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgument('--update-shards', $arguments)) {
return $this->handleUpdateShards($arguments);
}
if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
self::$updateShards = true;
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
return $arguments;
}
if (! $this->hasArgument('--shard', $arguments)) {
return $arguments;
}
@ -116,24 +63,7 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
/** @phpstan-ignore-next-line */
$tests = $this->allTests($arguments);
$timings = $this->loadShardsFile();
if ($timings !== null) {
$knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
$newTests = array_values(array_diff($tests, $knownTests));
$partitions = $this->partitionByTime($knownTests, $timings, $total);
foreach ($newTests as $i => $test) {
$partitions[$i % $total][] = $test;
}
$testsToRun = $partitions[$index - 1] ?? [];
self::$timeBalanced = true;
self::$shardsOutdated = $newTests !== [];
} else {
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
}
$testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
self::$shard = [
'index' => $index,
@ -142,43 +72,9 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
'testsCount' => count($tests),
];
if ($testsToRun === []) {
return $arguments;
}
return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
}
/**
* Handles the --update-shards argument.
*
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function handleUpdateShards(array $arguments): array
{
if ($this->hasArgument('--shard', $arguments)) {
throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
}
$arguments = $this->popArgument('--update-shards', $arguments);
self::$updateShards = true;
/** @phpstan-ignore-next-line */
self::$knownTests = $this->allTests($arguments);
if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
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);
}
return $arguments;
}
/**
* Returns all tests that the test suite would run.
*
@ -187,11 +83,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();
])->mustRun()->getOutput();
preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);
@ -220,22 +116,6 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
*/
public function addOutput(int $exitCode): int
{
self::$passed = $exitCode === 0;
if (self::$updateShards && self::$passed && ! Parallel::isWorker()) {
self::$collectedTimings = $this->collectTimings();
$count = self::$knownTests !== null
? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
: count(self::$collectedTimings);
$this->output->writeln(sprintf(
' <fg=gray>Shards:</> <fg=default>shards.json updated with timings for %d test class%s.</>',
$count,
$count === 1 ? '' : 'es',
));
}
if (self::$shard === null) {
return $exitCode;
}
@ -248,250 +128,17 @@ final class Shard implements AddsOutput, HandlesArguments, Terminable
] = self::$shard;
$this->output->writeln(sprintf(
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d%s.',
' <fg=gray>Shard:</> <fg=default>%d of %d</> — %d file%s ran, out of %d.',
$index,
$total,
$testsRan,
$testsRan === 1 ? '' : 's',
$testsCount,
self::$timeBalanced ? ' <fg=gray>(time-balanced)</>' : '',
));
if (self::$shardsOutdated) {
$this->output->writeln(' <fg=yellow;options=bold>WARN</> <fg=default>The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.</>');
}
return $exitCode;
}
/**
* Terminates the plugin.
*/
public function terminate(): void
{
if (! self::$updateShards) {
return;
}
if (Parallel::isWorker()) {
$this->writeWorkerTimings();
return;
}
if (! self::$passed) {
return;
}
$timings = self::$collectedTimings ?? $this->collectTimings();
if ($timings === []) {
return;
}
$this->writeTimings($timings);
}
/**
* Collects timings from subscribers or worker temp files.
*
* @return array<string, float>
*/
private function collectTimings(): array
{
$runId = Parallel::getGlobal('SHARD_RUN_ID');
if (is_string($runId)) {
return $this->readWorkerTimings($runId);
}
return EnsureShardTimingsAreCollected::timings();
}
/**
* Writes the current worker's timing data to a temp file.
*/
private function writeWorkerTimings(): void
{
$timings = EnsureShardTimingsAreCollected::timings();
if ($timings === []) {
return;
}
$runId = Parallel::getGlobal('SHARD_RUN_ID');
if (! is_string($runId)) {
return;
}
$path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';
file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
}
/**
* Reads and merges timing data from all worker temp files.
*
* @return array<string, float>
*/
private function readWorkerTimings(string $runId): array
{
$pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
$files = glob($pattern);
if ($files === false || $files === []) {
return [];
}
$merged = [];
foreach ($files as $file) {
$contents = file_get_contents($file);
if ($contents === false) {
continue;
}
$timings = json_decode($contents, true);
if (is_array($timings)) {
$merged = array_merge($merged, $timings);
}
unlink($file);
}
return $merged;
}
/**
* Returns the path to shards.json.
*/
private function shardsPath(): string
{
$testSuite = TestSuite::getInstance();
return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
}
/**
* Loads the timings from shards.json.
*
* @return array<string, float>|null
*/
private function loadShardsFile(): ?array
{
$path = $this->shardsPath();
if (! file_exists($path)) {
return null;
}
$contents = file_get_contents($path);
if ($contents === false) {
throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
}
$data = json_decode($contents, true);
if (! is_array($data) || ! isset($data['timings']) || ! is_array($data['timings'])) {
throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
}
return $data['timings'];
}
/**
* Partitions tests across shards using the LPT (Longest Processing Time) algorithm.
*
* @param list<string> $tests
* @param array<string, float> $timings
* @return list<list<string>>
*/
private function partitionByTime(array $tests, array $timings, int $total): array
{
$knownTimings = array_filter(
array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
fn (?float $t): bool => $t !== null,
);
$median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;
$testsWithTimings = array_map(
fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
$tests,
);
usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);
/** @var list<list<string>> */
$bins = array_fill(0, $total, []);
/** @var non-empty-list<float> */
$binTimes = array_fill(0, $total, 0.0);
foreach ($testsWithTimings as $item) {
$minIndex = array_search(min($binTimes), $binTimes, strict: true);
assert(is_int($minIndex));
$bins[$minIndex][] = $item['test'];
$binTimes[$minIndex] += $item['time'];
}
return $bins;
}
/**
* Calculates the median of an array of floats.
*
* @param list<float> $values
*/
private function median(array $values): float
{
sort($values);
$count = count($values);
$middle = (int) floor($count / 2);
if ($count % 2 === 0) {
return ($values[$middle - 1] + $values[$middle]) / 2;
}
return $values[$middle];
}
/**
* Writes the timings to shards.json.
*
* @param array<string, float> $timings
*/
private function writeTimings(array $timings): void
{
$path = $this->shardsPath();
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
if (self::$knownTests !== null) {
$knownSet = array_flip(self::$knownTests);
$timings = array_intersect_key($timings, $knownSet);
}
ksort($timings);
$canonical = self::$knownTests ?? array_keys($timings);
sort($canonical);
file_put_contents($path, json_encode([
'timings' => $timings,
'checksum' => md5(implode("\n", $canonical)),
'updated_at' => date('c'),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
}
/**
* Returns the shard information.
*

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\FinishedSubscriber;
/**
* @internal
*/
final class EnsureShardTimingFinished implements FinishedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Finished $event): void
{
EnsureShardTimingsAreCollected::finished($event);
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\TestSuite\Started;
use PHPUnit\Event\TestSuite\StartedSubscriber;
/**
* @internal
*/
final class EnsureShardTimingStarted implements StartedSubscriber
{
/**
* Runs the subscriber.
*/
public function notify(Started $event): void
{
EnsureShardTimingsAreCollected::started($event);
}
}

View File

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use PHPUnit\Event\Telemetry\HRTime;
use PHPUnit\Event\TestSuite\Finished;
use PHPUnit\Event\TestSuite\Started;
/**
* @internal
*/
final class EnsureShardTimingsAreCollected
{
/**
* The start times for each test class.
*
* @var array<string, HRTime>
*/
private static array $startTimes = [];
/**
* The collected timings for each test class.
*
* @var array<string, float>
*/
private static array $timings = [];
/**
* Records the start time for a test suite.
*/
public static function started(Started $event): void
{
if (! $event->testSuite()->isForTestClass()) {
return;
}
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
if (is_string($name)) {
self::$startTimes[$name] = $event->telemetryInfo()->time();
}
}
/**
* Records the duration for a test suite.
*/
public static function finished(Finished $event): void
{
if (! $event->testSuite()->isForTestClass()) {
return;
}
$name = preg_replace('/^P\\\\/', '', $event->testSuite()->name());
if (! is_string($name) || ! isset(self::$startTimes[$name])) {
return;
}
$duration = $event->telemetryInfo()->time()->duration(self::$startTimes[$name]);
self::$timings[$name] = round($duration->asFloat(), 4);
}
/**
* Returns the collected timings.
*
* @return array<string, float>
*/
public static function timings(): array
{
return self::$timings;
}
}

View File

@ -8,6 +8,7 @@ use Pest\Exceptions\ShouldNotHappen;
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;
@ -92,10 +93,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

@ -1,5 +1,5 @@
Pest Testing Framework 4.6.0.
Pest Testing Framework 5.0.0-rc.3.
USAGE: pest <file> [options]
@ -45,11 +45,11 @@
--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
--update-snapshots Update snapshots for tests using the "toMatchSnapshot" expectation
--update-shards Update shards.json with test timing data for time-balanced sharding
--globals-backup ................. Backup and restore $GLOBALS for each test
--static-backup ......... Backup and restore static properties for each test
--strict-coverage ................... Be strict about code coverage metadata
@ -121,12 +121,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.6.0.
Pest Testing Framework 5.0.0-rc.3.

View File

@ -1699,6 +1699,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
@ -1903,4 +1905,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, 1296 passed (2977 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1298 passed (2982 assertions)

View File

@ -17,9 +17,7 @@ arch()->preset()->security()->ignoring([
'eval',
'str_shuffle',
'exec',
'md5',
'unserialize',
'uniqid',
'extract',
'assert',
]);

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

@ -23,13 +23,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, 1280 passed (2926 assertions)';",
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1282 passed (2931 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1280 passed (2926 assertions)';
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1282 passed (2931 assertions)';
expect($output)
->toContain("Tests: {$expected}")