mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
5 Commits
d9c18f9c02
...
3d3c5d41ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d3c5d41ac | |||
| caabebf2a1 | |||
| 470a5833d4 | |||
| c1feefbb9e | |||
| e876dba8ba |
181
bin/pest-tia-vite-deps.mjs
Normal file
181
bin/pest-tia-vite-deps.mjs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TIA Vite dependency resolver.
|
||||||
|
*
|
||||||
|
* Spins up a throwaway headless Vite dev server using the project's
|
||||||
|
* `vite.config.*`, walks every `resources/js/Pages/**` entry to warm
|
||||||
|
* up the module graph, then serializes the graph as a reverse map:
|
||||||
|
*
|
||||||
|
* { "<abs source path>": ["<page component name>", ...], ... }
|
||||||
|
*
|
||||||
|
* The resulting JSON is written to stdout. Stderr is silent on
|
||||||
|
* success so Pest can parse stdout without stripping.
|
||||||
|
*
|
||||||
|
* Why this exists: at TIA record time we need to know which Inertia
|
||||||
|
* page components depend on each shared source file (Button.vue,
|
||||||
|
* Layouts/*.vue, etc.) so a later edit to one of those files can
|
||||||
|
* invalidate only the tests that rendered an affected page. Vite
|
||||||
|
* already knows this via its module graph — we borrow it.
|
||||||
|
*
|
||||||
|
* Called from `Pest\Plugins\Tia\JsModuleGraph::build()` as:
|
||||||
|
*
|
||||||
|
* node bin/pest-tia-vite-deps.mjs <absoluteProjectRoot>
|
||||||
|
*
|
||||||
|
* Environment:
|
||||||
|
* TIA_VITE_PAGES_DIR override the `resources/js/Pages` default.
|
||||||
|
* TIA_VITE_TIMEOUT_MS override the 20s internal watchdog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdir } from 'node:fs/promises'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { resolve, relative, extname, posix, sep, join } from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte'])
|
||||||
|
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
|
||||||
|
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
|
||||||
|
const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10)
|
||||||
|
|
||||||
|
// Resolve Vite from the project's own `node_modules`, not from this
|
||||||
|
// helper's location (which lives under `vendor/pestphp/pest/bin/` and
|
||||||
|
// has no `node_modules`). `createRequire` anchored at the project
|
||||||
|
// root walks up from there, matching the resolution behaviour any
|
||||||
|
// project-local script would see.
|
||||||
|
async function loadVite() {
|
||||||
|
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||||
|
const vitePath = projectRequire.resolve('vite')
|
||||||
|
return await import(pathToFileURL(vitePath).href)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createServer } = await loadVite()
|
||||||
|
|
||||||
|
async function listPageFiles(pagesDir) {
|
||||||
|
if (!existsSync(pagesDir)) return []
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
const walk = async (dir) => {
|
||||||
|
let entries
|
||||||
|
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = resolve(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) { await walk(full); continue }
|
||||||
|
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(pagesDir)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentNameFor(pageAbs, pagesDir) {
|
||||||
|
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
|
||||||
|
const ext = extname(rel)
|
||||||
|
return rel.slice(0, rel.length - ext.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
|
||||||
|
const pages = await listPageFiles(pagesDir)
|
||||||
|
|
||||||
|
if (pages.length === 0) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot Vite in middleware mode (no port binding, no HMR server).
|
||||||
|
// We only need the module graph; transformRequest per page warms
|
||||||
|
// it without running a bundle.
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: undefined, // auto-detect vite.config.*
|
||||||
|
root: PROJECT_ROOT,
|
||||||
|
logLevel: 'silent',
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
middlewareMode: true,
|
||||||
|
hmr: false,
|
||||||
|
watch: null,
|
||||||
|
},
|
||||||
|
appType: 'custom',
|
||||||
|
optimizeDeps: { disabled: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watchdog — don't let a pathological config hang the record run.
|
||||||
|
const killer = setTimeout(() => {
|
||||||
|
server.close().catch(() => {}).finally(() => process.exit(2))
|
||||||
|
}, TIMEOUT_MS)
|
||||||
|
|
||||||
|
// Reverse map: depSourcePath → Set<component name>.
|
||||||
|
const reverse = new Map()
|
||||||
|
|
||||||
|
const pageComponentCache = new Map()
|
||||||
|
for (const page of pages) {
|
||||||
|
pageComponentCache.set(page, componentNameFor(page, pagesDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const pagePath of pages) {
|
||||||
|
const pageComponent = pageComponentCache.get(pagePath)
|
||||||
|
const pageUrl = '/' + posix.relative(
|
||||||
|
PROJECT_ROOT.split(sep).join('/'),
|
||||||
|
pagePath.split(sep).join('/'),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.transformRequest(pageUrl, { ssr: false })
|
||||||
|
} catch {
|
||||||
|
// Transform errors (missing deps, syntax issues) shouldn't
|
||||||
|
// poison the whole graph — skip this page and continue.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
|
||||||
|
if (!pageModule) continue
|
||||||
|
|
||||||
|
// BFS over importedModules, scoped to files inside the project.
|
||||||
|
const visited = new Set()
|
||||||
|
const queue = [pageModule]
|
||||||
|
while (queue.length) {
|
||||||
|
const mod = queue.shift()
|
||||||
|
for (const imported of mod.importedModules) {
|
||||||
|
const id = imported.file ?? imported.id
|
||||||
|
if (!id || visited.has(id)) continue
|
||||||
|
visited.add(id)
|
||||||
|
|
||||||
|
// Skip files outside the project root (node_modules, etc.)
|
||||||
|
// and virtual modules (`\0`-prefixed ids from plugins).
|
||||||
|
if (id.startsWith('\0')) continue
|
||||||
|
if (!id.startsWith(PROJECT_ROOT)) continue
|
||||||
|
|
||||||
|
const rel = relative(PROJECT_ROOT, id).split(sep).join('/')
|
||||||
|
const bucket = reverse.get(rel) ?? new Set()
|
||||||
|
bucket.add(pageComponent)
|
||||||
|
reverse.set(rel, bucket)
|
||||||
|
|
||||||
|
queue.push(imported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(killer)
|
||||||
|
await server.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Object.create(null)
|
||||||
|
const keys = [...reverse.keys()].sort()
|
||||||
|
for (const key of keys) {
|
||||||
|
payload[key] = [...reverse.get(key)].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Node 20 dynamic-import path — some environments are pickier than others.
|
||||||
|
void pathToFileURL // retained to silence tree-shakers referencing the import
|
||||||
|
await main()
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
@ -8,6 +8,10 @@ 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\BladeEdges;
|
||||||
|
use Pest\Plugins\Tia\InertiaEdges;
|
||||||
|
use Pest\Plugins\Tia\Recorder;
|
||||||
|
use Pest\Plugins\Tia\TableTracker;
|
||||||
use Pest\Preset;
|
use Pest\Preset;
|
||||||
use Pest\Support\ChainableClosure;
|
use Pest\Support\ChainableClosure;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
@ -315,6 +319,19 @@ trait Testable
|
|||||||
|
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
// TIA blade-edge + table-edge recording (Laravel-only). Runs
|
||||||
|
// right after `parent::setUp()` so the Laravel app exists and
|
||||||
|
// the View / DB facades are bound; each arm call is
|
||||||
|
// 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);
|
||||||
|
InertiaEdges::arm($recorder);
|
||||||
|
}
|
||||||
|
|
||||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||||
|
|
||||||
if ($this->__beforeEach instanceof Closure) {
|
if ($this->__beforeEach instanceof Closure) {
|
||||||
@ -395,7 +412,7 @@ trait Testable
|
|||||||
$tia = Container::getInstance()->get(Tia::class);
|
$tia = Container::getInstance()->get(Tia::class);
|
||||||
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
|
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
|
||||||
|
|
||||||
$this->addToAssertionCount($assertions > 0 ? $assertions : 1);
|
$this->addToAssertionCount($assertions);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ use Pest\Plugins\Tia\Contracts\State;
|
|||||||
use Pest\Plugins\Tia\CoverageCollector;
|
use Pest\Plugins\Tia\CoverageCollector;
|
||||||
use Pest\Plugins\Tia\Fingerprint;
|
use Pest\Plugins\Tia\Fingerprint;
|
||||||
use Pest\Plugins\Tia\Graph;
|
use Pest\Plugins\Tia\Graph;
|
||||||
|
use Pest\Plugins\Tia\JsModuleGraph;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
use Pest\Plugins\Tia\ResultCollector;
|
||||||
use Pest\Plugins\Tia\WatchPatterns;
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
@ -412,8 +413,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$perTestTables = $recorder->perTestTables();
|
||||||
|
$perTestInertia = $recorder->perTestInertiaComponents();
|
||||||
|
|
||||||
if (Parallel::isWorker()) {
|
if (Parallel::isWorker()) {
|
||||||
$this->flushWorkerPartial($perTest);
|
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
|
||||||
$recorder->reset();
|
$recorder->reset();
|
||||||
$this->coverageCollector->reset();
|
$this->coverageCollector->reset();
|
||||||
|
|
||||||
@ -436,6 +440,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||||
);
|
);
|
||||||
$graph->replaceEdges($perTest);
|
$graph->replaceEdges($perTest);
|
||||||
|
$graph->replaceTestTables($perTestTables);
|
||||||
|
$graph->replaceTestInertiaComponents($perTestInertia);
|
||||||
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
$graph->pruneMissingTests();
|
$graph->pruneMissingTests();
|
||||||
|
|
||||||
// Fold in the results collected during this same record run. The
|
// Fold in the results collected during this same record run. The
|
||||||
@ -522,7 +529,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||||
);
|
);
|
||||||
|
|
||||||
$merged = [];
|
$mergedFiles = [];
|
||||||
|
$mergedTables = [];
|
||||||
|
$mergedInertia = [];
|
||||||
|
|
||||||
foreach ($partialKeys as $key) {
|
foreach ($partialKeys as $key) {
|
||||||
$data = $this->readPartial($key);
|
$data = $this->readPartial($key);
|
||||||
@ -531,13 +540,33 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($data as $testFile => $sources) {
|
foreach ($data['files'] as $testFile => $sources) {
|
||||||
if (! isset($merged[$testFile])) {
|
if (! isset($mergedFiles[$testFile])) {
|
||||||
$merged[$testFile] = [];
|
$mergedFiles[$testFile] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
foreach ($sources as $source) {
|
||||||
$merged[$testFile][$source] = true;
|
$mergedFiles[$testFile][$source] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['tables'] as $testFile => $tables) {
|
||||||
|
if (! isset($mergedTables[$testFile])) {
|
||||||
|
$mergedTables[$testFile] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$mergedTables[$testFile][$table] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($data['inertia'] as $testFile => $components) {
|
||||||
|
if (! isset($mergedInertia[$testFile])) {
|
||||||
|
$mergedInertia[$testFile] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
$mergedInertia[$testFile][$component] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -546,10 +575,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
$finalised = [];
|
$finalised = [];
|
||||||
|
|
||||||
foreach ($merged as $testFile => $sourceSet) {
|
foreach ($mergedFiles as $testFile => $sourceSet) {
|
||||||
$finalised[$testFile] = array_keys($sourceSet);
|
$finalised[$testFile] = array_keys($sourceSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$finalisedTables = [];
|
||||||
|
|
||||||
|
foreach ($mergedTables as $testFile => $tableSet) {
|
||||||
|
$finalisedTables[$testFile] = array_keys($tableSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
$finalisedInertia = [];
|
||||||
|
|
||||||
|
foreach ($mergedInertia as $testFile => $componentSet) {
|
||||||
|
$finalisedInertia[$testFile] = array_keys($componentSet);
|
||||||
|
}
|
||||||
|
|
||||||
// Empty-edges guard: if every worker returned no edges it almost
|
// Empty-edges guard: if every worker returned no edges it almost
|
||||||
// always means the coverage driver wasn't loaded in the workers
|
// always means the coverage driver wasn't loaded in the workers
|
||||||
// (common footgun with custom PHP ini scan dirs, Herd profiles,
|
// (common footgun with custom PHP ini scan dirs, Herd profiles,
|
||||||
@ -567,6 +608,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$graph->replaceEdges($finalised);
|
$graph->replaceEdges($finalised);
|
||||||
|
$graph->replaceTestTables($finalisedTables);
|
||||||
|
$graph->replaceTestInertiaComponents($finalisedInertia);
|
||||||
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
$graph->pruneMissingTests();
|
$graph->pruneMissingTests();
|
||||||
|
|
||||||
if (! $this->saveGraph($graph)) {
|
if (! $this->saveGraph($graph)) {
|
||||||
@ -812,14 +856,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $arguments;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
$changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? [];
|
$branchSha = $graph->recordedAtSha($this->branch);
|
||||||
|
$changed = $changedFiles->since($branchSha) ?? [];
|
||||||
|
|
||||||
// Drop files whose content hash matches the last-run snapshot. This
|
// Drop files whose content hash matches the last-run snapshot. This
|
||||||
// is the "dirty but identical" filter: if a file is uncommitted but
|
// is the "dirty but identical" filter: if a file is uncommitted but
|
||||||
// its content hasn't moved since the last `--tia` invocation, its
|
// its content hasn't moved since the last `--tia` invocation, its
|
||||||
// dependents already re-ran last time and don't need re-running
|
// dependents already re-ran last time and don't need re-running
|
||||||
// again.
|
// again. Passing the recorded sha also catches reverts: a file
|
||||||
$changed = $changedFiles->filterUnchangedSinceLastRun($changed, $graph->lastRunTree($this->branch));
|
// that was edited last run but is now back to its committed
|
||||||
|
// form no longer looks "changed".
|
||||||
|
$changed = $changedFiles->filterUnchangedSinceLastRun(
|
||||||
|
$changed,
|
||||||
|
$graph->lastRunTree($this->branch),
|
||||||
|
$branchSha,
|
||||||
|
);
|
||||||
|
|
||||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||||
|
|
||||||
@ -949,11 +1000,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, array<int, string>> $perTest
|
* @param array<string, array<int, string>> $perTestFiles
|
||||||
|
* @param array<string, array<int, string>> $perTestTables
|
||||||
|
* @param array<string, array<int, string>> $perTestInertiaComponents
|
||||||
*/
|
*/
|
||||||
private function flushWorkerPartial(array $perTest): void
|
private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void
|
||||||
{
|
{
|
||||||
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
|
$json = json_encode([
|
||||||
|
'files' => $perTestFiles,
|
||||||
|
'tables' => $perTestTables,
|
||||||
|
'inertia' => $perTestInertiaComponents,
|
||||||
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
if ($json === false) {
|
if ($json === false) {
|
||||||
return;
|
return;
|
||||||
@ -1090,7 +1147,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, string>>|null
|
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
|
||||||
*/
|
*/
|
||||||
private function readPartial(string $key): ?array
|
private function readPartial(string $key): ?array
|
||||||
{
|
{
|
||||||
@ -1106,20 +1163,38 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$filesSource = is_array($data['files'] ?? null) ? $data['files'] : [];
|
||||||
|
$tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : [];
|
||||||
|
$inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'files' => $this->cleanPartialSection($filesSource),
|
||||||
|
'tables' => $this->cleanPartialSection($tablesSource),
|
||||||
|
'inertia' => $this->cleanPartialSection($inertiaSource),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<mixed, mixed> $section
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private function cleanPartialSection(array $section): array
|
||||||
|
{
|
||||||
$out = [];
|
$out = [];
|
||||||
|
|
||||||
foreach ($data as $test => $sources) {
|
foreach ($section as $test => $items) {
|
||||||
if (! is_string($test)) {
|
if (! is_string($test)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! is_array($sources)) {
|
if (! is_array($items)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$clean = [];
|
$clean = [];
|
||||||
|
|
||||||
foreach ($sources as $source) {
|
foreach ($items as $item) {
|
||||||
if (is_string($source)) {
|
if (is_string($item)) {
|
||||||
$clean[] = $source;
|
$clean[] = $item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
src/Plugins/Tia/BladeEdges.php
Normal file
92
src/Plugins/Tia/BladeEdges.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laravel-only collaborator: during record mode, attributes every
|
||||||
|
* rendered Blade view to the currently-running test.
|
||||||
|
*
|
||||||
|
* Why this exists: the coverage driver only sees compiled view files
|
||||||
|
* under `storage/framework/views/<hash>.php`, not the `.blade.php`
|
||||||
|
* source. Without a dedicated hook TIA has no edges for blade files,
|
||||||
|
* so it leans on the Laravel WatchDefault's broad "any .blade.php
|
||||||
|
* change → every feature test" fallback. Safe but noisy — editing a
|
||||||
|
* single partial re-runs the whole suite.
|
||||||
|
*
|
||||||
|
* With this armed at record time, each test's edge set grows to
|
||||||
|
* include the precise `.blade.php` files it rendered (directly or
|
||||||
|
* through `@include`, layouts, components, Livewire, Inertia root
|
||||||
|
* views — anything that goes through Laravel's view factory fires
|
||||||
|
* `View::composer('*')`). Replay then invalidates exactly the tests
|
||||||
|
* that rendered the changed template.
|
||||||
|
*
|
||||||
|
* Implementation note: everything Laravel-touching goes through
|
||||||
|
* string class names, `class_exists`, and `method_exists` so Pest
|
||||||
|
* core doesn't pull `illuminate/container` into its `require`.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class BladeEdges
|
||||||
|
{
|
||||||
|
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-scoped marker that makes `arm()` idempotent. Tests call it
|
||||||
|
* from every `setUp()`, and Laravel reuses the same app instance
|
||||||
|
* across tests in most configurations — without this guard we'd
|
||||||
|
* stack one composer per test and replay every one of them on
|
||||||
|
* every view render.
|
||||||
|
*/
|
||||||
|
private const string MARKER = 'pest.tia.blade-edges-armed';
|
||||||
|
|
||||||
|
public static function arm(Recorder $recorder): void
|
||||||
|
{
|
||||||
|
if (! $recorder->isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerClass = self::CONTAINER_CLASS;
|
||||||
|
|
||||||
|
if (! class_exists($containerClass)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var object $app */
|
||||||
|
$app = $containerClass::getInstance();
|
||||||
|
|
||||||
|
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($app->bound(self::MARKER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $app->bound('view')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$app->instance(self::MARKER, true);
|
||||||
|
|
||||||
|
$factory = $app->make('view');
|
||||||
|
|
||||||
|
if (! is_object($factory) || ! method_exists($factory, 'composer')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$factory->composer('*', static function (object $view) use ($recorder): void {
|
||||||
|
if (! method_exists($view, 'getPath')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var mixed $path */
|
||||||
|
$path = $view->getPath();
|
||||||
|
|
||||||
|
if (is_string($path) && $path !== '') {
|
||||||
|
$recorder->linkSource($path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,7 +42,7 @@ final readonly class ChangedFiles
|
|||||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
|
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array
|
||||||
{
|
{
|
||||||
if ($lastRunTree === []) {
|
if ($lastRunTree === []) {
|
||||||
return $files;
|
return $files;
|
||||||
@ -85,11 +85,38 @@ final readonly class ChangedFiles
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hash = @hash_file('xxh128', $absolute);
|
$hash = ContentHash::of($absolute);
|
||||||
|
|
||||||
if ($hash === false || $hash !== $snapshot) {
|
if ($hash === false) {
|
||||||
$remaining[] = $file;
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($hash === $snapshot) {
|
||||||
|
// Same state as the last TIA invocation — unchanged.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Differs from the snapshot, but may still be a revert back
|
||||||
|
// to the committed version (scenario: last run had an edit,
|
||||||
|
// this run reverted it). Skipping this check causes stale
|
||||||
|
// snapshots from previous scenarios to cascade into the
|
||||||
|
// current run's invalidation set. Cheap to verify via
|
||||||
|
// `git show <sha>:<path>`.
|
||||||
|
if ($sha !== null && $sha !== '') {
|
||||||
|
$baselineContent = $this->contentAtSha($sha, $file);
|
||||||
|
|
||||||
|
if ($baselineContent !== null) {
|
||||||
|
$baselineHash = ContentHash::ofContent($file, $baselineContent);
|
||||||
|
|
||||||
|
if ($hash === $baselineHash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining[] = $file;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $remaining;
|
return $remaining;
|
||||||
@ -119,7 +146,7 @@ final readonly class ChangedFiles
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hash = @hash_file('xxh128', $absolute);
|
$hash = ContentHash::of($absolute);
|
||||||
|
|
||||||
if ($hash !== false) {
|
if ($hash !== false) {
|
||||||
$out[$file] = $hash;
|
$out[$file] = $hash;
|
||||||
@ -167,7 +194,85 @@ final readonly class ChangedFiles
|
|||||||
$unique[$file] = true;
|
$unique[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_keys($unique);
|
$candidates = array_keys($unique);
|
||||||
|
|
||||||
|
// Behavioural de-noising: for every file git calls "changed", hash
|
||||||
|
// the current content and the content at `$sha` through
|
||||||
|
// `ContentHash::of()`. A change that only touched comments /
|
||||||
|
// whitespace / blade `{{-- --}}` blocks produces the same hash on
|
||||||
|
// both sides and gets dropped before it can invalidate any test.
|
||||||
|
// Without this, a single-comment edit on a migration re-runs the
|
||||||
|
// entire DB-touching suite.
|
||||||
|
if ($sha !== null && $sha !== '') {
|
||||||
|
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $files
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function filterBehaviourallyUnchanged(array $files, string $sha): array
|
||||||
|
{
|
||||||
|
$remaining = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
// Deleted on disk — a genuine change, keep it.
|
||||||
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentHash = ContentHash::of($absolute);
|
||||||
|
|
||||||
|
if ($currentHash === false) {
|
||||||
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineContent = $this->contentAtSha($sha, $file);
|
||||||
|
|
||||||
|
if ($baselineContent === null) {
|
||||||
|
// Couldn't read the baseline (new file, binary, `git show`
|
||||||
|
// failed). Err on the side of re-running.
|
||||||
|
$remaining[] = $file;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineHash = ContentHash::ofContent($file, $baselineContent);
|
||||||
|
|
||||||
|
if ($currentHash !== $baselineHash) {
|
||||||
|
$remaining[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `$path` at `$sha` via `git show`. Returns null when the file
|
||||||
|
* didn't exist at that SHA, when git errors, or when the content
|
||||||
|
* isn't valid UTF-8-safe bytes (rare — binary files that happen to
|
||||||
|
* be tracked).
|
||||||
|
*/
|
||||||
|
private function contentAtSha(string $sha, string $path): ?string
|
||||||
|
{
|
||||||
|
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
||||||
|
$process->setTimeout(5.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $process->getOutput();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shouldIgnore(string $path): bool
|
private function shouldIgnore(string $path): bool
|
||||||
|
|||||||
118
src/Plugins/Tia/ContentHash.php
Normal file
118
src/Plugins/Tia/ContentHash.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-file hashing that ignores changes which can't alter behaviour —
|
||||||
|
* comments and whitespace for PHP, `{{-- … --}}` comments and whitespace
|
||||||
|
* runs for Blade templates. Every other file type falls back to a plain
|
||||||
|
* xxh128 of the raw bytes.
|
||||||
|
*
|
||||||
|
* Why it matters: TIA's file diff signals drive which tests re-run. A
|
||||||
|
* one-line comment tweak on a migration is a behavioural no-op, but the
|
||||||
|
* raw-bytes hash still differs, so every test that talks to the DB would
|
||||||
|
* currently re-execute. Normalising to the parsed-token / compiled-shape
|
||||||
|
* keeps the drift signal honest: edits that can't change runtime
|
||||||
|
* behaviour don't invalidate the replay cache.
|
||||||
|
*
|
||||||
|
* Important: this hash is stored in the graph's last-run tree, so any
|
||||||
|
* format change here must be paired with a `Fingerprint::SCHEMA_VERSION`
|
||||||
|
* bump — otherwise stale hashes from older graphs would be compared
|
||||||
|
* against normalised hashes from the new code and everything would
|
||||||
|
* appear changed.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ContentHash
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* xxh128 hex of the file's "behavioural" shape, or `false` when the
|
||||||
|
* file can't be read. Callers should treat `false` the same way they
|
||||||
|
* treated a failed `hash_file()` previously.
|
||||||
|
*/
|
||||||
|
public static function of(string $absolute): string|false
|
||||||
|
{
|
||||||
|
$raw = @file_get_contents($absolute);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::ofContent($absolute, $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as `of()` but accepts the file contents in memory. Used when
|
||||||
|
* we already have the bytes (e.g. from `git show <sha>:<path>`) and
|
||||||
|
* want to avoid a disk round-trip.
|
||||||
|
*/
|
||||||
|
public static function ofContent(string $path, string $raw): string
|
||||||
|
{
|
||||||
|
$lower = strtolower($path);
|
||||||
|
|
||||||
|
if (str_ends_with($lower, '.blade.php')) {
|
||||||
|
return self::hashBladeContent($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($lower, '.php')) {
|
||||||
|
return self::hashPhpContent($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('xxh128', $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenise the content and hash the concatenated values of every
|
||||||
|
* token except whitespace / comment / docblock. `token_get_all()`
|
||||||
|
* is built-in, fast, and enough to collapse any formatting-only
|
||||||
|
* edit. If tokenisation fails (rare syntax error), fall back to
|
||||||
|
* the raw hash so the caller still gets a deterministic signal.
|
||||||
|
*/
|
||||||
|
private static function hashPhpContent(string $raw): string
|
||||||
|
{
|
||||||
|
$tokens = @token_get_all($raw);
|
||||||
|
|
||||||
|
if ($tokens === []) {
|
||||||
|
return hash('xxh128', $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = '';
|
||||||
|
|
||||||
|
foreach ($tokens as $token) {
|
||||||
|
if (is_array($token)) {
|
||||||
|
if ($token[0] === T_WHITESPACE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($token[0] === T_COMMENT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($token[0] === T_DOC_COMMENT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalised .= $token[1];
|
||||||
|
} else {
|
||||||
|
$normalised .= $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('xxh128', $normalised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blade templates aren't PHP syntactically, so `token_get_all()`
|
||||||
|
* doesn't help. Strip `{{-- … --}}` comments (the only Blade-native
|
||||||
|
* comment form) and collapse whitespace runs. Output differences
|
||||||
|
* that would survive the Blade compiler (markup reordering, new
|
||||||
|
* directives, changed interpolation) still flip the hash; pure
|
||||||
|
* reformatting does not.
|
||||||
|
*/
|
||||||
|
private static function hashBladeContent(string $raw): string
|
||||||
|
{
|
||||||
|
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
||||||
|
$stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped;
|
||||||
|
|
||||||
|
return hash('xxh128', trim($stripped));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,7 +29,30 @@ final readonly class Fingerprint
|
|||||||
{
|
{
|
||||||
// Bump this whenever the set of inputs or the hash algorithm changes,
|
// Bump this whenever the set of inputs or the hash algorithm changes,
|
||||||
// so older graphs are invalidated automatically.
|
// so older graphs are invalidated automatically.
|
||||||
private const int SCHEMA_VERSION = 4;
|
//
|
||||||
|
// v5: ChangedFiles now hashes via `ContentHash` (normalises PHP
|
||||||
|
// tokens + Blade whitespace/comments) instead of raw bytes.
|
||||||
|
// Old graphs' run-tree hashes are incompatible and must be
|
||||||
|
// rebuilt.
|
||||||
|
// v6: Graph gained per-test table edges (`$testTables`) powering
|
||||||
|
// surgical migration invalidation. Worker partial shape
|
||||||
|
// changed to `{files, tables}`. Old graphs have no table
|
||||||
|
// coverage, which would leave every DB test invalidated by
|
||||||
|
// any migration change — force a rebuild so the new edges
|
||||||
|
// are populated.
|
||||||
|
// v7: Graph gained per-test Inertia page-component edges
|
||||||
|
// (`$testInertiaComponents`) for surgical page-file
|
||||||
|
// invalidation. Worker partial now includes an `inertia`
|
||||||
|
// section. Old graphs have no component edges; without a
|
||||||
|
// rebuild Vue/React page edits would fall through to the
|
||||||
|
// broad watch pattern even when precise matching could have
|
||||||
|
// worked.
|
||||||
|
// v8: Graph gained `$jsFileToComponents` — reverse dependency
|
||||||
|
// map computed at record time from Vite's module graph (or
|
||||||
|
// the PHP fallback) so shared components / layouts /
|
||||||
|
// composables invalidate the specific pages they're used
|
||||||
|
// by, not every browser test.
|
||||||
|
private const int SCHEMA_VERSION = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
|
|||||||
@ -40,6 +40,51 @@ final class Graph
|
|||||||
*/
|
*/
|
||||||
private array $edges = [];
|
private array $edges = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table edges: test file (relative) → list of lowercase SQL table
|
||||||
|
* names the test queried during record. Populated from the
|
||||||
|
* Recorder's `perTestTables()` snapshot; consumed at replay time
|
||||||
|
* to do surgical invalidation when a migration changes — the
|
||||||
|
* test only re-runs if its set intersects the tables the changed
|
||||||
|
* migration touches. Empty for tests that never hit the DB, which
|
||||||
|
* is exactly why those tests stay unaffected by migration edits.
|
||||||
|
*
|
||||||
|
* Unlike `$edges`, we store names rather than ids: the table
|
||||||
|
* universe is small (hundreds at most on a giant app), storing
|
||||||
|
* strings keeps the on-disk graph diff-readable, and the lookup
|
||||||
|
* cost is negligible compared to the per-file ids used above.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private array $testTables = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inertia page component edges: test file (relative) → list of
|
||||||
|
* component names the test server-side rendered (whatever was
|
||||||
|
* passed to `Inertia::render($component, …)`). Populated from
|
||||||
|
* `Recorder::perTestInertiaComponents()`; consumed at replay time
|
||||||
|
* so an edit to `resources/js/Pages/Users/Show.vue` only invalidates
|
||||||
|
* tests that rendered `Users/Show`. Same string-keyed shape as
|
||||||
|
* `$testTables` for the same diff-readable reasons.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private array $testInertiaComponents = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inverted JS dependency map: project-relative source path under
|
||||||
|
* `resources/js/**` → list of Inertia page components that
|
||||||
|
* transitively import it. Populated at record time by
|
||||||
|
* `JsModuleGraph::build()` (Vite module graph via Node helper,
|
||||||
|
* with a PHP fallback). Replay uses this to route a
|
||||||
|
* `Components/Button.vue` edit directly to the pages that depend
|
||||||
|
* on it, intersecting against `$testInertiaComponents` for
|
||||||
|
* surgical invalidation.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private array $jsFileToComponents = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment fingerprint captured at record time.
|
* Environment fingerprint captured at record time.
|
||||||
*
|
*
|
||||||
@ -126,11 +171,155 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Coverage-edge lookup (PHP → PHP).
|
$affectedSet = [];
|
||||||
|
|
||||||
|
// Migration changes don't flow through the coverage-edge path —
|
||||||
|
// `RefreshDatabase` in every test's `setUp()` means every test
|
||||||
|
// has an edge to every migration, so step 1 would re-run the
|
||||||
|
// whole DB-touching suite on any migration edit. Route them
|
||||||
|
// separately: static-parse the migration source, union the
|
||||||
|
// referenced tables, and match tests whose recorded query
|
||||||
|
// footprint intersects that set. Missed files (rare: migrations
|
||||||
|
// with pure raw SQL or dynamic names) fall back to the watch
|
||||||
|
// pattern below.
|
||||||
|
$migrationPaths = [];
|
||||||
|
$nonMigrationPaths = [];
|
||||||
|
|
||||||
|
foreach ($normalised as $rel) {
|
||||||
|
if ($this->isMigrationPath($rel)) {
|
||||||
|
$migrationPaths[] = $rel;
|
||||||
|
} else {
|
||||||
|
$nonMigrationPaths[] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$changedTables = [];
|
||||||
|
$unparseableMigrations = [];
|
||||||
|
|
||||||
|
foreach ($migrationPaths as $rel) {
|
||||||
|
$tables = $this->tablesForMigration($rel);
|
||||||
|
|
||||||
|
if ($tables === []) {
|
||||||
|
$unparseableMigrations[] = $rel;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$changedTables[$table] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changedTables !== []) {
|
||||||
|
foreach ($this->testTables as $testFile => $tables) {
|
||||||
|
if (isset($affectedSet[$testFile])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
if (isset($changedTables[$table])) {
|
||||||
|
$affectedSet[$testFile] = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inertia page-component routing. When a Vue/React/Svelte page
|
||||||
|
// under `resources/js/Pages/` changes, map it to the component
|
||||||
|
// name Inertia would use (the path relative to `Pages/`, with
|
||||||
|
// the extension stripped) and intersect with the captured
|
||||||
|
// component edges. Only invalidates tests that actually
|
||||||
|
// rendered the page. Pages with no captured edges (never
|
||||||
|
// rendered during record, brand-new on this branch) fall
|
||||||
|
// through to the watch-pattern fallback via
|
||||||
|
// `$unknownPageComponents` — safe over-run.
|
||||||
|
$changedComponents = [];
|
||||||
|
$unknownPageComponents = [];
|
||||||
|
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
$component = $this->componentForInertiaPage($rel);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->anyTestUses($this->testInertiaComponents, $component)) {
|
||||||
|
$changedComponents[$component] = true;
|
||||||
|
} else {
|
||||||
|
$unknownPageComponents[] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages whose component already resolved precisely via the
|
||||||
|
// direct Inertia edges path must not leak back through any
|
||||||
|
// broader mechanism (either the JS-dep lookup below, or the
|
||||||
|
// watch pattern further down).
|
||||||
|
$preciselyHandledPages = [];
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
$component = $this->componentForInertiaPage($rel);
|
||||||
|
|
||||||
|
if ($component !== null && isset($changedComponents[$component])) {
|
||||||
|
$preciselyHandledPages[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared JS files (Components, Layouts, composables, etc.)
|
||||||
|
// aren't Inertia pages but pages depend on them transitively.
|
||||||
|
// `$jsFileToComponents` was computed at record time by walking
|
||||||
|
// Vite's module graph, so a change to
|
||||||
|
// `resources/js/Components/Button.vue` resolves directly to
|
||||||
|
// the set of page components that import it. Union those into
|
||||||
|
// `$changedComponents`. Files that aren't in the JS dep map
|
||||||
|
// fall through to the watch pattern below — same safety-net
|
||||||
|
// path the Inertia block above uses for unresolved pages.
|
||||||
|
$sharedFilesResolved = [];
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($this->jsFileToComponents[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touchedAny = false;
|
||||||
|
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
|
||||||
|
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||||
|
$changedComponents[$pageComponent] = true;
|
||||||
|
$touchedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($touchedAny) {
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changedComponents !== []) {
|
||||||
|
foreach ($this->testInertiaComponents as $testFile => $components) {
|
||||||
|
if (isset($affectedSet[$testFile])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if (isset($changedComponents[$component])) {
|
||||||
|
$affectedSet[$testFile] = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already
|
||||||
|
// handled above; skipping them here prevents their always-on
|
||||||
|
// coverage edges from invalidating the whole DB suite.
|
||||||
$changedIds = [];
|
$changedIds = [];
|
||||||
$unknownSourceDirs = [];
|
$unknownSourceDirs = [];
|
||||||
|
|
||||||
foreach ($normalised 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/')) {
|
} elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
|
||||||
@ -141,9 +330,11 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$affectedSet = [];
|
|
||||||
|
|
||||||
foreach ($this->edges as $testFile => $ids) {
|
foreach ($this->edges as $testFile => $ids) {
|
||||||
|
if (isset($affectedSet[$testFile])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($ids as $id) {
|
foreach ($ids as $id) {
|
||||||
if (isset($changedIds[$id])) {
|
if (isset($changedIds[$id])) {
|
||||||
$affectedSet[$testFile] = true;
|
$affectedSet[$testFile] = true;
|
||||||
@ -153,11 +344,39 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Watch-pattern lookup (non-PHP assets → test directories).
|
// 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
|
||||||
|
// watch pattern would re-add every test the pattern maps to,
|
||||||
|
// defeating the point of recording the edge in the first place.
|
||||||
|
// Blade templates captured via Laravel's view composer are the
|
||||||
|
// motivating case — we want their specific tests, not every
|
||||||
|
// feature test. Migrations whose static parse yielded nothing
|
||||||
|
// (exotic syntax, raw SQL) are funneled back in here too so
|
||||||
|
// broad invalidation still kicks in for edge cases we can't
|
||||||
|
// parse.
|
||||||
|
// Exclude paths that were already routed precisely through
|
||||||
|
// either the Inertia page-component path or the shared-JS
|
||||||
|
// dependency path. Broadcasting them again via the watch
|
||||||
|
// pattern would re-add every test the pattern maps to,
|
||||||
|
// defeating the surgical match.
|
||||||
|
$unknownToGraph = $unparseableMigrations;
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($sharedFilesResolved[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! isset($this->fileIds[$rel])) {
|
||||||
|
$unknownToGraph[] = $rel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @var WatchPatterns $watchPatterns */
|
/** @var WatchPatterns $watchPatterns */
|
||||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||||
|
|
||||||
$dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised);
|
$dirs = $watchPatterns->matchedDirectories($this->projectRoot, $unknownToGraph);
|
||||||
$allTestFiles = array_keys($this->edges);
|
$allTestFiles = array_keys($this->edges);
|
||||||
|
|
||||||
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
|
||||||
@ -392,6 +611,201 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces table edges for the given test files. Table names are
|
||||||
|
* lowercased + deduplicated; the input comes straight from the
|
||||||
|
* Recorder's `perTestTables()` snapshot. Tests absent from the
|
||||||
|
* input keep their existing table set (same partial-update policy
|
||||||
|
* as `replaceEdges`).
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $testToTables
|
||||||
|
*/
|
||||||
|
public function replaceTestTables(array $testToTables): void
|
||||||
|
{
|
||||||
|
foreach ($testToTables as $testFile => $tables) {
|
||||||
|
$testRel = $this->relative($testFile);
|
||||||
|
|
||||||
|
if ($testRel === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = [];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
$lower = strtolower($table);
|
||||||
|
|
||||||
|
if ($lower !== '') {
|
||||||
|
$normalised[$lower] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = array_keys($normalised);
|
||||||
|
sort($names);
|
||||||
|
|
||||||
|
$this->testTables[$testRel] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces Inertia component edges for the given test files. Names
|
||||||
|
* preserve case (they're identifiers like `Users/Show`, not
|
||||||
|
* user-supplied strings) but duplicates are collapsed. Same
|
||||||
|
* partial-update policy as `replaceTestTables`.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $testToComponents
|
||||||
|
*/
|
||||||
|
public function replaceTestInertiaComponents(array $testToComponents): void
|
||||||
|
{
|
||||||
|
foreach ($testToComponents as $testFile => $components) {
|
||||||
|
$testRel = $this->relative($testFile);
|
||||||
|
|
||||||
|
if ($testRel === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalised = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if ($component !== '') {
|
||||||
|
$normalised[$component] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = array_keys($normalised);
|
||||||
|
sort($names);
|
||||||
|
|
||||||
|
$this->testInertiaComponents[$testRel] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the whole JS dep map. Called at record time with the
|
||||||
|
* output of `JsModuleGraph::build()`. Unlike the test-level
|
||||||
|
* replacements above this is a wholesale overwrite — the
|
||||||
|
* resolver produces the full graph on every run.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $fileToComponents
|
||||||
|
*/
|
||||||
|
public function replaceJsFileToComponents(array $fileToComponents): void
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($fileToComponents as $path => $components) {
|
||||||
|
if ($path === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if ($component !== '') {
|
||||||
|
$names[$component] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($names === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_keys($names);
|
||||||
|
sort($keys);
|
||||||
|
$out[$path] = $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($out);
|
||||||
|
|
||||||
|
$this->jsFileToComponents = $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects under Laravel conventionally keep migrations at
|
||||||
|
* `database/migrations/`. We recognise the directory as a prefix
|
||||||
|
* so nested subdirectories (a pattern some teams use for grouping
|
||||||
|
* — `database/migrations/tenant/`, `database/migrations/archived/`)
|
||||||
|
* are still routed through the table-intersection path.
|
||||||
|
*/
|
||||||
|
private function isMigrationPath(string $rel): bool
|
||||||
|
{
|
||||||
|
return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `$rel` relative to the project root and extracts the
|
||||||
|
* tables it declares via `Schema::create/table/drop/rename`.
|
||||||
|
* Empty on missing/unreadable files or when the parser finds
|
||||||
|
* nothing — the caller escalates those cases to the watch
|
||||||
|
* pattern safety net.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function tablesForMigration(string $rel): array
|
||||||
|
{
|
||||||
|
$absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel;
|
||||||
|
|
||||||
|
if (! is_file($absolute)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = @file_get_contents($absolute);
|
||||||
|
|
||||||
|
if ($content === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return TableExtractor::fromMigrationSource($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a project-relative path to its Inertia component name if it
|
||||||
|
* lives under `resources/js/Pages/` with a recognised framework
|
||||||
|
* extension. Returns null otherwise so callers can cheaply ignore
|
||||||
|
* non-page files. Matches Inertia's resolver convention: strip the
|
||||||
|
* `resources/js/Pages/` prefix, strip the extension, preserve the
|
||||||
|
* remaining slashes (`Users/Show.vue` → `Users/Show`).
|
||||||
|
*/
|
||||||
|
private function componentForInertiaPage(string $rel): ?string
|
||||||
|
{
|
||||||
|
$prefix = 'resources/js/Pages/';
|
||||||
|
|
||||||
|
if (! str_starts_with($rel, $prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tail = substr($rel, strlen($prefix));
|
||||||
|
$dot = strrpos($tail, '.');
|
||||||
|
|
||||||
|
if ($dot === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = substr($tail, $dot + 1);
|
||||||
|
|
||||||
|
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = substr($tail, 0, $dot);
|
||||||
|
|
||||||
|
return $name === '' ? null : $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any test's component set contains `$component`. Used to
|
||||||
|
* decide between precise edge matching and watch-pattern fallback
|
||||||
|
* for a changed Inertia page file.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $edges
|
||||||
|
*/
|
||||||
|
private function anyTestUses(array $edges, string $component): bool
|
||||||
|
{
|
||||||
|
foreach ($edges as $components) {
|
||||||
|
if (in_array($component, $components, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
||||||
* from keeping stale entries for deleted / renamed tests that would later
|
* from keeping stale entries for deleted / renamed tests that would later
|
||||||
@ -406,6 +820,18 @@ final class Graph
|
|||||||
unset($this->edges[$testRel]);
|
unset($this->edges[$testRel]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($this->testInertiaComponents) as $testRel) {
|
||||||
|
if (! is_file($root.$testRel)) {
|
||||||
|
unset($this->testInertiaComponents[$testRel]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($this->testTables) as $testRel) {
|
||||||
|
if (! is_file($root.$testRel)) {
|
||||||
|
unset($this->testTables[$testRel]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -429,6 +855,75 @@ final class Graph
|
|||||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||||
|
|
||||||
|
if (isset($data['test_tables']) && is_array($data['test_tables'])) {
|
||||||
|
foreach ($data['test_tables'] as $testRel => $tables) {
|
||||||
|
if (! is_string($testRel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($tables)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($tables as $table) {
|
||||||
|
if (is_string($table) && $table !== '') {
|
||||||
|
$names[] = $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($names !== []) {
|
||||||
|
$graph->testTables[$testRel] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
|
||||||
|
foreach ($data['test_inertia_components'] as $testRel => $components) {
|
||||||
|
if (! is_string($testRel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($components)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if (is_string($component) && $component !== '') {
|
||||||
|
$names[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($names !== []) {
|
||||||
|
$graph->testInertiaComponents[$testRel] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
|
||||||
|
foreach ($data['js_file_to_components'] as $path => $components) {
|
||||||
|
if (! is_string($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($path === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($components)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if (is_string($component) && $component !== '') {
|
||||||
|
$names[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($names !== []) {
|
||||||
|
$graph->jsFileToComponents[$path] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $graph;
|
return $graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -446,6 +941,9 @@ final class Graph
|
|||||||
'files' => $this->files,
|
'files' => $this->files,
|
||||||
'edges' => $this->edges,
|
'edges' => $this->edges,
|
||||||
'baselines' => $this->baselines,
|
'baselines' => $this->baselines,
|
||||||
|
'test_tables' => $this->testTables,
|
||||||
|
'test_inertia_components' => $this->testInertiaComponents,
|
||||||
|
'js_file_to_components' => $this->jsFileToComponents,
|
||||||
];
|
];
|
||||||
|
|
||||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
170
src/Plugins/Tia/InertiaEdges.php
Normal file
170
src/Plugins/Tia/InertiaEdges.php
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inertia-aware collaborator: during record mode, attributes every
|
||||||
|
* Inertia component the test server-side renders to the currently-
|
||||||
|
* running test file.
|
||||||
|
*
|
||||||
|
* Why this exists: a change to `resources/js/Pages/Users/Show.vue`
|
||||||
|
* should only invalidate tests that actually rendered `Users/Show`.
|
||||||
|
* The Laravel `WatchDefaults\Inertia` glob is a broad fallback — fine
|
||||||
|
* for brand-new pages, but noisy once the graph has real data. With
|
||||||
|
* this armed, each test's recorded edge set grows to include the
|
||||||
|
* component names it returned through `Inertia::render()`, and
|
||||||
|
* subsequent replay intersects page-file changes against that set.
|
||||||
|
*
|
||||||
|
* Mechanism: listen for `Illuminate\Foundation\Http\Events\RequestHandled`
|
||||||
|
* on Laravel's event dispatcher. Inertia responses are identifiable by
|
||||||
|
* either an `X-Inertia` header (XHR / JSON shape) or a `data-page`
|
||||||
|
* attribute on the root `<div id="app">` (full HTML shape). Both carry
|
||||||
|
* the component name in a structured payload we can parse cheaply.
|
||||||
|
*
|
||||||
|
* Same dep-free handshake as `BladeEdges` / `TableTracker`: string
|
||||||
|
* class lookup + method-capability probes so Pest's `require` stays
|
||||||
|
* Laravel-free.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class InertiaEdges
|
||||||
|
{
|
||||||
|
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||||
|
|
||||||
|
private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-scoped marker that makes `arm()` idempotent across per-test
|
||||||
|
* `setUp()` calls. Laravel reuses the same app across tests in
|
||||||
|
* most configurations — without this guard we'd stack one
|
||||||
|
* listener per test.
|
||||||
|
*/
|
||||||
|
private const string MARKER = 'pest.tia.inertia-edges-armed';
|
||||||
|
|
||||||
|
public static function arm(Recorder $recorder): void
|
||||||
|
{
|
||||||
|
if (! $recorder->isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerClass = self::CONTAINER_CLASS;
|
||||||
|
|
||||||
|
if (! class_exists($containerClass)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var object $app */
|
||||||
|
$app = $containerClass::getInstance();
|
||||||
|
|
||||||
|
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($app->bound(self::MARKER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $app->bound('events')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$app->instance(self::MARKER, true);
|
||||||
|
|
||||||
|
/** @var object $events */
|
||||||
|
$events = $app->make('events');
|
||||||
|
|
||||||
|
if (! method_exists($events, 'listen')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
|
||||||
|
if (! property_exists($event, 'response')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var mixed $response */
|
||||||
|
$response = $event->response;
|
||||||
|
|
||||||
|
if (! is_object($response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$component = self::extractComponent($response);
|
||||||
|
|
||||||
|
if ($component !== null) {
|
||||||
|
$recorder->linkInertiaComponent($component);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the Inertia component name out of a Laravel response,
|
||||||
|
* handling both XHR (`X-Inertia` + JSON body) and full HTML
|
||||||
|
* (`<div id="app" data-page="…">`) shapes. Returns null for any
|
||||||
|
* non-Inertia response so the caller can ignore it cheaply.
|
||||||
|
*/
|
||||||
|
private static function extractComponent(object $response): ?string
|
||||||
|
{
|
||||||
|
// XHR path: Inertia sets an `X-Inertia: true` header and the
|
||||||
|
// body is JSON with a `component` key.
|
||||||
|
if (property_exists($response, 'headers') && is_object($response->headers)) {
|
||||||
|
$headers = $response->headers;
|
||||||
|
|
||||||
|
if (method_exists($headers, 'has') && $headers->has('X-Inertia')) {
|
||||||
|
$content = self::readContent($response);
|
||||||
|
|
||||||
|
if ($content !== null) {
|
||||||
|
/** @var mixed $decoded */
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
|
||||||
|
if (is_array($decoded)
|
||||||
|
&& isset($decoded['component'])
|
||||||
|
&& is_string($decoded['component'])
|
||||||
|
&& $decoded['component'] !== '') {
|
||||||
|
return $decoded['component'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial-load HTML path: Inertia embeds the page payload in a
|
||||||
|
// `data-page` attribute on the root `<div id="app">`. We only
|
||||||
|
// pay the regex cost when the body actually contains the
|
||||||
|
// attribute, so non-Inertia HTML responses are effectively a
|
||||||
|
// no-op.
|
||||||
|
$content = self::readContent($response);
|
||||||
|
|
||||||
|
if ($content === null || ! str_contains($content, 'data-page=')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode(html_entity_decode($match[1]), true);
|
||||||
|
|
||||||
|
if (is_array($decoded)
|
||||||
|
&& isset($decoded['component'])
|
||||||
|
&& is_string($decoded['component'])
|
||||||
|
&& $decoded['component'] !== '') {
|
||||||
|
return $decoded['component'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function readContent(object $response): ?string
|
||||||
|
{
|
||||||
|
if (! method_exists($response, 'getContent')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var mixed $content */
|
||||||
|
$content = $response->getContent();
|
||||||
|
|
||||||
|
return is_string($content) ? $content : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
270
src/Plugins/Tia/JsImportParser.php
Normal file
270
src/Plugins/Tia/JsImportParser.php
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback parser for ES module imports under `resources/js/`.
|
||||||
|
*
|
||||||
|
* Used only when the Node helper (`bin/pest-tia-vite-deps.mjs`) is
|
||||||
|
* unavailable — typically when Node isn't on `PATH` or the user's
|
||||||
|
* `vite.config.*` can't be loaded. Pure PHP, so it degrades
|
||||||
|
* gracefully on locked-down environments but cannot match the
|
||||||
|
* full-fidelity Vite resolver.
|
||||||
|
*
|
||||||
|
* Known limits (intentional — preserving correctness over precision):
|
||||||
|
* - Only `@/` and `~/` aliases recognised (both resolve to
|
||||||
|
* `resources/js/`, the community default). Custom aliases from
|
||||||
|
* `vite.config.ts` are ignored; anything we can't resolve is
|
||||||
|
* simply skipped and falls through to the watch-pattern safety
|
||||||
|
* net.
|
||||||
|
* - Dynamic imports with variable expressions
|
||||||
|
* (`import(`./${name}`.vue)`) can't be resolved; the literal
|
||||||
|
* prefix is ignored and the caller over-runs. Safe.
|
||||||
|
* - Vue SFC `<script>` blocks parsed whole; imports inside
|
||||||
|
* `<template>` blocks (rare but legal) are not scanned.
|
||||||
|
*
|
||||||
|
* Output shape mirrors the Node helper: project-relative source path
|
||||||
|
* → sorted list of component names of pages that depend on it.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class JsImportParser
|
||||||
|
{
|
||||||
|
private const array PAGE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte'];
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks `resources/js/Pages` 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>>
|
||||||
|
*/
|
||||||
|
public static function parse(string $projectRoot): array
|
||||||
|
{
|
||||||
|
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
|
||||||
|
$pagesRoot = $projectRoot.DIRECTORY_SEPARATOR.self::PAGES_DIR;
|
||||||
|
|
||||||
|
if (! is_dir($pagesRoot)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$reverse = [];
|
||||||
|
|
||||||
|
foreach (self::collectPages($pagesRoot) as $pageAbs) {
|
||||||
|
$component = self::componentName($pagesRoot, $pageAbs);
|
||||||
|
|
||||||
|
if ($component === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visited = [];
|
||||||
|
self::collectTransitive($pageAbs, $projectRoot, $jsRoot, $visited);
|
||||||
|
|
||||||
|
foreach (array_keys($visited) as $depAbs) {
|
||||||
|
if ($depAbs === $pageAbs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($depAbs, strlen($projectRoot) + 1));
|
||||||
|
$reverse[$rel][$component] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($reverse as $path => $components) {
|
||||||
|
$names = array_keys($components);
|
||||||
|
sort($names);
|
||||||
|
$out[$path] = $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($out);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function collectPages(string $pagesRoot): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $fileInfo) {
|
||||||
|
if (! $fileInfo->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower((string) $fileInfo->getExtension());
|
||||||
|
if (in_array($ext, self::PAGE_EXTENSIONS, true)) {
|
||||||
|
$out[] = $fileInfo->getPathname();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function componentName(string $pagesRoot, string $pageAbs): ?string
|
||||||
|
{
|
||||||
|
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($pageAbs, strlen($pagesRoot) + 1));
|
||||||
|
$dot = strrpos($rel, '.');
|
||||||
|
|
||||||
|
if ($dot === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = substr($rel, 0, $dot);
|
||||||
|
|
||||||
|
return $name === '' ? null : $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $visited
|
||||||
|
*/
|
||||||
|
private static function collectTransitive(string $fileAbs, string $projectRoot, string $jsRoot, array &$visited): void
|
||||||
|
{
|
||||||
|
if (isset($visited[$fileAbs])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$visited[$fileAbs] = true;
|
||||||
|
|
||||||
|
$source = self::loadSource($fileAbs);
|
||||||
|
if ($source === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::extractImports($source) as $spec) {
|
||||||
|
$resolved = self::resolveImport($spec, $fileAbs, $jsRoot);
|
||||||
|
if ($resolved === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_file($resolved)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::collectTransitive($resolved, $projectRoot, $jsRoot, $visited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the importable region of a file. For Vue SFCs, only the
|
||||||
|
* `<script>` block is relevant for imports; ignoring the rest
|
||||||
|
* avoids false-positive matches inside `<template>` attributes.
|
||||||
|
*/
|
||||||
|
private static function loadSource(string $fileAbs): ?string
|
||||||
|
{
|
||||||
|
$content = @file_get_contents($fileAbs);
|
||||||
|
if ($content === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with(strtolower($fileAbs), '.vue')) {
|
||||||
|
$scripts = [];
|
||||||
|
if (preg_match_all('/<script[^>]*>(.*?)<\/script>/si', $content, $m) !== false) {
|
||||||
|
foreach ($m[1] as $block) {
|
||||||
|
$scripts[] = $block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $scripts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks out every `import … from '…'` / `import '…'` / `import('…')`
|
||||||
|
* target. We strip line comments first so a commented-out import
|
||||||
|
* doesn't bloat the dep set.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function extractImports(string $source): array
|
||||||
|
{
|
||||||
|
$stripped = preg_replace('#//[^\n]*#', '', $source) ?? $source;
|
||||||
|
$stripped = preg_replace('#/\*.*?\*/#s', '', $stripped) ?? $stripped;
|
||||||
|
|
||||||
|
$specs = [];
|
||||||
|
|
||||||
|
if (preg_match_all('/\bimport\s+(?:[^\'"()]*?\s+from\s+)?[\'"]([^\'"]+)[\'"]/', $stripped, $matches) !== false) {
|
||||||
|
foreach ($matches[1] as $spec) {
|
||||||
|
$specs[] = $spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match_all('/\bimport\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $stripped, $matches) !== false) {
|
||||||
|
foreach ($matches[1] as $spec) {
|
||||||
|
$specs[] = $spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveImport(string $spec, string $importerAbs, string $jsRoot): ?string
|
||||||
|
{
|
||||||
|
if ($spec === '' || $spec[0] === '.' || $spec[0] === '/') {
|
||||||
|
return self::resolveRelative($spec, $importerAbs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($spec, '@/') || str_starts_with($spec, '~/')) {
|
||||||
|
$tail = substr($spec, 2);
|
||||||
|
|
||||||
|
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything else is either a node_modules package or an
|
||||||
|
// unrecognised alias — skip. The watch-pattern fallback
|
||||||
|
// handles the safety-net case for non-matched paths.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolveRelative(string $spec, string $importerAbs): ?string
|
||||||
|
{
|
||||||
|
if ($spec === '' || $spec[0] === '/') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = dirname($importerAbs);
|
||||||
|
$path = $base.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $spec);
|
||||||
|
|
||||||
|
return self::withExtension($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports may omit the extension or point at a directory (index.vue,
|
||||||
|
* index.ts). Probe the common targets in order.
|
||||||
|
*/
|
||||||
|
private static function withExtension(string $path): ?string
|
||||||
|
{
|
||||||
|
if (is_file($path)) {
|
||||||
|
return realpath($path) ?: $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
|
||||||
|
$candidate = $path.'.'.$ext;
|
||||||
|
if (is_file($candidate)) {
|
||||||
|
return realpath($candidate) ?: $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
|
||||||
|
$candidate = $path.DIRECTORY_SEPARATOR.'index.'.$ext;
|
||||||
|
if (is_file($candidate)) {
|
||||||
|
return realpath($candidate) ?: $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/Plugins/Tia/JsModuleGraph.php
Normal file
142
src/Plugins/Tia/JsModuleGraph.php
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\ExecutableFinder;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a reverse dependency map for the project's JS sources under
|
||||||
|
* `resources/js/**` — for every source file, the list of Inertia page
|
||||||
|
* components that transitively import it.
|
||||||
|
*
|
||||||
|
* Tries two resolvers in order:
|
||||||
|
*
|
||||||
|
* 1. **Node helper** (`bin/pest-tia-vite-deps.mjs`). Spins up a
|
||||||
|
* headless Vite server in middleware mode, walks Vite's own
|
||||||
|
* module graph for each page entry, and outputs JSON. Uses the
|
||||||
|
* project's real `vite.config.*`, so aliases, plugins, and SFC
|
||||||
|
* transformers produce the same graph Vite itself would use.
|
||||||
|
*
|
||||||
|
* 2. **PHP fallback** (`JsImportParser`). Regex-scans ES imports
|
||||||
|
* and resolves `@/` / `~/` aliases manually. Strictly less
|
||||||
|
* precise — anything it can't resolve is skipped, leaving the
|
||||||
|
* caller to fall back to the broad watch pattern. Only kicks in
|
||||||
|
* when the Node helper is unusable (no Node on PATH, no Vite
|
||||||
|
* installed, vite.config fails to load).
|
||||||
|
*
|
||||||
|
* Callers invoke this at record time; results are persisted into the
|
||||||
|
* graph so replay never re-runs the resolver. On stale-map detection
|
||||||
|
* the callers decide whether to rebuild.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class JsModuleGraph
|
||||||
|
{
|
||||||
|
private const int NODE_TIMEOUT_SECONDS = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>> project-relative source path → sorted list of page component names
|
||||||
|
*/
|
||||||
|
public static function build(string $projectRoot): array
|
||||||
|
{
|
||||||
|
$viaNode = self::tryNodeHelper($projectRoot);
|
||||||
|
|
||||||
|
if ($viaNode !== null) {
|
||||||
|
return $viaNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsImportParser::parse($projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the project looks like a Vite + Node project we can
|
||||||
|
* ask for a module graph. Gate for callers that want to skip the
|
||||||
|
* resolver entirely on non-Vite apps.
|
||||||
|
*/
|
||||||
|
public static function isApplicable(string $projectRoot): bool
|
||||||
|
{
|
||||||
|
return self::hasViteConfig($projectRoot) && is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.'Pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<string>>|null
|
||||||
|
*/
|
||||||
|
private static function tryNodeHelper(string $projectRoot): ?array
|
||||||
|
{
|
||||||
|
if (! self::hasViteConfig($projectRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodeBinary = (new ExecutableFinder)->find('node');
|
||||||
|
|
||||||
|
if ($nodeBinary === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
|
||||||
|
|
||||||
|
if (! is_file($helperPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
|
||||||
|
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var mixed $decoded */
|
||||||
|
$decoded = json_decode($process->getOutput(), true);
|
||||||
|
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($decoded as $path => $components) {
|
||||||
|
if (! is_string($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($components)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if (is_string($component) && $component !== '') {
|
||||||
|
$names[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($names !== []) {
|
||||||
|
sort($names);
|
||||||
|
$out[$path] = $names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($out);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hasViteConfig(string $projectRoot): bool
|
||||||
|
{
|
||||||
|
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||||
|
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,27 @@ final class Recorder
|
|||||||
*/
|
*/
|
||||||
private array $perTestFiles = [];
|
private array $perTestFiles = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated map: absolute test file → set<lowercase table name>.
|
||||||
|
* Populated by `TableTracker` from `DB::listen` callbacks; consumed
|
||||||
|
* at record finalize to populate the graph's `$testTables` edges
|
||||||
|
* that drive migration-change impact analysis.
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, true>>
|
||||||
|
*/
|
||||||
|
private array $perTestTables = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated map: absolute test file → set<Inertia component name>.
|
||||||
|
* Populated by `InertiaEdges` from Inertia responses observed at
|
||||||
|
* request-handled time; consumed at record finalize to populate
|
||||||
|
* the graph's per-test component edges that drive Vue / React
|
||||||
|
* page-file impact analysis.
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, true>>
|
||||||
|
*/
|
||||||
|
private array $perTestInertiaComponents = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached class → test file resolution.
|
* Cached class → test file resolution.
|
||||||
*
|
*
|
||||||
@ -144,6 +165,83 @@ final class Recorder
|
|||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an extra source-file dependency for the currently-running
|
||||||
|
* test. Used by collaborators that capture edges the coverage driver
|
||||||
|
* cannot see — Blade templates rendered through Laravel's view
|
||||||
|
* factory are the motivating case (their `.blade.php` source never
|
||||||
|
* executes directly; a cached compiled PHP file does). No-op when
|
||||||
|
* the recorder is inactive or no test is in flight, so callers can
|
||||||
|
* fire it unconditionally from app-level hooks.
|
||||||
|
*/
|
||||||
|
public function linkSource(string $sourceFile): void
|
||||||
|
{
|
||||||
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceFile === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the currently-running test queried `$table`. Called
|
||||||
|
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
||||||
|
* reports; the table name has already been extracted by
|
||||||
|
* `TableExtractor::fromSql()` so we just store it. No-op outside
|
||||||
|
* a test window, so the callback is safe to leave armed across
|
||||||
|
* setUp / tearDown boundaries.
|
||||||
|
*/
|
||||||
|
public function linkTable(string $table): void
|
||||||
|
{
|
||||||
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($table === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records that the currently-running test server-side-rendered the
|
||||||
|
* named Inertia component. The name is whatever
|
||||||
|
* `Inertia::render($component, …)` was called with — typically a
|
||||||
|
* slash-separated path like `Users/Show` that maps to
|
||||||
|
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
|
||||||
|
* so the underlying listener can stay armed without leaking
|
||||||
|
* state between tests.
|
||||||
|
*/
|
||||||
|
public function linkInertiaComponent(string $component): void
|
||||||
|
{
|
||||||
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($component === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
||||||
*/
|
*/
|
||||||
@ -158,6 +256,38 @@ final class Recorder
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>> absolute test file → sorted list of table names.
|
||||||
|
*/
|
||||||
|
public function perTestTables(): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($this->perTestTables as $testFile => $tables) {
|
||||||
|
$names = array_keys($tables);
|
||||||
|
sort($names);
|
||||||
|
$out[$testFile] = $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
|
||||||
|
*/
|
||||||
|
public function perTestInertiaComponents(): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach ($this->perTestInertiaComponents as $testFile => $components) {
|
||||||
|
$names = array_keys($components);
|
||||||
|
sort($names);
|
||||||
|
$out[$testFile] = $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
||||||
{
|
{
|
||||||
if (array_key_exists($className, $this->classFileCache)) {
|
if (array_key_exists($className, $this->classFileCache)) {
|
||||||
@ -223,6 +353,8 @@ final class Recorder
|
|||||||
{
|
{
|
||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
$this->perTestFiles = [];
|
$this->perTestFiles = [];
|
||||||
|
$this->perTestTables = [];
|
||||||
|
$this->perTestInertiaComponents = [];
|
||||||
$this->classFileCache = [];
|
$this->classFileCache = [];
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/Plugins/Tia/TableExtractor.php
Normal file
154
src/Plugins/Tia/TableExtractor.php
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts table names from SQL statements and migration PHP sources.
|
||||||
|
*
|
||||||
|
* Two callers, two methods:
|
||||||
|
*
|
||||||
|
* - `fromSql()` runs against query strings Laravel's `DB::listen`
|
||||||
|
* hands us at record time. We only look at DML (`SELECT`, `INSERT`,
|
||||||
|
* `UPDATE`, `DELETE`) because DDL emitted by `RefreshDatabase` in
|
||||||
|
* `setUp()` is noise — we don't want every test to end up linked
|
||||||
|
* to every migration's `CREATE TABLE`.
|
||||||
|
* - `fromMigrationSource()` reads a migration file on disk at
|
||||||
|
* replay time and pulls table names out of `Schema::` calls.
|
||||||
|
* Used in two places:
|
||||||
|
* 1. For every migration file reported as changed — what
|
||||||
|
* tables does the current version of this file touch?
|
||||||
|
* 2. For brand-new migration files that weren't in the graph
|
||||||
|
* yet, so we never had a chance to observe their DDL.
|
||||||
|
*
|
||||||
|
* Regex isn't a parser. CTEs, subqueries, and raw `DB::statement()`
|
||||||
|
* that reference tables only inside exotic syntax can slip through.
|
||||||
|
* The direction of that error is under-attribution (a table the test
|
||||||
|
* genuinely touches but we missed), so the safety net is to keep the
|
||||||
|
* broad `database/migrations/**` watch pattern as a last resort for
|
||||||
|
* files that produce an empty extraction.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TableExtractor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* DML prefixes we accept. DDL (`CREATE`, `ALTER`, `DROP`,
|
||||||
|
* `TRUNCATE`, `RENAME`) is deliberately excluded — those come
|
||||||
|
* from migrations fired by `RefreshDatabase`, and capturing them
|
||||||
|
* here would attribute every migration table to every test.
|
||||||
|
*/
|
||||||
|
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Sorted, deduped table names referenced by the
|
||||||
|
* SQL statement. Empty when the statement is
|
||||||
|
* DDL, empty, or unparseable.
|
||||||
|
*/
|
||||||
|
public static function fromSql(string $sql): array
|
||||||
|
{
|
||||||
|
$trimmed = ltrim($sql);
|
||||||
|
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = strtolower(substr($trimmed, 0, 6));
|
||||||
|
|
||||||
|
$matched = false;
|
||||||
|
foreach (self::DML_PREFIXES as $dml) {
|
||||||
|
if (str_starts_with($prefix, $dml)) {
|
||||||
|
$matched = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matched) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match `from`, `into`, `update`, `join` and capture the
|
||||||
|
// following identifier, tolerating the common quoting
|
||||||
|
// styles: "double", `back`, [bracket], or bare.
|
||||||
|
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
||||||
|
|
||||||
|
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tables = [];
|
||||||
|
|
||||||
|
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
||||||
|
$name = $matches[1][$i] !== ''
|
||||||
|
? $matches[1][$i]
|
||||||
|
: ($matches[2][$i] !== ''
|
||||||
|
? $matches[2][$i]
|
||||||
|
: ($matches[3][$i] !== ''
|
||||||
|
? $matches[3][$i]
|
||||||
|
: $matches[4][$i]));
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (self::isSchemaMeta($name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tables[strtolower($name)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = array_keys($tables);
|
||||||
|
sort($out);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Table names referenced by `Schema::` calls
|
||||||
|
* in the given migration file contents. Empty
|
||||||
|
* when nothing matches — callers treat that
|
||||||
|
* as "fall back to the broad watch pattern".
|
||||||
|
*/
|
||||||
|
public static function fromMigrationSource(string $php): array
|
||||||
|
{
|
||||||
|
$pattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
||||||
|
|
||||||
|
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tables = [];
|
||||||
|
|
||||||
|
foreach ($matches[1] as $i => $primary) {
|
||||||
|
// Group 1 always captures at least one char per the regex.
|
||||||
|
$tables[strtolower($primary)] = true;
|
||||||
|
|
||||||
|
// Group 2 (`Schema::rename('old', 'new')`) is optional and
|
||||||
|
// absent from non-rename matches.
|
||||||
|
$secondary = $matches[2][$i] ?? '';
|
||||||
|
if ($secondary !== '') {
|
||||||
|
$tables[strtolower($secondary)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = array_keys($tables);
|
||||||
|
sort($out);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out driver-internal tables that show up as DB::listen
|
||||||
|
* targets without representing user schema: SQLite's master
|
||||||
|
* catalogue, Laravel's own `migrations` metadata.
|
||||||
|
*/
|
||||||
|
private static function isSchemaMeta(string $name): bool
|
||||||
|
{
|
||||||
|
$lower = strtolower($name);
|
||||||
|
|
||||||
|
return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true)
|
||||||
|
|| str_starts_with($lower, 'pg_')
|
||||||
|
|| str_starts_with($lower, 'information_schema');
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/Plugins/Tia/TableTracker.php
Normal file
123
src/Plugins/Tia/TableTracker.php
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Pest\Plugins\Tia;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laravel-only collaborator: during record mode, attributes every SQL
|
||||||
|
* table the test body queries to the currently-running test.
|
||||||
|
*
|
||||||
|
* Why this exists: the coverage graph can tell us which PHP files a
|
||||||
|
* test touched but cannot distinguish "this test depends on the
|
||||||
|
* `users` table" from "this test depends on `questions`". That
|
||||||
|
* distinction is the whole point of surgical migration invalidation —
|
||||||
|
* a column rename in `create_questions_table.php` should only re-run
|
||||||
|
* tests whose body actually queried `questions`.
|
||||||
|
*
|
||||||
|
* Mechanism: install a listener on Laravel's event dispatcher that
|
||||||
|
* subscribes to `Illuminate\Database\Events\QueryExecuted`. Each
|
||||||
|
* query string is piped through `TableExtractor::fromSql()`; DDL is
|
||||||
|
* filtered at extraction time so migrations running in `setUp` don't
|
||||||
|
* attribute every table to every test.
|
||||||
|
*
|
||||||
|
* Same dep-free handshake as `BladeEdges`: string class lookup +
|
||||||
|
* method-capability probes so Pest's `require` stays Laravel-free.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class TableTracker
|
||||||
|
{
|
||||||
|
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App-scoped marker that makes `arm()` idempotent across the 774
|
||||||
|
* per-test `setUp()` calls — Laravel reuses the same app instance
|
||||||
|
* within a single test run, so without this guard we'd stack
|
||||||
|
* one listener per test and each query would fire the closure
|
||||||
|
* hundreds of times.
|
||||||
|
*/
|
||||||
|
private const string MARKER = 'pest.tia.table-tracker-armed';
|
||||||
|
|
||||||
|
public static function arm(Recorder $recorder): void
|
||||||
|
{
|
||||||
|
if (! $recorder->isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerClass = self::CONTAINER_CLASS;
|
||||||
|
|
||||||
|
if (! class_exists($containerClass)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var object $app */
|
||||||
|
$app = $containerClass::getInstance();
|
||||||
|
|
||||||
|
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($app->bound(self::MARKER)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $app->bound('db')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$app->instance(self::MARKER, true);
|
||||||
|
|
||||||
|
$listener = static function (object $query) use ($recorder): void {
|
||||||
|
if (! property_exists($query, 'sql')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var mixed $sql */
|
||||||
|
$sql = $query->sql;
|
||||||
|
|
||||||
|
if (! is_string($sql) || $sql === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (TableExtractor::fromSql($sql) as $table) {
|
||||||
|
$recorder->linkTable($table);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preferred path: `DatabaseManager::listen(Closure $callback)`.
|
||||||
|
// It's a real method — `method_exists` returns false because
|
||||||
|
// some Laravel versions compose it via a trait the reflection
|
||||||
|
// probe can't always see, so we gate via `is_callable` instead.
|
||||||
|
// This path pushes the listener onto every existing AND future
|
||||||
|
// connection, which is what we want for a process-wide capture.
|
||||||
|
/** @var object $db */
|
||||||
|
$db = $app->make('db');
|
||||||
|
|
||||||
|
if (is_callable([$db, 'listen'])) {
|
||||||
|
/** @var callable $listen */
|
||||||
|
$listen = [$db, 'listen'];
|
||||||
|
$listen($listener);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: register directly on the event dispatcher. Works
|
||||||
|
// as long as every connection shares the same dispatcher
|
||||||
|
// instance this app resolved to — true in vanilla setups,
|
||||||
|
// but not guaranteed with connections instantiated pre-arm
|
||||||
|
// that captured an older dispatcher.
|
||||||
|
if (! $app->bound('events')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var object $events */
|
||||||
|
$events = $app->make('events');
|
||||||
|
|
||||||
|
if (! method_exists($events, 'listen')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$events->listen('\\Illuminate\\Database\\Events\\QueryExecuted', $listener);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,11 +31,18 @@ final readonly class Inertia implements WatchDefault
|
|||||||
: $testPath;
|
: $testPath;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Inertia page components (React / Vue / Svelte).
|
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||||
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
|
// `$browserDir` only — a Vue/React edit cannot change the
|
||||||
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
|
// output of a server-side Inertia test (those assert on the
|
||||||
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
|
// component *name* returned by `Inertia::render()`, not its
|
||||||
'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir],
|
// client-side implementation). Broad invalidation is only
|
||||||
|
// meaningful for tests that actually render the DOM. Precise
|
||||||
|
// per-component edges come from `InertiaEdges` at record
|
||||||
|
// time and replace this fallback when available.
|
||||||
|
'resources/js/Pages/**/*.vue' => [$browserDir],
|
||||||
|
'resources/js/Pages/**/*.tsx' => [$browserDir],
|
||||||
|
'resources/js/Pages/**/*.jsx' => [$browserDir],
|
||||||
|
'resources/js/Pages/**/*.svelte' => [$browserDir],
|
||||||
|
|
||||||
// Shared layouts / components consumed by pages.
|
// Shared layouts / components consumed by pages.
|
||||||
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
||||||
|
|||||||
Reference in New Issue
Block a user