Compare commits

...

4 Commits

Author SHA1 Message Date
466259646d wip 2026-04-28 22:12:42 +01:00
00f8d56083 wip 2026-04-28 21:41:20 +01:00
ca2dca592d wup 2026-04-28 21:34:40 +01:00
405d8d4406 wip 2026-04-28 21:28:46 +01:00
10 changed files with 656 additions and 66 deletions

View File

@ -8,6 +8,7 @@ use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\AutoloadEdges;
use Pest\Plugins\Tia\BladeEdges;
use Pest\Plugins\Tia\InertiaEdges;
use Pest\Plugins\Tia\Recorder;
@ -317,6 +318,16 @@ trait Testable
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();
// 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
// suite doesn't stack 774 composers / listeners when Laravel
// keeps the same app across tests.
$recorder = Container::getInstance()->get(Recorder::class);
if ($recorder instanceof Recorder) {
BladeEdges::arm($recorder);
TableTracker::arm($recorder);
@ -339,6 +349,18 @@ trait Testable
}
$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,
),
);
}
}
/**
@ -412,6 +434,10 @@ trait Testable
$tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
if ($assertions === 0) {
$this->expectNotToPerformAssertions();
}
$this->addToAssertionCount($assertions);
return null;

View File

@ -241,6 +241,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private bool $forceRefetch = false;
/**
* True once structural-drift recovery has already tried the remote
* baseline during this process. Prevents the later "no local graph" path
* from fetching the same stale baseline again and printing duplicate drift
* / rebuild messages.
*/
private bool $baselineFetchAttemptedForDrift = false;
/**
* True when `--fresh` is in the current argv — record-mode paths
* use it to gate `Graph::pruneMissingTests()`. On a partial record
@ -829,7 +837,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// to pull a team-shared baseline so fresh checkouts (new devs, CI
// containers) don't pay the full record cost. If the pull succeeds
// the graph is re-read and reconciled against the local env.
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
if (! $graph instanceof Graph
&& ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint);
@ -1506,8 +1517,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function tryRemoteBaselineForDrift(array $current): ?Graph
{
$projectRoot = TestSuite::getInstance()->rootPath;
$this->baselineFetchAttemptedForDrift = true;
if (! $this->baselineSync->fetchIfAvailable($projectRoot, false)) {
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
return null;
}

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;
}
}
}
@ -409,6 +426,39 @@ final class Graph
}
}
// Unknown Blade files can still be routed precisely when another
// recorded Blade view statically references them (`@include`,
// `@extends`, `<x-alert />`, etc.). Walk the source-level Blade graph
// upward to rendered ancestors and invalidate tests that rendered those
// ancestors instead of broadcasting every Blade edit to the whole suite.
$staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) {
continue;
}
if (! $this->isBladePath($rel)) {
continue;
}
if (! is_file($this->projectRoot.'/'.$rel)) {
continue;
}
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
if ($bladeAffected !== []) {
foreach ($bladeAffected as $testFile) {
$affectedSet[$testFile] = true;
}
$staticallyHandledBlade[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) {
// Anonymous Blade components are leaf templates. If nothing in
// the project statically renders the component, treat it like an
// orphan rather than running the full suite.
$staticallyHandledBlade[$rel] = true;
}
}
// 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
@ -433,7 +483,16 @@ final class Graph
if (isset($sharedFilesResolved[$rel])) {
continue;
}
if (isset($staticallyHandledBlade[$rel])) {
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 +514,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 +861,216 @@ 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;
}
private function isBladePath(string $rel): bool
{
return str_starts_with($rel, 'resources/views/') && str_ends_with($rel, '.blade.php');
}
private function isBladeComponentPath(string $rel): bool
{
return str_starts_with($rel, 'resources/views/components/') && str_ends_with($rel, '.blade.php');
}
/**
* @return list<string> Project-relative test files.
*/
private function affectedByStaticBladeUsage(string $changedBlade): array
{
$ancestors = $this->bladeAncestorsFor($changedBlade);
if ($ancestors === []) {
return [];
}
$ancestorIds = [];
foreach ($ancestors as $ancestor) {
if (isset($this->fileIds[$ancestor])) {
$ancestorIds[$this->fileIds[$ancestor]] = true;
}
}
if ($ancestorIds === []) {
return [];
}
$affected = [];
foreach ($this->edges as $testFile => $ids) {
foreach ($ids as $id) {
if (isset($ancestorIds[$id])) {
$affected[$testFile] = true;
break;
}
}
}
return array_keys($affected);
}
/**
* @return list<string> Project-relative Blade files that statically depend on $changedBlade, directly or transitively.
*/
private function bladeAncestorsFor(string $changedBlade): array
{
$allBladeFiles = $this->allBladeFiles();
if ($allBladeFiles === []) {
return [];
}
$targets = [$changedBlade => true];
$ancestors = [];
$changed = true;
while ($changed) {
$changed = false;
foreach ($allBladeFiles as $candidate) {
if (isset($targets[$candidate])) {
continue;
}
if (isset($ancestors[$candidate])) {
continue;
}
$source = @file_get_contents($this->projectRoot.'/'.$candidate);
if ($source === false) {
continue;
}
foreach (array_keys($targets) as $target) {
if ($this->bladeSourceReferences($source, $target)) {
$ancestors[$candidate] = true;
$targets[$candidate] = true;
$changed = true;
break;
}
}
}
}
return array_keys($ancestors);
}
/**
* @return list<string>
*/
private function allBladeFiles(): array
{
$views = $this->projectRoot.'/resources/views';
if (! is_dir($views)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($views, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $file) {
if (! $file instanceof \SplFileInfo || ! $file->isFile()) {
continue;
}
$path = $file->getPathname();
if (! str_ends_with($path, '.blade.php')) {
continue;
}
$files[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->projectRoot) + 1));
}
sort($files);
return $files;
}
private function bladeSourceReferences(string $source, string $targetBlade): bool
{
$view = $this->viewNameForBlade($targetBlade);
if ($view !== null) {
$quoted = preg_quote($view, '#');
if (preg_match('#@(include|includeIf|includeWhen|includeUnless|extends|component|each)\s*\([^)]*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) {
return true;
}
if (preg_match('#\b(view|View::make)\s*\(\s*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) {
return true;
}
}
foreach ($this->componentNamesForBlade($targetBlade) as $component) {
$quoted = preg_quote($component, '#');
if (preg_match('#<x-'.$quoted.'(?=[\s>/.:])#i', $source) === 1) {
return true;
}
}
return false;
}
private function viewNameForBlade(string $rel): ?string
{
if (! $this->isBladePath($rel)) {
return null;
}
$tail = substr($rel, strlen('resources/views/'));
$tail = substr($tail, 0, -strlen('.blade.php'));
return str_replace('/', '.', $tail);
}
/**
* @return list<string>
*/
private function componentNamesForBlade(string $rel): array
{
if (! $this->isBladeComponentPath($rel)) {
return [];
}
$tail = substr($rel, strlen('resources/views/components/'));
$tail = substr($tail, 0, -strlen('.blade.php'));
$name = str_replace('/', '.', $tail);
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
}
/**
* 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;

View File

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