Compare commits

..

30 Commits

Author SHA1 Message Date
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
60 changed files with 128 additions and 4720 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@v2
with:
php-version: 8.3
php-version: 8.4
tools: composer:v2
coverage: none
extensions: sockets
@ -47,10 +47,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', '**/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
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 * * *'
@ -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 }}

View File

@ -17,20 +17,20 @@
}
],
"require": {
"php": "^8.3.0",
"brianium/paratest": "^7.20.0",
"php": "^8.4",
"brianium/paratest": "^7.22.3",
"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.23",
"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.7",
"symfony/process": "^8.1.0"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.23",
"phpunit/phpunit": ">13.1.7",
"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",
@ -123,7 +124,6 @@
"Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard",
"Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel"
]
},

View File

@ -25,16 +25,6 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureIgnorableTestCasesAreIgnored::class,
Subscribers\EnsureKernelDumpIsFlushed::class,
Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaCoverageIsRecorded::class,
Subscribers\EnsureTiaCoverageIsFlushed::class,
Subscribers\EnsureTiaResultsAreCollected::class,
Subscribers\EnsureTiaResultIsRecordedOnPassed::class,
Subscribers\EnsureTiaResultIsRecordedOnFailed::class,
Subscribers\EnsureTiaResultIsRecordedOnErrored::class,
Subscribers\EnsureTiaResultIsRecordedOnSkipped::class,
Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class,
Subscribers\EnsureTiaResultIsRecordedOnRisky::class,
Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class,
];
/**

View File

@ -7,15 +7,12 @@ namespace Pest\Concerns;
use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Preset;
use Pest\Support\Container;
use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\TestSuite;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest;
use PHPUnit\Framework\SkippedTest;
@ -78,12 +75,6 @@ trait Testable
*/
public bool $__ran = false;
/**
* Set when a `BeforeEachable` plugin returns a cached success result.
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
*/
private bool $__cachedPass = false;
/**
* The test's test closure.
*/
@ -236,45 +227,6 @@ trait Testable
{
TestSuite::getInstance()->test = $this;
$this->__cachedPass = false;
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
if ($cached !== null) {
if ($cached->isSuccess()) {
$this->__cachedPass = true;
return;
}
// Risky tests have no public PHPUnit hook to replay as-risky.
// Best available: short-circuit as a pass so the test doesn't
// misreport as a failure. Aggregate risky totals won't
// survive replay — accepted trade-off until PHPUnit grows a
// programmatic risky-marker API.
if ($cached->isRisky()) {
$this->__cachedPass = true;
return;
}
// Non-success: throw the matching PHPUnit exception. Runner
// catches it and marks the test with the correct status so
// skips, failures, incompletes and todos appear in output
// exactly as they did in the cached run.
if ($cached->isSkipped()) {
$this->markTestSkipped($cached->message());
}
if ($cached->isIncomplete()) {
$this->markTestIncomplete($cached->message());
}
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
}
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description;
@ -350,12 +302,6 @@ trait Testable
*/
protected function tearDown(...$arguments): void
{
if ($this->__cachedPass) {
TestSuite::getInstance()->test = null;
return;
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) {
@ -381,19 +327,6 @@ trait Testable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
if ($this->__cachedPass) {
// Feed the exact assertion count captured during the recorded
// run so Pest's "Tests: N passed (M assertions)" banner stays
// accurate on replay instead of collapsing to 1-per-test.
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
$this->addToAssertionCount($assertions > 0 ? $assertions : 1);
return null;
}
$arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);

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();
}
/**
@ -119,14 +119,6 @@ final readonly class Configuration
return new Browser\Configuration;
}
/**
* Gets the TIA (Test Impact Analysis) configuration.
*/
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/**
* Proxies calls to the uses method.
*

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

@ -36,7 +36,6 @@ final readonly class Kernel
*/
private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class,

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.');

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

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,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

@ -187,11 +187,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);

File diff suppressed because it is too large Load Diff

View File

