diff --git a/src/Configuration.php b/src/Configuration.php index 4261f3ef..a37871ee 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -119,6 +119,14 @@ final readonly class Configuration return new Browser\Configuration; } + /** + * Gets the TIA (Test Impact Analysis) configuration. + */ + public function tia(): Plugins\Tia\Configuration + { + return new Plugins\Tia\Configuration; + } + /** * Proxies calls to the uses method. * diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 33070b0b..0df1bfdf 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -284,6 +284,11 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg */ private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array { + // Initialise watch patterns (defaults + any user additions from + // tests/Pest.php which has already been loaded by BootFiles at + // this point). + WatchPatterns::instance()->useDefaults($projectRoot); + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; $fingerprint = Fingerprint::compute($projectRoot); diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php new file mode 100644 index 00000000..d711e561 --- /dev/null +++ b/src/Plugins/Tia/Configuration.php @@ -0,0 +1,38 @@ +tia()`. + * + * Usage in `tests/Pest.php`: + * + * pest()->tia()->watch([ + * 'resources/js/**\/*.tsx' => 'tests/Browser', + * 'public/build/**\/*' => 'tests/Browser', + * ]); + * + * Patterns are merged with the built-in defaults (config, routes, views, + * frontend assets, migrations). Duplicate glob keys overwrite the default + * mapping so users can redirect a pattern to a narrower directory. + * + * @internal + */ +final class Configuration +{ + /** + * Adds watch-pattern → test-directory mappings that supplement (or + * override) the built-in defaults. + * + * @param array $patterns glob → project-relative test dir + * @return $this + */ + public function watch(array $patterns): self + { + WatchPatterns::instance()->add($patterns); + + return $this; + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 6fd9a3bf..08d46e08 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -88,11 +88,18 @@ final class Graph /** * Returns the set of test files whose dependencies intersect $changedFiles. * + * Two resolution paths: + * 1. **Coverage edges** — test depends on a PHP source file that changed. + * 2. **Watch patterns** — a non-PHP file (JS, CSS, config, …) matches a + * glob that maps to a test directory; every test under that directory + * is affected. + * * @param array $changedFiles Absolute or relative paths. * @return array Relative test file paths. */ public function affected(array $changedFiles): array { + // 1. Coverage-edge lookup (PHP → PHP). $changedIds = []; foreach ($changedFiles as $file) { @@ -107,19 +114,38 @@ final class Graph } } - $affected = []; + $affectedSet = []; foreach ($this->edges as $testFile => $ids) { foreach ($ids as $id) { if (isset($changedIds[$id])) { - $affected[] = $testFile; + $affectedSet[$testFile] = true; break; } } } - return $affected; + // 2. Watch-pattern lookup (non-PHP assets → test directories). + $watchPatterns = WatchPatterns::instance(); + $normalised = []; + + foreach ($changedFiles as $file) { + $rel = $this->relative($file); + + if ($rel !== null) { + $normalised[] = $rel; + } + } + + $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised); + $allTestFiles = array_keys($this->edges); + + foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { + $affectedSet[$testFile] = true; + } + + return array_keys($affectedSet); } /** diff --git a/src/Plugins/Tia/WatchDefaults/Browser.php b/src/Plugins/Tia/WatchDefaults/Browser.php new file mode 100644 index 00000000..2c712f19 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Browser.php @@ -0,0 +1,118 @@ +detectBrowserTestDirs($projectRoot, $testPath); + + $globs = [ + 'resources/js/**/*.js', + 'resources/js/**/*.ts', + 'resources/js/**/*.tsx', + 'resources/js/**/*.jsx', + 'resources/js/**/*.vue', + 'resources/js/**/*.svelte', + 'resources/css/**/*.css', + 'resources/css/**/*.scss', + 'resources/css/**/*.less', + // Vite / Webpack build output that browser tests may consume. + 'public/build/**/*.js', + 'public/build/**/*.css', + ]; + + $patterns = []; + + foreach ($globs as $glob) { + $patterns[$glob] = $browserDirs; + } + + return $patterns; + } + + /** + * @return array + */ + private function detectBrowserTestDirs(string $projectRoot, string $testPath): array + { + $dirs = []; + + $candidate = $testPath.'/Browser'; + + if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) { + $dirs[] = $candidate; + } + + // Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser + // is installed to find tests using `visit()` outside the conventional + // Browser/ folder. + if (class_exists(\Pest\Browser\Support\BrowserTestIdentifier::class)) { + $repo = TestSuite::getInstance()->tests; + + foreach ($repo->getFilenames() as $filename) { + $factory = $repo->get($filename); + + if (! $factory instanceof TestCaseFactory) { + continue; + } + + foreach ($factory->methods as $method) { + if (\Pest\Browser\Support\BrowserTestIdentifier::isBrowserTest($method)) { + $rel = $this->fileRelative($projectRoot, $filename); + + if ($rel !== null) { + $dirs[] = dirname($rel); + } + + break; + } + } + } + } + + return array_values(array_unique($dirs === [] ? [$testPath] : $dirs)); + } + + private function fileRelative(string $projectRoot, string $path): ?string + { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + $root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + if (! str_starts_with($real, $root)) { + return null; + } + + return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Inertia.php b/src/Plugins/Tia/WatchDefaults/Inertia.php new file mode 100644 index 00000000..9f0e32f1 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Inertia.php @@ -0,0 +1,53 @@ + [$testPath, $browserDir], + 'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir], + 'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir], + 'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir], + + // Shared layouts / components consumed by pages. + 'resources/js/Layouts/**/*.vue' => [$browserDir], + 'resources/js/Layouts/**/*.tsx' => [$browserDir], + 'resources/js/Components/**/*.vue' => [$browserDir], + 'resources/js/Components/**/*.tsx' => [$browserDir], + + // SSR entry point. + 'resources/js/ssr.js' => [$browserDir], + 'resources/js/ssr.ts' => [$browserDir], + 'resources/js/app.js' => [$browserDir], + 'resources/js/app.ts' => [$browserDir], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php new file mode 100644 index 00000000..3810c0cf --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Laravel.php @@ -0,0 +1,81 @@ + [$testPath], + 'config/**/*.php' => [$testPath], + + // Routes — loaded during boot. HTTP/Feature tests depend on them. + 'routes/*.php' => [$featurePath], + 'routes/**/*.php' => [$featurePath], + + // Service providers / bootstrap — loaded during boot, affect + // bindings, middleware, event listeners, scheduled tasks. + 'bootstrap/app.php' => [$testPath], + 'bootstrap/providers.php' => [$testPath], + + // Migrations — run via RefreshDatabase/FastRefreshDatabase in + // setUp. Schema changes can break any test that touches DB. + 'database/migrations/**/*.php' => [$testPath], + + // Seeders — often run globally via Pest.php beforeEach. + 'database/seeders/**/*.php' => [$testPath], + + // Factories — loaded lazily but still PHP that coverage may miss + // if the factory file was already autoloaded before Prepared. + 'database/factories/**/*.php' => [$testPath], + + // Blade templates — compiled to cache, source file not executed. + 'resources/views/**/*.blade.php' => [$featurePath], + + // 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], + + // 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], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Livewire.php b/src/Plugins/Tia/WatchDefaults/Livewire.php new file mode 100644 index 00000000..3a37c487 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Livewire.php @@ -0,0 +1,38 @@ + [$testPath], + 'resources/views/components/**/*.blade.php' => [$testPath], + + // Livewire JS interop / Alpine plugins. + 'resources/js/**/*.js' => [$testPath], + 'resources/js/**/*.ts' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php new file mode 100644 index 00000000..389966cc --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -0,0 +1,53 @@ + [$testPath], + '.env.testing' => [$testPath], + + // Docker / CI — can affect integration test infrastructure. + 'docker-compose.yml' => [$testPath], + 'docker-compose.yaml' => [$testPath], + + // PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but + // phpunit.xml.dist and other XML overrides are not individually + // tracked by the coverage driver. + 'phpunit.xml.dist' => [$testPath], + + // Test fixtures — JSON, CSV, XML, TXT data files consumed by + // assertions. A fixture change can flip a test result. + $testPath.'/Fixtures/**/*.json' => [$testPath], + $testPath.'/Fixtures/**/*.csv' => [$testPath], + $testPath.'/Fixtures/**/*.xml' => [$testPath], + $testPath.'/Fixtures/**/*.txt' => [$testPath], + + // Pest snapshots — external edits to snapshot files invalidate + // snapshot assertions. + $testPath.'/.pest/snapshots/**/*.snap' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Symfony.php b/src/Plugins/Tia/WatchDefaults/Symfony.php new file mode 100644 index 00000000..a3d4b0b3 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Symfony.php @@ -0,0 +1,75 @@ + [$testPath], + 'config/*.yml' => [$testPath], + 'config/*.php' => [$testPath], + 'config/*.xml' => [$testPath], + 'config/**/*.yaml' => [$testPath], + 'config/**/*.yml' => [$testPath], + 'config/**/*.php' => [$testPath], + 'config/**/*.xml' => [$testPath], + + // Routes — loaded during boot. + 'config/routes/*.yaml' => [$testPath], + 'config/routes/*.php' => [$testPath], + 'config/routes/*.xml' => [$testPath], + 'config/routes/**/*.yaml' => [$testPath], + + // Kernel / bootstrap — loaded during boot. + 'src/Kernel.php' => [$testPath], + + // Migrations — run during setUp (before coverage window). + 'migrations/**/*.php' => [$testPath], + + // Twig templates — compiled, source not PHP-executed. + 'templates/**/*.html.twig' => [$testPath], + 'templates/**/*.twig' => [$testPath], + + // Translations (YAML / XLF / XLIFF). + 'translations/**/*.yaml' => [$testPath], + 'translations/**/*.yml' => [$testPath], + 'translations/**/*.xlf' => [$testPath], + 'translations/**/*.xliff' => [$testPath], + + // Doctrine XML/YAML mappings. + 'config/doctrine/**/*.xml' => [$testPath], + 'config/doctrine/**/*.yaml' => [$testPath], + + // Webpack Encore / asset-mapper config + frontend sources. + 'webpack.config.js' => [$testPath], + 'importmap.php' => [$testPath], + 'assets/**/*.js' => [$testPath], + 'assets/**/*.ts' => [$testPath], + 'assets/**/*.vue' => [$testPath], + 'assets/**/*.css' => [$testPath], + 'assets/**/*.scss' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/WatchDefault.php b/src/Plugins/Tia/WatchDefaults/WatchDefault.php new file mode 100644 index 00000000..ce84b5ae --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/WatchDefault.php @@ -0,0 +1,28 @@ +> glob → list of project-relative test dirs + */ + public function defaults(string $projectRoot, string $testPath): array; +} diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php new file mode 100644 index 00000000..6bff5810 --- /dev/null +++ b/src/Plugins/Tia/WatchPatterns.php @@ -0,0 +1,194 @@ +tia()->watch(…)`. + * + * @internal + */ +final class WatchPatterns +{ + /** + * All known default providers, in evaluation order. + * + * @var array> + */ + private const array DEFAULTS = [ + WatchDefaults\Php::class, + WatchDefaults\Laravel::class, + WatchDefaults\Symfony::class, + WatchDefaults\Livewire::class, + WatchDefaults\Inertia::class, + WatchDefaults\Browser::class, + ]; + + /** + * @var array> glob → list of project-relative test dirs + */ + private array $patterns = []; + + private static ?self $instance = null; + + public static function instance(): self + { + return self::$instance ??= new self; + } + + /** + * Probes every registered `WatchDefault` and merges the patterns of + * those that apply. Called once during Tia plugin boot, after BootFiles + * has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()` + * calls are already in `$this->patterns`). + */ + public function useDefaults(string $projectRoot): void + { + $testPath = \Pest\TestSuite::getInstance()->testPath; + + foreach (self::DEFAULTS as $class) { + $default = new $class; + + if (! $default->applicable()) { + continue; + } + + foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) { + $this->patterns[$glob] = array_values(array_unique( + array_merge($this->patterns[$glob] ?? [], $dirs), + )); + } + } + } + + /** + * Adds user-defined patterns. Merges with existing entries so a single + * glob can map to multiple directories. + * + * @param array $patterns glob → project-relative test dir + */ + public function add(array $patterns): void + { + foreach ($patterns as $glob => $dir) { + $this->patterns[$glob] = array_values(array_unique( + array_merge($this->patterns[$glob] ?? [], [$dir]), + )); + } + } + + /** + * Returns all test directories whose watch patterns match at least one of + * the given changed files. + * + * @param string $projectRoot Absolute path. + * @param array $changedFiles Project-relative paths. + * @return array Project-relative test directories. + */ + public function matchedDirectories(string $projectRoot, array $changedFiles): array + { + if ($this->patterns === []) { + return []; + } + + $matched = []; + + foreach ($changedFiles as $file) { + foreach ($this->patterns as $glob => $dirs) { + if ($this->globMatches($glob, $file)) { + foreach ($dirs as $dir) { + $matched[$dir] = true; + } + } + } + } + + return array_keys($matched); + } + + /** + * Given the affected directories, returns every test file in the graph + * that lives under one of those directories. + * + * @param array $directories Project-relative dirs. + * @param array $allTestFiles Project-relative test files from graph. + * @return array + */ + public function testsUnderDirectories(array $directories, array $allTestFiles): array + { + if ($directories === []) { + return []; + } + + $affected = []; + + foreach ($allTestFiles as $testFile) { + foreach ($directories as $dir) { + $prefix = rtrim($dir, '/').'/'; + + if (str_starts_with($testFile, $prefix)) { + $affected[] = $testFile; + + break; + } + } + } + + return $affected; + } + + public function reset(): void + { + $this->patterns = []; + } + + /** + * Matches a project-relative file against a glob pattern. + * + * Supports `*` (single segment), `**` (any depth) and `?`. + */ + private function globMatches(string $pattern, string $file): bool + { + $pattern = str_replace('\\', '/', $pattern); + $file = str_replace('\\', '/', $file); + + $regex = ''; + $len = strlen($pattern); + $i = 0; + + while ($i < $len) { + $c = $pattern[$i]; + + if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') { + $regex .= '.*'; + $i += 2; + + if (isset($pattern[$i]) && $pattern[$i] === '/') { + $i++; + } + } elseif ($c === '*') { + $regex .= '[^/]*'; + $i++; + } elseif ($c === '?') { + $regex .= '[^/]'; + $i++; + } else { + $regex .= preg_quote($c, '#'); + $i++; + } + } + + return (bool) preg_match('#^'.$regex.'$#', $file); + } +}