diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php index 617898018..b077f33fa 100644 --- a/src/Plugins/Tia/Configuration.php +++ b/src/Plugins/Tia/Configuration.php @@ -4,6 +4,7 @@ namespace Pest\Plugins\Tia; +use Closure; use Pest\Support\Container; /** @@ -60,6 +61,35 @@ public function baselined(): self return $this; } + /** + * Register a warm-up callback whose executed lines are treated as + * framework bootstrap noise: it runs once per process under the coverage + * driver before the first test, and every line it executes is excluded + * from each test's recorded dependency edges. + * + * Intended for frameworks that boot inside every test (e.g. Laravel): + * + * ```php + * pest()->tia()->warmupUsing(function (): void { + * $app = require __DIR__.'/../bootstrap/app.php'; + * $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + * + * // Undo global state the boot mutated (container, facades, + * // environment variables, error handlers) before returning. + * }); + * ``` + * + * @return $this + */ + public function warmupUsing(Closure $callback): self + { + /** @var Recorder $recorder */ + $recorder = Container::getInstance()->get(Recorder::class); + $recorder->warmupUsing($callback); + + return $this; + } + /** * @param array $patterns glob → project-relative test dir * @return $this diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index c9e71870a..b1692ffd4 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -4,6 +4,7 @@ namespace Pest\Plugins\Tia; +use Closure; use Pest\TestSuite; use ReflectionClass; @@ -44,6 +45,33 @@ final class Recorder private ?SourceScope $sourceScope = null; + private ?Closure $warmup = null; + + /** @var array>|null */ + private ?array $warmupBaseline = null; + + /** + * Register a callback whose executed lines form the "warm-up baseline": + * before the first test of the process, the callback runs under the + * coverage driver, and every line it executes is subtracted from each + * test's recorded coverage before dependency edges are derived. + * + * Frameworks that boot inside every test's setUp() re-execute the same + * bootstrap lines in every test window (service providers, route + * registration, per-panel resource registration, …), which otherwise links + * every test to every bootstrap-executed file. Booting the framework once + * in the callback removes those edges while keeping any coverage a test + * adds beyond the baseline. + * + * The callback is responsible for cleaning up global state it mutates + * (container instances, facades, environment variables, error handlers) — + * it runs in the test process, immediately before the first test. + */ + public function warmupUsing(?Closure $callback): void + { + $this->warmup = $callback; + } + public function activate(): void { $this->active = true; @@ -116,6 +144,10 @@ public function beginTest(string $className, string $methodName, string $fallbac return; } + if ($this->warmupBaseline === null) { + $this->warmupBaseline = $this->collectWarmupBaseline(); + } + if ($this->driver === 'pcov') { \pcov\clear(); \pcov\start(); @@ -126,6 +158,71 @@ public function beginTest(string $className, string $methodName, string $fallbac \xdebug_start_code_coverage(); } + /** + * Run the registered warm-up callback under the coverage driver and + * collect the lines it executes, scoped to project sources. + * + * @return array> + */ + private function collectWarmupBaseline(): array + { + if (! $this->warmup instanceof Closure) { + return []; + } + + $scope = $this->sourceScope(); + + if ($this->driver === 'pcov') { + \pcov\clear(); + \pcov\start(); + + ($this->warmup)(); + + \pcov\stop(); + + $filesToCollectCoverageFor = []; + + foreach (\pcov\waiting() as $file) { + if (is_string($file) && $scope->contains($file)) { + $filesToCollectCoverageFor[] = $file; + } + } + + /** @var array $data */ + $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor); + } else { + \xdebug_start_code_coverage(); + + ($this->warmup)(); + + /** @var array $data */ + $data = \xdebug_get_code_coverage(); + \xdebug_stop_code_coverage(true); + + foreach (array_keys($data) as $file) { + if (! $scope->contains($file)) { + unset($data[$file]); + } + } + } + + $baseline = []; + + foreach ($data as $file => $lines) { + if (! is_array($lines)) { + continue; + } + + foreach ($lines as $line => $count) { + if (is_int($count) && $count > 0) { + $baseline[$file][$line] = true; + } + } + } + + return $baseline; + } + public function endTest(): void { if (! $this->active || $this->currentTestFile === null) { @@ -348,9 +445,10 @@ private function filesWithExecutedLines(array $data): array if (! is_array($lines)) { continue; } + $baseline = $this->warmupBaseline[$file] ?? []; $covered = []; foreach ($lines as $line => $count) { - if (is_int($count) && $count > 0) { + if (is_int($count) && $count > 0 && ! isset($baseline[$line])) { $covered[] = $line; } } @@ -387,5 +485,6 @@ public function reset(): void $this->sourceScope = null; $this->active = false; $this->captureCoverage = false; + $this->warmupBaseline = null; } } diff --git a/tests/Fixtures/Tia/SharedSource.php b/tests/Fixtures/Tia/SharedSource.php new file mode 100644 index 000000000..972892dac --- /dev/null +++ b/tests/Fixtures/Tia/SharedSource.php @@ -0,0 +1,19 @@ +perTestTables())->toBeEmpty(); }); }); + +describe('warmupUsing()', function (): void { + /* + * The driver must not only exist — it must be able to instrument the + * fixture files (pcov only instruments files under pcov.directory). + */ + $driverCanInstrumentFixtures = function (): bool { + $fixture = dirname(__DIR__, 3).'/Fixtures/Tia/WarmupExecutedSource.php'; + require_once $fixture; + + if (function_exists('pcov\start')) { + \pcov\clear(); + \pcov\start(); + tia_warmup_executed_source(); + \pcov\stop(); + + $collected = \pcov\collect(\pcov\inclusive, [realpath($fixture) ?: $fixture]); + + return $collected !== []; + } + + return function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info') && in_array('coverage', (array) xdebug_info('mode'), true); + }; + + beforeEach(function (): void { + require_once dirname(__DIR__, 3).'/Fixtures/Tia/WarmupExecutedSource.php'; + require_once dirname(__DIR__, 3).'/Fixtures/Tia/TestOnlySource.php'; + require_once dirname(__DIR__, 3).'/Fixtures/Tia/SharedSource.php'; + }); + + it('excludes files whose executed lines are fully covered by the warm-up baseline', function (): void { + $recorder = new Recorder; + $recorder->warmupUsing(function (): void { + tia_warmup_executed_source(); + }); + $recorder->activate(); + + $recorder->beginTest('Some\Missing\TestClass', 'boot noise', '/project/tests/Feature/BootNoiseTest.php'); + tia_warmup_executed_source(); + tia_test_only_source(); + $recorder->endTest(); + + $files = array_map(basename(...), $recorder->perTestFiles()['/project/tests/Feature/BootNoiseTest.php'] ?? []); + + expect($files)->toContain('TestOnlySource.php') + ->not->toContain('WarmupExecutedSource.php'); + })->skipOnWindows()->skip(fn (): bool => ! $driverCanInstrumentFixtures(), 'requires pcov (with pcov.directory covering the test fixtures) or xdebug (coverage mode)'); + + it('keeps files where a test executes lines beyond the warm-up baseline', function (): void { + $recorder = new Recorder; + $recorder->warmupUsing(function (): void { + tia_shared_source_boot_path(); + }); + $recorder->activate(); + + $recorder->beginTest('Some\Missing\TestClass', 'beyond baseline', '/project/tests/Feature/BeyondBaselineTest.php'); + tia_shared_source_boot_path(); + tia_shared_source_test_path(); + $recorder->endTest(); + + $files = array_map(basename(...), $recorder->perTestFiles()['/project/tests/Feature/BeyondBaselineTest.php'] ?? []); + + expect($files)->toContain('SharedSource.php'); + })->skipOnWindows()->skip(fn (): bool => ! $driverCanInstrumentFixtures(), 'requires pcov (with pcov.directory covering the test fixtures) or xdebug (coverage mode)'); + + it('records unchanged edges when no warm-up is registered', function (): void { + $recorder = new Recorder; + $recorder->activate(); + + $recorder->beginTest('Some\Missing\TestClass', 'no warmup', '/project/tests/Feature/NoWarmupTest.php'); + tia_warmup_executed_source(); + $recorder->endTest(); + + $files = array_map(basename(...), $recorder->perTestFiles()['/project/tests/Feature/NoWarmupTest.php'] ?? []); + + expect($files)->toContain('WarmupExecutedSource.php'); + })->skipOnWindows()->skip(fn (): bool => ! $driverCanInstrumentFixtures(), 'requires pcov (with pcov.directory covering the test fixtures) or xdebug (coverage mode)'); + + it('does not invoke the warm-up callback without a coverage driver', function (): void { + $recorder = new Recorder; + $invoked = false; + $recorder->warmupUsing(function () use (&$invoked): void { + $invoked = true; + }); + $recorder->activateLinkTracking(); + + $recorder->beginTest('Some\Missing\TestClass', 'link tracking', '/project/tests/Feature/LinkTrackingTest.php'); + $recorder->endTest(); + + expect($invoked)->toBeFalse(); + }); +});