@ -1,524 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* Pulls a team-shared TIA baseline on the first `--tia` run so new
* contributors and fresh CI workspaces start in replay mode instead of
* paying the ~30s record cost.
*
* The baseline lives as a GitHub Release with a fixed tag containing two
* assets — the graph JSON and the coverage cache. The repo is inferred
* from `.git/config`'s `origin` remote, so no per-project configuration
* is required. Non-GitHub remotes silently opt out.
*
* Fetching is attempted in order:
* 1. `gh release download` — uses the user's existing GitHub auth,
* works for private repos.
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
* composer.lock, etc.) discards the pulled baseline and falls through to
* the regular record path.
*
* @internal
*/
final class BaselineSync
{
/**
* Conventional tag the CI recipe publishes under. Not configurable for
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
* var is the likely escape hatch.
*/
private const string RELEASE_TAG = 'pest-tia-baseline';
/**
* Asset filenames within the release — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
public function __construct(
private readonly State $state,
private readonly OutputInterface $output,
private readonly InputInterface $input,
) {}
/**
* Attempts the full detect → prompt → download flow. Returns true when
* the graph blob was pulled and written to state. Coverage is best-
* effort: its absence doesn't fail the sync, since plain `--tia` (no
* `--coverage`) works fine without it.
*/
public function fetchIfAvailable(string $projectRoot): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
return false;
}
if (! $this->confirm($repo)) {
return false;
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
$repo,
));
$graphJson = $this->download($repo, self::GRAPH_ASSET);
if ($graphJson === null) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
);
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
return false;
}
// Coverage cache is optional. The baseline is useful even without
// it (plain `--tia` never needs it) so don't fail the whole sync
// just because this asset is missing or slow.
$coverageBin = $this->download($repo, self::COVERAGE_ASSET);
if ($coverageBin !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin);
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
));
return true;
}
/**
* Publishes the *local* baseline to GitHub Releases under the
* conventional tag, creating the release on first run or uploading
* into the existing one otherwise.
*
* Uploading from a developer workstation is intentionally discouraged
* — CI is the authoritative publisher because its environment is
* reproducible, its working tree is clean, and its result cache
* isn't contaminated by local flakiness. The prompt here defaults to
* *No* to keep this an explicit, opt-in action.
*
* Returns a CLI-style exit code so the caller can `exit()` on it.
*/
public function publish(string $projectRoot): int
{
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
if ($graphBytes === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> no local baseline to publish.',
' Run <fg=cyan>./vendor/bin/pest --tia</> first to record one, then retry.',
'',
]);
return 1;
}
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
' Publishing is supported only for GitHub-hosted projects.',
'',
]);
return 1;
}
if (! $this->commandExists('gh')) {
$this->output->writeln([
'',
' <fg=red>TIA</> publishing requires the <fg=cyan>gh</> CLI.',
' Install: <fg=gray>https://cli.github.com</>',
'',
]);
return 1;
}
$this->output->writeln([
'',
' <fg=black;bg=yellow> WARNING </> Publishing local baselines is discouraged.',
'',
' Local runs can bake flaky results or dirty working-tree state into the',
' baseline, which your team then replays. CI-published baselines are safer.',
' See <fg=gray>https://pestphp.com/docs/tia/ci</> for the recommended workflow.',
'',
]);
if (! $this->confirmPublish($repo)) {
$this->output->writeln(' <fg=yellow>TIA</> publish cancelled.');
return 0;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
$this->output->writeln(' <fg=red>TIA</> failed to create temp dir for upload.');
return 1;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
if (@file_put_contents($graphPath, $graphBytes) === false) {
$this->cleanup($tmpDir);
return 1;
}
$filesToUpload = [$graphPath];
$coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE);
if ($coverageBytes !== null) {
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
if (@file_put_contents($coveragePath, $coverageBytes) !== false) {
$filesToUpload[] = $coveragePath;
}
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> publishing to <fg=white>%s</> (tag <fg=white>%s</>)…',
$repo,
self::RELEASE_TAG,
));
$exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload);
$this->cleanup($tmpDir);
if ($exitCode !== 0) {
$this->output->writeln(' <fg=red>TIA</> <fg=cyan>gh release</> failed.');
return $exitCode;
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline published (%s).',
$this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))),
));
return 0;
}
/**
* Uploads into the existing release if present, falls back to
* creating the release with the assets attached on first run.
*
* @param array<int, string> $files
*/
private function ghReleaseUploadOrCreate(string $repo, array $files): int
{
$uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber'];
$upload = new Process($uploadArgs);
$upload->setTimeout(300.0);
$upload->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
if ($upload->isSuccessful()) {
return 0;
}
// Release likely doesn't exist yet — create it, attaching the files.
$createArgs = [
'gh', 'release', 'create', self::RELEASE_TAG,
...$files,
'-R', $repo,
'--title', 'Pest TIA baseline',
'--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.',
];
$create = new Process($createArgs);
$create->setTimeout(300.0);
$create->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
return $create->isSuccessful() ? 0 : 1;
}
private function confirmPublish(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln(sprintf(
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
$repo,
self::RELEASE_TAG,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
// Unlike the fetch prompt, this one defaults to *No*. Empty input
// or anything other than an explicit "y"/"yes" cancels.
$line = strtolower(trim($line));
return $line === 'y' || $line === 'yes';
}
/**
* Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box.
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
* silently opts the repo out of auto-sync.
*/
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($gitConfig)) {
return null;
}
$content = @file_get_contents($gitConfig);
if ($content === false) {
return null;
}
// Find the `[remote "origin"]` section and the first `url` line
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
// SSH: git@github.com:org/repo(.git)
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
return null;
}
/**
* One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI,
* piped input) returns false so scripted runs never hang waiting for
* input.
*/
private function confirm(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln('');
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> no local cache — fetch baseline from <fg=white>%s</>? <fg=gray>[Y/n]</>',
$repo,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
$line = strtolower(trim($line));
return $line === '' || $line === 'y' || $line === 'yes';
}
/**
* Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true
* unless `--no-interaction` is explicitly passed, which would make
* scripted invocations (CI, pipes, subshells) hang at a prompt nobody
* sees. Combining both signals is the safe default.
*/
private function isTerminal(): bool
{
if (! $this->input->isInteractive()) {
return false;
}
if (! defined('STDIN')) {
return false;
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN) === true;
}
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN) === true;
}
return false;
}
/**
* Tries `gh` first (handles private repos + rate limiting via the
* user's GitHub auth), falls through to public HTTPS. Returns the
* raw asset bytes, or null on any failure.
*/
private function download(string $repo, string $asset): ?string
{
$viaGh = $this->downloadViaGh($repo, $asset);
if ($viaGh !== null) {
return $viaGh;
}
return $this->downloadViaHttps($repo, $asset);
}
private function downloadViaGh(string $repo, string $asset): ?string
{
if (! $this->commandExists('gh')) {
return null;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
return null;
}
$process = new Process([
'gh', 'release', 'download', self::RELEASE_TAG,
'-R', $repo,
'-p', $asset,
'-D', $tmpDir,
'--clobber',
]);
$process->setTimeout(120.0);
$process->run();
$payload = null;
if ($process->isSuccessful()) {
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
if (is_file($path)) {
$content = @file_get_contents($path);
$payload = $content === false ? null : $content;
}
}
$this->cleanup($tmpDir);
return $payload;
}
private function downloadViaHttps(string $repo, string $asset): ?string
{
$url = sprintf(
'https://github.com/%s/releases/download/%s/%s',
$repo,
self::RELEASE_TAG,
$asset,
);
$ctx = stream_context_create([
'http' => [
'timeout' => 120,
'follow_location' => 1,
'ignore_errors' => false,
],
]);
$content = @file_get_contents($url, false, $ctx);
return $content === false ? null : $content;
}
private function commandExists(string $cmd): bool
{
$probe = new Process(['command', '-v', $cmd]);
$probe->run();
if ($probe->isSuccessful()) {
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
}
private function cleanup(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
if ($entries !== false) {
foreach ($entries as $entry) {
if (is_file($entry)) {
@unlink($entry);
}
}
}
@rmdir($dir);
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return sprintf('%.1f MB', $bytes / 1024 / 1024);
}
if ($bytes >= 1024) {
return sprintf('%.1f KB', $bytes / 1024);
}
return $bytes.' B';
}
}

View File

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
/**
* Plugin-level container registrations for TIA. Runs as part of Kernel's
* bootstrapper chain so Tia's own service graph is set up without Kernel
* having to know about any of its internals.
*
* Most Tia services (`Recorder`, `CoverageCollector`, `WatchPatterns`,
* `ResultCollector`, `BaselineSync`) are auto-buildable — Pest's container
* resolves them lazily via constructor reflection. The only service that
* requires an explicit binding is the `State` contract, because the
* filesystem implementation needs a root-directory string that reflection
* can't infer.
*
* @internal
*/
final readonly class Bootstrapper implements BootstrapperContract
{
public function __construct(private Container $container) {}
public function boot(): void
{
$this->container->add(State::class, new FileState($this->tempDir()));
}
/**
* Resolve Pest's `.temp/` directory relative to this file so TIA's
* caches share the same location as the rest of Pest's transient
* state (PHPUnit result cache, coverage PHP dumps, etc.).
*/
private function tempDir(): string
{
return __DIR__
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'.temp';
}
}

View File

