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

@ -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) {
if (isset($this->fileIds[$rel])) {
$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
// that only exists on this branch (graph inherited from main).
// Track its directory for the sibling heuristic (step 3).
$unknownSourceDirs[dirname($rel)] = true;
// Only use the sibling heuristic for files that commonly
// 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;
}
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;
}
}
@ -455,10 +478,11 @@ final class Graph
// whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file.
//
// To avoid silent misses: find tests that already cover ANY file in
// the same directory. If `app/Models/OrderItem.php` is unknown but
// `app/Models/Order.php` is covered by `OrderTest`, run `OrderTest`
// — it likely exercises sibling files in the same module.
// To avoid silent misses for framework-discovered files: find tests
// that already cover ANY file in the same directory. If
// `app/Listeners/SendWelcomeEmail.php` is unknown but neighbouring
// 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
// 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');
}
/**
* 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
* tables it declares via `Schema::create/table/drop/rename`.

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
use ReflectionClass;
/**
@ -108,6 +109,20 @@ final class Recorder
*/
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 $driverChecked = false;
@ -169,6 +184,10 @@ final class Recorder
return;
}
if ($this->currentTestFile !== null) {
return;
}
$file = $this->resolveTestFile($className, $fallbackFile);
if ($file === null) {
@ -176,6 +195,7 @@ final class Recorder
}
$this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true;
@ -193,6 +213,7 @@ final class Recorder
// the explicit walk for ancestors whose own bodies might be
// empty.
$this->linkAncestorFiles($className);
$this->linkImportedFiles($file);
if ($this->driver === 'pcov') {
\pcov\clear();
@ -228,6 +249,15 @@ final class Recorder
$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
// and link those files explicitly. Interface declarations have
// no executable bytecode, so coverage drivers never emit lines
@ -239,6 +269,7 @@ final class Recorder
$this->linkSourceDependencies(array_keys($data));
$this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
}
/**
@ -267,6 +298,31 @@ final class Recorder
$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
* 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
* 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.
*
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to
* auto-discover directories containing browser tests. Falls back to the
* `tests/Browser` convention when the plugin is absent.
* Uses `BrowserTestIdentifier` from pest-plugin-browser to auto-discover tests
* using `visit()`. Also keeps the `tests/Browser` convention when present.
*
* @internal
*/
@ -31,7 +30,7 @@ final readonly class Browser implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath);
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [
'resources/js/**/*.js',
@ -51,7 +50,7 @@ final readonly class Browser implements WatchDefault
$patterns = [];
foreach ($globs as $glob) {
$patterns[$glob] = $browserDirs;
$patterns[$glob] = $browserTargets;
}
return $patterns;
@ -60,19 +59,19 @@ final readonly class Browser implements WatchDefault
/**
* @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';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$dirs[] = $candidate;
$targets[] = $candidate;
}
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find tests using `visit()` outside the conventional
// Browser/ folder.
// is installed to find exact tests using `visit()` outside the
// conventional Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
@ -85,10 +84,10 @@ final readonly class Browser implements WatchDefault
foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = $this->fileRelative($projectRoot, $filename);
$rel = self::fileRelative($projectRoot, $filename);
if ($rel !== null) {
$dirs[] = dirname($rel);
$targets[] = $rel;
}
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);

View File

@ -26,12 +26,10 @@ final readonly class Inertia implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array
{
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser')
? $testPath.'/Browser'
: $testPath;
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
// 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
// component *name* returned by `Inertia::render()`, not its
// client-side implementation). Broad invalidation is only
@ -47,21 +45,21 @@ final readonly class Inertia implements WatchDefault
foreach (['Pages', 'pages'] as $pages) {
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 (['vue', 'tsx', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir];
$patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
}
}
// SSR entry point.
$patterns['resources/js/ssr.js'] = [$browserDir];
$patterns['resources/js/ssr.ts'] = [$browserDir];
$patterns['resources/js/app.js'] = [$browserDir];
$patterns['resources/js/app.ts'] = [$browserDir];
$patterns['resources/js/ssr.js'] = $browserTargets;
$patterns['resources/js/ssr.ts'] = $browserTargets;
$patterns['resources/js/app.js'] = $browserTargets;
$patterns['resources/js/app.ts'] = $browserTargets;
return $patterns;
}

View File

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

View File

@ -8,12 +8,13 @@ use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
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,
* Blade templates, routes and environment files are invisible to the graph.
* 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 —
* 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 = [];
@ -71,7 +72,7 @@ final class WatchPatterns
* Adds user-defined patterns. Merges with existing entries so a single
* 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
{
@ -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.
*
* @param string $projectRoot Absolute path.
* @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
{
@ -112,10 +113,10 @@ final class WatchPatterns
}
/**
* Given the affected directories, returns every test file in the graph
* that lives under one of those directories.
* Given the affected targets, returns every test file in the graph that
* 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.
* @return array<int, string>
*/
@ -128,8 +129,14 @@ final class WatchPatterns
$affected = [];
foreach ($allTestFiles as $testFile) {
foreach ($directories as $dir) {
$prefix = rtrim($dir, '/').'/';
foreach ($directories as $target) {
if ($testFile === $target) {
$affected[] = $testFile;
break;
}
$prefix = rtrim($target, '/').'/';
if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile;