This commit is contained in:
nuno maduro
2026-04-28 21:28:46 +01:00
parent b944ee5841
commit 405d8d4406
9 changed files with 421 additions and 64 deletions

View File

@ -8,6 +8,7 @@ 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\AutoloadEdges;
use Pest\Plugins\Tia\BladeEdges; use Pest\Plugins\Tia\BladeEdges;
use Pest\Plugins\Tia\InertiaEdges; use Pest\Plugins\Tia\InertiaEdges;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
@ -317,6 +318,16 @@ trait Testable
throw new AssertionFailedError($cached->message() ?: 'Cached failure'); throw new AssertionFailedError($cached->message() ?: 'Cached failure');
} }
$recorder = Container::getInstance()->get(Recorder::class);
if ($recorder instanceof Recorder && $recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename);
}
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
? AutoloadEdges::snapshot()
: [];
parent::setUp(); parent::setUp();
// TIA blade-edge + table-edge recording (Laravel-only). Runs // TIA blade-edge + table-edge recording (Laravel-only). Runs
@ -325,7 +336,6 @@ trait Testable
// idempotent against the current app instance so the 774-test // idempotent against the current app instance so the 774-test
// suite doesn't stack 774 composers / listeners when Laravel // suite doesn't stack 774 composers / listeners when Laravel
// keeps the same app across tests. // keeps the same app across tests.
$recorder = Container::getInstance()->get(Recorder::class);
if ($recorder instanceof Recorder) { if ($recorder instanceof Recorder) {
BladeEdges::arm($recorder); BladeEdges::arm($recorder);
TableTracker::arm($recorder); TableTracker::arm($recorder);
@ -339,6 +349,18 @@ trait Testable
} }
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) {
$recorder->linkSourcesForTest(
self::$__filename,
AutoloadEdges::newProjectFiles(
$autoloadBeforeSetUp,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
self::$__filename,
),
);
}
} }
/** /**

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Captures PHP files that were included while a test was running.
*
* Coverage drivers miss declaration-only files (classes, enums, interfaces,
* traits) and files loaded before the coverage window opens. Diffing
* `get_included_files()` gives TIA an explicit edge for those autoloaded files.
*
* @internal
*/
final readonly class AutoloadEdges
{
/**
* @return array<string, true>
*/
public static function snapshot(): array
{
$files = [];
foreach (get_included_files() as $file) {
if (is_string($file) && $file !== '') {
$files[$file] = true;
}
}
return $files;
}
/**
* @param array<string, true> $before
* @param array<string, true> $after
* @return list<string>
*/
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
{
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
$out = [];
foreach (array_keys($after) as $file) {
if (isset($before[$file])) {
continue;
}
$real = @realpath($file);
if ($real === false) {
$real = $file;
}
if ($testReal !== false && $real === $testReal) {
continue;
}
if (! str_starts_with($real, $root)) {
continue;
}
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
if (self::ignored($relative)) {
continue;
}
if (! str_ends_with($relative, '.php')) {
continue;
}
$out[$real] = true;
}
return array_keys($out);
}
private static function ignored(string $relative): bool
{
static $prefixes = [
'vendor/',
'node_modules/',
'storage/framework/',
'bootstrap/cache/',
];
foreach ($prefixes as $prefix) {
if (str_starts_with($relative, $prefix)) {
return true;
}
}
return false;
}
}

View File