@ -1,339 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Process\Process;
/**
* Detects files that changed between the last recorded TIA run and the
* current working tree.
*
* Strategy:
* 1. If we have a `recordedAtSha`, `git diff <sha>..HEAD` captures committed
* changes on top of the recording point.
* 2. `git status --short` captures unstaged + staged + untracked changes on
* top of that.
*
* We return relative paths to the project root. Deletions are included so the
* caller can decide whether to invalidate: a deleted source file may still
* appear in the graph and should mark its dependents as affected.
*
* @internal
*/
final readonly class ChangedFiles
{
public function __construct(private string $projectRoot) {}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push) — in
* that case the graph should be rebuilt.
*/
/**
* Removes files whose current content hash matches the snapshot from the
* last `--tia` run. Used to ignore "dirty but unchanged" files — a file
* that git still reports as modified but whose content is bit-identical
* to the previous TIA invocation.
*
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string>
*/
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{
if ($lastRunTree === []) {
return $files;
}
// Union: `$files` (what git currently reports) + every path that was
// dirty last run. The second set matters for reverts — when a user
// undoes a local edit, the file matches HEAD again and git reports
// it clean, so it would never enter `$files`. But it has genuinely
// changed vs the snapshot we captured during the bad run, so it
// must be checked.
$candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) {
$candidates[$snapshotted] = true;
}
$remaining = [];
foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null;
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
$exists = is_file($absolute);
if ($snapshot === null) {
// File wasn't in last-run tree at all — trust git's signal.
$remaining[] = $file;
continue;
}
if (! $exists) {
// Missing now. If the snapshot recorded it as absent too
// (sentinel ''), state is identical to last run — unchanged.
// Otherwise it was present last run and got deleted since.
if ($snapshot !== '') {
$remaining[] = $file;
}
continue;
}
$hash = @hash_file('xxh128', $absolute);
if ($hash === false || $hash !== $snapshot) {
$remaining[] = $file;
}
}
return $remaining;
}
/**
* Computes content hashes for the given project-relative files. Used to
* snapshot the working tree after a successful run so the next run can
* detect which files are actually different.
*
* @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash
*/
public function snapshotTree(array $files): array
{
$out = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
// Record the deletion with an empty-string sentinel so the
// next run recognises "still deleted" as unchanged rather
// than re-flagging the file as a fresh change.
$out[$file] = '';
continue;
}
$hash = @hash_file('xxh128', $absolute);
if ($hash !== false) {
$out[$file] = $hash;
}
}
return $out;
}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push).
*/
public function since(?string $sha): ?array
{
if (! $this->gitAvailable()) {
return null;
}
$files = [];
if ($sha !== null && $sha !== '') {
if (! $this->shaIsReachable($sha)) {
return null;
}
$files = array_merge($files, $this->diffSinceSha($sha));
}
$files = array_merge($files, $this->workingTreeChanges());
// Normalise + dedupe, filtering out paths that can never belong to the
// graph: vendor (caught by the fingerprint instead), cache dirs, and
// anything starting with a dot we don't care about.
$unique = [];
foreach ($files as $file) {
if ($file === '') {
continue;
}
if ($this->shouldIgnore($file)) {
continue;
}
$unique[$file] = true;
}
return array_keys($unique);
}
private function shouldIgnore(string $path): bool
{
static $prefixes = [
'.pest/',
'.phpunit.cache/',
'.phpunit.result.cache',
'vendor/',
'node_modules/',
];
foreach ($prefixes as $prefix) {
if (str_starts_with($path, (string) $prefix)) {
return true;
}
}
return false;
}
public function currentBranch(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$branch = trim($process->getOutput());
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
public function gitAvailable(): bool
{
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
$process->run();
return $process->isSuccessful();
}
private function shaIsReachable(string $sha): bool
{
$process = new Process(
['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'],
$this->projectRoot,
);
$process->run();
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
// "unreachable" and force a rebuild.
return $process->getExitCode() === 0;
}
/**
* @return array<int, string>
*/
private function diffSinceSha(string $sha): array
{
$process = new Process(
['git', 'diff', '--name-only', $sha.'..HEAD'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
return [];
}
return $this->splitLines($process->getOutput());
}
/**
* @return array<int, string>
*/
private function workingTreeChanges(): array
{
// `-z` produces NUL-terminated records with no path quoting, so paths
// that contain spaces, tabs, unicode or other special characters
// are passed through verbatim. Without `-z`, git wraps such paths in
// quotes with backslash escapes, which would corrupt our lookup keys.
//
// Record format: `XY <SP> <path> <NUL>` for most entries, and
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
// fields).
$process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot,
);
$process->run();
if (! $process->isSuccessful()) {
return [];
}
$output = $process->getOutput();
if ($output === '') {
return [];
}
$records = explode("\x00", rtrim($output, "\x00"));
$files = [];
$count = count($records);
for ($i = 0; $i < $count; $i++) {
$record = $records[$i];
if (strlen($record) < 4) {
continue;
}
$status = substr($record, 0, 2);
$path = substr($record, 3);
// Renames/copies emit two records: the new path first, then the
// original. Consume both.
if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path;
if (isset($records[$i + 1]) && $records[$i + 1] !== '') {
$files[] = $records[$i + 1];
$i++;
}
continue;
}
$files[] = $path;
}
return $files;
}
public function currentSha(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$sha = trim($process->getOutput());
return $sha === '' ? null : $sha;
}
/**
* @return array<int, string>
*/
private function splitLines(string $output): array
{
$lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY);
return $lines === false ? [] : $lines;
}
}

View File

@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/**
* User-facing TIA configuration, returned by `pest()->tia()`.
*
* Usage in `tests/Pest.php`:
*
* pest()->tia()->watch([
* 'resources/js/**\/*.tsx' => 'tests/Browser',
* 'public/build/**\/*' => 'tests/Browser',
* ]);
*
* Patterns are merged with the built-in defaults (config, routes, views,
* frontend assets, migrations). Duplicate glob keys overwrite the default
* mapping so users can redirect a pattern to a narrower directory.
*
* @internal
*/
final class Configuration
{
/**
* Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults.
*
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this
*/
public function watch(array $patterns): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->add($patterns);
return $this;
}
}

View File

@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts;
/**
* Storage contract for TIA's persistent state (graph, baselines, affected
* set, worker partials, coverage snapshots). Modelled as a flat key/value
* store of raw byte blobs so implementations can sit on top of whatever
* backend fits — a directory, a shared cache, a remote object store — and
* TIA's logic stays identical.
*
* @internal
*/
interface State
{
/**
* Returns the stored blob for `$key`, or `null` when the key is unset
* or cannot be read.
*/
public function read(string $key): ?string;
/**
* Atomically stores `$content` under `$key`. Existing value (if any) is
* replaced. Implementations SHOULD guarantee that concurrent readers
* never observe partial writes.
*/
public function write(string $key, string $content): bool;
/**
* Removes `$key`. Returns true whether or not the key existed beforehand
* — callers should treat a `true` result as "the key is now absent",
* not "the key was present and has been removed."
*/
public function delete(string $key): bool;
public function exists(string $key): bool;
/**
* Returns every key whose name starts with `$prefix`. Used to collect
* paratest worker partials (`tia-worker-<token>.json`, etc.) without
* exposing backend-specific glob semantics.
*
* @return list<string>
*/
public function keysWithPrefix(string $prefix): array;
}

View File

@ -1,152 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Runner\CodeCoverage as PhpUnitCodeCoverage;
use ReflectionClass;
use Throwable;
/**
* Extracts per-test file coverage from PHPUnit's shared `CodeCoverage`
* instance. Used when TIA piggybacks on `--coverage` instead of starting
* its own driver session — both share the same PCOV / Xdebug state, so
* running two recorders in parallel would corrupt each other's data.
*
* PHPUnit tags every coverage sample with the current test's id
* (`$test->valueObjectForEvents()->id()`, e.g. `Foo\BarTest::baz`). The
* per-file / per-line coverage map therefore already carries everything
* we need to rebuild TIA edges at the end of the run.
*
* @internal
*/
final class CoverageCollector
{
/**
* Cached `className → test file` lookups. Class reflection is cheap
* individually but the record run can visit tens of thousands of
* samples, so the cache matters.
*
* @var array<string, string|null>
*/
private array $classFileCache = [];
/**
* Rebuilds the same `absolute test file → list<absolute source file>`
* shape that `Recorder::perTestFiles()` exposes, so callers can treat
* the two collectors interchangeably when feeding the graph.
*
* @return array<string, array<int, string>>
*/
public function perTestFiles(): array
{
if (! PhpUnitCodeCoverage::instance()->isActive()) {
return [];
}
try {
$lineCoverage = PhpUnitCodeCoverage::instance()
->codeCoverage()
->getData()
->lineCoverage();
} catch (Throwable) {
return [];
}
/** @var array<string, array<string, true>> $edges */
$edges = [];
foreach ($lineCoverage as $sourceFile => $lines) {
// Collect the set of tests that hit any line in this file once,
// then emit one edge per (testFile, sourceFile) pair. Walking
// the lines per test would re-resolve the test file repeatedly.
$testIds = [];
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$testIds[$id] = true;
}
}
foreach (array_keys($testIds) as $testId) {
$testFile = $this->testIdToFile($testId);
if ($testFile === null) {
continue;
}
$edges[$testFile][$sourceFile] = true;
}
}
$out = [];
foreach ($edges as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
public function reset(): void
{
$this->classFileCache = [];
}
private function testIdToFile(string $testId): ?string
{
// PHPUnit's test id is `ClassName::methodName` with an optional
// `#dataSetName` suffix for data-provider runs. Strip the dataset
// part — we only need the class.
$hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
if (! str_contains($identifier, '::')) {
return null;
}
[$className] = explode('::', $identifier, 2);
if (array_key_exists($className, $this->classFileCache)) {
return $this->classFileCache[$className];
}
$file = $this->resolveClassFile($className);
$this->classFileCache[$className] = $file;
return $file;
}
private function resolveClassFile(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
$reflection = new ReflectionClass($className);
// Pest's eval'd test classes expose the original `.php` path on a
// static `$__filename`. The eval'd class itself has no file of its
// own, so prefer this property when present.
if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
}
}

View File

