This commit is contained in:
nuno maduro
2026-04-23 09:44:12 -07:00
parent e876dba8ba
commit c1feefbb9e
4 changed files with 146 additions and 2 deletions

View File

@ -8,6 +8,8 @@ use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\BladeEdges;
use Pest\Plugins\Tia\Recorder;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container; use Pest\Support\Container;
@ -315,6 +317,16 @@ trait Testable
parent::setUp(); parent::setUp();
// TIA blade-edge recording (Laravel-only). Runs right after
// `parent::setUp()` so the Laravel app exists and the View
// facade is bound; idempotent against the current app instance
// so the 774-test suite doesn't stack 774 composers when Laravel
// keeps the same app across tests.
$recorder = Container::getInstance()->get(Recorder::class);
if ($recorder instanceof Recorder) {
BladeEdges::arm($recorder);
}
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
if ($this->__beforeEach instanceof Closure) { if ($this->__beforeEach instanceof Closure) {

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Laravel-only collaborator: during record mode, attributes every
* rendered Blade view to the currently-running test.
*
* Why this exists: the coverage driver only sees compiled view files
* under `storage/framework/views/<hash>.php`, not the `.blade.php`
* source. Without a dedicated hook TIA has no edges for blade files,
* so it leans on the Laravel WatchDefault's broad "any .blade.php
* change → every feature test" fallback. Safe but noisy — editing a
* single partial re-runs the whole suite.
*
* With this armed at record time, each test's edge set grows to
* include the precise `.blade.php` files it rendered (directly or
* through `@include`, layouts, components, Livewire, Inertia root
* views — anything that goes through Laravel's view factory fires
* `View::composer('*')`). Replay then invalidates exactly the tests
* that rendered the changed template.
*
* Implementation note: everything Laravel-touching goes through
* string class names, `class_exists`, and `method_exists` so Pest
* core doesn't pull `illuminate/container` into its `require`.
*
* @internal
*/
final class BladeEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* App-scoped marker that makes `arm()` idempotent. Tests call it
* from every `setUp()`, and Laravel reuses the same app instance
* across tests in most configurations — without this guard we'd
* stack one composer per test and replay every one of them on
* every view render.
*/
private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('view')) {
return;
}
$app->instance(self::MARKER, true);
$factory = $app->make('view');
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
return;
}
$factory->composer('*', static function (object $view) use ($recorder): void {
if (! method_exists($view, 'getPath')) {
return;
}
/** @var mixed $path */
$path = $view->getPath();
if (is_string($path) && $path !== '') {
$recorder->linkSource($path);
}
});
}
}

View File

@ -153,11 +153,25 @@ final class Graph
} }
} }
// 2. Watch-pattern lookup (non-PHP assets → test directories). // 2. Watch-pattern lookup — fallback for files we don't have
// precise edges for. When a file is already in `$fileIds` step
// 1 resolved it surgically; broadcasting it again through the
// watch pattern would re-add every test the pattern maps to,
// defeating the point of recording the edge in the first place.
// Blade templates captured via Laravel's view composer are the
// motivating case — we want their specific tests, not every
// feature test.
$unknownToGraph = [];
foreach ($normalised as $rel) {
if (! isset($this->fileIds[$rel])) {
$unknownToGraph[] = $rel;
}
}
/** @var WatchPatterns $watchPatterns */ /** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class); $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised); $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $unknownToGraph);
$allTestFiles = array_keys($this->edges); $allTestFiles = array_keys($this->edges);
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {

View File

@ -144,6 +144,32 @@ final class Recorder
$this->currentTestFile = null; $this->currentTestFile = null;
} }
/**
* Records an extra source-file dependency for the currently-running
* test. Used by collaborators that capture edges the coverage driver
* cannot see — Blade templates rendered through Laravel's view
* factory are the motivating case (their `.blade.php` source never
* executes directly; a cached compiled PHP file does). No-op when
* the recorder is inactive or no test is in flight, so callers can
* fire it unconditionally from app-level hooks.
*/
public function linkSource(string $sourceFile): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($sourceFile === '') {
return;
}
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
/** /**
* @return array<string, array<int, string>> absolute test file → list of absolute source files. * @return array<string, array<int, string>> absolute test file → list of absolute source files.
*/ */