Compare commits

...

4 Commits

Author SHA1 Message Date
b944ee5841 wip 2026-04-27 19:15:42 +01:00
f4e22dcafe wip 2026-04-27 18:57:41 +01:00
339c1e8cac wip 2026-04-27 18:14:10 +01:00
d4c7362132 wip 2026-04-27 16:56:27 +01:00
8 changed files with 420 additions and 99 deletions

View File

@ -75,12 +75,22 @@ final readonly class ChangedFiles
} }
if (! $exists) { if (! $exists) {
// Missing now. If the snapshot recorded it as absent too // Missing on disk. We always invalidate here, even when
// (sentinel ''), state is identical to last run — unchanged. // the snapshot also recorded "deleted" (sentinel '').
// Otherwise it was present last run and got deleted since. // The `snapshot=='' && !exists` shortcut would in
if ($snapshot !== '') { // principle say "no change since last run, cached
// result is still valid" — but it's only safe if the
// cached result was recorded *during* a run that saw
// the file as deleted. A previous run that captured
// the deletion in `lastRunTree` but failed to refresh
// the cached pass/fail (paratest worker race, an
// earlier plugin bug, etc.) would leave the cache
// stuck on a stale pass from before the deletion.
// Skipping invalidation in that state perpetuates the
// wrong result on every subsequent run. Treat any
// missing file as a change; cost is one re-run per
// `--tia` while the file stays deleted.
$remaining[] = $file; $remaining[] = $file;
}
continue; continue;
} }
@ -94,28 +104,21 @@ final readonly class ChangedFiles
} }
if ($hash === $snapshot) { if ($hash === $snapshot) {
// Same state as the last TIA invocation — unchanged. // Same state as the last TIA invocation — cached
// result is still valid, no need to re-run.
continue; continue;
} }
// Differs from the snapshot, but may still be a revert back // Differs from the snapshot. This includes the
// to the committed version (scenario: last run had an edit, // revert-back-to-baseline case (last run had a real edit
// this run reverted it). Skipping this check causes stale // and was cached against that edit; this run reverted).
// snapshots from previous scenarios to cascade into the // Even though the file now matches what's at the recorded
// current run's invalidation set. Cheap to verify via // SHA, the cached test result reflects the *modified*
// `git show <sha>:<path>`. // version, not the baseline version — so it's stale and
if ($sha !== null && $sha !== '') { // the test must re-run to refresh the cache. An earlier
$baselineContent = $this->contentAtSha($sha, $file); // version of this filter short-circuited on
// matches-baseline, which served the stale failure
if ($baselineContent !== null) { // forever after the user reverted.
$baselineHash = ContentHash::ofContent($file, $baselineContent);
if ($hash === $baselineHash) {
continue;
}
}
}
$remaining[] = $file; $remaining[] = $file;
} }

View File