@ -1,186 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable;
/**
* Merges the current run's PHPUnit coverage into a cached full-suite
* snapshot so `--tia --coverage` can produce a complete report after
* executing only the affected tests.
*
* Invoked from `Pest\Support\Coverage::report()` right before the coverage
* file is consumed. A marker dropped by the `Tia` plugin gates the
* behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
* and therefore keep their existing semantics.
*
* Algorithm
* ---------
* The PHPUnit coverage PHP file unserialises to a `CodeCoverage` object.
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the
* list of test IDs that covered that line. We:
*
* 1. Load the cached snapshot from `State` (serialised bytes).
* 2. Strip every test id that re-ran this time from the cached map —
* the tests that ran now are the ones whose attribution is fresh.
* 3. Merge the current run into the stripped cached snapshot via
* `CodeCoverage::merge()`.
* 4. Write the merged result back to the report path (so Pest's report
* generator sees the full suite) and back into `State` (for the
* next invocation).
*
* If no cache exists yet (first `--tia --coverage` run on this machine)
* we serialise the current object and save it — nothing to merge yet.
*
* @internal
*/
final class CoverageMerger
{
public static function applyIfMarked(string $reportPath): void
{
$state = self::state();
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return;
}
$state->delete(Tia::KEY_COVERAGE_MARKER);
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) {
// First `--tia --coverage` run: nothing cached yet, so the
// current file already represents the full suite. Capture it
// verbatim (as serialised bytes) for next time.
$current = self::requireCoverage($reportPath);
if ($current !== null) {
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
}
return;
}
$cached = self::unserializeCoverage($cachedBytes);
$current = self::requireCoverage($reportPath);
if ($cached === null || $current === null) {
return;
}
self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current);
$serialised = serialize($cached);
// Write back to the PHPUnit-style `.cov` path so the report reader
// can `require` it, and to the state cache for the next run.
@file_put_contents(
$reportPath,
"<?php return unserialize(".var_export($serialised, true).");\n",
);
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
}
/**
* Removes from `$cached`'s per-line test attribution any test id that
* appears in `$current`. Those tests just ran, so the fresh slice is
* authoritative — keeping stale attribution in the cache would claim
* a test still covers a line it no longer touches.
*/
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{
$currentIds = self::collectTestIds($current);
if ($currentIds === []) {
return;
}
$cachedData = $cached->getData();
$lineCoverage = $cachedData->lineCoverage();
foreach ($lineCoverage as $file => $lines) {
foreach ($lines as $line => $ids) {
if ($ids === null || $ids === []) {
continue;
}
$filtered = array_values(array_diff($ids, $currentIds));
if ($filtered !== $ids) {
$lineCoverage[$file][$line] = $filtered;
}
}
}
$cachedData->setLineCoverage($lineCoverage);
}
/**
* @return array<int, string>
*/
private static function collectTestIds(CodeCoverage $coverage): array
{
$ids = [];
foreach ($coverage->getData()->lineCoverage() as $lines) {
foreach ($lines as $hits) {
if ($hits === null) {
continue;
}
foreach ($hits as $id) {
$ids[$id] = true;
}
}
}
return array_keys($ids);
}
private static function state(): ?State
{
try {
$state = Container::getInstance()->get(State::class);
} catch (Throwable) {
return null;
}
return $state instanceof State ? $state : null;
}
private static function requireCoverage(string $reportPath): ?CodeCoverage
{
if (! is_file($reportPath)) {
return null;
}
try {
/** @var mixed $value */
$value = require $reportPath;
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
private static function unserializeCoverage(string $bytes): ?CodeCoverage
{
try {
/** @var mixed $value */
$value = @unserialize($bytes);
} catch (Throwable) {
return null;
}
return $value instanceof CodeCoverage ? $value : null;
}
}

View File

@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
/**
* Filesystem-backed implementation of the TIA `State` contract. Each key
* maps verbatim to a file name under `$rootDir`, so existing `.temp/*.json`
* layouts are preserved exactly.
*
* The root directory is created lazily on first write — callers don't have
* to pre-provision it, and reads against a missing directory simply return
* `null` rather than throwing.
*
* @internal
*/
final class FileState implements State
{
/**
* Configured root. May not exist on disk yet; resolved + created on
* the first write. Keeping the raw string lets the instance be built
* before Pest's temp dir has been materialised.
*/
private readonly string $rootDir;
public function __construct(string $rootDir)
{
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
}
public function read(string $key): ?string
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return null;
}
$bytes = @file_get_contents($path);
return $bytes === false ? null : $bytes;
}
public function write(string $key, string $content): bool
{
if (! $this->ensureRoot()) {
return false;
}
$path = $this->pathFor($key);
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $content) === false) {
return false;
}
// Atomic rename — on POSIX filesystems this is a single-step
// replacement, so concurrent readers never see a half-written file.
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
}
public function delete(string $key): bool
{
$path = $this->pathFor($key);
if (! is_file($path)) {
return true;
}
return @unlink($path);
}
public function exists(string $key): bool
{
return is_file($this->pathFor($key));
}
public function keysWithPrefix(string $prefix): array
{
$root = $this->resolvedRoot();
if ($root === null) {
return [];
}
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
$matches = glob($pattern);
if ($matches === false) {
return [];
}
$keys = [];
foreach ($matches as $path) {
$keys[] = basename($path);
}
return $keys;
}
/**
* Absolute path for `$key`. Not part of the interface — used by the
* coverage merger and similar callers that need direct filesystem
* access (e.g. `require` on a cached PHP file). Consumers that only
* deal in bytes should go through `read()` / `write()`.
*/
public function pathFor(string $key): string
{
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
}
/**
* Returns the resolved root if it exists already, otherwise `null`.
* Used by read-side helpers so they don't eagerly create the directory
* just to find nothing inside.
*/
private function resolvedRoot(): ?string
{
$resolved = @realpath($this->rootDir);
return $resolved === false ? null : $resolved;
}
/**
* Creates the root dir on demand. Returns false only when creation
* fails and the directory still isn't there afterwards.
*/
private function ensureRoot(): bool
{
if (is_dir($this->rootDir)) {
return true;
}
if (@mkdir($this->rootDir, 0755, true)) {
return true;
}
return is_dir($this->rootDir);
}
}

View File

@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Captures environmental inputs that, when changed, make the TIA graph stale.
*
* Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change
* what a test actually exercises, so the graph must be rebuilt in those cases.
*
* @internal
*/
final readonly class Fingerprint
{
// Bump this whenever the set of inputs or the hash algorithm changes, so
// older graphs are invalidated automatically.
private const int SCHEMA_VERSION = 2;
/**
* @return array<string, int|string|null>
*/
public static function compute(string $projectRoot): array
{
return [
'schema' => self::SCHEMA_VERSION,
'php' => PHP_VERSION,
'pest' => self::readPestVersion($projectRoot),
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
// Pest's generated classes bake the code-generation logic in — if
// TestCaseFactory changes (new attribute, different method
// signature, etc.) every previously-recorded edge is stale.
// Hashing the factory sources makes path-repo / dev-main installs
// automatically rebuild their graphs when Pest itself is edited.
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
];
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
*/
public static function matches(array $a, array $b): bool
{
ksort($a);
ksort($b);
return $a === $b;
}
private static function hashIfExists(string $path): ?string
{
if (! is_file($path)) {
return null;
}
$hash = @hash_file('xxh128', $path);
return $hash === false ? null : $hash;
}
private static function readPestVersion(string $projectRoot): string
{
$installed = $projectRoot.'/vendor/composer/installed.json';
if (! is_file($installed)) {
return 'unknown';
}
$raw = @file_get_contents($installed);
if ($raw === false) {
return 'unknown';
}
$data = json_decode($raw, true);
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
return 'unknown';
}
foreach ($data['packages'] as $package) {
if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') {
return (string) ($package['version'] ?? 'unknown');
}
}
return 'unknown';
}
}

View File

