diff --git a/src/Kernel.php b/src/Kernel.php index ad5c4512..bf57f51e 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -67,6 +67,7 @@ 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); diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 388553e8..a2d6ea5b 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -9,6 +9,7 @@ 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; @@ -99,6 +100,15 @@ 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=` — 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; @@ -186,9 +196,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json'; } + /** + * 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, ) {} @@ -249,16 +276,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); - if ($this->coverageReportActive()) { - if (! $isWorker) { - $this->output->writeln( - ' TIA `--coverage` is active — TIA disabled to avoid '. - 'conflicting with PHPUnit\'s own coverage collection.', - ); - } - - return $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=`) so the parent broadcasts + // via a global. + $this->piggybackCoverage = $isWorker + ? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1' + : $this->coverageReportActive(); $projectRoot = TestSuite::getInstance()->rootPath; @@ -285,17 +313,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $recorder = $this->recorder; - if (! $recorder->isActive()) { + if (! $this->recordingActive && ! $recorder->isActive()) { return; } $this->graphWritten = true; $projectRoot = TestSuite::getInstance()->rootPath; - $perTest = $recorder->perTestFiles(); + $perTest = $this->piggybackCoverage + ? $this->coverageCollector->perTestFiles() + : $recorder->perTestFiles(); if ($perTest === []) { $recorder->reset(); + $this->coverageCollector->reset(); return; } @@ -303,6 +334,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if (Parallel::isWorker()) { $this->flushWorkerPartial($projectRoot, $perTest); $recorder->reset(); + $this->coverageCollector->reset(); return; } @@ -330,6 +362,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable )); $recorder->reset(); + $this->coverageCollector->reset(); } /** @@ -472,10 +505,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } } - if ($graph instanceof Graph) { + // Force record mode whenever `--coverage` is active. Replay short- + // circuits tests via cached results, which would make their code + // paths invisible to PHPUnit's coverage driver and tank the report. + // A `--tia --coverage` run is the one the user wants FULL coverage + // from — we just harvest graph edges alongside, to feed future + // `--tia` (no `--coverage`) runs. + if ($graph instanceof Graph && ! $this->piggybackCoverage) { return $this->enterReplayMode($graph, $projectRoot, $arguments); } + if ($graph instanceof Graph && $this->piggybackCoverage) { + $this->output->writeln( + ' TIA `--coverage` active — running full suite and refreshing graph.', + ); + } + return $this->enterRecordMode($projectRoot, $arguments); } @@ -501,6 +546,15 @@ 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()) { @@ -511,6 +565,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } $recorder->activate(); + $this->recordingActive = true; return $arguments; } @@ -656,7 +711,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable { $recorder = $this->recorder; - if (! $recorder->driverAvailable()) { + // 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 @@ -677,8 +736,24 @@ 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 + ? ' TIA recording dependency graph in parallel via `--coverage` (first run) — '. + 'subsequent `--tia` runs will only re-execute affected tests.' + : ' 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( - ' TIA recording dependency graph in parallel (first run) — '. + ' TIA recording dependency graph via `--coverage` (first run) — '. 'subsequent `--tia` runs will only re-execute affected tests.', ); @@ -686,6 +761,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } $recorder->activate(); + $this->recordingActive = true; $this->output->writeln(sprintf( ' TIA recording dependency graph via %s (first run) — '. diff --git a/src/Plugins/Tia/CoverageCollector.php b/src/Plugins/Tia/CoverageCollector.php new file mode 100644 index 00000000..c1109afa --- /dev/null +++ b/src/Plugins/Tia/CoverageCollector.php @@ -0,0 +1,152 @@ +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 + */ + private array $classFileCache = []; + + /** + * Rebuilds the same `absolute test file → list` + * shape that `Recorder::perTestFiles()` exposes, so callers can treat + * the two collectors interchangeably when feeding the graph. + * + * @return array> + */ + public function perTestFiles(): array + { + if (! PhpUnitCodeCoverage::instance()->isActive()) { + return []; + } + + try { + $lineCoverage = PhpUnitCodeCoverage::instance() + ->codeCoverage() + ->getData() + ->lineCoverage(); + } catch (Throwable) { + return []; + } + + /** @var array> $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; + } +}