@ -387,11 +387,28 @@ final class Graph
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) { if (isset($this->fileIds[$rel])) {
$changedIds[$this->fileIds[$rel]] = true; $changedIds[$this->fileIds[$rel]] = true;
} elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
continue;
}
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
$absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) {
// Deleted source file unknown to the graph — can't affect
// any test because no edge ever pointed to it.
continue;
}
// Source PHP file unknown to the graph — might be a new file // Source PHP file unknown to the graph — might be a new file
// that only exists on this branch (graph inherited from main). // that only exists on this branch (graph inherited from main).
// Track its directory for the sibling heuristic (step 3). // Only use the sibling heuristic for files that commonly
$unknownSourceDirs[dirname($rel)] = true; // participate in framework discovery / bootstrap. Ordinary new
// classes, enums, DTOs, services, etc. should not re-run sibling
// tests just because they live in the same directory.
if ($this->usesSiblingHeuristicForUnknownPhp($rel)) {
$unknownSourceDirs[dirname($rel)] = true;
}
} }
} }
@ -434,6 +451,12 @@ final class Graph
continue; continue;
} }
if (! isset($this->fileIds[$rel])) { if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever
// pointed to it, so it can't affect any test.
continue;
}
$unknownToGraph[] = $rel; $unknownToGraph[] = $rel;
} }
} }
@ -455,10 +478,11 @@ final class Graph
// whose graph was inherited from another branch (e.g. main). In the // whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file. // latter case the graph simply never saw the file.
// //
// To avoid silent misses: find tests that already cover ANY file in // To avoid silent misses for framework-discovered files: find tests
// the same directory. If `app/Models/OrderItem.php` is unknown but // that already cover ANY file in the same directory. If
// `app/Models/Order.php` is covered by `OrderTest`, run `OrderTest` // `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
// — it likely exercises sibling files in the same module. // listeners are covered by a mail-flow test, run that test — it likely
// exercises the same discovery surface.
// //
// This over-runs slightly (sibling may be unrelated) but never // This over-runs slightly (sibling may be unrelated) but never
// under-runs. And once the test executes, its coverage captures the // under-runs. And once the test executes, its coverage captures the
@ -801,6 +825,35 @@ final class Graph
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php'); return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
} }
/**
* Unknown PHP files have no historical edge yet. Keep sibling fan-out only
* for framework-discovered / boot-loaded conventions where adding a file can
* change behaviour without another source file changing too.
*/
private function usesSiblingHeuristicForUnknownPhp(string $rel): bool
{
static $prefixes = [
'app/Providers/',
'app/Listeners/',
'app/Events/',
'app/Observers/',
'app/Policies/',
'app/Console/Commands/',
'app/Mail/',
'app/Notifications/',
'database/factories/',
'database/seeders/',
];
foreach ($prefixes as $prefix) {
if (str_starts_with($rel, $prefix)) {
return true;
}
}
return false;
}
/** /**
* Reads `$rel` relative to the project root and extracts the * Reads `$rel` relative to the project root and extracts the
* tables it declares via `Schema::create/table/drop/rename`. * tables it declares via `Schema::create/table/drop/rename`.

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use ReflectionClass; use ReflectionClass;
/** /**
@ -108,6 +109,20 @@ final class Recorder
*/ */
private array $classDependencyCache = []; private array $classDependencyCache = [];
/**
* Cached test-file import resolution.
*
* @var array<string, list<string>>
*/
private array $testImportFileCache = [];
/**
* Included-file snapshot captured at the start of the current test.
*
* @var array<string, true>
*/
private array $includedFilesAtTestStart = [];
private bool $active = false; private bool $active = false;
private bool $driverChecked = false; private bool $driverChecked = false;
@ -169,6 +184,10 @@ final class Recorder
return; return;
} }
if ($this->currentTestFile !== null) {
return;
}
$file = $this->resolveTestFile($className, $fallbackFile); $file = $this->resolveTestFile($className, $fallbackFile);
if ($file === null) { if ($file === null) {
@ -176,6 +195,7 @@ final class Recorder
} }
$this->currentTestFile = $file; $this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) { if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
@ -193,6 +213,7 @@ final class Recorder
// the explicit walk for ancestors whose own bodies might be // the explicit walk for ancestors whose own bodies might be
// empty. // empty.
$this->linkAncestorFiles($className); $this->linkAncestorFiles($className);
$this->linkImportedFiles($file);
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\clear(); \pcov\clear();
@ -228,6 +249,15 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
foreach (AutoloadEdges::newProjectFiles(
$this->includedFilesAtTestStart,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
$this->currentTestFile,
) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// Walk each covered class's interfaces / traits / parent chain // Walk each covered class's interfaces / traits / parent chain
// and link those files explicitly. Interface declarations have // and link those files explicitly. Interface declarations have
// no executable bytecode, so coverage drivers never emit lines // no executable bytecode, so coverage drivers never emit lines
@ -239,6 +269,7 @@ final class Recorder
$this->linkSourceDependencies(array_keys($data)); $this->linkSourceDependencies(array_keys($data));
$this->currentTestFile = null; $this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
} }
/** /**
@ -267,6 +298,31 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
/**
* Records source dependencies for a specific test file. Used for edges
* captured before `Prepared` has opened the normal per-test recorder window.
*
* @param iterable<int, string> $sourceFiles
*/
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
return;
}
if ($testFile === '') {
return;
}
foreach ($sourceFiles as $sourceFile) {
if ($sourceFile === '') {
continue;
}
$this->perTestFiles[$testFile][$sourceFile] = true;
}
}
/** /**
* For each project-local source file the coverage driver * For each project-local source file the coverage driver
* captured for this test, finds the classes / interfaces / traits * captured for this test, finds the classes / interfaces / traits
@ -468,6 +524,135 @@ final class Recorder
} }
} }
/**
* Links project-local classes imported by the test file. This catches
* declaration-only support classes / enums / interfaces that may never emit
* executable coverage lines, and avoids relying on global autoload timing.
*/
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
return;
}
foreach ($this->importedFilesFor($testFile) as $file) {
$this->perTestFiles[$this->currentTestFile][$file] = true;
}
}
/**
* @return list<string>
*/
private function importedFilesFor(string $testFile): array
{
if (array_key_exists($testFile, $this->testImportFileCache)) {
return $this->testImportFileCache[$testFile];
}
$source = @file_get_contents($testFile);
if ($source === false) {
return $this->testImportFileCache[$testFile] = [];
}
$files = [];
foreach ($this->importedClassNames($source) as $className) {
$file = $this->findAutoloadFile($className);
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$file] = true;
}
}
return $this->testImportFileCache[$testFile] = array_keys($files);
}
/**
* @return list<string>
*/
private function importedClassNames(string $source): array
{
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
$classes = [];
foreach ($matches[1] as $import) {
$import = trim($import);
if ($import === '') {
continue;
}
$open = strpos($import, '{');
$close = strrpos($import, '}');
if ($open !== false && $close !== false && $close > $open) {
$prefix = trim(trim(substr($import, 0, $open)), '\\');
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
foreach ($items as $item) {
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
if ($class !== null) {
$classes[$class] = true;
}
}
continue;
}
$class = $this->normaliseImportedClass($import);
if ($class !== null) {
$classes[$class] = true;
}
}
return array_keys($classes);
}
private function normaliseImportedClass(string $import): ?string
{
$import = trim(trim($import), '\\');
if ($import === '') {
return null;
}
$parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false || $parts === []) {
return null;
}
$class = trim(trim($parts[0]), '\\');
return $class === '' ? null : $class;
}
private function findAutoloadFile(string $className): ?string
{
foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) {
continue;
}
if (! method_exists($loader[0], 'findFile')) {
continue;
}
/** @var mixed $file */
$file = $loader[0]->findFile($className);
if (is_string($file) && $file !== '') {
$real = @realpath($file);
return $real === false ? $file : $real;
}
}
return null;
}
/** /**
* True when `$className` (or any of its ancestors) uses one of * True when `$className` (or any of its ancestors) uses one of
* Laravel's database-resetting traits. Walking up `getTraits()` is * Laravel's database-resetting traits. Walking up `getTraits()` is

View File

@ -12,9 +12,8 @@ use Pest\TestSuite;
/** /**
* Watch patterns for frontend assets that affect browser tests. * Watch patterns for frontend assets that affect browser tests.
* *
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to * Uses `BrowserTestIdentifier` from pest-plugin-browser to auto-discover tests
* auto-discover directories containing browser tests. Falls back to the * using `visit()`. Also keeps the `tests/Browser` convention when present.
* `tests/Browser` convention when the plugin is absent.
* *
* @internal * @internal
*/ */
@ -31,7 +30,7 @@ final readonly class Browser implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath); $browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [ $globs = [
'resources/js/**/*.js', 'resources/js/**/*.js',
@ -51,7 +50,7 @@ final readonly class Browser implements WatchDefault
$patterns = []; $patterns = [];
foreach ($globs as $glob) { foreach ($globs as $glob) {
$patterns[$glob] = $browserDirs; $patterns[$glob] = $browserTargets;
} }
return $patterns; return $patterns;
@ -60,19 +59,19 @@ final readonly class Browser implements WatchDefault
/** /**
* @return array<int, string> * @return array<int, string>
*/ */
private function detectBrowserTestDirs(string $projectRoot, string $testPath): array public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array
{ {
$dirs = []; $targets = [];
$candidate = $testPath.'/Browser'; $candidate = $testPath.'/Browser';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) { if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$dirs[] = $candidate; $targets[] = $candidate;
} }
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser // Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find tests using `visit()` outside the conventional // is installed to find exact tests using `visit()` outside the
// Browser/ folder. // conventional Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) { if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests; $repo = TestSuite::getInstance()->tests;
@ -85,10 +84,10 @@ final readonly class Browser implements WatchDefault
foreach ($factory->methods as $method) { foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) { if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = $this->fileRelative($projectRoot, $filename); $rel = self::fileRelative($projectRoot, $filename);
if ($rel !== null) { if ($rel !== null) {
$dirs[] = dirname($rel); $targets[] = $rel;
} }
break; break;
@ -97,10 +96,10 @@ final readonly class Browser implements WatchDefault
} }
} }
return array_values(array_unique($dirs === [] ? [$testPath] : $dirs)); return array_values(array_unique($targets));
} }
private function fileRelative(string $projectRoot, string $path): ?string private static function fileRelative(string $projectRoot, string $path): ?string
{ {
$real = @realpath($path); $real = @realpath($path);

View File

@ -26,12 +26,10 @@ final readonly class Inertia implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser') $browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
? $testPath.'/Browser'
: $testPath;
// Inertia page components (React / Vue / Svelte). Scoped to // Inertia page components (React / Vue / Svelte). Scoped to
// `$browserDir` only — a Vue/React edit cannot change the // browser tests only — a Vue/React edit cannot change the
// output of a server-side Inertia test (those assert on the // output of a server-side Inertia test (those assert on the
// component *name* returned by `Inertia::render()`, not its // component *name* returned by `Inertia::render()`, not its
// client-side implementation). Broad invalidation is only // client-side implementation). Broad invalidation is only
@ -47,21 +45,21 @@ final readonly class Inertia implements WatchDefault
foreach (['Pages', 'pages'] as $pages) { foreach (['Pages', 'pages'] as $pages) {
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) { foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$pages}/**/*.{$ext}"] = [$browserDir]; $patterns["resources/js/{$pages}/**/*.{$ext}"] = $browserTargets;
} }
} }
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) { foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) { foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir]; $patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
} }
} }
// SSR entry point. // SSR entry point.
$patterns['resources/js/ssr.js'] = [$browserDir]; $patterns['resources/js/ssr.js'] = $browserTargets;
$patterns['resources/js/ssr.ts'] = [$browserDir]; $patterns['resources/js/ssr.ts'] = $browserTargets;
$patterns['resources/js/app.js'] = [$browserDir]; $patterns['resources/js/app.js'] = $browserTargets;
$patterns['resources/js/app.ts'] = [$browserDir]; $patterns['resources/js/app.ts'] = $browserTargets;
return $patterns; return $patterns;
} }