@ -1,502 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* File-level Test Impact Analysis graph.
*
* Persists the mapping `test_file → set<source_file>` so that subsequent runs
* can skip tests whose dependencies have not changed. Paths are stored relative
* to the project root and source files are deduplicated via an index so that
* the on-disk JSON stays compact for large suites.
*
* @internal
*/
final class Graph
{
/**
* Relative path of each known source file, indexed by numeric id.
*
* @var array<int, string>
*/
private array $files = [];
/**
* Reverse lookup: source file → numeric id.
*
* @var array<string, int>
*/
private array $fileIds = [];
/**
* Edges: test file (relative) → list of source file ids.
*
* @var array<string, array<int, int>>
*/
private array $edges = [];
/**
* Environment fingerprint captured at record time.
*
* @var array<string, mixed>
*/
private array $fingerprint = [];
/**
* Per-branch baselines. Each branch independently tracks:
* - `sha` — last HEAD at which `--tia` ran on this branch
* - `tree` — content hashes of modified files at that point
* - `results` — per-test status + message + time
*
* Graph edges (test → source) stay shared across branches because
* structure doesn't change per branch. Only run-state is per-branch so
* a failing test on one branch doesn't poison another branch's replay.
*
* @var array<string, array{
* sha: ?string,
* tree: array<string, string>,
* results: array<string, array{status: int, message: string, time: float, assertions?: int}>
* }>
*/
private array $baselines = [];
/**
* Canonicalised project root. Resolved through `realpath()` so paths
* captured by coverage drivers (always real filesystem targets) match
* regardless of whether the user's CWD is a symlink or has trailing
* separators.
*/
private readonly string $projectRoot;
public function __construct(string $projectRoot)
{
$real = @realpath($projectRoot);
$this->projectRoot = $real !== false ? $real : $projectRoot;
}
/**
* Records that a test file depends on the given source file.
*/
public function link(string $testFile, string $sourceFile): void
{
$testRel = $this->relative($testFile);
$sourceRel = $this->relative($sourceFile);
if ($sourceRel === null || $testRel === null) {
return;
}
if (! isset($this->fileIds[$sourceRel])) {
$id = count($this->files);
$this->files[$id] = $sourceRel;
$this->fileIds[$sourceRel] = $id;
}
$this->edges[$testRel][] = $this->fileIds[$sourceRel];
}
/**
* Returns the set of test files whose dependencies intersect $changedFiles.
*
* Two resolution paths:
* 1. **Coverage edges** — test depends on a PHP source file that changed.
* 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a
* glob that maps to a test directory; every test under that directory
* is affected.
*
* @param array<int, string> $changedFiles Absolute or relative paths.
* @return array<int, string> Relative test file paths.
*/
public function affected(array $changedFiles): array
{
// Normalise all changed paths once.
$normalised = [];
foreach ($changedFiles as $file) {
$rel = $this->relative($file);
if ($rel !== null) {
$normalised[] = $rel;
}
}
// 1. Coverage-edge lookup (PHP → PHP).
$changedIds = [];
$unknownSourceDirs = [];
foreach ($normalised as $rel) {
if (isset($this->fileIds[$rel])) {
$changedIds[$this->fileIds[$rel]] = true;
} elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
// Source PHP file unknown to the graph — might be a new file
// that only exists on this branch (graph inherited from main).
// Track its directory for the sibling heuristic (step 3).
$unknownSourceDirs[dirname($rel)] = true;
}
}
$affectedSet = [];
foreach ($this->edges as $testFile => $ids) {
foreach ($ids as $id) {
if (isset($changedIds[$id])) {
$affectedSet[$testFile] = true;
break;
}
}
}
// 2. Watch-pattern lookup (non-PHP assets → test directories).
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised);
$allTestFiles = array_keys($this->edges);
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
$affectedSet[$testFile] = true;
}
// 3. Sibling heuristic for unknown source files.
//
// When a PHP source file is unknown to the graph (no test depends on
// it), it is either genuinely untested OR it was added on a branch
// whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file.
//
// To avoid silent misses: find tests that already cover ANY file in
// the same directory. If `app/Models/OrderItem.php` is unknown but
// `app/Models/Order.php` is covered by `OrderTest`, run `OrderTest`
// — it likely exercises sibling files in the same module.
//
// This over-runs slightly (sibling may be unrelated) but never
// under-runs. And once the test executes, its coverage captures the
// new file → graph self-heals for next run.
if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) {
continue;
}
foreach ($ids as $id) {
if (! isset($this->files[$id])) {
continue;
}
$depDir = dirname($this->files[$id]);
if (isset($unknownSourceDirs[$depDir])) {
$affectedSet[$testFile] = true;
break;
}
}
}
}
return array_keys($affectedSet);
}
/**
* Returns `true` if the given test file has any recorded dependencies.
*/
public function knowsTest(string $testFile): bool
{
$rel = $this->relative($testFile);
return $rel !== null && isset($this->edges[$rel]);
}
/**
* @return array<int, string> All project-relative test files the graph knows.
*/
public function allTestFiles(): array
{
return array_keys($this->edges);
}
/**
* @param array<string, int|string|null> $fingerprint
*/
public function setFingerprint(array $fingerprint): void
{
$this->fingerprint = $fingerprint;
}
/**
* @return array<string, int|string|null>
*/
public function fingerprint(): array
{
return $this->fingerprint;
}
/**
* Returns the SHA the given branch last ran against, or falls back to
* `$fallbackBranch` (typically `main`) when this branch has no baseline
* yet. That way a freshly-created feature branch inherits main's
* baseline on its first run.
*/
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
return $baseline['sha'];
}
public function setRecordedAtSha(string $branch, ?string $sha): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['sha'] = $sha;
}
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [
'status' => $status,
'message' => $message,
'time' => $time,
'assertions' => $assertions,
];
}
/**
* Returns the cached assertion count for a test, or `null` if unknown.
* Callers use this to feed `addToAssertionCount()` at replay time so
* the "Tests: N passed (M assertions)" banner matches the recorded run
* instead of defaulting to 1 assertion per test.
*/
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
if (! isset($baseline['results'][$testId]['assertions'])) {
return null;
}
$value = $baseline['results'][$testId]['assertions'];
return is_int($value) ? $value : null;
}
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
if (! isset($baseline['results'][$testId])) {
return null;
}
$r = $baseline['results'][$testId];
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
// each variant via its specific factory. Keeps the stored message
// intact (important for skips/failures shown to the user).
return match ($r['status']) {
0 => TestStatus::success(),
1 => TestStatus::skipped($r['message']),
2 => TestStatus::incomplete($r['message']),
3 => TestStatus::notice($r['message']),
4 => TestStatus::deprecation($r['message']),
5 => TestStatus::risky($r['message']),
6 => TestStatus::warning($r['message']),
7 => TestStatus::failure($r['message']),
8 => TestStatus::error($r['message']),
default => TestStatus::unknown(),
};
}
/**
* @param array<string, string> $tree project-relative path → content hash
*/
public function setLastRunTree(string $branch, array $tree): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['tree'] = $tree;
}
/**
* @return array<string, string>
*/
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
{
return $this->baselineFor($branch, $fallbackBranch)['tree'];
}
/**
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int}>}
*/
private function baselineFor(string $branch, string $fallbackBranch): array
{
if (isset($this->baselines[$branch])) {
return $this->baselines[$branch];
}
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) {
return $this->baselines[$fallbackBranch];
}
return ['sha' => null, 'tree' => [], 'results' => []];
}
private function ensureBaseline(string $branch): void
{
if (! isset($this->baselines[$branch])) {
$this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []];
}
}
/**
* Replaces edges for the given test files. Used during a partial record
* run so that existing edges for other tests are preserved.
*
* @param array<string, array<int, string>> $testToFiles
*/
public function replaceEdges(array $testToFiles): void
{
foreach ($testToFiles as $testFile => $sources) {
$testRel = $this->relative($testFile);
if ($testRel === null) {
continue;
}
$this->edges[$testRel] = [];
foreach ($sources as $source) {
$this->link($testFile, $source);
}
// Deduplicate ids for this test.
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
}
}
/**
* Drops edges whose test file no longer exists on disk. Prevents the graph
* from keeping stale entries for deleted / renamed tests that would later
* be flagged as affected and confuse PHPUnit's discovery.
*/
public function pruneMissingTests(): void
{
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
foreach (array_keys($this->edges) as $testRel) {
if (! is_file($root.$testRel)) {
unset($this->edges[$testRel]);
}
}
}
/**
* Rebuilds a graph from its JSON representation. Returns `null` when
* the payload is missing, unreadable, or schema-incompatible. Separated
* from transport (state backend, file, etc.) so tests can feed bytes
* directly without touching disk.
*/
public static function decode(string $json, string $projectRoot): ?self
{
$data = json_decode($json, true);
if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
return null;
}
$graph = new self($projectRoot);
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
$graph->fileIds = array_flip($graph->files);
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
return $graph;
}
/**
* Serialises the graph to its JSON on-disk form. Returns `null` if the
* payload can't be encoded (extremely rare — pathological UTF-8 only).
* Persistence is the caller's responsibility: write the returned bytes
* through whatever `State` implementation is in play.
*/
public function encode(): ?string
{
$payload = [
'schema' => 1,
'fingerprint' => $this->fingerprint,
'files' => $this->files,
'edges' => $this->edges,
'baselines' => $this->baselines,
];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
return $json === false ? null : $json;
}
/**
* Normalises a path to be relative to the project root; returns `null` for
* paths we should ignore (outside the project, unknown, virtual, vendor).
*
* Accepts both absolute paths (from Xdebug/PCOV coverage) and
* project-relative paths (from `git diff`) — we normalise without relying
* on `realpath()` of relative paths because the current working directory
* is not guaranteed to be the project root.
*/
private function relative(string $path): ?string
{
if ($path === '' || $path === 'unknown') {
return null;
}
if (str_contains($path, "eval()'d")) {
return null;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive
if ($isAbsolute) {
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
if (! str_starts_with($real, $root)) {
return null;
}
// Always normalise to forward slashes. Windows' native separator
// would otherwise produce keys that never match paths reported
// by `git` (which always uses forward slashes).
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else {
// Normalise directory separators and strip any "./" prefix.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
while (str_starts_with($relative, './')) {
$relative = substr($relative, 2);
}
}
// Vendor packages are pinned by composer.lock. Any upgrade bumps the
// fingerprint and invalidates the graph wholesale, so there is no
// reason to track individual vendor files — doing so inflates the
// graph by orders of magnitude on Laravel-style projects.
if (str_starts_with($relative, 'vendor/')) {
return null;
}
return $relative;
}
}

View File

@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use ReflectionClass;
/**
* Captures per-test file coverage using the PCOV driver.
*
* Acts as a singleton because PCOV has a single global collection state and
* the recorder is wired into PHPUnit through two distinct subscribers
* (`Prepared` / `Finished`) that must share context.
*
* @internal
*/
final class Recorder
{
/**
* Test file currently being recorded, or `null` when idle.
*/
private ?string $currentTestFile = null;
/**
* Aggregated map: absolute test file → set<absolute source file>.
*
* @var array<string, array<string, true>>
*/
private array $perTestFiles = [];
/**
* Cached class → test file resolution.
*
* @var array<string, string|null>
*/
private array $classFileCache = [];
private bool $active = false;
private bool $driverChecked = false;
private bool $driverAvailable = false;
private string $driver = 'none';
public function activate(): void
{
$this->active = true;
}
public function isActive(): bool
{
return $this->active;
}
public function driverAvailable(): bool
{
if (! $this->driverChecked) {
if (function_exists('pcov\\start')) {
$this->driver = 'pcov';
$this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage')) {
// Xdebug is loaded. Probe whether coverage mode is active by
// attempting a start — it emits E_WARNING when the mode is off.
// We capture the warning via a temporary error handler.
$probeOk = true;
set_error_handler(static function () use (&$probeOk): bool {
$probeOk = false;
return true;
});
\xdebug_start_code_coverage();
restore_error_handler();
if ($probeOk) {
\xdebug_stop_code_coverage(false);
$this->driver = 'xdebug';
$this->driverAvailable = true;
}
}
$this->driverChecked = true;
}
return $this->driverAvailable;
}
public function driver(): string
{
$this->driverAvailable();
return $this->driver;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void
{
if (! $this->active || ! $this->driverAvailable()) {
return;
}
$file = $this->resolveTestFile($className, $fallbackFile);
if ($file === null) {
return;
}
$this->currentTestFile = $file;
if ($this->driver === 'pcov') {
\pcov\clear();
\pcov\start();
return;
}
// Xdebug
\xdebug_start_code_coverage();
}
public function endTest(): void
{
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
return;
}
if ($this->driver === 'pcov') {
\pcov\stop();
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
// `true` resets Xdebug's internal buffer so the next `start()`
// does not accumulate earlier tests' coverage into the current
// one — otherwise the graph becomes progressively polluted.
\xdebug_stop_code_coverage(true);
}
foreach (array_keys($data) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
$this->currentTestFile = null;
}
/**
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
*/
public function perTestFiles(): array
{
$out = [];
foreach ($this->perTestFiles as $testFile => $sources) {
$out[$testFile] = array_keys($sources);
}
return $out;
}
private function resolveTestFile(string $className, string $fallbackFile): ?string
{
if (array_key_exists($className, $this->classFileCache)) {
$file = $this->classFileCache[$className];
} else {
$file = $this->readPestFilename($className);
$this->classFileCache[$className] = $file;
}
if ($file !== null) {
return $file;
}
if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) {
return $fallbackFile;
}
return null;
}
/**
* Resolves the file that *defines* the test class.
*
* Order of preference:
* 1. Pest's generated `$__filename` static — the original `*.php` file
* containing the `test()` calls (the eval'd class itself has no file).
* 2. `ReflectionClass::getFileName()` — the concrete class's file. This
* is intentionally more specific than `ReflectionMethod::getFileName()`
* (which would return the *trait* file for methods brought in via
* `uses SharedTestBehavior`).
*/
private function readPestFilename(string $className): ?string
{
if (! class_exists($className, false)) {
return null;
}
$reflection = new ReflectionClass($className);
if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
}
/**
* Clears all captured state. Useful for long-running hosts (daemons,
* PHP-FPM, watchers) that invoke Pest multiple times in a single process
* — without this, coverage from run N would bleed into run N+1.
*/
public function reset(): void
{
$this->currentTestFile = null;
$this->perTestFiles = [];
$this->classFileCache = [];
$this->active = false;
}
}

View File

@ -1,155 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Collects per-test status + message during the run so the graph can persist
* them for faithful replay. PHPUnit's own result cache discards messages
* during serialisation — this collector retains them.
*
* @internal
*/
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?float $startTime = null;
public function testPrepared(string $testId): void
{
$this->currentTestId = $testId;
$this->startTime = microtime(true);
}
public function testPassed(): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(0, '');
}
public function testFailed(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(7, $message);
}
public function testErrored(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(8, $message);
}
public function testSkipped(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(1, $message);
}
public function testIncomplete(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(2, $message);
}
public function testRisky(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(5, $message);
}
/**
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
*/
public function all(): array
{
return $this->results;
}
public function recordAssertions(string $testId, int $assertions): void
{
if (isset($this->results[$testId])) {
$this->results[$testId]['assertions'] = $assertions;
}
}
/**
* Injects externally-collected results (e.g. partials flushed by parallel
* workers) into this collector so the parent can persist them in the same
* snapshot pass as non-parallel runs.
*
* @param array<string, array{status: int, message: string, time: float, assertions: int}> $results
*/
public function merge(array $results): void
{
foreach ($results as $testId => $result) {
$this->results[$testId] = $result;
}
}
public function reset(): void
{
$this->results = [];
$this->currentTestId = null;
$this->startTime = null;
}
/**
* Called by the Finished subscriber after a test's outcome + assertion
* events have all fired. Clears the "currently recording" pointer so
* the next test's events don't get mis-attributed.
*/
public function finishTest(): void
{
$this->currentTestId = null;
$this->startTime = null;
}
private function record(int $status, string $message): void
{
if ($this->currentTestId === null) {
return;
}
$time = $this->startTime !== null
? round(microtime(true) - $this->startTime, 3)
: 0.0;
// PHPUnit can fire more than one outcome event per test — the
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
// Last-wins semantics preserve the most specific status; the
// existing assertion count (if any) survives the overwrite.
$existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [
'status' => $status,
'message' => $message,
'time' => $time,
'assertions' => $existing['assertions'] ?? 0,
];
}
}

