mirror of
https://github.com/pestphp/pest.git
synced 2026-04-24 16:07:31 +02:00
Compare commits
11 Commits
0d99c33c4e
...
c89493dd9b
| Author | SHA1 | Date | |
|---|---|---|---|
| c89493dd9b | |||
| 15035d37ef | |||
| 41f11c0ef3 | |||
| e91634ff05 | |||
| df0f440f84 | |||
| 50601e6118 | |||
| 247d59abf6 | |||
| b24c375d72 | |||
| 30fff116fd | |||
| 192f289e7e | |||
| 4b8e303cd5 |
@ -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"
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ namespace Pest;
|
||||
|
||||
function version(): string
|
||||
{
|
||||
return '4.6.3';
|
||||
return '4.6.1';
|
||||
}
|
||||
|
||||
function testDirectory(string $file = ''): string
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 = [];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
Pest Testing Framework 4.6.3.
|
||||
Pest Testing Framework 4.6.1.
|
||||
|
||||
USAGE: pest <file> [options]
|
||||
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
|
||||
Pest Testing Framework 4.6.3.
|
||||
Pest Testing Framework 4.6.1.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user