View File

@ -27,10 +27,6 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
$featurePath = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Feature')
? $testPath.'/Feature'
: $testPath;
return [ return [
// Config — loaded during app boot (setUp), invisible to coverage. // Config — loaded during app boot (setUp), invisible to coverage.
// Affects both Feature and Unit: Pest.php commonly binds fakes // Affects both Feature and Unit: Pest.php commonly binds fakes
@ -39,8 +35,8 @@ final readonly class Laravel implements WatchDefault
'config/**/*.php' => [$testPath], 'config/**/*.php' => [$testPath],
// Routes — loaded during boot. HTTP/Feature tests depend on them. // Routes — loaded during boot. HTTP/Feature tests depend on them.
'routes/*.php' => [$featurePath], 'routes/*.php' => [$testPath],
'routes/**/*.php' => [$featurePath], 'routes/**/*.php' => [$testPath],
// Service providers / bootstrap — loaded during boot, affect // Service providers / bootstrap — loaded during boot, affect
// bindings, middleware, event listeners, scheduled tasks. // bindings, middleware, event listeners, scheduled tasks.
@ -59,27 +55,27 @@ final readonly class Laravel implements WatchDefault
'database/factories/**/*.php' => [$testPath], 'database/factories/**/*.php' => [$testPath],
// Blade templates — compiled to cache, source file not executed. // Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$featurePath], 'resources/views/**/*.blade.php' => [$testPath],
// Email templates are nested under views/email or views/emails // Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup. // by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$featurePath], 'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$featurePath], 'resources/views/emails/**/*.blade.php' => [$testPath],
// Translations — JSON translations read via file_get_contents, // Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot). // PHP translations loaded via include (but during boot).
'lang/**/*.php' => [$featurePath], 'lang/**/*.php' => [$testPath],
'lang/**/*.json' => [$featurePath], 'lang/**/*.json' => [$testPath],
'resources/lang/**/*.php' => [$featurePath], 'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$featurePath], 'resources/lang/**/*.json' => [$testPath],
// Build tool config — affects compiled assets consumed by // Build tool config — affects compiled assets consumed by
// browser and Inertia tests. // browser and Inertia tests.
'vite.config.js' => [$featurePath], 'vite.config.js' => [$testPath],
'vite.config.ts' => [$featurePath], 'vite.config.ts' => [$testPath],
'webpack.mix.js' => [$featurePath], 'webpack.mix.js' => [$testPath],
'tailwind.config.js' => [$featurePath], 'tailwind.config.js' => [$testPath],
'tailwind.config.ts' => [$featurePath], 'tailwind.config.ts' => [$testPath],
'postcss.config.js' => [$featurePath], 'postcss.config.js' => [$testPath],
]; ];
} }
} }