View File

@ -1,119 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\TestSuite;
/**
* Watch patterns for frontend assets that affect browser tests.
*
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to
* auto-discover directories containing browser tests. Falls back to the
* `tests/Browser` convention when the plugin is absent.
*
* @internal
*/
final readonly class Browser implements WatchDefault
{
public function applicable(): bool
{
// Browser tests can exist in any PHP project. We only activate when
// there is an actual `tests/Browser` directory OR pest-plugin-browser
// is installed.
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath);
$globs = [
'resources/js/**/*.js',
'resources/js/**/*.ts',
'resources/js/**/*.tsx',
'resources/js/**/*.jsx',
'resources/js/**/*.vue',
'resources/js/**/*.svelte',
'resources/css/**/*.css',
'resources/css/**/*.scss',
'resources/css/**/*.less',
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js',
'public/build/**/*.css',
];
$patterns = [];
foreach ($globs as $glob) {
$patterns[$glob] = $browserDirs;
}
return $patterns;
}
/**
* @return array<int, string>
*/
private function detectBrowserTestDirs(string $projectRoot, string $testPath): array
{
$dirs = [];
$candidate = $testPath.'/Browser';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$dirs[] = $candidate;
}
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find tests using `visit()` outside the conventional
// Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename);
if (! $factory instanceof TestCaseFactory) {
continue;
}
foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = $this->fileRelative($projectRoot, $filename);
if ($rel !== null) {
$dirs[] = dirname($rel);
}
break;
}
}
}
}
return array_values(array_unique($dirs === [] ? [$testPath] : $dirs));
}
private function fileRelative(string $projectRoot, string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for Inertia.js projects (Laravel or otherwise).
*
* Inertia bridges PHP controllers with JS/TS page components. A change to
* a React / Vue / Svelte page can break assertions in browser tests or
* Inertia-specific feature tests.
*
* @internal
*/
final readonly class Inertia implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& (InstalledVersions::isInstalled('inertiajs/inertia-laravel')
|| InstalledVersions::isInstalled('rompetomp/inertia-bundle'));
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser')
? $testPath.'/Browser'
: $testPath;
return [
// Inertia page components (React / Vue / Svelte).
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir],
// Shared layouts / components consumed by pages.
'resources/js/Layouts/**/*.vue' => [$browserDir],
'resources/js/Layouts/**/*.tsx' => [$browserDir],
'resources/js/Components/**/*.vue' => [$browserDir],
'resources/js/Components/**/*.tsx' => [$browserDir],
// SSR entry point.
'resources/js/ssr.js' => [$browserDir],
'resources/js/ssr.ts' => [$browserDir],
'resources/js/app.js' => [$browserDir],
'resources/js/app.ts' => [$browserDir],
];
}
}

