mirror of
https://github.com/pestphp/pest.git
synced 2026-07-24 10:30: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:
@@ -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);
|
||||
|
||||
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 {
|
||||
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];
|
||||
}
|
||||
|
||||
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 {
|
||||
if ((new ExecutableFinder)->find('node') === null) {
|
||||
$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);
|
||||
})->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