From c1feefbb9e1da7a5f1836df0fd87f88a1bfd0cf3 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 09:44:12 -0700 Subject: [PATCH] wip --- src/Concerns/Testable.php | 12 +++++ src/Plugins/Tia/BladeEdges.php | 92 ++++++++++++++++++++++++++++++++++ src/Plugins/Tia/Graph.php | 18 ++++++- src/Plugins/Tia/Recorder.php | 26 ++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/Plugins/Tia/BladeEdges.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 3f8e2dc9..dcc111b2 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -8,6 +8,8 @@ use Closure; use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Panic; use Pest\Plugins\Tia; +use Pest\Plugins\Tia\BladeEdges; +use Pest\Plugins\Tia\Recorder; use Pest\Preset; use Pest\Support\ChainableClosure; use Pest\Support\Container; @@ -315,6 +317,16 @@ trait Testable 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]; if ($this->__beforeEach instanceof Closure) { diff --git a/src/Plugins/Tia/BladeEdges.php b/src/Plugins/Tia/BladeEdges.php new file mode 100644 index 00000000..8a7465b5 --- /dev/null +++ b/src/Plugins/Tia/BladeEdges.php @@ -0,0 +1,92 @@ +.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); + } + }); + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index a8bc912f..887b4582 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -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 */ $watchPatterns = Container::getInstance()->get(WatchPatterns::class); - $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised); + $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $unknownToGraph); $allTestFiles = array_keys($this->edges); foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 635b9088..6f87f682 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -144,6 +144,32 @@ final class Recorder $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> absolute test file → list of absolute source files. */