View File

@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for Laravel projects.
*
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
* `Prepared` event where TIA's coverage window opens). That means PHP files
* loaded during boot — config, routes, service providers, migrations — are
* invisible to the coverage driver. Watch patterns are the only way to
* track them.
*
* @internal
*/
final readonly class Laravel implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
public function defaults(string $projectRoot, string $testPath): array
{
$featurePath = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Feature')
? $testPath.'/Feature'
: $testPath;
return [
// Config — loaded during app boot (setUp), invisible to coverage.
// Affects both Feature and Unit: Pest.php commonly binds fakes
// and seeds DB based on config values.
'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath],
// Routes — loaded during boot. HTTP/Feature tests depend on them.
'routes/*.php' => [$featurePath],
'routes/**/*.php' => [$featurePath],
// Service providers / bootstrap — loaded during boot, affect
// bindings, middleware, event listeners, scheduled tasks.
'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath],
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
// setUp. Schema changes can break any test that touches DB.
'database/migrations/**/*.php' => [$testPath],
// Seeders — often run globally via Pest.php beforeEach.
'database/seeders/**/*.php' => [$testPath],
// Factories — loaded lazily but still PHP that coverage may miss
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath],
// Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$featurePath],
// Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot).
'lang/**/*.php' => [$featurePath],
'lang/**/*.json' => [$featurePath],
'resources/lang/**/*.php' => [$featurePath],
'resources/lang/**/*.json' => [$featurePath],
// Build tool config — affects compiled assets consumed by
// browser and Inertia tests.
'vite.config.js' => [$featurePath],
'vite.config.ts' => [$featurePath],
'webpack.mix.js' => [$featurePath],
'tailwind.config.js' => [$featurePath],
'tailwind.config.ts' => [$featurePath],
'postcss.config.js' => [$featurePath],
];
}
}

View File

@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for projects using Livewire.
*
* Livewire components pair a PHP class with a Blade view. A view change can
* break rendering or assertions in feature / browser tests even though the
* PHP side is untouched.
*
* @internal
*/
final readonly class Livewire implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('livewire/livewire');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
// Livewire views live alongside Blade views or in a dedicated dir.
'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath],
// Livewire JS interop / Alpine plugins.
'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath],
];
}
}

View File

@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
/**
* Baseline watch patterns for any PHP project.
*
* @internal
*/
final readonly class Php implements WatchDefault
{
public function applicable(): bool
{
return true;
}
public function defaults(string $projectRoot, string $testPath): array
{
// NOTE: composer.json / composer.lock changes are caught by the
// fingerprint (which hashes composer.lock). PHP files are tracked by
// the coverage driver. Only non-PHP, non-fingerprinted files that
// can silently alter test behaviour belong here.
return [
// Environment files — can change DB drivers, feature flags,
// queue connections, etc. Not PHP, not fingerprinted.
'.env' => [$testPath],
'.env.testing' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
// phpunit.xml.dist and other XML overrides are not individually
// tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath],
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
// assertions. A fixture change can flip a test result.
$testPath.'/Fixtures/**/*.json' => [$testPath],
$testPath.'/Fixtures/**/*.csv' => [$testPath],
$testPath.'/Fixtures/**/*.xml' => [$testPath],
$testPath.'/Fixtures/**/*.txt' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
];
}
}

View File

@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for Symfony projects.
*
* @internal
*/
final readonly class Symfony implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('symfony/framework-bundle');
}
public function defaults(string $projectRoot, string $testPath): array
{
// Symfony boots the kernel in setUp() (before the coverage window).
// PHP config, routes, kernel, and migrations are loaded during boot
// and invisible to the coverage driver. Same reasoning as Laravel.
return [
// Config — YAML, XML, and PHP. All loaded during kernel boot.
'config/*.yaml' => [$testPath],
'config/*.yml' => [$testPath],
'config/*.php' => [$testPath],
'config/*.xml' => [$testPath],
'config/**/*.yaml' => [$testPath],
'config/**/*.yml' => [$testPath],
'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$testPath],
// Routes — loaded during boot.
'config/routes/*.yaml' => [$testPath],
'config/routes/*.php' => [$testPath],
'config/routes/*.xml' => [$testPath],
'config/routes/**/*.yaml' => [$testPath],
// Kernel / bootstrap — loaded during boot.
'src/Kernel.php' => [$testPath],
// Migrations — run during setUp (before coverage window).
'migrations/**/*.php' => [$testPath],
// Twig templates — compiled, source not PHP-executed.
'templates/**/*.html.twig' => [$testPath],
'templates/**/*.twig' => [$testPath],
// Translations (YAML / XLF / XLIFF).
'translations/**/*.yaml' => [$testPath],
'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath],
// Doctrine XML/YAML mappings.
'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath],
// Webpack Encore / asset-mapper config + frontend sources.
'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath],
'assets/**/*.ts' => [$testPath],
'assets/**/*.vue' => [$testPath],
'assets/**/*.css' => [$testPath],
'assets/**/*.scss' => [$testPath],
];
}
}

