mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
wip
This commit is contained in:
@ -9,15 +9,22 @@ namespace Pest\Plugins\Tia;
|
|||||||
* or its recorded results stale. The fingerprint is split into two buckets:
|
* or its recorded results stale. The fingerprint is split into two buckets:
|
||||||
*
|
*
|
||||||
* - **structural** — describes what the graph's *edges* were recorded
|
* - **structural** — describes what the graph's *edges* were recorded
|
||||||
* against. If any of these drift (`composer.lock`, `tests/Pest.php`,
|
* against. If any of these drift (`composer.lock`, `composer.json`,
|
||||||
* Pest's factory codegen, etc.) the edges themselves are potentially
|
* `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the
|
||||||
* wrong and the graph must rebuild from scratch.
|
* edges themselves are potentially wrong and the graph must rebuild
|
||||||
|
* from scratch. `tests/TestCase.php` and `tests/Pest.php` are
|
||||||
|
* intentionally NOT here — those are handled by per-test ancestor
|
||||||
|
* linking (`Recorder::linkAncestorFiles`) and the Php watch pattern
|
||||||
|
* respectively, which give precise invalidation rather than a wholesale
|
||||||
|
* rebuild.
|
||||||
* - **environmental** — describes the *runtime* the results were captured
|
* - **environmental** — describes the *runtime* the results were captured
|
||||||
* on (PHP minor, extension set, Pest version). Drift here means the
|
* on (PHP minor, extension set). Drift here means the edges are still
|
||||||
* edges are still trustworthy, but the cached per-test results (pass/
|
* trustworthy, but the cached per-test results (pass/fail/time) may
|
||||||
* fail/time) may not reproduce on this machine. Tia's handler drops the
|
* not reproduce on this machine. Tia's handler drops the branch's
|
||||||
* branch's results + coverage cache and re-runs to freshen them, rather
|
* results + coverage cache and re-runs to freshen them, rather than
|
||||||
* than re-recording from scratch.
|
* re-recording from scratch. Pest's own version is intentionally NOT
|
||||||
|
* here — `composer.lock`'s structural hash already moves whenever the
|
||||||
|
* installed Pest version changes.
|
||||||
*
|
*
|
||||||
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
||||||
* rebuilt on first load; the schema bump in the structural bucket takes
|
* rebuilt on first load; the schema bump in the structural bucket takes
|
||||||
@ -60,7 +67,13 @@ final readonly class Fingerprint
|
|||||||
// Vite config change reshapes the module dependency graph
|
// Vite config change reshapes the module dependency graph
|
||||||
// that `JsModuleGraph` records; without a graph rebuild
|
// that `JsModuleGraph` records; without a graph rebuild
|
||||||
// the stored `$jsFileToComponents` map silently goes stale.
|
// the stored `$jsFileToComponents` map silently goes stale.
|
||||||
private const int SCHEMA_VERSION = 10;
|
// v11: `composer.json` added (autoload-dev / extra discovery
|
||||||
|
// changes). `tests/TestCase.php` and `tests/Pest.php` are
|
||||||
|
// intentionally NOT fingerprinted — they're handled by the
|
||||||
|
// watch pattern + `Recorder::linkAncestorFiles` reflection
|
||||||
|
// walk, which gives precise per-test invalidation rather
|
||||||
|
// than a wholesale rebuild that trashes the entire graph.
|
||||||
|
private const int SCHEMA_VERSION = 11;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
@ -76,7 +89,6 @@ final readonly class Fingerprint
|
|||||||
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
||||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||||
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
|
||||||
// Pest's generated classes bake the code-generation logic
|
// Pest's generated classes bake the code-generation logic
|
||||||
// in — if TestCaseFactory changes (new attribute, different
|
// in — if TestCaseFactory changes (new attribute, different
|
||||||
// method signature, etc.) every previously-recorded edge is
|
// method signature, etc.) every previously-recorded edge is
|
||||||
@ -90,6 +102,12 @@ final readonly class Fingerprint
|
|||||||
// the config drifts without a rebuild, the stored
|
// the config drifts without a rebuild, the stored
|
||||||
// `$jsFileToComponents` map is silently stale.
|
// `$jsFileToComponents` map is silently stale.
|
||||||
'vite_config' => self::viteConfigHash($projectRoot),
|
'vite_config' => self::viteConfigHash($projectRoot),
|
||||||
|
// `composer.json` carries `autoload-dev`, `extra.laravel`
|
||||||
|
// package discovery, etc. — any change reshapes which
|
||||||
|
// classes Pest can resolve at boot. Hashing the whole
|
||||||
|
// file is over-conservative (cosmetic edits force
|
||||||
|
// rebuild) but cheap, and over-rebuild is always safe.
|
||||||
|
'composer_json' => self::hashIfExists($projectRoot.'/composer.json'),
|
||||||
],
|
],
|
||||||
'environmental' => [
|
'environmental' => [
|
||||||
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
||||||
@ -97,7 +115,6 @@ final readonly class Fingerprint
|
|||||||
// the patch rarely changes anything test-visible.
|
// the patch rarely changes anything test-visible.
|
||||||
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
||||||
'extensions' => self::extensionsFingerprint($projectRoot),
|
'extensions' => self::extensionsFingerprint($projectRoot),
|
||||||
'pest' => self::readPestVersion($projectRoot),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -308,33 +325,4 @@ final readonly class Fingerprint
|
|||||||
|
|
||||||
return array_values(array_unique($extensions));
|
return array_values(array_unique($extensions));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function readPestVersion(string $projectRoot): string
|
|
||||||
{
|
|
||||||
$installed = $projectRoot.'/vendor/composer/installed.json';
|
|
||||||
|
|
||||||
if (! is_file($installed)) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($installed);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
|
||||||
|
|
||||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data['packages'] as $package) {
|
|
||||||
if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') {
|
|
||||||
return (string) ($package['version'] ?? 'unknown');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -128,6 +128,19 @@ final class Recorder
|
|||||||
|
|
||||||
$this->currentTestFile = $file;
|
$this->currentTestFile = $file;
|
||||||
|
|
||||||
|
// Walk the parent-class chain and link each ancestor's defining
|
||||||
|
// file as a source dependency of this test. Captures the common
|
||||||
|
// `tests/TestCase.php` case (where the user's base may be
|
||||||
|
// trait-only and have no executable lines for the coverage
|
||||||
|
// driver to pick up), and any deeper hierarchy. Vendor parents
|
||||||
|
// are skipped — those are pinned by `composer.lock` and don't
|
||||||
|
// need per-test edges. Same idea applies to traits used by the
|
||||||
|
// ancestors: a trait's body executes when the test method
|
||||||
|
// calls into it, so coverage already captures it; we only need
|
||||||
|
// the explicit walk for ancestors whose own bodies might be
|
||||||
|
// empty.
|
||||||
|
$this->linkAncestorFiles($className);
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
\pcov\start();
|
\pcov\start();
|
||||||
@ -191,6 +204,40 @@ final class Recorder
|
|||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records every project-local ancestor class's defining file as a
|
||||||
|
* source dependency of the currently-running test. PCOV / Xdebug
|
||||||
|
* record *executable lines* — a base class whose body is just
|
||||||
|
* `class TestCase extends BaseTestCase { use CreatesApplication; }`
|
||||||
|
* has no executable bytecode of its own, so the driver doesn't
|
||||||
|
* emit a line for it and it never enters the graph through the
|
||||||
|
* usual coverage path. This walk fills that gap by asking
|
||||||
|
* reflection for each parent's file and linking it explicitly.
|
||||||
|
*/
|
||||||
|
private function linkAncestorFiles(string $className): void
|
||||||
|
{
|
||||||
|
if (! class_exists($className, false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($className);
|
||||||
|
$parent = $reflection->getParentClass();
|
||||||
|
|
||||||
|
while ($parent !== false) {
|
||||||
|
if ($parent->isInternal()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $parent->getFileName();
|
||||||
|
|
||||||
|
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
|
||||||
|
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = $parent->getParentClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records that the currently-running test queried `$table`. Called
|
* Records that the currently-running test queried `$table`. Called
|
||||||
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
||||||
|
|||||||
@ -106,29 +106,54 @@ final class TableExtractor
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string> Table names referenced by `Schema::` calls
|
* @return list<string> Table names referenced by `Schema::` calls
|
||||||
* in the given migration file contents. Empty
|
* OR raw DDL statements in the given migration
|
||||||
* when nothing matches — callers treat that
|
* file contents. Empty when nothing matches —
|
||||||
* as "fall back to the broad watch pattern".
|
* callers treat that as "fall back to the
|
||||||
|
* broad watch pattern".
|
||||||
|
*
|
||||||
|
* Two passes:
|
||||||
|
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
|
||||||
|
* captures the conventional Laravel migration shape.
|
||||||
|
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
|
||||||
|
* TRUNCATE / RENAME TABLE <name>` patterns inside string
|
||||||
|
* literals (i.e. `DB::statement('CREATE TABLE …')`,
|
||||||
|
* `DB::unprepared('ALTER TABLE …')`). False positives possible
|
||||||
|
* if the same syntax appears in a comment or unrelated string,
|
||||||
|
* but over-attribution is correctness-safe.
|
||||||
*/
|
*/
|
||||||
public static function fromMigrationSource(string $php): array
|
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 = [];
|
$tables = [];
|
||||||
|
|
||||||
foreach ($matches[1] as $i => $primary) {
|
// Pass 1: Schema:: calls. `dropColumn` (singular) covers
|
||||||
// Group 1 always captures at least one char per the regex.
|
// `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
|
||||||
$tables[strtolower($primary)] = true;
|
// — the closure body's column op is on Blueprint, but the
|
||||||
|
// outer `Schema::table('users', …)` is what we capture here.
|
||||||
|
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
||||||
|
|
||||||
// Group 2 (`Schema::rename('old', 'new')`) is optional and
|
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||||
// absent from non-rename matches.
|
foreach ($matches[1] as $i => $primary) {
|
||||||
$secondary = $matches[2][$i] ?? '';
|
$tables[strtolower($primary)] = true;
|
||||||
if ($secondary !== '') {
|
|
||||||
$tables[strtolower($secondary)] = true;
|
$secondary = $matches[2][$i] ?? '';
|
||||||
|
if ($secondary !== '') {
|
||||||
|
$tables[strtolower($secondary)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: raw DDL fallback. Matches the table name following
|
||||||
|
// `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres'
|
||||||
|
// `IF EXISTS` / `IF NOT EXISTS` variants), with optional
|
||||||
|
// ANSI / MySQL / SQL Server quoting.
|
||||||
|
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||||
|
|
||||||
|
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||||
|
foreach ($matches[1] as $primary) {
|
||||||
|
$lower = strtolower($primary);
|
||||||
|
if (! self::isSchemaMeta($lower)) {
|
||||||
|
$tables[$lower] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,14 @@ final readonly class Php implements WatchDefault
|
|||||||
// tracked by the coverage driver.
|
// tracked by the coverage driver.
|
||||||
'phpunit.xml.dist' => [$testPath],
|
'phpunit.xml.dist' => [$testPath],
|
||||||
|
|
||||||
|
// `tests/Pest.php` is loaded once per suite (during BootFiles)
|
||||||
|
// so its `pest()->extend()`, `expect()->extend()`, helpers,
|
||||||
|
// etc. execute outside the per-test coverage window — no
|
||||||
|
// edge captures it. Watch-pattern broadcast triggers a
|
||||||
|
// replay of every test (results refresh) without a full
|
||||||
|
// record-mode graph rebuild.
|
||||||
|
$testPath.'/Pest.php' => [$testPath],
|
||||||
|
|
||||||
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
||||||
// assertions. A fixture change can flip a test result.
|
// assertions. A fixture change can flip a test result.
|
||||||
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
||||||
|
|||||||
Reference in New Issue
Block a user