@ -829,18 +829,24 @@ final class Graph
/** /**
* Maps a project-relative path to its Inertia component name if it * Maps a project-relative path to its Inertia component name if it
* lives under `resources/js/Pages/` with a recognised framework * lives under the project's pages directory with a recognised
* extension. Returns null otherwise so callers can cheaply ignore * framework extension. Returns null otherwise so callers can
* non-page files. Matches Inertia's resolver convention: strip the * cheaply ignore non-page files. Matches Inertia's resolver
* `resources/js/Pages/` prefix, strip the extension, preserve the * convention: strip the pages prefix, strip the extension, preserve
* remaining slashes (`Users/Show.vue` → `Users/Show`). * the remaining slashes (`Users/Show.vue` → `Users/Show`).
*
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
* and `resources/js/pages/` (the Laravel React starter kit, and
* other lowercase-by-default setups) are accepted — paths from
* git are case-sensitive on Linux, so we must match the exact
* casing used by the project rather than picking one and forcing
* the other to fall through to the broad watch pattern.
*/ */
private function componentForInertiaPage(string $rel): ?string private function componentForInertiaPage(string $rel): ?string
{ {
$prefix = 'resources/js/Pages/'; foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
if (! str_starts_with($rel, $prefix)) { if (! str_starts_with($rel, $prefix)) {
return null; continue;
} }
$tail = substr($rel, strlen($prefix)); $tail = substr($rel, strlen($prefix));
@ -861,6 +867,9 @@ final class Graph
return $name === '' ? null : $name; return $name === '' ? null : $name;
} }
return null;
}
/** /**
* Whether any test's component set contains `$component`. Used to * Whether any test's component set contains `$component`. Used to
* decide between precise edge matching and watch-pattern fallback * decide between precise edge matching and watch-pattern fallback

View File

@ -33,7 +33,15 @@ final class InertiaEdges
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled'; /**
* Event class name used as the listener key. Stored *without* a
* leading backslash because Laravel's `Dispatcher` keys
* `$listeners[$eventName]` by the literal string passed to
* `listen()`, and looks up incoming events by their PHP-class
* name (`get_class($event)`), which never has a leading
* backslash. A `\Illuminate\…` key would silently never match.
*/
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
/** /**
* App-scoped marker that makes `arm()` idempotent across per-test * App-scoped marker that makes `arm()` idempotent across per-test
@ -129,22 +137,66 @@ final class InertiaEdges
} }
} }
// Initial-load HTML path: Inertia embeds the page payload in a // Initial-load HTML path. Inertia ships two shapes here and
// `data-page` attribute on the root `<div id="app">`. We only // we honour both:
// pay the regex cost when the body actually contains the //
// attribute, so non-Inertia HTML responses are effectively a // 1. SSR-safe script tag — `<script data-page="app"
// no-op. // type="application/json">{…JSON…}</script>`. The
// Laravel React starter kit (and modern Inertia-React)
// use this so the JSON survives server-rendered
// hydration without HTML-encoding the payload into an
// attribute. The `data-page="app"` *attribute value* is
// the literal string `"app"` — only the tag *body*
// carries the page JSON.
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
// Inertia-Vue and Inertia-React still emit this. Here
// `data-page` IS the JSON, HTML-entity-encoded.
//
// Try the script-tag shape first; if the response uses it,
// the classic regex would also see a `data-page="app"` token
// and try to JSON-decode the literal string `"app"`.
$content = self::readContent($response); $content = self::readContent($response);
if ($content === null || ! str_contains($content, 'data-page=')) { if ($content === null) {
return null; return null;
} }
if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) { // Lookahead pair handles arbitrary attribute order on the
// `<script>` tag.
if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
}
// Classic: only accept a value that looks like a JSON object
// (`{…}`). Avoids matching the script-tag form's
// `data-page="app"` attribute when both shapes coexist.
if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
}
return null; return null;
} }
$decoded = json_decode(html_entity_decode($match[1]), true); /**
* Parses an Inertia page JSON blob and returns the `component`
* field if it's a non-empty string. Used by both the script-tag
* and the `data-page`-attribute paths so the success criteria are
* identical.
*/
private static function componentFromJson(string $json): ?string
{
/** @var mixed $decoded */
$decoded = json_decode($json, true);
if (is_array($decoded) if (is_array($decoded)
&& isset($decoded['component']) && isset($decoded['component'])

View File

@ -36,23 +36,32 @@ final class JsImportParser
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts']; private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
private const string PAGES_DIR = 'resources/js/Pages';
private const string JS_DIR = 'resources/js'; private const string JS_DIR = 'resources/js';
/** /**
* Walks `resources/js/Pages` and, for each page, collects its * Walks the project's pages directory (`resources/js/Pages` or its
* transitive file imports. Returns the inverted graph so callers * lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
* can look up "what pages depend on this shared file". * and, for each page, collects its transitive file imports. Returns
* the inverted graph so callers can look up "what pages depend on
* this shared file".
* *
* @return array<string, list<string>> * @return array<string, list<string>>
*/ */
public static function parse(string $projectRoot): array public static function parse(string $projectRoot): array
{ {
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR; $jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
$pagesRoot = $projectRoot.DIRECTORY_SEPARATOR.self::PAGES_DIR; $pagesRoot = null;
if (! is_dir($pagesRoot)) { foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
if (is_dir($abs)) {
$pagesRoot = $abs;
break;
}
}
if ($pagesRoot === null) {
return []; return [];
} }

View File

@ -76,7 +76,22 @@ final class JsModuleGraph
*/ */
public static function isApplicable(string $projectRoot): bool public static function isApplicable(string $projectRoot): bool
{ {
return self::hasViteConfig($projectRoot) && is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.'Pages'); if (! self::hasViteConfig($projectRoot)) {
return false;
}
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
// starter kit (`pages/`) conventions are accepted — projects
// running on a case-sensitive filesystem (Linux CI) get
// exactly one of the two, and we shouldn't refuse to walk the
// graph based on which one it picks.
foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return true;
}
}
return false;
} }
/** /**
@ -104,7 +119,21 @@ final class JsModuleGraph
return null; return null;
} }
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot); // Tell the Node helper which casing this project uses for its
// pages directory. The helper defaults to `resources/js/Pages`;
// the Laravel React starter ships lowercase `resources/js/pages`,
// and on a case-sensitive filesystem the helper would otherwise
// walk a non-existent directory and emit an empty module graph.
$env = [];
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$env['TIA_VITE_PAGES_DIR'] = $candidate;
break;
}
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS); $process->setTimeout(self::NODE_TIMEOUT_SECONDS);
$process->run(); $process->run();

View File

@ -76,6 +76,38 @@ final class Recorder
*/ */
private array $classUsesDatabaseCache = []; private array $classUsesDatabaseCache = [];
/**
* Reverse map of project-local source file → list of class /
* interface / trait names declared in it. Built incrementally as
* tests run and new classes get autoloaded; consumed by
* `linkSourceDependencies()` so a test's covered file's
* declared classes can be walked for their interfaces, traits,
* and parents (which the coverage driver doesn't capture
* because interface declarations and empty traits emit no
* executable bytecode).
*
* @var array<string, list<string>>
*/
private array $fileToClassNames = [];
/**
* Names already folded into `$fileToClassNames`. Lets the
* incremental refresher skip classes seen in a previous test.
*
* @var array<string, true>
*/
private array $indexedClassNames = [];
/**
* Cached "files this class transitively depends on (interfaces,
* traits, parent chain, parents' interfaces and traits)" for
* project-local class names. Avoids re-walking the same
* hierarchy on every test that touches the same class.
*
* @var array<string, list<string>>
*/
private array $classDependencyCache = [];
private bool $active = false; private bool $active = false;
private bool $driverChecked = false; private bool $driverChecked = false;
@ -196,6 +228,16 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $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
// for them — without this walk, a signature change to an
// interface like `Viewable` would leave the cached results of
// every test that exercises an implementing class stale,
// because the interface file never enters the graph through
// the coverage path.
$this->linkSourceDependencies(array_keys($data));
$this->currentTestFile = null; $this->currentTestFile = null;
} }
@ -225,6 +267,173 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
/**
* For each project-local source file the coverage driver
* captured for this test, finds the classes / interfaces / traits
* declared in it and links every file in their declarative
* hierarchy: implemented interfaces (transitive), used traits,
* and parent classes (with their own interfaces and traits).
*
* Coverage drivers only record executable lines, so an interface
* signature change (e.g. adding a return type to a `Viewable`
* method) never registers — the interface file has no bytecode
* to instrument. Without this walk, every class implementing the
* interface would silently keep its stale cached result through
* the change, even though `--parallel` (no TIA) catches the
* incompatibility immediately.
*
* @param array<int, string> $coveredFiles absolute paths from coverage
*/
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
return;
}
$this->refreshClassMap();
foreach ($coveredFiles as $coveredFile) {
if (! isset($this->fileToClassNames[$coveredFile])) {
continue;
}
foreach ($this->fileToClassNames[$coveredFile] as $name) {
foreach ($this->classDependencies($name) as $depFile) {
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
}
}
}
}
/**
* Incrementally folds every project-local class / interface /
* trait declared since the last refresh into `$fileToClassNames`.
* PHP only ever appends to its declared-symbol lists (classes
* never get unloaded), so iterating from `$indexedClassNames`'s
* cardinality forward is sufficient — and over a long suite this
* is dominated by the first test, since most classes are loaded
* by then.
*/
private function refreshClassMap(): void
{
$names = array_merge(
get_declared_classes(),
get_declared_interfaces(),
get_declared_traits(),
);
foreach ($names as $name) {
if (isset($this->indexedClassNames[$name])) {
continue;
}
$this->indexedClassNames[$name] = true;
// Names came directly from `get_declared_*`, so the
// class/interface/trait is guaranteed loaded — but
// `class_exists($name, false)` (no autoload) keeps the
// string narrowed to `class-string` for static analysis
// and the `ReflectionClass` constructor stays in its
// documented happy path.
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
continue;
}
$reflection = new ReflectionClass($name);
if ($reflection->isInternal()) {
continue;
}
$file = $reflection->getFileName();
if (! is_string($file)) {
continue;
}
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
continue;
}
$this->fileToClassNames[$file][] = $name;
}
}
/**
* Returns the project-local files the named class declaratively
* depends on: implemented interfaces (transitive), used traits,
* and the entire parent chain (each with their own interfaces
* and traits). Cached per class because the answer is invariant
* across a single process.
*
* @return list<string>
*/
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
return $this->classDependencyCache[$className];
}
if (! class_exists($className, false)
&& ! interface_exists($className, false)
&& ! trait_exists($className, false)) {
return $this->classDependencyCache[$className] = [];
}
$reflection = new ReflectionClass($className);
$files = [];
$linkSymbol = static function (string $name) use (&$files): void {
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
return;
}
$r = new ReflectionClass($name);
if ($r->isInternal()) {
return;
}
$f = $r->getFileName();
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
return;
}
$files[$f] = true;
};
// `getInterfaceNames()` is transitive — it returns interfaces
// from parent classes and parent interfaces too — so a single
// pass covers the whole interface graph.
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
// Direct + ancestor traits. `getTraitNames()` doesn't recurse
// into traits-using-traits, but that's a rare pattern in
// application code; if a project genuinely needs it, the
// coverage driver will pick up the executed bytecode of the
// outer trait and the dependency walk runs against the
// resulting class anyway.
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $reflection->getParentClass();
while ($parent !== false && ! $parent->isInternal()) {
$f = $parent->getFileName();
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$f] = true;
}
foreach ($parent->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $parent->getParentClass();
}
return $this->classDependencyCache[$className] = array_keys($files);
}
/** /**
* Records every project-local ancestor class's defining file as a * Records every project-local ancestor class's defining file as a
* source dependency of the currently-running test. PCOV / Xdebug * source dependency of the currently-running test. PCOV / Xdebug
@ -473,6 +682,9 @@ final class Recorder
$this->perTestUsesDatabase = []; $this->perTestUsesDatabase = [];
$this->classFileCache = []; $this->classFileCache = [];
$this->classUsesDatabaseCache = []; $this->classUsesDatabaseCache = [];
$this->fileToClassNames = [];
$this->indexedClassNames = [];
$this->classDependencyCache = [];
$this->active = false; $this->active = false;
} }
} }

View File

@ -118,6 +118,11 @@ final class TableTracker
return; return;
} }
$events->listen('\\Illuminate\\Database\\Events\\QueryExecuted', $listener); // Event class key intentionally has no leading backslash —
// `Dispatcher::listen()` stores by the literal string and the
// lookup at dispatch time uses `get_class($event)` (no
// leading backslash), so a `\Illuminate\…` key would never
// match the fired event.
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
} }
} }

View File

@ -30,7 +30,6 @@ final readonly class Inertia implements WatchDefault
? $testPath.'/Browser' ? $testPath.'/Browser'
: $testPath; : $testPath;
return [
// 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 // `$browserDir` 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
@ -39,28 +38,31 @@ final readonly class Inertia implements WatchDefault
// meaningful for tests that actually render the DOM. Precise // meaningful for tests that actually render the DOM. Precise
// per-component edges come from `InertiaEdges` at record // per-component edges come from `InertiaEdges` at record
// time and replace this fallback when available. // time and replace this fallback when available.
'resources/js/Pages/**/*.vue' => [$browserDir], //
'resources/js/Pages/**/*.tsx' => [$browserDir], // Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
'resources/js/Pages/**/*.jsx' => [$browserDir], // React starter kit, and other lowercase-by-default setups)
'resources/js/Pages/**/*.svelte' => [$browserDir], // are emitted — paths from git are case-sensitive on Linux,
'resources/js/Pages/**/*.ts' => [$browserDir], // so a single casing would silently miss the other convention.
'resources/js/Pages/**/*.js' => [$browserDir], $patterns = [];
// Shared layouts / components consumed by pages. foreach (['Pages', 'pages'] as $pages) {
'resources/js/Layouts/**/*.vue' => [$browserDir], foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
'resources/js/Layouts/**/*.tsx' => [$browserDir], $patterns["resources/js/{$pages}/**/*.{$ext}"] = [$browserDir];
'resources/js/Layouts/**/*.ts' => [$browserDir], }
'resources/js/Layouts/**/*.js' => [$browserDir], }
'resources/js/Components/**/*.vue' => [$browserDir],
'resources/js/Components/**/*.tsx' => [$browserDir], foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
'resources/js/Components/**/*.ts' => [$browserDir], foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
'resources/js/Components/**/*.js' => [$browserDir], $patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir];
}
}
// SSR entry point. // SSR entry point.
'resources/js/ssr.js' => [$browserDir], $patterns['resources/js/ssr.js'] = [$browserDir];
'resources/js/ssr.ts' => [$browserDir], $patterns['resources/js/ssr.ts'] = [$browserDir];
'resources/js/app.js' => [$browserDir], $patterns['resources/js/app.js'] = [$browserDir];
'resources/js/app.ts' => [$browserDir], $patterns['resources/js/app.ts'] = [$browserDir];
];
return $patterns;
} }
} }