diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index cd44a9d6..4dc439e0 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -30,7 +30,7 @@ use Throwable; * ----- * - **Record** — no graph (or fingerprint / recording commit drifted). The * full suite runs with PCOV / Xdebug capture per test; the resulting - * `test → [source_file, …]` edges land in `.temp/tia.json`. + * `test → [source_file, …]` edges land in `.temp/tia/graph.json`. * - **Replay** — graph valid. We diff the working tree against the recording * commit, intersect changed files with graph edges, and run only the * affected tests. Newly-added tests unknown to the graph are always @@ -53,7 +53,7 @@ use Throwable; * - **Worker, record**: boots through `bin/worker.php`, which re-runs * `CallsHandleArguments`. We detect the worker context + recording flag, * activate the `Recorder`, and flush the partial graph on `terminate()` - * into `.temp/tia-worker-.json`. + * into `.temp/tia/worker-edges-.json`. * - **Worker, replay**: nothing to do; args already narrowed. * * Guardrails @@ -77,29 +77,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable /** * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in - * one place, and so `CoverageMerger` can reference the same keys. + * one place, and so `CoverageMerger` can reference the same keys. All + * files live under `.temp/tia/` — the `tia-` filename prefix is gone + * because the directory already namespaces them. */ - public const string KEY_GRAPH = 'tia.json'; + public const string KEY_GRAPH = 'graph.json'; - public const string KEY_AFFECTED = 'tia-affected.json'; + public const string KEY_AFFECTED = 'affected.json'; - private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-'; + private const string KEY_WORKER_EDGES_PREFIX = 'worker-edges-'; - private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-'; + private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; /** * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage` * run. Stored as bytes so the backend stays JSON/file-agnostic — the * merger un/serialises rather than `require`-ing a PHP file. */ - public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin'; + public const string KEY_COVERAGE_CACHE = 'coverage.bin'; /** * Marker key dropped by `Tia` to tell `Support\Coverage` to apply the * merge. Absent on plain `--coverage` runs so non-TIA usage keeps its * current (narrow) behaviour. */ - public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker'; + public const string KEY_COVERAGE_MARKER = 'coverage.marker'; /** * Global flag toggled by the parent process so workers know to record. @@ -108,7 +110,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable /** * Global flag that tells workers to install the TIA filter (replay mode). - * Workers read the affected set from `.temp/tia-affected.json`. + * Workers read the affected set from `.temp/tia/affected.json`. */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index d245ddf1..f81f3657 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -16,8 +16,9 @@ use Symfony\Component\Process\Process; * * Storage: **workflow artifacts**, not releases. A dedicated CI workflow * (conventionally `.github/workflows/tia-baseline.yml`) runs the full - * suite under `--tia` and uploads the resulting `tia.json` + - * `tia-coverage.bin` as a named artifact (`pest-tia-baseline`). On dev + * suite under `--tia` and uploads the `.temp/tia/` directory as a named + * artifact (`pest-tia-baseline`) containing `graph.json` + + * `coverage.bin`. On dev * machines, this class finds the latest successful run of that workflow * and downloads the artifact via `gh`. * @@ -48,7 +49,7 @@ final class BaselineSync /** * Artifact name the workflow uploads under. The artifact is a zip - * containing `tia.json` (always) + `tia-coverage.bin` (optional). + * containing `graph.json` (always) + `coverage.bin` (optional). */ private const string ARTIFACT_NAME = 'pest-tia-baseline'; @@ -87,9 +88,7 @@ final class BaselineSync $payload = $this->download($repo); if ($payload === null) { - $this->output->writeln( - ' TIA no baseline published yet — recording locally.', - ); + $this->emitPublishInstructions($repo); return false; } @@ -110,6 +109,48 @@ final class BaselineSync return true; } + /** + * Prints actionable instructions for publishing a first baseline when + * the consumer-side fetch finds nothing. Without this, the "no + * baseline yet" state is a dead-end for users — they see the message + * and have to guess what to do next. + */ + private function emitPublishInstructions(string $repo): void + { + $this->output->writeln([ + ' TIA no baseline published yet — recording locally.', + '', + ' To share the baseline with your team, add this workflow to the repo:', + '', + ' .github/workflows/tia-baseline.yml', + '', + ' name: TIA Baseline', + ' on:', + ' push: { branches: [main] }', + ' schedule: [{ cron: \'0 3 * * *\' }]', + ' workflow_dispatch:', + ' jobs:', + ' baseline:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v4', + ' with: { fetch-depth: 0 }', + ' - uses: shivammathur/setup-php@v2', + ' with: { php-version: \'8.4\', coverage: xdebug }', + ' - run: composer install --no-interaction --prefer-dist', + ' - run: ./vendor/bin/pest --parallel --tia --coverage', + ' - uses: actions/upload-artifact@v4', + ' with:', + ' name: pest-tia-baseline', + ' path: vendor/pestphp/pest/.temp/tia/', + ' retention-days: 30', + '', + sprintf(' Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo), + ' Details: https://pestphp.com/docs/tia/ci', + '', + ]); + } + /** * Parses `.git/config` for the `origin` remote and extracts * `org/repo`. Supports the two URL flavours git emits out of the box. diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php index 6abf2ea5..5585e945 100644 --- a/src/Plugins/Tia/Bootstrapper.php +++ b/src/Plugins/Tia/Bootstrapper.php @@ -32,9 +32,11 @@ final readonly class Bootstrapper implements BootstrapperContract } /** - * Resolve Pest's `.temp/` directory relative to this file so TIA's - * caches share the same location as the rest of Pest's transient - * state (PHPUnit result cache, coverage PHP dumps, etc.). + * TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob + * in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad + * alongside PHPUnit's unrelated files (coverage.php, test-results, + * code-coverage/) and makes the CI artifact-upload path a single + * directory instead of a list of individual files. */ private function tempDir(): string { @@ -42,6 +44,7 @@ final readonly class Bootstrapper implements BootstrapperContract .DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'.temp'; + .DIRECTORY_SEPARATOR.'.temp' + .DIRECTORY_SEPARATOR.'tia'; } } diff --git a/src/Plugins/Tia/Contracts/State.php b/src/Plugins/Tia/Contracts/State.php index b109e3ec..d440c4e1 100644 --- a/src/Plugins/Tia/Contracts/State.php +++ b/src/Plugins/Tia/Contracts/State.php @@ -39,7 +39,7 @@ interface State /** * Returns every key whose name starts with `$prefix`. Used to collect - * paratest worker partials (`tia-worker-.json`, etc.) without + * paratest worker partials (`worker-edges-.json`, etc.) without * exposing backend-specific glob semantics. * * @return list diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index a978f8d8..a4a2de34 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -16,7 +16,7 @@ final readonly class Fingerprint { // Bump this whenever the set of inputs or the hash algorithm changes, so // older graphs are invalidated automatically. - private const int SCHEMA_VERSION = 2; + private const int SCHEMA_VERSION = 3; /** * @return array @@ -26,6 +26,13 @@ final readonly class Fingerprint return [ 'schema' => self::SCHEMA_VERSION, 'php' => PHP_VERSION, + // Loaded extensions + their versions. Guards against the + // "recorded without pcov/xdebug → subsequent run has the + // driver but graph has no edges" trap where the fingerprint + // matches but the graph is effectively empty. Sorted so two + // processes with the same extensions in different load order + // still produce the same hash. + 'extensions' => self::extensionsFingerprint(), 'pest' => self::readPestVersion($projectRoot), 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), @@ -41,6 +48,27 @@ final readonly class Fingerprint ]; } + /** + * Deterministic hash of the PHP extension set: `ext-name@version` pairs + * sorted alphabetically and joined. Captures both presence (pcov + * disappeared? graph must rebuild) and version changes (xdebug minor + * bump with coverage-mode semantics). + */ + private static function extensionsFingerprint(): string + { + $extensions = get_loaded_extensions(); + sort($extensions); + + $parts = []; + + foreach ($extensions as $name) { + $version = phpversion($name); + $parts[] = $name.'@'.($version === false ? '?' : $version); + } + + return hash('xxh128', implode("\n", $parts)); + } + /** * @param array $a * @param array $b diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php index 3810c0cf..b2e8445e 100644 --- a/src/Plugins/Tia/WatchDefaults/Laravel.php +++ b/src/Plugins/Tia/WatchDefaults/Laravel.php @@ -60,6 +60,10 @@ final readonly class Laravel implements WatchDefault // Blade templates — compiled to cache, source file not executed. 'resources/views/**/*.blade.php' => [$featurePath], + // Email templates are nested under views/email or views/emails + // by convention and power mailable tests that render markup. + 'resources/views/email/**/*.blade.php' => [$featurePath], + 'resources/views/emails/**/*.blade.php' => [$featurePath], // Translations — JSON translations read via file_get_contents, // PHP translations loaded via include (but during boot). diff --git a/src/Plugins/Tia/WatchDefaults/Livewire.php b/src/Plugins/Tia/WatchDefaults/Livewire.php index 3a37c487..80f3fb1f 100644 --- a/src/Plugins/Tia/WatchDefaults/Livewire.php +++ b/src/Plugins/Tia/WatchDefaults/Livewire.php @@ -29,6 +29,10 @@ final readonly class Livewire implements WatchDefault // Livewire views live alongside Blade views or in a dedicated dir. 'resources/views/livewire/**/*.blade.php' => [$testPath], 'resources/views/components/**/*.blade.php' => [$testPath], + // Volt's second default mount — single-file components used as + // full-page routes. Missing this means editing a Volt page + // doesn't re-run its tests. + 'resources/views/pages/**/*.blade.php' => [$testPath], // Livewire JS interop / Alpine plugins. 'resources/js/**/*.js' => [$testPath], diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php index 389966cc..bc6f0dc2 100644 --- a/src/Plugins/Tia/WatchDefaults/Php.php +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -25,9 +25,14 @@ final readonly class Php implements WatchDefault return [ // Environment files — can change DB drivers, feature flags, - // queue connections, etc. Not PHP, not fingerprinted. + // queue connections, etc. Not PHP, not fingerprinted. Covers + // the local-override variants (`.env.local`, `.env.testing.local`) + // that both Laravel and Symfony recommend for machine-specific + // config. '.env' => [$testPath], '.env.testing' => [$testPath], + '.env.local' => [$testPath], + '.env.*.local' => [$testPath], // Docker / CI — can affect integration test infrastructure. 'docker-compose.yml' => [$testPath], diff --git a/src/Plugins/Tia/WatchDefaults/Symfony.php b/src/Plugins/Tia/WatchDefaults/Symfony.php index a3d4b0b3..ee8683d7 100644 --- a/src/Plugins/Tia/WatchDefaults/Symfony.php +++ b/src/Plugins/Tia/WatchDefaults/Symfony.php @@ -46,7 +46,11 @@ final readonly class Symfony implements WatchDefault 'src/Kernel.php' => [$testPath], // Migrations — run during setUp (before coverage window). + // DoctrineMigrationsBundle's default is `migrations/` at the + // project root; many Symfony projects relocate to + // `src/Migrations/` — both covered. 'migrations/**/*.php' => [$testPath], + 'src/Migrations/**/*.php' => [$testPath], // Twig templates — compiled, source not PHP-executed. 'templates/**/*.html.twig' => [$testPath],