mirror of
https://github.com/pestphp/pest.git
synced 2026-07-21 17:10:03 +02:00
fix: tia engine correctness issues
- keep table/Inertia/database link-tracking alive in piggyback-coverage recording, and merge link-only edges into the piggybacked edge set - route migration changes through the watch-pattern fallback when the graph has no recorded table usage - re-run cached failures whose test file cannot be located, downgrading filtered runs to full replay instead of silently skipping them - align non-filtered replay with the failOn*/displayDetailsOn* rerun policy - resolve anonymous index Blade components (<x-card> from card/index.blade.php) and stop swallowing components whose static usage walk selected no tests - parse CTE/REPLACE queries and schema-qualified identifiers in TableExtractor - scope-filter the xdebug recording path like pcov - ignore in-flight .tmp files in FileState::keysWithPrefix and delete unreadable worker partials - vite deps helper: resolve rolldown through rolldown-vite installs and degrade gracefully, honour vite.config resolve.alias (incl. the laravel-vite-plugin '@' default), resolve .vue/.svelte/.mts/.cts extensions, and stop memoizing cycle-tainted transitive sets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+105
-6
@@ -25,7 +25,21 @@ const PAGE_DIR_CANDIDATES = [
|
|||||||
|
|
||||||
async function loadRolldown() {
|
async function loadRolldown() {
|
||||||
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
|
||||||
const path = projectRequire.resolve('rolldown')
|
let path = null
|
||||||
|
|
||||||
|
try { path = projectRequire.resolve('rolldown') } catch {}
|
||||||
|
|
||||||
|
if (path === null) {
|
||||||
|
// rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a
|
||||||
|
// dependency of the vite package rather than a top-level install.
|
||||||
|
try {
|
||||||
|
const viteRequire = createRequire(projectRequire.resolve('vite/package.json'))
|
||||||
|
path = viteRequire.resolve('rolldown')
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === null) return null
|
||||||
|
|
||||||
return await import(pathToFileURL(path).href)
|
return await import(pathToFileURL(path).href)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +104,74 @@ export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) {
|
|||||||
return alias
|
return alias
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VITE_CONFIG_FILES = [
|
||||||
|
'vite.config.ts',
|
||||||
|
'vite.config.js',
|
||||||
|
'vite.config.mjs',
|
||||||
|
'vite.config.cjs',
|
||||||
|
'vite.config.mts',
|
||||||
|
'vite.config.cts',
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveAliasTarget(projectRoot, target) {
|
||||||
|
if (target.startsWith('/')) {
|
||||||
|
// Vite resolves a leading slash against the project root, unless the
|
||||||
|
// config author used a genuinely absolute path.
|
||||||
|
return existsSync(target) ? target : resolve(projectRoot, '.' + target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(projectRoot, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function usesLaravelVitePlugin(projectRoot) {
|
||||||
|
const p = join(projectRoot, 'package.json')
|
||||||
|
if (!existsSync(p)) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(await readFile(p, 'utf8'))
|
||||||
|
return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin'])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) {
|
||||||
|
const alias = {}
|
||||||
|
let source = null
|
||||||
|
|
||||||
|
for (const name of VITE_CONFIG_FILES) {
|
||||||
|
const p = join(projectRoot, name)
|
||||||
|
if (!existsSync(p)) continue
|
||||||
|
try { source = await readFile(p, 'utf8') } catch { continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source !== null) {
|
||||||
|
// The config is executable code we cannot import safely, so extract alias
|
||||||
|
// entries textually: a quoted `@…`/`~…` key, then the last quoted path in
|
||||||
|
// the value expression — covers `'@': '/resources/js'` as well as
|
||||||
|
// `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a
|
||||||
|
// top-level comma; call arguments are kept via the paren group.
|
||||||
|
for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) {
|
||||||
|
const key = m[1]
|
||||||
|
if (alias[key] !== undefined) continue
|
||||||
|
|
||||||
|
const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1])
|
||||||
|
const target = paths.length > 0 ? paths[paths.length - 1] : null
|
||||||
|
if (!target || target.includes('*')) continue
|
||||||
|
|
||||||
|
alias[key] = resolveAliasTarget(projectRoot, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// laravel-vite-plugin registers '@' → resources/js by default.
|
||||||
|
if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) {
|
||||||
|
alias['@'] = resolve(projectRoot, 'resources/js')
|
||||||
|
}
|
||||||
|
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
|
||||||
async function listPageFiles(pagesDir) {
|
async function listPageFiles(pagesDir) {
|
||||||
if (!existsSync(pagesDir)) return []
|
if (!existsSync(pagesDir)) return []
|
||||||
|
|
||||||
@@ -153,8 +235,16 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rolldown } = await loadRolldown()
|
const loaded = await loadRolldown()
|
||||||
const alias = await loadAliasFromTsconfig()
|
|
||||||
|
if (loaded === null) {
|
||||||
|
process.stdout.write('{}')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rolldown } = loaded
|
||||||
|
// The vite config is what the dev server actually resolves with — let it win.
|
||||||
|
const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) }
|
||||||
const aliasKeys = Object.keys(alias)
|
const aliasKeys = Object.keys(alias)
|
||||||
|
|
||||||
const graph = new Map()
|
const graph = new Map()
|
||||||
@@ -199,7 +289,7 @@ async function main() {
|
|||||||
cwd: PROJECT_ROOT,
|
cwd: PROJECT_ROOT,
|
||||||
resolve: {
|
resolve: {
|
||||||
alias,
|
alias,
|
||||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
|
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'],
|
||||||
},
|
},
|
||||||
transform: { jsx: 'preserve' },
|
transform: { jsx: 'preserve' },
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
@@ -224,6 +314,11 @@ async function main() {
|
|||||||
|
|
||||||
stack.add(id)
|
stack.add(id)
|
||||||
const acc = new Set()
|
const acc = new Set()
|
||||||
|
// A set computed while skipping an in-stack (cyclic) dependency is missing
|
||||||
|
// that dependency's subtree and must not be memoized — the ancestor call
|
||||||
|
// completes it for the current traversal, but a cached copy would leak the
|
||||||
|
// incomplete set into other pages' traversals.
|
||||||
|
let complete = true
|
||||||
const deps = graph.get(id)
|
const deps = graph.get(id)
|
||||||
if (deps) {
|
if (deps) {
|
||||||
for (const dep of deps) {
|
for (const dep of deps) {
|
||||||
@@ -232,13 +327,17 @@ async function main() {
|
|||||||
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
const rel = relative(PROJECT_ROOT, dep).split(sep).join('/')
|
||||||
acc.add(rel)
|
acc.add(rel)
|
||||||
}
|
}
|
||||||
if (stack.has(dep)) continue
|
if (stack.has(dep)) {
|
||||||
|
complete = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
const child = computeTransitive(dep, stack)
|
const child = computeTransitive(dep, stack)
|
||||||
if (child) for (const r of child) acc.add(r)
|
if (child) for (const r of child) acc.add(r)
|
||||||
|
if (!transitiveCache.has(dep)) complete = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stack.delete(id)
|
stack.delete(id)
|
||||||
transitiveCache.set(id, acc)
|
if (complete) transitiveCache.set(id, acc)
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-2
@@ -296,7 +296,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||||
|
|
||||||
if ($result instanceof TestStatus) {
|
if ($result instanceof TestStatus) {
|
||||||
if ($result->isFailure() || $result->isError()) {
|
if ($this->replayGraph->shouldRerunStatus($result)) {
|
||||||
$this->executedCount++;
|
$this->executedCount++;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -402,7 +402,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
$perTest = $this->piggybackCoverage
|
$perTest = $this->piggybackCoverage
|
||||||
? $this->coverageCollector->perTestFiles()
|
? $this->mergePerTestFiles($this->coverageCollector->perTestFiles(), $recorder->perTestFiles())
|
||||||
: $recorder->perTestFiles();
|
: $recorder->perTestFiles();
|
||||||
|
|
||||||
if ($perTest === []) {
|
if ($perTest === []) {
|
||||||
@@ -715,6 +715,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
|
$this->recorder->activateLinkTracking();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
@@ -782,6 +783,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private function activateWorkerRecorderForReplay(array $arguments): array
|
private function activateWorkerRecorderForReplay(array $arguments): array
|
||||||
{
|
{
|
||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
|
$this->recorder->activateLinkTracking();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
return $arguments;
|
return $arguments;
|
||||||
@@ -834,6 +836,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
||||||
$rerunFromCache = [];
|
$rerunFromCache = [];
|
||||||
|
|
||||||
|
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
|
||||||
|
$this->filteredMode = false;
|
||||||
|
|
||||||
|
$this->renderBadge('WARN', 'Some cached tests due a re-run could not be located on disk.');
|
||||||
|
$this->renderChild('Running the full suite with replay instead of a filtered run.');
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->filteredMode) {
|
if ($this->filteredMode) {
|
||||||
$rerunFromCache = $graph->testFilesToRerun($this->branch);
|
$rerunFromCache = $graph->testFilesToRerun($this->branch);
|
||||||
}
|
}
|
||||||
@@ -1014,6 +1023,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->piggybackCoverage) {
|
if ($this->piggybackCoverage) {
|
||||||
|
$recorder->activateLinkTracking();
|
||||||
$this->recordingActive = true;
|
$this->recordingActive = true;
|
||||||
|
|
||||||
$this->output->writeln('');
|
$this->output->writeln('');
|
||||||
@@ -1235,6 +1245,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$data = $this->readPartial($key);
|
$data = $this->readPartial($key);
|
||||||
|
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
|
$this->state->delete($key);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,6 +1380,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$this->saveGraph($graph);
|
$this->saveGraph($graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of two per-test edge maps — piggybacked line-coverage edges plus
|
||||||
|
* the recorder's link-tracked edges (rendered Blade views, ...), which
|
||||||
|
* never appear in line coverage.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, string>> $coverage
|
||||||
|
* @param array<string, array<int, string>> $linked
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private function mergePerTestFiles(array $coverage, array $linked): array
|
||||||
|
{
|
||||||
|
foreach ($linked as $testFile => $sources) {
|
||||||
|
$existing = $coverage[$testFile] ?? [];
|
||||||
|
|
||||||
|
$coverage[$testFile] = array_values(array_unique([...$existing, ...$sources]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $coverage;
|
||||||
|
}
|
||||||
|
|
||||||
private function seedResultsInto(Graph $graph): void
|
private function seedResultsInto(Graph $graph): void
|
||||||
{
|
{
|
||||||
/** @var ResultCollector $collector */
|
/** @var ResultCollector $collector */
|
||||||
@@ -1379,6 +1411,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
foreach ($results as $testId => $result) {
|
foreach ($results as $testId => $result) {
|
||||||
$file = $result['file'] ?? null;
|
$file = $result['file'] ?? null;
|
||||||
|
|
||||||
|
if ($file === null || str_contains($file, "eval()'d")) {
|
||||||
|
$file = $this->resolveFailedTestFile($testId);
|
||||||
|
}
|
||||||
|
|
||||||
if (is_string($file) && $file !== '') {
|
if (is_string($file) && $file !== '') {
|
||||||
$touchedFiles[$file] = true;
|
$touchedFiles[$file] = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ final class FileState implements State
|
|||||||
$keys = [];
|
$keys = [];
|
||||||
|
|
||||||
foreach ($matches as $path) {
|
foreach ($matches as $path) {
|
||||||
|
if (str_ends_with($path, '.tmp')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$keys[] = basename($path);
|
$keys[] = basename($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ final class Graph
|
|||||||
*/
|
*/
|
||||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||||
{
|
{
|
||||||
|
// With no recorded table usage at all, table intersection can never
|
||||||
|
// select anything — route every migration change through the
|
||||||
|
// watch-pattern fallback instead of silently skipping tests.
|
||||||
|
if ($this->testTables === []) {
|
||||||
|
return $migrationPaths;
|
||||||
|
}
|
||||||
|
|
||||||
$changedTables = [];
|
$changedTables = [];
|
||||||
$unparseable = [];
|
$unparseable = [];
|
||||||
|
|
||||||
@@ -446,13 +453,14 @@ final class Graph
|
|||||||
|
|
||||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||||
|
|
||||||
|
// Only a walk that actually selected tests counts as handled — a
|
||||||
|
// component whose usage the static walk missed must still reach
|
||||||
|
// the watch-pattern fallback instead of being silently swallowed.
|
||||||
if ($bladeAffected !== []) {
|
if ($bladeAffected !== []) {
|
||||||
foreach ($bladeAffected as $testFile) {
|
foreach ($bladeAffected as $testFile) {
|
||||||
$affectedSet[$testFile] = true;
|
$affectedSet[$testFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$staticallyHandled[$rel] = true;
|
|
||||||
} elseif ($this->isBladeComponentPath($rel)) {
|
|
||||||
$staticallyHandled[$rel] = true;
|
$staticallyHandled[$rel] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,8 +698,16 @@ final class Graph
|
|||||||
|
|
||||||
private function shouldRerun(int $status): bool
|
private function shouldRerun(int $status): bool
|
||||||
{
|
{
|
||||||
$testStatus = TestStatus::from($status);
|
return $this->shouldRerunStatus(TestStatus::from($status));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a cached result with this status must be re-executed rather
|
||||||
|
* than replayed, honouring the configured failOn* / displayDetailsOn*
|
||||||
|
* policies.
|
||||||
|
*/
|
||||||
|
public function shouldRerunStatus(TestStatus $testStatus): bool
|
||||||
|
{
|
||||||
if ($testStatus->isFailure() || $testStatus->isError()) {
|
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1249,7 +1265,21 @@ final class Graph
|
|||||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||||
$name = str_replace('/', '.', $tail);
|
$name = str_replace('/', '.', $tail);
|
||||||
|
|
||||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
if ($name === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = [$name, str_replace('_', '-', $name)];
|
||||||
|
|
||||||
|
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||||
|
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||||
|
$base = substr($name, 0, -strlen('.index'));
|
||||||
|
|
||||||
|
$names[] = $base;
|
||||||
|
$names[] = str_replace('_', '-', $base);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($names));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
|
|||||||
@@ -163,7 +163,9 @@ final class JsModuleGraph
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||||
|
|
||||||
|
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ final class Recorder
|
|||||||
|
|
||||||
private bool $active = false;
|
private bool $active = false;
|
||||||
|
|
||||||
|
private bool $captureCoverage = false;
|
||||||
|
|
||||||
private bool $driverChecked = false;
|
private bool $driverChecked = false;
|
||||||
|
|
||||||
private bool $driverAvailable = false;
|
private bool $driverAvailable = false;
|
||||||
@@ -43,6 +45,17 @@ final class Recorder
|
|||||||
private ?SourceScope $sourceScope = null;
|
private ?SourceScope $sourceScope = null;
|
||||||
|
|
||||||
public function activate(): void
|
public function activate(): void
|
||||||
|
{
|
||||||
|
$this->active = true;
|
||||||
|
$this->captureCoverage = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable per-test link tracking (tables, Inertia components, database
|
||||||
|
* usage, rendered views) without driving pcov/xdebug — for runs where
|
||||||
|
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
||||||
|
*/
|
||||||
|
public function activateLinkTracking(): void
|
||||||
{
|
{
|
||||||
$this->active = true;
|
$this->active = true;
|
||||||
}
|
}
|
||||||
@@ -75,7 +88,11 @@ final class Recorder
|
|||||||
|
|
||||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||||
{
|
{
|
||||||
if (! $this->active || ! $this->driverAvailable()) {
|
if (! $this->active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +112,10 @@ final class Recorder
|
|||||||
$this->perTestUsesDatabase[$file] = true;
|
$this->perTestUsesDatabase[$file] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->captureCoverage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
\pcov\start();
|
\pcov\start();
|
||||||
@@ -107,7 +128,13 @@ final class Recorder
|
|||||||
|
|
||||||
public function endTest(): void
|
public function endTest(): void
|
||||||
{
|
{
|
||||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
if (! $this->active || $this->currentTestFile === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||||
|
$this->currentTestFile = null;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +159,15 @@ final class Recorder
|
|||||||
$data = \xdebug_get_code_coverage();
|
$data = \xdebug_get_code_coverage();
|
||||||
\xdebug_stop_code_coverage(true);
|
\xdebug_stop_code_coverage(true);
|
||||||
|
|
||||||
$coveredFiles = array_keys($data);
|
$scope = $this->sourceScope();
|
||||||
|
|
||||||
|
foreach (array_keys($data) as $file) {
|
||||||
|
if (! $scope->contains($file)) {
|
||||||
|
unset($data[$file]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($coveredFiles as $sourceFile) {
|
foreach ($coveredFiles as $sourceFile) {
|
||||||
@@ -351,5 +386,6 @@ final class Recorder
|
|||||||
$this->classUsesDatabaseCache = [];
|
$this->classUsesDatabaseCache = [];
|
||||||
$this->sourceScope = null;
|
$this->sourceScope = null;
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
|
$this->captureCoverage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
|
|||||||
*/
|
*/
|
||||||
final class TableExtractor
|
final class TableExtractor
|
||||||
{
|
{
|
||||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single (optionally quoted) identifier segment.
|
||||||
|
*/
|
||||||
|
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string> Sorted, deduped table names referenced by the
|
* @return list<string> Sorted, deduped table names referenced by the
|
||||||
@@ -22,14 +27,15 @@ final class TableExtractor
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||||
$matched = array_any(self::DML_PREFIXES, fn (string $dml): bool => str_starts_with($prefix, $dml));
|
|
||||||
|
|
||||||
if (! $matched) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||||
|
|
||||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||||
return [];
|
return [];
|
||||||
@@ -37,14 +43,9 @@ final class TableExtractor
|
|||||||
|
|
||||||
$tables = [];
|
$tables = [];
|
||||||
|
|
||||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
foreach ($matches[1] as $qualified) {
|
||||||
$name = $matches[1][$i] !== ''
|
$name = self::unqualified($qualified);
|
||||||
? $matches[1][$i]
|
|
||||||
: ($matches[2][$i] !== ''
|
|
||||||
? $matches[2][$i]
|
|
||||||
: ($matches[3][$i] !== ''
|
|
||||||
? $matches[3][$i]
|
|
||||||
: $matches[4][$i]));
|
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -72,40 +73,40 @@ final class TableExtractor
|
|||||||
|
|
||||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||||
foreach ($matches[1] as $i => $primary) {
|
foreach ($matches[1] as $i => $primary) {
|
||||||
$tables[strtolower($primary)] = true;
|
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||||
|
|
||||||
$secondary = $matches[2][$i] ?? '';
|
$secondary = $matches[2][$i] ?? '';
|
||||||
if ($secondary !== '') {
|
if ($secondary !== '') {
|
||||||
$tables[strtolower($secondary)] = true;
|
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||||
|
|
||||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
$sqlPatterns = [
|
||||||
foreach ($matches[1] as $primary) {
|
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
||||||
$lower = strtolower($primary);
|
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
||||||
if (! self::isSchemaMeta($lower)) {
|
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
||||||
|
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($sqlPatterns as $pattern) {
|
||||||
|
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($matches[1] as $name) {
|
||||||
|
$lower = strtolower(self::unqualified($name));
|
||||||
|
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||||
$tables[$lower] = true;
|
$tables[$lower] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$dmlPatterns = [
|
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
|
||||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
|
||||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
|
||||||
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
|
|
||||||
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($dmlPatterns as $pattern) {
|
|
||||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($matches[1] as $name) {
|
foreach ($matches[1] as $name) {
|
||||||
$lower = strtolower($name);
|
$lower = strtolower(self::lastDottedSegment($name));
|
||||||
if (! self::isSchemaMeta($lower)) {
|
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||||
$tables[$lower] = true;
|
$tables[$lower] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +118,39 @@ final class TableExtractor
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The table segment of a possibly schema-qualified identifier chain,
|
||||||
|
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
||||||
|
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
||||||
|
*/
|
||||||
|
private static function unqualified(string $qualified): string
|
||||||
|
{
|
||||||
|
$name = '';
|
||||||
|
|
||||||
|
foreach (explode('.', $qualified) as $segment) {
|
||||||
|
$segment = trim($segment, " \t\n\r\"`[]");
|
||||||
|
|
||||||
|
if ($segment === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isSchemaMeta($segment)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function lastDottedSegment(string $name): string
|
||||||
|
{
|
||||||
|
$position = strrpos($name, '.');
|
||||||
|
|
||||||
|
return $position === false ? $name : substr($name, $position + 1);
|
||||||
|
}
|
||||||
|
|
||||||
private static function isSchemaMeta(string $name): bool
|
private static function isSchemaMeta(string $name): bool
|
||||||
{
|
{
|
||||||
$lower = strtolower($name);
|
$lower = strtolower($name);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\FileState;
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->root = sys_get_temp_dir().'/pest-tia-file-state-'.bin2hex(random_bytes(4));
|
||||||
|
mkdir($this->root, 0755, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
foreach (glob($this->root.'/*') ?: [] as $file) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@rmdir($this->root);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keysWithPrefix()', function (): void {
|
||||||
|
it('lists keys matching the prefix', function (): void {
|
||||||
|
$state = new FileState($this->root);
|
||||||
|
$state->write('worker-edges-a.json', '{}');
|
||||||
|
$state->write('worker-edges-b.json', '{}');
|
||||||
|
$state->write('worker-results-a.json', '{}');
|
||||||
|
|
||||||
|
$keys = $state->keysWithPrefix('worker-edges-');
|
||||||
|
sort($keys);
|
||||||
|
|
||||||
|
expect($keys)->toBe(['worker-edges-a.json', 'worker-edges-b.json']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores in-flight temporary files from concurrent writes', function (): void {
|
||||||
|
$state = new FileState($this->root);
|
||||||
|
$state->write('worker-edges-a.json', '{}');
|
||||||
|
|
||||||
|
// Simulate another process mid-write: its temp file exists but has not been renamed yet.
|
||||||
|
file_put_contents($this->root.'/worker-edges-b.json.'.bin2hex(random_bytes(4)).'.tmp', '{');
|
||||||
|
|
||||||
|
expect($state->keysWithPrefix('worker-edges-'))->toBe(['worker-edges-a.json']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,133 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Pest\Plugins\Tia\Graph;
|
use Pest\Plugins\Tia\Graph;
|
||||||
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
|
use Pest\Support\Container;
|
||||||
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
|
||||||
|
describe('shouldRerunStatus()', function (): void {
|
||||||
|
it('re-runs failures and errors, replays successes', function (): void {
|
||||||
|
$graph = new Graph(sys_get_temp_dir());
|
||||||
|
|
||||||
|
expect($graph->shouldRerunStatus(TestStatus::failure('boom')))->toBeTrue()
|
||||||
|
->and($graph->shouldRerunStatus(TestStatus::error('boom')))->toBeTrue()
|
||||||
|
->and($graph->shouldRerunStatus(TestStatus::success()))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyMigrationChanges()', function (): void {
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->projectRoot = sys_get_temp_dir().'/pest-tia-graph-'.bin2hex(random_bytes(4));
|
||||||
|
mkdir($this->projectRoot.'/database/migrations', 0755, true);
|
||||||
|
file_put_contents(
|
||||||
|
$this->projectRoot.'/database/migrations/2024_01_01_000000_create_orders_table.php',
|
||||||
|
"<?php Schema::create('orders', function () {});",
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->watchPatterns = new WatchPatterns;
|
||||||
|
Container::getInstance()->add(WatchPatterns::class, $this->watchPatterns);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
@unlink($this->projectRoot.'/database/migrations/2024_01_01_000000_create_orders_table.php');
|
||||||
|
@rmdir($this->projectRoot.'/database/migrations');
|
||||||
|
@rmdir($this->projectRoot.'/database');
|
||||||
|
@rmdir($this->projectRoot);
|
||||||
|
|
||||||
|
Container::getInstance()->add(WatchPatterns::class, new WatchPatterns);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects tests whose recorded tables intersect the changed migration', function (): void {
|
||||||
|
$graph = new Graph($this->projectRoot);
|
||||||
|
$graph->link('tests/Feature/OrderTest.php', 'app/Models/Order.php');
|
||||||
|
$graph->link('tests/Feature/UserTest.php', 'app/Models/User.php');
|
||||||
|
$graph->replaceTestTables([
|
||||||
|
'tests/Feature/OrderTest.php' => ['orders'],
|
||||||
|
'tests/Feature/UserTest.php' => ['users'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$affected = $graph->affected(['database/migrations/2024_01_01_000000_create_orders_table.php']);
|
||||||
|
|
||||||
|
expect($affected)->toBe(['tests/Feature/OrderTest.php']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to watch patterns when no table usage was recorded at all', function (): void {
|
||||||
|
$this->watchPatterns->add(['database/migrations/**' => 'tests/Feature']);
|
||||||
|
|
||||||
|
$graph = new Graph($this->projectRoot);
|
||||||
|
$graph->link('tests/Feature/OrderTest.php', 'app/Models/Order.php');
|
||||||
|
|
||||||
|
$affected = $graph->affected(['database/migrations/2024_01_01_000000_create_orders_table.php']);
|
||||||
|
|
||||||
|
expect($affected)->toBe(['tests/Feature/OrderTest.php']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rerun tracking', function (): void {
|
||||||
|
it('reruns cached failures via their file', function (): void {
|
||||||
|
$graph = new Graph(sys_get_temp_dir());
|
||||||
|
$graph->setResult('main', 'Tests\FooTest::it fails', 7, 'boom', 0.1, 1, 'tests/Feature/FooTest.php');
|
||||||
|
$graph->setResult('main', 'Tests\BarTest::it passes', 0, '', 0.1, 1, 'tests/Feature/BarTest.php');
|
||||||
|
|
||||||
|
expect($graph->testFilesToRerun('main'))->toBe(['tests/Feature/FooTest.php'])
|
||||||
|
->and($graph->hasUnlocatedTestsToRerun('main'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags cached failures whose file is unknown', function (): void {
|
||||||
|
$graph = new Graph(sys_get_temp_dir());
|
||||||
|
$graph->setResult('main', 'Tests\EvalTest::it fails', 7, 'boom', 0.1, 1);
|
||||||
|
|
||||||
|
expect($graph->testFilesToRerun('main'))->toBeEmpty()
|
||||||
|
->and($graph->hasUnlocatedTestsToRerun('main'))->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyBladeStaticChanges()', function (): void {
|
||||||
|
beforeEach(function (): void {
|
||||||
|
$this->projectRoot = sys_get_temp_dir().'/pest-tia-blade-'.bin2hex(random_bytes(4));
|
||||||
|
mkdir($this->projectRoot.'/resources/views/components/card', 0755, true);
|
||||||
|
|
||||||
|
file_put_contents($this->projectRoot.'/resources/views/page.blade.php', '<div><x-card /></div>');
|
||||||
|
file_put_contents($this->projectRoot.'/resources/views/components/card/index.blade.php', '<div>{{ $slot }}</div>');
|
||||||
|
file_put_contents($this->projectRoot.'/resources/views/components/unused.blade.php', '<div>never referenced</div>');
|
||||||
|
|
||||||
|
$this->watchPatterns = new WatchPatterns;
|
||||||
|
Container::getInstance()->add(WatchPatterns::class, $this->watchPatterns);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(function (): void {
|
||||||
|
@unlink($this->projectRoot.'/resources/views/page.blade.php');
|
||||||
|
@unlink($this->projectRoot.'/resources/views/components/card/index.blade.php');
|
||||||
|
@unlink($this->projectRoot.'/resources/views/components/unused.blade.php');
|
||||||
|
@rmdir($this->projectRoot.'/resources/views/components/card');
|
||||||
|
@rmdir($this->projectRoot.'/resources/views/components');
|
||||||
|
@rmdir($this->projectRoot.'/resources/views');
|
||||||
|
@rmdir($this->projectRoot.'/resources');
|
||||||
|
@rmdir($this->projectRoot);
|
||||||
|
|
||||||
|
Container::getInstance()->add(WatchPatterns::class, new WatchPatterns);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps an anonymous index component to the views that render it', function (): void {
|
||||||
|
$graph = new Graph($this->projectRoot);
|
||||||
|
$graph->link('tests/Feature/PageTest.php', 'resources/views/page.blade.php');
|
||||||
|
|
||||||
|
$affected = $graph->affected(['resources/views/components/card/index.blade.php']);
|
||||||
|
|
||||||
|
expect($affected)->toBe(['tests/Feature/PageTest.php']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to watch patterns for components with no matched usage', function (): void {
|
||||||
|
$this->watchPatterns->add(['resources/views/**' => 'tests/Feature']);
|
||||||
|
|
||||||
|
$graph = new Graph($this->projectRoot);
|
||||||
|
$graph->link('tests/Feature/PageTest.php', 'resources/views/page.blade.php');
|
||||||
|
|
||||||
|
$affected = $graph->affected(['resources/views/components/unused.blade.php']);
|
||||||
|
|
||||||
|
expect($affected)->toBe(['tests/Feature/PageTest.php']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('markKnownTestFiles()', function (): void {
|
describe('markKnownTestFiles()', function (): void {
|
||||||
it('makes a test file with no edges known', function (): void {
|
it('makes a test file with no edges known', function (): void {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\Recorder;
|
||||||
|
|
||||||
|
describe('activateLinkTracking()', function (): void {
|
||||||
|
it('tracks tables without a coverage driver', function (): void {
|
||||||
|
$recorder = new Recorder;
|
||||||
|
$recorder->activateLinkTracking();
|
||||||
|
|
||||||
|
$recorder->beginTest('Some\Missing\TestClass', 'it does things', '/project/tests/Feature/OrderTest.php');
|
||||||
|
$recorder->linkTable('orders');
|
||||||
|
$recorder->linkTable('users');
|
||||||
|
$recorder->endTest();
|
||||||
|
|
||||||
|
expect($recorder->perTestTables())
|
||||||
|
->toBe(['/project/tests/Feature/OrderTest.php' => ['orders', 'users']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks inertia components without a coverage driver', function (): void {
|
||||||
|
$recorder = new Recorder;
|
||||||
|
$recorder->activateLinkTracking();
|
||||||
|
|
||||||
|
$recorder->beginTest('Some\Missing\TestClass', 'it renders', '/project/tests/Feature/DashboardTest.php');
|
||||||
|
$recorder->linkInertiaComponent('Dashboard/Index');
|
||||||
|
$recorder->endTest();
|
||||||
|
|
||||||
|
expect($recorder->perTestInertiaComponents())
|
||||||
|
->toBe(['/project/tests/Feature/DashboardTest.php' => ['Dashboard/Index']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks linked sources across consecutive tests', function (): void {
|
||||||
|
$recorder = new Recorder;
|
||||||
|
$recorder->activateLinkTracking();
|
||||||
|
|
||||||
|
$recorder->beginTest('Some\Missing\TestClass', 'first', '/project/tests/Feature/FirstTest.php');
|
||||||
|
$recorder->linkSource('/project/resources/views/welcome.blade.php');
|
||||||
|
$recorder->endTest();
|
||||||
|
|
||||||
|
// A second test must start cleanly — endTest resets state even with no driver.
|
||||||
|
$recorder->beginTest('Some\Missing\TestClass', 'second', '/project/tests/Feature/SecondTest.php');
|
||||||
|
$recorder->linkSource('/project/resources/views/about.blade.php');
|
||||||
|
$recorder->endTest();
|
||||||
|
|
||||||
|
expect($recorder->perTestFiles())->toBe([
|
||||||
|
'/project/tests/Feature/FirstTest.php' => ['/project/resources/views/welcome.blade.php'],
|
||||||
|
'/project/tests/Feature/SecondTest.php' => ['/project/resources/views/about.blade.php'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records nothing while inactive', function (): void {
|
||||||
|
$recorder = new Recorder;
|
||||||
|
|
||||||
|
$recorder->beginTest('Some\Missing\TestClass', 'it does things', '/project/tests/Feature/OrderTest.php');
|
||||||
|
$recorder->linkTable('orders');
|
||||||
|
$recorder->endTest();
|
||||||
|
|
||||||
|
expect($recorder->perTestTables())->toBeEmpty();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Pest\Plugins\Tia\TableExtractor;
|
||||||
|
|
||||||
|
describe('fromSql()', function (): void {
|
||||||
|
it('extracts tables from plain DML', function (): void {
|
||||||
|
expect(TableExtractor::fromSql('select * from users'))->toBe(['users'])
|
||||||
|
->and(TableExtractor::fromSql('INSERT INTO orders (id) VALUES (1)'))->toBe(['orders'])
|
||||||
|
->and(TableExtractor::fromSql('UPDATE posts SET title = ?'))->toBe(['posts'])
|
||||||
|
->and(TableExtractor::fromSql('DELETE FROM sessions WHERE id = ?'))->toBe(['sessions']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tables from joins', function (): void {
|
||||||
|
expect(TableExtractor::fromSql('select * from orders join users on users.id = orders.user_id'))
|
||||||
|
->toBe(['orders', 'users']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tables from CTE queries', function (): void {
|
||||||
|
$sql = 'WITH recent AS (SELECT * FROM orders WHERE created_at > ?) SELECT * FROM recent JOIN users ON users.id = recent.user_id';
|
||||||
|
|
||||||
|
expect(TableExtractor::fromSql($sql))->toBe(['orders', 'recent', 'users']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tables from REPLACE INTO', function (): void {
|
||||||
|
expect(TableExtractor::fromSql('REPLACE INTO settings (key, value) VALUES (?, ?)'))
|
||||||
|
->toBe(['settings']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the table, not the schema, for qualified identifiers', function (): void {
|
||||||
|
expect(TableExtractor::fromSql('select * from public.users'))->toBe(['users'])
|
||||||
|
->and(TableExtractor::fromSql('select * from "public"."users"'))->toBe(['users'])
|
||||||
|
->and(TableExtractor::fromSql('select * from `analytics`.`events`'))->toBe(['events'])
|
||||||
|
->and(TableExtractor::fromSql('UPDATE public.posts SET title = ?'))->toBe(['posts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles quoted identifiers', function (): void {
|
||||||
|
expect(TableExtractor::fromSql('select * from "users"'))->toBe(['users'])
|
||||||
|
->and(TableExtractor::fromSql('select * from `users`'))->toBe(['users'])
|
||||||
|
->and(TableExtractor::fromSql('select * from [users]'))->toBe(['users']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores schema metadata tables', function (): void {
|
||||||
|
expect(TableExtractor::fromSql("select * from sqlite_master where type = 'table'"))->toBeEmpty()
|
||||||
|
->and(TableExtractor::fromSql('select * from pg_catalog.pg_tables'))->toBeEmpty()
|
||||||
|
->and(TableExtractor::fromSql('select * from information_schema.tables'))->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing for non-DML statements', function (): void {
|
||||||
|
expect(TableExtractor::fromSql('PRAGMA foreign_keys = ON'))->toBeEmpty()
|
||||||
|
->and(TableExtractor::fromSql(''))->toBeEmpty()
|
||||||
|
->and(TableExtractor::fromSql(' '))->toBeEmpty();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromMigrationSource()', function (): void {
|
||||||
|
it('extracts tables from Schema builder calls', function (): void {
|
||||||
|
$php = <<<'PHP'
|
||||||
|
Schema::create('users', function (Blueprint $table) {});
|
||||||
|
Schema::table('orders', function (Blueprint $table) {});
|
||||||
|
Schema::rename('old_posts', 'posts');
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
PHP;
|
||||||
|
|
||||||
|
expect(TableExtractor::fromMigrationSource($php))
|
||||||
|
->toBe(['old_posts', 'orders', 'posts', 'sessions', 'users']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tables from raw DDL statements', function (): void {
|
||||||
|
$php = <<<'PHP'
|
||||||
|
DB::statement('ALTER TABLE users ADD COLUMN age INT');
|
||||||
|
DB::statement('CREATE TABLE IF NOT EXISTS invoices (id INT)');
|
||||||
|
PHP;
|
||||||
|
|
||||||
|
expect(TableExtractor::fromMigrationSource($php))->toBe(['invoices', 'users']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the table, not the schema, in qualified DDL and DML', function (): void {
|
||||||
|
$php = <<<'PHP'
|
||||||
|
DB::statement('ALTER TABLE public.users ADD COLUMN age INT');
|
||||||
|
DB::statement('CREATE TABLE "analytics"."events" (id INT)');
|
||||||
|
DB::statement('INSERT INTO public.settings (key) VALUES (1)');
|
||||||
|
DB::statement('DELETE FROM `public`.`sessions`');
|
||||||
|
DB::table('public.audits')->delete();
|
||||||
|
PHP;
|
||||||
|
|
||||||
|
expect(TableExtractor::fromMigrationSource($php))
|
||||||
|
->toBe(['audits', 'events', 'sessions', 'settings', 'users']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts tables from DB::table calls', function (): void {
|
||||||
|
expect(TableExtractor::fromMigrationSource("DB::table('permissions')->insert([]);"))
|
||||||
|
->toBe(['permissions']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -259,6 +259,104 @@ function tiaAliasResults(): array
|
|||||||
return $cache = ['roots' => $roots, 'aliases' => $aliases];
|
return $cache = ['roots' => $roots, 'aliases' => $aliases];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tiaViteAliasFixtures(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'root-slash-literal' => [
|
||||||
|
['vite.config.js' => "export default { resolve: { alias: { '@': '/resources/js' } } }"],
|
||||||
|
['@' => 'resources/js'],
|
||||||
|
],
|
||||||
|
'path-resolve' => [
|
||||||
|
['vite.config.ts' => "export default { resolve: { alias: { '@': path.resolve(__dirname, 'resources/js') } } }"],
|
||||||
|
['@' => 'resources/js'],
|
||||||
|
],
|
||||||
|
'file-url' => [
|
||||||
|
['vite.config.mjs' => "export default { resolve: { alias: { '@': fileURLToPath(new URL('./resources/js', import.meta.url)) } } }"],
|
||||||
|
['@' => 'resources/js'],
|
||||||
|
],
|
||||||
|
'tilde-alias' => [
|
||||||
|
['vite.config.js' => "export default { resolve: { alias: { '~': path.resolve(__dirname, 'resources') } } }"],
|
||||||
|
['~' => 'resources'],
|
||||||
|
],
|
||||||
|
'multiple-aliases' => [
|
||||||
|
['vite.config.js' => "export default { resolve: { alias: { '@': '/resources/js', '@css': '/resources/css' } } }"],
|
||||||
|
['@' => 'resources/js', '@css' => 'resources/css'],
|
||||||
|
],
|
||||||
|
'laravel-plugin-default' => [
|
||||||
|
[
|
||||||
|
'vite.config.js' => "import laravel from 'laravel-vite-plugin'\nexport default { plugins: [laravel({ input: ['resources/js/app.ts'] })] }",
|
||||||
|
'package.json' => tiaJson(['devDependencies' => ['laravel-vite-plugin' => '^1.0']]),
|
||||||
|
],
|
||||||
|
['@' => 'resources/js'],
|
||||||
|
],
|
||||||
|
'no-alias-no-plugin' => [
|
||||||
|
[
|
||||||
|
'vite.config.js' => 'export default { plugins: [] }',
|
||||||
|
'package.json' => tiaJson(['devDependencies' => ['vue' => '^3.0']]),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
'no-config' => [
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tiaViteAliasResults(): array
|
||||||
|
{
|
||||||
|
static $cache = null;
|
||||||
|
if ($cache !== null) {
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roots = [];
|
||||||
|
$written = [];
|
||||||
|
$payload = [];
|
||||||
|
|
||||||
|
foreach (tiaViteAliasFixtures() as $name => [$files]) {
|
||||||
|
$root = sys_get_temp_dir().'/pest-tia-vite-'.bin2hex(random_bytes(6));
|
||||||
|
mkdir($root, 0755, true);
|
||||||
|
foreach ($files as $fname => $content) {
|
||||||
|
file_put_contents($root.'/'.$fname, $content);
|
||||||
|
$written[] = $root.'/'.$fname;
|
||||||
|
}
|
||||||
|
$root = str_replace('\\', '/', $root);
|
||||||
|
$roots[$name] = $root;
|
||||||
|
$payload[] = ['name' => $name, 'root' => $root];
|
||||||
|
}
|
||||||
|
|
||||||
|
$inputFile = tempnam(sys_get_temp_dir(), 'tia-vite-alias-');
|
||||||
|
file_put_contents($inputFile, json_encode($payload));
|
||||||
|
|
||||||
|
$helper = str_replace('\\', '/', tiaViteHelperPath());
|
||||||
|
$input = str_replace('\\', '/', $inputFile);
|
||||||
|
|
||||||
|
$script = <<<JS
|
||||||
|
import { loadAliasFromViteConfig } from '{$helper}'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
const cases = JSON.parse(readFileSync('{$input}', 'utf8'))
|
||||||
|
const out = {}
|
||||||
|
for (const c of cases) out[c.name] = await loadAliasFromViteConfig(c.root)
|
||||||
|
process.stdout.write(JSON.stringify(out))
|
||||||
|
JS;
|
||||||
|
|
||||||
|
$process = new Process(['node', '--input-type=module', '-e', $script]);
|
||||||
|
$process->mustRun();
|
||||||
|
|
||||||
|
$aliases = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
@unlink($inputFile);
|
||||||
|
foreach ($written as $f) {
|
||||||
|
@unlink($f);
|
||||||
|
}
|
||||||
|
foreach ($roots as $r) {
|
||||||
|
@rmdir($r);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache = ['roots' => $roots, 'aliases' => $aliases];
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
if ((new ExecutableFinder)->find('node') === null) {
|
if ((new ExecutableFinder)->find('node') === null) {
|
||||||
$this->markTestSkipped('node is not available.');
|
$this->markTestSkipped('node is not available.');
|
||||||
@@ -298,3 +396,16 @@ it('builds the expected alias map from a tsconfig', function (string $name): voi
|
|||||||
|
|
||||||
expect($results['aliases'][$name])->toEqual($expected);
|
expect($results['aliases'][$name])->toEqual($expected);
|
||||||
})->with(array_keys(tiaAliasFixtures()));
|
})->with(array_keys(tiaAliasFixtures()));
|
||||||
|
|
||||||
|
it('builds the expected alias map from a vite config', function (string $name): void {
|
||||||
|
$results = tiaViteAliasResults();
|
||||||
|
[, $expectedRelative] = tiaViteAliasFixtures()[$name];
|
||||||
|
|
||||||
|
$root = $results['roots'][$name];
|
||||||
|
$expected = [];
|
||||||
|
foreach ($expectedRelative as $key => $relative) {
|
||||||
|
$expected[$key] = $root.'/'.$relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($results['aliases'][$name])->toEqual($expected);
|
||||||
|
})->with(array_keys(tiaViteAliasFixtures()));
|
||||||
|
|||||||
Reference in New Issue
Block a user