Compare commits

..

11 Commits

11 changed files with 54 additions and 646 deletions

View File

@ -25,12 +25,12 @@
"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",
"phpunit/phpunit": "^12.5.20",
"symfony/process": "^7.4.8|^8.0.8"
},
"conflict": {
"filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.23",
"phpunit/phpunit": ">12.5.20",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},

View File

@ -67,7 +67,6 @@ final readonly class Kernel
->add(OutputInterface::class, $output)
->add(Container::class, $container)
->add(Tia\Recorder::class, new Tia\Recorder)
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
->add(Tia\ResultCollector::class, new Tia\ResultCollector);

View File

@ -6,7 +6,7 @@ namespace Pest;
function version(): string
{
return '4.6.3';
return '4.6.1';
}
function testDirectory(string $file = ''): string

View File

@ -9,7 +9,6 @@ use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use PHPUnit\Framework\TestStatus\TestStatus;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\CoverageCollector;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Recorder;
@ -85,25 +84,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string AFFECTED_FILE = 'tia-affected.json';
/**
* Cache file holding PHPUnit's `CodeCoverage` object from the last
* `--tia --coverage` run. When the next run replays most tests from
* the TIA graph, only the affected tests produce fresh coverage; the
* rest is merged in from this cache so the report stays complete.
*/
private const string COVERAGE_CACHE_FILE = 'tia-coverage.php';
/**
* Marker file dropped by `Tia` to tell `Support\Coverage` to apply the
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
* current (narrow) behaviour.
*/
private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker';
private const string WORKER_PREFIX = 'tia-worker-';
private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-';
/**
* Global flag toggled by the parent process so workers know to record.
*/
@ -115,15 +97,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
/**
* Global flag that tells workers to piggyback on PHPUnit's coverage
* driver (set by the parent whenever `--tia --coverage` is used). Workers
* can't infer this from their own argv because paratest forwards only
* `--coverage-php=<path>` — not the `--coverage` flag Pest's Coverage
* plugin inspects.
*/
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
private bool $graphWritten = false;
private bool $replayRan = false;
@ -201,46 +174,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
}
private static function workerResultsPath(string $token): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.$token.'.json';
}
private static function workerResultsGlob(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json';
}
public static function coverageCachePath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_CACHE_FILE;
}
public static function coverageMarkerPath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_MARKER_FILE;
}
/**
* True when TIA is piggybacking on PHPUnit's own coverage driver. Toggled
* in `handleArguments` whenever `--tia` runs alongside `--coverage` so
* both the parent and workers read edges from the shared `CodeCoverage`
* instance instead of starting a second PCOV / Xdebug session.
*/
private bool $piggybackCoverage = false;
/**
* True once we have committed to recording in this process — either by
* activating our own `Recorder` or by delegating to PHPUnit's coverage
* driver via `CoverageCollector`. `terminate()` only flushes when this
* is set, so runs that never entered record mode don't poke the graph.
*/
private bool $recordingActive = false;
public function __construct(
private readonly OutputInterface $output,
private readonly Recorder $recorder,
private readonly CoverageCollector $coverageCollector,
private readonly WatchPatterns $watchPatterns,
) {}
@ -301,17 +237,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::REBUILD_OPTION, $arguments);
// When `--coverage` is active, piggyback on PHPUnit's CodeCoverage
// instead of starting our own PCOV / Xdebug session. Running two
// collectors against the same driver corrupts both — so we let
// PHPUnit drive, and read per-test edges from the shared instance
// at the end of the run via `CoverageCollector`. Workers can't
// detect `--coverage` from their own argv (paratest strips it,
// keeping only `--coverage-php=<path>`) so the parent broadcasts
// via a global.
$this->piggybackCoverage = $isWorker
? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1'
: $this->coverageReportActive();
if ($this->coverageReportActive()) {
if (! $isWorker) {
$this->output->writeln(
' <fg=yellow>TIA</> `--coverage` is active — TIA disabled to avoid '.
'conflicting with PHPUnit\'s own coverage collection.',
);
}
return $arguments;
}
$projectRoot = TestSuite::getInstance()->rootPath;
@ -328,30 +263,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return;
}
// Worker in replay mode: flush the ResultCollector + replay counter
// into a partial so the parent can merge them into the graph after
// paratest returns. Parent's own ResultCollector is empty in parallel
// runs because workers — not the parent — execute the tests.
if (Parallel::isWorker() && $this->replayGraph !== null) {
$this->flushWorkerReplay();
}
$recorder = $this->recorder;
if (! $this->recordingActive && ! $recorder->isActive()) {
if (! $recorder->isActive()) {
return;
}
$this->graphWritten = true;
$projectRoot = TestSuite::getInstance()->rootPath;
$perTest = $this->piggybackCoverage
? $this->coverageCollector->perTestFiles()
: $recorder->perTestFiles();
$perTest = $recorder->perTestFiles();
if ($perTest === []) {
$recorder->reset();
$this->coverageCollector->reset();
return;
}
@ -359,7 +283,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (Parallel::isWorker()) {
$this->flushWorkerPartial($projectRoot, $perTest);
$recorder->reset();
$this->coverageCollector->reset();
return;
}
@ -387,7 +310,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
));
$recorder->reset();
$this->coverageCollector->reset();
}
/**
@ -407,14 +329,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// twice in a row would re-execute the same affected tests both
// times even though nothing new changed.
if ($this->replayRan) {
// In parallel runs the workers executed the tests, so their
// ResultCollector + replay counter live in other processes. Pull
// those partials in before both the summary and the graph
// snapshot so the parent state reflects the whole run.
if (Parallel::isEnabled()) {
$this->mergeWorkerReplayPartials();
}
$this->bumpRecordedSha();
$this->emitReplaySummary();
}
@ -530,13 +444,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
}
// Drop the marker so `Support\Coverage::report()` knows to merge the
// current (narrow) coverage with the cached full-run snapshot. Plain
// `--coverage` runs don't drop it, so their behaviour is untouched.
if ($this->piggybackCoverage) {
@file_put_contents(self::coverageMarkerPath(), '');
}
if ($graph instanceof Graph) {
return $this->enterReplayMode($graph, $projectRoot, $arguments);
}
@ -566,15 +473,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments;
}
// Piggyback: PHPUnit starts its coverage driver, `CoverageCollector`
// harvests the per-test edges in `terminate()`. The Recorder stays
// idle — starting our own driver would corrupt PHPUnit's data.
if ($this->piggybackCoverage) {
$this->recordingActive = true;
return $arguments;
}
$recorder = $this->recorder;
if (! $recorder->driverAvailable()) {
@ -585,7 +483,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
$recorder->activate();
$this->recordingActive = true;
return $arguments;
}
@ -681,10 +578,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments;
}
// Clear stale partials from a previous interrupted run so the merge
// pass doesn't pick up results from an unrelated invocation.
$this->purgeWorkerPartials($projectRoot);
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
return $arguments;
@ -729,24 +622,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private function enterRecordMode(string $projectRoot, array $arguments): array
{
$recorder = $this->recorder;
// Piggyback: PHPUnit's coverage driver is already running under
// `--coverage`. We don't need our own driver — `CoverageCollector`
// harvests the per-test edges from PHPUnit's shared `CodeCoverage`
// at terminate time. Skip the driver check entirely in this mode.
if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) {
// Both series and parallel record require the coverage driver.
// Parallel also requires it because workers inherit the parent's
// PHP config — if the parent lacks the driver, workers will too
// and would silently produce no graph. Warn once, up-front, and
// continue running the suite without TIA so the user still gets
// their test results.
$this->emitCoverageDriverMissing();
return $arguments;
}
if (Parallel::isEnabled()) {
// Parent driving `--parallel`: workers will do the actual
// recording. We only advertise the intent through a global.
@ -756,32 +631,30 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
if ($this->piggybackCoverage) {
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
}
$this->output->writeln($this->piggybackCoverage
? ' <fg=cyan>TIA</> recording dependency graph in parallel via `--coverage` (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.'
: ' <fg=cyan>TIA</> recording dependency graph in parallel (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.');
return $arguments;
}
if ($this->piggybackCoverage) {
$this->recordingActive = true;
$this->output->writeln(
' <fg=cyan>TIA</> recording dependency graph via `--coverage` (first run) — '.
' <fg=cyan>TIA</> recording dependency graph in parallel (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.',
);
return $arguments;
}
$recorder = $this->recorder;
if (! $recorder->driverAvailable()) {
$this->output->writeln([
'',
' <fg=white;options=bold;bg=red> ERROR </> No coverage driver is available.',
'',
' TIA requires ext-pcov or Xdebug with coverage mode enabled to',
' record the dependency graph. Install one and rerun with `--tia`.',
'',
]);
exit(1);
}
$recorder->activate();
$this->recordingActive = true;
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> recording dependency graph via %s (first run) — '.
@ -792,24 +665,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments;
}
private function emitCoverageDriverMissing(): void
{
$this->output->writeln([
'',
' <fg=black;bg=yellow> WARNING </> No coverage driver is available — TIA skipped.',
'',
' TIA needs <fg=cyan>ext-pcov</> or <fg=cyan>Xdebug</> with <fg=cyan>coverage</> mode enabled to record',
' the dependency graph. Install or enable one and rerun with `--tia`.',
'',
]);
}
/**
* @param array<string, array<int, string>> $perTest
*/
private function flushWorkerPartial(string $projectRoot, array $perTest): void
{
$path = self::workerPath($this->workerToken());
$token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? getmypid();
// Defensive: token might arrive as int or string depending on paratest
// version. Cast + filter to keep filenames sane.
$token = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $token);
if ($token === '') {
$token = (string) getmypid();
}
$path = self::workerPath($token);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
@ -849,132 +719,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
foreach ($this->collectWorkerPartials($projectRoot) as $path) {
@unlink($path);
}
foreach ($this->collectWorkerReplayPartials() as $path) {
@unlink($path);
}
}
/**
* Worker-side flush of replay state (collected results + cache-hit
* counter) into a per-worker partial file. Parent merges them in
* `addOutput` so the graph snapshot + summary reflect the full run.
*/
private function flushWorkerReplay(): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
$results = $collector->all();
if ($results === [] && $this->replayedCount === 0) {
return;
}
$token = $this->workerToken();
$path = self::workerResultsPath($token);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return;
}
$json = json_encode([
'results' => $results,
'replayed' => $this->replayedCount,
], JSON_UNESCAPED_SLASHES);
if ($json === false) {
return;
}
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
if (@file_put_contents($tmp, $json) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
}
/**
* @return array<int, string>
*/
private function collectWorkerReplayPartials(): array
{
$matches = glob(self::workerResultsGlob());
return $matches === false ? [] : $matches;
}
/**
* Parent-side merge of per-worker replay partials. Feeds the results into
* the parent's `ResultCollector` so the existing snapshot pass persists
* them, and rolls up the cache-hit counts so the summary is accurate.
*/
private function mergeWorkerReplayPartials(): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
foreach ($this->collectWorkerReplayPartials() as $path) {
$raw = @file_get_contents($path);
if ($raw === false) {
@unlink($path);
continue;
}
$decoded = json_decode($raw, true);
@unlink($path);
if (! is_array($decoded)) {
continue;
}
if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
$this->replayedCount += $decoded['replayed'];
}
if (isset($decoded['results']) && is_array($decoded['results'])) {
$normalised = [];
/** @var mixed $result */
foreach ($decoded['results'] as $testId => $result) {
if (! is_string($testId) || ! is_array($result)) {
continue;
}
$normalised[$testId] = [
'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
'message' => is_string($result['message'] ?? null) ? $result['message'] : '',
'time' => is_float($result['time'] ?? null) || is_int($result['time'] ?? null) ? (float) $result['time'] : 0.0,
'assertions' => is_int($result['assertions'] ?? null) ? $result['assertions'] : 0,
];
}
if ($normalised !== []) {
$collector->merge($normalised);
}
}
}
}
private function workerToken(): string
{
$raw = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? null;
$token = is_scalar($raw) ? (string) $raw : (string) getmypid();
$token = preg_replace('/[^A-Za-z0-9_-]/', '', $token);
if ($token === null || $token === '') {
return (string) getmypid();
}
return $token;
}
/**

View File

@ -48,37 +48,23 @@ final readonly class ChangedFiles
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.
foreach ($files as $file) {
if (! isset($lastRunTree[$file])) {
$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 !== '') {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
// File is absent now. If the snapshot recorded it as absent
// too (sentinel ''), state is identical to last run — treat
// as unchanged. Otherwise it was present last run and got
// deleted since — that's a real change.
if ($lastRunTree[$file] !== '') {
$remaining[] = $file;
}
@ -87,7 +73,7 @@ final readonly class ChangedFiles
$hash = @hash_file('xxh128', $absolute);
if ($hash === false || $hash !== $snapshot) {
if ($hash === false || $hash !== $lastRunTree[$file]) {
$remaining[] = $file;
}
}

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,149 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
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 file 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 a previous `--tia --coverage` run).
* 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 to the cache path (for the
* next invocation).
* 5. Remove the marker so subsequent plain `--coverage` runs are
* untouched.
*
* If no cache exists yet (first `--tia --coverage` run on this machine)
* we simply save the current file as the cache — nothing to merge yet.
*
* @internal
*/
final class CoverageMerger
{
public static function applyIfMarked(string $reportPath): void
{
$markerPath = Tia::coverageMarkerPath();
if (! is_file($markerPath)) {
return;
}
@unlink($markerPath);
$cachePath = Tia::coverageCachePath();
if (! is_file($cachePath)) {
// First `--tia --coverage` run: nothing cached yet, the current
// report is the full suite itself. Save it verbatim so the next
// run has a snapshot to merge against.
@copy($reportPath, $cachePath);
return;
}
try {
/** @var CodeCoverage $cached */
$cached = require $cachePath;
/** @var CodeCoverage $current */
$current = require $reportPath;
} catch (Throwable) {
// Corrupt cache or unreadable report — fall back to the plain
// PHPUnit behaviour (the existing `require $reportPath` in the
// caller still runs against the untouched file).
return;
}
self::stripCurrentTestsFromCached($cached, $current);
$cached->merge($current);
// Serialise the merged object back using PHPUnit's own "return
// expression" PHP format. Using `var_export` on the serialised
// payload keeps the file self-contained and independent of
// PHPUnit's internal exporter — the reader only needs to
// `require` it back.
$serialised = "<?php return unserialize(".var_export(serialize($cached), true).");\n";
@file_put_contents($reportPath, $serialised);
@file_put_contents($cachePath, $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);
}
}

View File

@ -97,20 +97,6 @@ final class ResultCollector
}
}
/**
* 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 = [];

View File

@ -88,12 +88,6 @@ 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);

View File

@ -1,5 +1,5 @@
Pest Testing Framework 4.6.3.
Pest Testing Framework 4.6.1.
USAGE: pest <file> [options]

View File

@ -1,3 +1,3 @@
Pest Testing Framework 4.6.3.
Pest Testing Framework 4.6.1.