View File

@ -8,12 +8,13 @@ use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
* Maps non-PHP file globs to the test directories they should invalidate. * Maps non-PHP file globs to the tests they should invalidate.
* *
* Coverage drivers only see `.php` files. Frontend assets, config files, * Coverage drivers only see `.php` files. Frontend assets, config files,
* Blade templates, routes and environment files are invisible to the graph. * Blade templates, routes and environment files are invisible to the graph.
* Watch patterns bridge the gap: when a changed file matches a glob, every * Watch patterns bridge the gap: when a changed file matches a glob, every
* test under the associated directory is marked as affected. * test under the associated directory (or the exact associated test file) is
* marked as affected.
* *
* Defaults are assembled dynamically from the `WatchDefaults/` registry — * Defaults are assembled dynamically from the `WatchDefaults/` registry —
* each implementation probes the current project and contributes patterns * each implementation probes the current project and contributes patterns
@ -38,7 +39,7 @@ final class WatchPatterns
]; ];
/** /**
* @var array<string, array<int, string>> glob → list of project-relative test dirs * @var array<string, array<int, string>> glob → list of project-relative test dirs/files
*/ */
private array $patterns = []; private array $patterns = [];
@ -71,7 +72,7 @@ final class WatchPatterns
* Adds user-defined patterns. Merges with existing entries so a single * Adds user-defined patterns. Merges with existing entries so a single
* glob can map to multiple directories. * glob can map to multiple directories.
* *
* @param array<string, string> $patterns glob → project-relative test dir * @param array<string, string> $patterns glob → project-relative test dir/file
*/ */
public function add(array $patterns): void public function add(array $patterns): void
{ {
@ -83,12 +84,12 @@ final class WatchPatterns
} }
/** /**
* Returns all test directories whose watch patterns match at least one of * Returns all test targets whose watch patterns match at least one of
* the given changed files. * the given changed files.
* *
* @param string $projectRoot Absolute path. * @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths. * @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test directories. * @return array<int, string> Project-relative test dirs/files.
*/ */
public function matchedDirectories(string $projectRoot, array $changedFiles): array public function matchedDirectories(string $projectRoot, array $changedFiles): array
{ {
@ -112,10 +113,10 @@ final class WatchPatterns
} }
/** /**
* Given the affected directories, returns every test file in the graph * Given the affected targets, returns every test file in the graph that
* that lives under one of those directories. * either matches an exact file target or lives under a directory target.
* *
* @param array<int, string> $directories Project-relative dirs. * @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph. * @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string> * @return array<int, string>
*/ */
@ -128,8 +129,14 @@ final class WatchPatterns
$affected = []; $affected = [];
foreach ($allTestFiles as $testFile) { foreach ($allTestFiles as $testFile) {
foreach ($directories as $dir) { foreach ($directories as $target) {
$prefix = rtrim($dir, '/').'/'; if ($testFile === $target) {
$affected[] = $testFile;
break;
}
$prefix = rtrim($target, '/').'/';
if (str_starts_with($testFile, $prefix)) { if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile; $affected[] = $testFile;

View File

@ -10,8 +10,9 @@ use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber; use PHPUnit\Event\Test\PreparedSubscriber;
/** /**
* Starts PCOV collection before each test. No-op unless the TIA recorder was * Starts PCOV collection before each test. Pest tests start from
* activated by the `--tia` plugin. * `Testable::setUp()` so Laravel boot is covered; this subscriber remains the
* fallback for PHPUnit-style tests and is idempotent for Pest tests.
* *
* @internal * @internal
*/ */