Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/Plugins/Tia/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Pest\Plugins\Tia;

use Closure;
use Pest\Support\Container;

/**
Expand Down Expand Up @@ -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<string, string> $patterns glob → project-relative test dir
* @return $this
Expand Down
101 changes: 100 additions & 1 deletion src/Plugins/Tia/Recorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Pest\Plugins\Tia;

use Closure;
use Pest\TestSuite;
use ReflectionClass;

Expand Down Expand Up @@ -44,6 +45,33 @@ final class Recorder

private ?SourceScope $sourceScope = null;

private ?Closure $warmup = null;

/** @var array<string, array<int, true>>|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;
Expand Down Expand Up @@ -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();
Expand All @@ -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<string, array<int, true>>
*/
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<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
} else {
\xdebug_start_code_coverage();

($this->warmup)();

/** @var array<string, mixed> $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) {
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -387,5 +485,6 @@ public function reset(): void
$this->sourceScope = null;
$this->active = false;
$this->captureCoverage = false;
$this->warmupBaseline = null;
}
}
19 changes: 19 additions & 0 deletions tests/Fixtures/Tia/SharedSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

function tia_shared_source_boot_path(): int
{
$value = 100;
$value++;

return $value;
}

function tia_shared_source_test_path(): int
{
$value = 200;
$value++;

return $value;
}
11 changes: 11 additions & 0 deletions tests/Fixtures/Tia/TestOnlySource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

function tia_test_only_source(): int
{
$value = 10;
$value++;

return $value;
}
11 changes: 11 additions & 0 deletions tests/Fixtures/Tia/WarmupExecutedSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

function tia_warmup_executed_source(): int
{
$value = 1;
$value++;

return $value;
}
92 changes: 92 additions & 0 deletions tests/Unit/Plugins/Tia/Recorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,95 @@
expect($recorder->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();
});
});