View File

@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
/**
* A set of file-watch patterns that apply when a particular framework,
* library or project layout is detected.
*
* Each implementation probes for the presence of the tool it covers
* (`applicable`) and returns glob → test-directory mappings (`defaults`)
* that are merged into `WatchPatterns`.
*
* @internal
*/
interface WatchDefault
{
/**
* Whether this default set applies to the current project.
*/
public function applicable(): bool;
/**
* @return array<string, array<int, string>> glob → list of project-relative test dirs
*/
public function defaults(string $projectRoot, string $testPath): array;
}

View File

@ -1,188 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\TestSuite;
/**
* Maps non-PHP file globs to the test directories they should invalidate.
*
* Coverage drivers only see `.php` files. Frontend assets, config files,
* Blade templates, routes and environment files are invisible to the graph.
* Watch patterns bridge the gap: when a changed file matches a glob, every
* test under the associated directory is marked as affected.
*
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
* each implementation probes the current project and contributes patterns
* when applicable. Users extend via `pest()->tia()->watch(…)`.
*
* @internal
*/
final class WatchPatterns
{
/**
* All known default providers, in evaluation order.
*
* @var array<int, class-string<WatchDefault>>
*/
private const array DEFAULTS = [
WatchDefaults\Php::class,
WatchDefaults\Laravel::class,
WatchDefaults\Symfony::class,
WatchDefaults\Livewire::class,
WatchDefaults\Inertia::class,
WatchDefaults\Browser::class,
];
/**
* @var array<string, array<int, string>> glob → list of project-relative test dirs
*/
private array $patterns = [];
/**
* Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
* calls are already in `$this->patterns`).
*/
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
foreach (self::DEFAULTS as $class) {
$default = new $class;
if (! $default->applicable()) {
continue;
}
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], $dirs),
));
}
}
}
/**
* Adds user-defined patterns. Merges with existing entries so a single
* glob can map to multiple directories.
*
* @param array<string, string> $patterns glob → project-relative test dir
*/
public function add(array $patterns): void
{
foreach ($patterns as $glob => $dir) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], [$dir]),
));
}
}
/**
* Returns all test directories whose watch patterns match at least one of
* the given changed files.
*
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test directories.
*/
public function matchedDirectories(string $projectRoot, array $changedFiles): array
{
if ($this->patterns === []) {
return [];
}
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $glob => $dirs) {
if ($this->globMatches($glob, $file)) {
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
}
return array_keys($matched);
}
/**
* Given the affected directories, returns every test file in the graph
* that lives under one of those directories.
*
* @param array<int, string> $directories Project-relative dirs.
* @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string>
*/
public function testsUnderDirectories(array $directories, array $allTestFiles): array
{
if ($directories === []) {
return [];
}
$affected = [];
foreach ($allTestFiles as $testFile) {
foreach ($directories as $dir) {
$prefix = rtrim($dir, '/').'/';
if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile;
break;
}
}
}
return $affected;
}
public function reset(): void
{
$this->patterns = [];
}
/**
* Matches a project-relative file against a glob pattern.
*
* Supports `*` (single segment), `**` (any depth) and `?`.
*/
private function globMatches(string $pattern, string $file): bool
{
$pattern = str_replace('\\', '/', $pattern);
$file = str_replace('\\', '/', $file);
$regex = '';
$len = strlen($pattern);
$i = 0;
while ($i < $len) {
$c = $pattern[$i];
if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
$regex .= '.*';
$i += 2;
if (isset($pattern[$i]) && $pattern[$i] === '/') {
$i++;
}
} elseif ($c === '*') {
$regex .= '[^/]*';
$i++;
} elseif ($c === '?') {
$regex .= '[^/]';
$i++;
} else {
$regex .= preg_quote($c, '#');
$i++;
}
}
return (bool) preg_match('#^'.$regex.'$#', $file);
}
}

View File

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* Fires last for each test, after the outcome subscribers. Records the exact
* assertion count so replay can emit the same `addToAssertionCount()` instead
* of a hardcoded value.
*
* @internal
*/
final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Finished $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->recordAssertions(
$test->className().'::'.$test->methodName(),
$event->numberOfAssertionsPerformed(),
);
}
// Close the "currently recording" window on Finished so the next
// test's events don't get mis-attributed. Keeping the pointer open
// through the outcome subscribers is what lets a late-firing
// `ConsideredRisky` overwrite an earlier `Passed`.
$this->collector->finishTest();
}
}

View File

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;
/**
* Stops PCOV collection after each test and merges the covered files into the
* TIA recorder's aggregate map. No-op unless the recorder is active.
*
* @internal
*/
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Finished $event): void
{
$this->recorder->endTest();
}
}

View File

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
/**
* Starts PCOV collection before each test. No-op unless the TIA recorder was
* activated by the `--tia` plugin.
*
* @internal
*/
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Prepared $event): void
{
if (! $this->recorder->isActive()) {
return;
}
$test = $event->test();
if (! $test instanceof TestMethod) {
return;
}
$this->recorder->beginTest($test->className(), $test->methodName(), $test->file());
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Errored $event): void
{
$this->collector->testErrored($event->throwable()->message());
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Failed $event): void
{
$this->collector->testFailed($event->throwable()->message());
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(MarkedIncomplete $event): void
{
$this->collector->testIncomplete($event->throwable()->message());
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Passed;
use PHPUnit\Event\Test\PassedSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Passed $event): void
{
$this->collector->testPassed();
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(ConsideredRisky $event): void
{
$this->collector->testRisky($event->message());
}
}

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
/**
* @internal
*/
final class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Skipped $event): void
{
$this->collector->testSkipped($event->message());
}
}

View File

@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
/**
* Starts a per-test recording window on Prepared. Sibling subscribers
* (`EnsureTia*`) close it with the outcome and the assertion count so the
* graph can persist everything needed for faithful replay.
*
* Why one subscriber per event: PHPUnit's `TypeMap::map()` picks only the
* first subscriber interface it finds on a class, so one class cannot fan
* out to multiple events — each event needs its own subscriber class.
*
* @internal
*/
final class EnsureTiaResultsAreCollected implements PreparedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Prepared $event): void
{
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->testPrepared($test->className().'::'.$test->methodName());
}
}
}

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;
@ -88,20 +89,22 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
}
// If TIA's marker is present, this run executed only the affected
// tests. Merge their fresh coverage slice into the cached full-run
// snapshot (stored by the previous `--tia --coverage` pass) so the
// report reflects the entire suite, not just what re-ran.
\Pest\Plugins\Tia\CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */
$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.3.
Pest Testing Framework 5.0.0-rc.6.
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.6.3.
Pest Testing Framework 5.0.0-rc.6.

View File

@ -1697,6 +1697,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
@ -1901,4 +1903,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, 1294 passed (2971 assertions)
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1296 passed (2976 assertions)

View File

@ -7,9 +7,6 @@ arch()->preset()->php()->ignoring([
'debug_backtrace',
'var_export',
'xdebug_info',
'xdebug_start_code_coverage',
'xdebug_stop_code_coverage',
'xdebug_get_code_coverage',
]);
arch()->preset()->strict()->ignoring([

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