diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce3b3349..fa653501 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,3 +76,21 @@ jobs: - name: Integration Tests run: composer test:integration + + # tests-tia records coverage inside its sandbox, which requires + # pcov (or xdebug) in the process PHP. The main setup-php step is + # `coverage: none` for speed — re-enable pcov here just for the + # TIA step. Cheap: pcov startup is near-zero. + - name: Enable pcov for TIA + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: pcov + extensions: sockets + + - name: TIA End-to-End Tests + # Black-box tests drive Pest `--tia` against a throw-away sandbox. + # First scenario takes ~60s (composer-installs the host Pest into a + # cached template); subsequent clones are cheap. + run: composer test:tia diff --git a/bin/pest b/bin/pest index 8cd27788..10a65dd0 100755 --- a/bin/pest +++ b/bin/pest @@ -142,6 +142,15 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Get $rootPath based on $autoloadPath $rootPath = dirname($autoloadPath, 2); + + // Re-execs PHP without Xdebug on TIA replay runs so repeat `--tia` + // invocations aren't slowed by a coverage driver they don't use. Plain + // `pest` runs are left alone — users may rely on Xdebug for IDE + // breakpoints, step-through debugging, or custom tooling. See + // XdebugGuard for the full decision (coverage / tia-rebuild / Xdebug + // mode gates). + \Pest\Support\XdebugGuard::maybeDrop($rootPath); + $input = new ArgvInput; $testSuite = TestSuite::getInstance( diff --git a/composer.json b/composer.json index 1f3eadf2..c9dc5eb3 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "require": { "php": "^8.3.0", "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", @@ -92,6 +93,7 @@ "test:inline": "php bin/pest --configuration=phpunit.inline.xml", "test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3", "test:integration": "php bin/pest --group=integration -v", + "test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "test": [ "@test:lint", @@ -99,7 +101,8 @@ "@test:type:coverage", "@test:unit", "@test:parallel", - "@test:integration" + "@test:integration", + "@test:tia" ] }, "extra": { diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index e66ee239..9a57f5ab 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -74,6 +74,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string REBUILD_OPTION = '--tia-rebuild'; + /** + * Bypasses `BaselineSync`'s post-failure cooldown. After a failed + * baseline fetch, subsequent `--tia` runs skip the fetch for 24h; this + * flag forces an immediate retry (e.g. right after publishing a + * baseline from CI for the first time). + */ + private const string REFETCH_OPTION = '--tia-refetch'; + /** * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in @@ -103,6 +111,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ public const string KEY_COVERAGE_MARKER = 'coverage.marker'; + /** + * Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds + * `{"until": }` — subsequent runs within the window skip the + * fetch attempt (and its `gh run list` network hop) until the + * cooldown expires or the user passes `--tia-refetch`. + */ + public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json'; + /** * Global flag toggled by the parent process so workers know to record. */ @@ -199,6 +215,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private bool $recordingActive = false; + /** + * True when `--tia-refetch` is in the current argv — `BaselineSync` + * uses it to bypass the post-failure fetch cooldown. + */ + private bool $forceRefetch = false; + public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, @@ -310,13 +332,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $enabled = $this->hasArgument(self::OPTION, $arguments); $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); + $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); - if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) { + if (! $enabled && ! $forceRebuild && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { return $arguments; } $arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); + $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); // When `--coverage` is active, piggyback on PHPUnit's CodeCoverage // instead of starting our own PCOV / Xdebug session. Running two @@ -401,6 +425,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $graph->replaceEdges($perTest); $graph->pruneMissingTests(); + // Fold in the results collected during this same record run. The + // `AddsOutput` pass that runs `snapshotTestResults` fires *before* + // `terminate()` in the shutdown chain, so by the time the graph + // lands on disk, the snapshot pass has already returned empty. + // Writing results here means a first `--tia` invocation produces + // a graph with edges *and* results — the immediate next run hits + // cache for every unchanged test rather than needing a "warm-up" + // pass. + $this->seedResultsInto($graph); + if (! $this->saveGraph($graph)) { $this->output->writeln(' TIA failed to write graph.'); $recorder->reset(); @@ -635,7 +669,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable // to pull a team-shared baseline so fresh checkouts (new devs, CI // containers) don't pay the full record cost. If the pull succeeds // the graph is re-read and reconciled against the local env. - if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) { + if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) { $graph = $this->loadGraph($projectRoot); if ($graph instanceof Graph) { $graph = $this->reconcileFingerprint($graph, $fingerprint); @@ -1147,6 +1181,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->saveGraph($graph); } + /** + * In-memory equivalent of `snapshotTestResults()` — transfers the + * collected results straight into the given graph instance without a + * load/save round-trip. Used on the record path where the graph + * hasn't hit disk yet and a separate `loadGraph()` would find nothing. + */ + private function seedResultsInto(Graph $graph): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + foreach ($collector->all() as $testId => $result) { + $graph->setResult( + $this->branch, + $testId, + $result['status'], + $result['message'], + $result['time'], + $result['assertions'], + ); + } + + $collector->reset(); + } + /** * Merges per-test status + message from the `ResultCollector` into the * TIA graph. Runs after every `--tia` invocation so the graph always has diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 97466f85..399bb72e 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -62,6 +62,15 @@ final readonly class BaselineSync private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; + /** + * Cooldown (in seconds) applied after a failed baseline fetch. + * Rationale: when the remote workflow hasn't published yet, every + * `pest --tia` invocation would otherwise re-hit `gh run list` and + * re-print the publish instructions — noisy + slow. Back off for a + * day, let the user override with `--tia-refetch`. + */ + private const int FETCH_COOLDOWN_SECONDS = 86400; + public function __construct( private State $state, private OutputInterface $output, @@ -72,8 +81,12 @@ final readonly class BaselineSync * contents into the TIA state store. Returns true when the graph blob * landed; coverage is best-effort since plain `--tia` (no `--coverage`) * never reads it. + * + * `$force = true` (driven by `--tia-refetch`) ignores the post-failure + * cooldown so the user can retry on demand without waiting out the + * 24h window. */ - public function fetchIfAvailable(string $projectRoot): bool + public function fetchIfAvailable(string $projectRoot, bool $force = false): bool { $repo = $this->detectGitHubRepo($projectRoot); @@ -81,6 +94,16 @@ final readonly class BaselineSync return false; } + if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { + $this->output->writeln(sprintf( + ' TIA last fetch found no baseline — next auto-retry in %s. ' + .'Override with --tia-refetch.', + $this->formatDuration($remaining), + )); + + return false; + } + $this->output->writeln(sprintf( ' TIA fetching baseline from %s…', $repo, @@ -89,6 +112,7 @@ final readonly class BaselineSync $payload = $this->download($repo); if ($payload === null) { + $this->startCooldown(); $this->emitPublishInstructions($repo); return false; @@ -102,6 +126,11 @@ final readonly class BaselineSync $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']); } + // Successful fetch wipes any stale cooldown so the next failure + // (say, weeks later) starts a fresh 24h timer rather than inheriting + // one from the deep past. + $this->clearCooldown(); + $this->output->writeln(sprintf( ' TIA baseline ready (%s).', $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), @@ -110,6 +139,54 @@ final readonly class BaselineSync return true; } + /** + * Seconds left on the cooldown, or `null` when the cooldown is cleared + * / expired / unreadable. + */ + private function cooldownRemaining(): ?int + { + $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN); + + if ($raw === null) { + return null; + } + + $decoded = json_decode($raw, true); + + if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) { + return null; + } + + $remaining = $decoded['until'] - time(); + + return $remaining > 0 ? $remaining : null; + } + + private function startCooldown(): void + { + $this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([ + 'until' => time() + self::FETCH_COOLDOWN_SECONDS, + ])); + } + + private function clearCooldown(): void + { + $this->state->delete(Tia::KEY_FETCH_COOLDOWN); + } + + private function formatDuration(int $seconds): string + { + if ($seconds >= 3600) { + return (int) round($seconds / 3600).'h'; + } + + if ($seconds >= 60) { + return (int) round($seconds / 60).'m'; + } + + return $seconds.'s'; + } + /** * Prints actionable instructions for publishing a first baseline when * the consumer-side fetch finds nothing. diff --git a/src/Support/XdebugGuard.php b/src/Support/XdebugGuard.php new file mode 100644 index 00000000..539a90ea --- /dev/null +++ b/src/Support/XdebugGuard.php @@ -0,0 +1,179 @@ +check(); + } + + /** + * True when Xdebug 3+ is running in coverage-only mode (or empty). False + * for older Xdebug without `xdebug_info` — be conservative and leave it + * loaded; we can't prove the mode is safe to drop. + */ + private static function xdebugIsCoverageOnly(): bool + { + if (! function_exists('xdebug_info')) { + return false; + } + + $modes = @xdebug_info('mode'); + + if (! is_array($modes)) { + return false; + } + + $modes = array_values(array_filter($modes, is_string(...))); + + if ($modes === []) { + return true; + } + + return $modes === ['coverage']; + } + + /** + * Encodes the argv-based rules: `--tia` must be present, no coverage + * flag, no forced rebuild, and TIA must be about to replay rather than + * record. Plain `pest` (and anything else without `--tia`) keeps Xdebug + * loaded so non-TIA users aren't surprised by behaviour changes. + * + * @param array $argv + */ + private static function runLooksDroppable(array $argv, string $projectRoot): bool + { + $hasTia = false; + + foreach ($argv as $value) { + if (! is_string($value)) { + continue; + } + + if ($value === '--coverage' + || str_starts_with($value, '--coverage=') + || str_starts_with($value, '--coverage-')) { + return false; + } + + if ($value === '--tia-rebuild') { + return false; + } + + if ($value === '--tia') { + $hasTia = true; + } + } + + if (! $hasTia) { + return false; + } + + return self::tiaWillReplay($projectRoot); + } + + /** + * True when a valid TIA graph already lives on disk AND its structural + * fingerprint matches the current environment. Any other outcome + * (missing graph, unreadable JSON, structural drift) means TIA will + * record and the driver must stay loaded. + */ + private static function tiaWillReplay(string $projectRoot): bool + { + $path = self::graphPath(); + + if (! is_file($path)) { + return false; + } + + $json = @file_get_contents($path); + + if ($json === false) { + return false; + } + + $graph = Graph::decode($json, $projectRoot); + + if (! $graph instanceof Graph) { + return false; + } + + return Fingerprint::structuralMatches( + $graph->fingerprint(), + Fingerprint::compute($projectRoot), + ); + } + + /** + * On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()` + * so both writer and reader stay in sync without a runtime container + * lookup (the container isn't booted yet at this point). + */ + private static function graphPath(): string + { + return dirname(__DIR__, 2) + .DIRECTORY_SEPARATOR.'.temp' + .DIRECTORY_SEPARATOR.'tia' + .DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; + } +} diff --git a/tests-tia/AffectedSetTest.php b/tests-tia/AffectedSetTest.php new file mode 100644 index 00000000..288b2227 --- /dev/null +++ b/tests-tia/AffectedSetTest.php @@ -0,0 +1,63 @@ +pest(['--tia']); + + $sandbox->write('src/Math.php', <<<'PHP' +pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toMatch('/2 affected,\s*1 replayed/'); + }); +}); + +test('adding a new test file runs the new test + replays the rest', function () { + tiaScenario(function (Sandbox $sandbox) { + $sandbox->pest(['--tia']); + + $sandbox->write('tests/ExtraTest.php', <<<'PHP' +toBeTrue(); +}); +PHP); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toMatch('/1 affected,\s*3 replayed/'); + }); +}); diff --git a/tests-tia/FingerprintDriftTest.php b/tests-tia/FingerprintDriftTest.php new file mode 100644 index 00000000..b7c5fccb --- /dev/null +++ b/tests-tia/FingerprintDriftTest.php @@ -0,0 +1,52 @@ +pest(['--tia']); + + $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graph = json_decode((string) file_get_contents($graphPath), true); + $graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32); + file_put_contents($graphPath, json_encode($graph)); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('graph structure outdated'); + }); +}); + +test('environmental drift keeps edges, drops results', function () { + tiaScenario(function (Sandbox $sandbox) { + $sandbox->pest(['--tia']); + + $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graph = json_decode((string) file_get_contents($graphPath), true); + + $edgeCountBefore = count($graph['edges']); + + $graph['fingerprint']['environmental']['php_minor'] = '7.4'; + $graph['fingerprint']['environmental']['extensions'] = str_repeat('0', 32); + file_put_contents($graphPath, json_encode($graph)); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('env differs from baseline'); + expect(tiaOutput($process))->toContain('results dropped, edges reused'); + + $graphAfter = $sandbox->graph(); + expect(count($graphAfter['edges']))->toBe($edgeCountBefore); + expect($graphAfter['fingerprint']['environmental']['php_minor']) + ->not()->toBe('7.4'); + }); +}); diff --git a/tests-tia/Fixtures/sample-project/composer.json b/tests-tia/Fixtures/sample-project/composer.json new file mode 100644 index 00000000..a72a1f25 --- /dev/null +++ b/tests-tia/Fixtures/sample-project/composer.json @@ -0,0 +1,27 @@ +{ + "name": "pest/tia-sample-project", + "type": "project", + "description": "Throw-away fixture used by tests-tia to exercise TIA end-to-end.", + "require": { + "php": "^8.3" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/tests-tia/Fixtures/sample-project/phpunit.xml b/tests-tia/Fixtures/sample-project/phpunit.xml new file mode 100644 index 00000000..0c3e5935 --- /dev/null +++ b/tests-tia/Fixtures/sample-project/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + src + + + diff --git a/tests-tia/Fixtures/sample-project/src/Greeter.php b/tests-tia/Fixtures/sample-project/src/Greeter.php new file mode 100644 index 00000000..e952204e --- /dev/null +++ b/tests-tia/Fixtures/sample-project/src/Greeter.php @@ -0,0 +1,13 @@ +toBe('Hello, Nuno!'); +}); diff --git a/tests-tia/Fixtures/sample-project/tests/MathTest.php b/tests-tia/Fixtures/sample-project/tests/MathTest.php new file mode 100644 index 00000000..609d97ea --- /dev/null +++ b/tests-tia/Fixtures/sample-project/tests/MathTest.php @@ -0,0 +1,13 @@ +toBe(5); +}); + +test('math add negative', function () { + expect(Math::add(-1, 1))->toBe(0); +}); diff --git a/tests-tia/Fixtures/sample-project/tests/Pest.php b/tests-tia/Fixtures/sample-project/tests/Pest.php new file mode 100644 index 00000000..f987b070 --- /dev/null +++ b/tests-tia/Fixtures/sample-project/tests/Pest.php @@ -0,0 +1,7 @@ +pest(['--tia']); + expect($sandbox->hasGraph())->toBeTrue(); + + $graphBefore = $sandbox->graph(); + + $process = $sandbox->pest(['--tia', '--tia-rebuild']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('recording dependency graph'); + + $graphAfter = $sandbox->graph(); + expect(array_keys($graphAfter['edges'])) + ->toEqualCanonicalizing(array_keys($graphBefore['edges'])); + }); +}); diff --git a/tests-tia/RecordReplayTest.php b/tests-tia/RecordReplayTest.php new file mode 100644 index 00000000..b4498e5a --- /dev/null +++ b/tests-tia/RecordReplayTest.php @@ -0,0 +1,42 @@ +pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('recording dependency graph'); + expect($sandbox->hasGraph())->toBeTrue(); + + $graph = $sandbox->graph(); + expect($graph)->toHaveKey('edges'); + expect(array_keys($graph['edges']))->toContain('tests/MathTest.php'); + expect(array_keys($graph['edges']))->toContain('tests/GreeterTest.php'); + }); +}); + +test('warm run replays every test', function () { + tiaScenario(function (Sandbox $sandbox) { + // Cold pass: records edges AND snapshots results (series mode + // runs `snapshotTestResults` in the same `addOutput` pass). + $sandbox->pest(['--tia']); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + // Zero changes → only the `replayed` fragment appears in the + // recap; the `affected` fragment is omitted when count is 0. + expect(tiaOutput($process))->toMatch('/3 replayed/'); + expect(tiaOutput($process))->not()->toMatch('/\d+ affected/'); + }); +}); diff --git a/tests-tia/SourceRevertTest.php b/tests-tia/SourceRevertTest.php new file mode 100644 index 00000000..c32fb50c --- /dev/null +++ b/tests-tia/SourceRevertTest.php @@ -0,0 +1,46 @@ +pest(['--tia']); + + $original = (string) file_get_contents($sandbox->path().'/src/Math.php'); + + $sandbox->write('src/Math.php', <<<'PHP' +pest(['--tia']); + expect($broken->isSuccessful())->toBeFalse(); + + $sandbox->write('src/Math.php', $original); + + $recovered = $sandbox->pest(['--tia']); + + expect($recovered->isSuccessful())->toBeTrue(tiaOutput($recovered)); + expect(tiaOutput($recovered))->toMatch('/2 affected,\s*1 replayed/'); + }); +}); diff --git a/tests-tia/StatusReplayTest.php b/tests-tia/StatusReplayTest.php new file mode 100644 index 00000000..d1eb4f0f --- /dev/null +++ b/tests-tia/StatusReplayTest.php @@ -0,0 +1,53 @@ +pest(['--tia']); + + $process = $sandbox->pest(['--tia']); + $output = tiaOutput($process); + + // MathTest has 2 assertions, GreeterTest has 1 → 3 total. + // The "Tests: … (N assertions, … replayed)" banner should show 3. + expect($output)->toMatch('/\(3 assertions/'); + }); +}); + +test('breaking a test replays as a failure on the next run', function () { + tiaScenario(function (Sandbox $sandbox) { + // Prime. + $sandbox->pest(['--tia']); + + // Break the test. Its test file's edge map still points at + // `src/Math.php`; editing the test file counts as a change + // and the test re-executes. + $sandbox->write('tests/MathTest.php', <<<'PHP' +toBe(999); // wrong +}); + +test('math add negative', function () { + expect(Math::add(-1, 1))->toBe(0); +}); +PHP); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeFalse(); + expect(tiaOutput($process))->toContain('math add'); + }); +}); diff --git a/tests-tia/Support/Sandbox.php b/tests-tia/Support/Sandbox.php new file mode 100644 index 00000000..4c3361a2 --- /dev/null +++ b/tests-tia/Support/Sandbox.php @@ -0,0 +1,440 @@ +path; + } + + public function write(string $relative, string $content): void + { + $absolute = $this->path.DIRECTORY_SEPARATOR.$relative; + $dir = dirname($absolute); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + throw new RuntimeException("Cannot create {$dir}"); + } + + if (@file_put_contents($absolute, $content) === false) { + throw new RuntimeException("Cannot write {$absolute}"); + } + } + + public function delete(string $relative): void + { + $absolute = $this->path.DIRECTORY_SEPARATOR.$relative; + + if (is_file($absolute)) { + @unlink($absolute); + } + } + + /** + * @param array $flags + */ + public function pest(array $flags = []): Process + { + // Invoke Pest's bin script through PHP directly rather than the + // `vendor/bin/pest` symlink — `rcopy()` loses the `+x` bit when + // cloning the template. Going through `php` bypasses the exec + // check. Use `PHP_BINARY` (not a bare `php`) so the sandbox + // executes under the same interpreter that launched the outer + // test suite — otherwise macOS multi-version setups (Herd, brew, + // asdf, …) fall back to the first `php` on `$PATH`, which often + // lacks the coverage driver TIA's record mode needs. + $process = new Process( + [PHP_BINARY, 'vendor/pestphp/pest/bin/pest', ...$flags], + $this->path, + [ + // Strip any CI signal so TIA doesn't suppress instructions. + 'GITHUB_ACTIONS' => '', + 'GITLAB_CI' => '', + 'CIRCLECI' => '', + ], + ); + $process->setTimeout(120.0); + $process->run(); + + return $process; + } + + /** + * @return array|null + */ + public function graph(): ?array + { + $path = $this->path.'/vendor/pestphp/pest/.temp/tia/graph.json'; + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : null; + } + + public function hasGraph(): bool + { + return $this->graph() !== null; + } + + /** + * @param array $args + */ + public function git(array $args): Process + { + $process = new Process(['git', ...$args], $this->path); + $process->setTimeout(30.0); + $process->run(); + + return $process; + } + + public function destroy(): void + { + if (getenv('PEST_TIA_KEEP') === '1') { + fwrite(STDERR, "[PEST_TIA_KEEP] sandbox: {$this->path}\n"); + + return; + } + + if (is_dir($this->path)) { + self::rrmdir($this->path); + } + } + + /** + * Lazily provisions a once-per-process template with composer already + * installed against the host Pest source. Every sandbox clone copies + * from here, avoiding a ~30s composer install per test. + */ + private static function ensureTemplate(): string + { + if (self::$templatePath !== null && is_dir(self::$templatePath.'/vendor')) { + return self::$templatePath; + } + + // Cache key includes a fingerprint of the host Pest source tree — + // when we edit Pest internals, the key changes, old templates + // become orphaned, the new template rebuilds. Without this, a + // stale template with yesterday's Pest code silently masks today's + // code under test. + $template = sys_get_temp_dir() + .DIRECTORY_SEPARATOR + .'pest-tia-template-' + .self::hostFingerprint(); + + // Serialise template creation across parallel paratest workers. + // Without the lock, three workers hitting `ensureTemplate()` + // simultaneously each see "no vendor yet → rebuild", stomp on + // each other's composer install, and produce half-written + // fixtures. `flock` on a sibling lockfile keeps it to one + // builder; the others block, then observe the finished + // template and skip straight to the fast path. + $lockPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template.lock'; + $lock = fopen($lockPath, 'c'); + + if ($lock === false) { + throw new RuntimeException('Cannot open template lock at '.$lockPath); + } + + flock($lock, LOCK_EX); + + try { + // Re-check after acquiring the lock — another worker may have + // just finished the build while we were waiting. + if (is_dir($template.'/vendor')) { + self::$templatePath = $template; + + return $template; + } + + // Garbage-collect every older template keyed by a different + // fingerprint so /tmp doesn't accumulate a 200 MB graveyard + // over a month of edits. + foreach (glob(sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template-*') ?: [] as $orphan) { + if ($orphan !== $template) { + self::rrmdir($orphan); + } + } + + if (is_dir($template)) { + self::rrmdir($template); + } + + $fixture = __DIR__.'/../Fixtures/sample-project'; + + if (! is_dir($fixture)) { + throw new RuntimeException('Missing fixture at '.$fixture); + } + + if (! @mkdir($template, 0755, true) && ! is_dir($template)) { + throw new RuntimeException('Cannot create template at '.$template); + } + + self::rcopy($fixture, $template); + self::wireHostPest($template); + self::composerInstall($template); + + self::$templatePath = $template; + + return $template; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private static function wireHostPest(string $path): void + { + $hostRoot = realpath(__DIR__.'/../..'); + + if ($hostRoot === false) { + throw new RuntimeException('Cannot resolve host Pest root'); + } + + $composerJson = $path.'/composer.json'; + $decoded = json_decode((string) file_get_contents($composerJson), true); + + $decoded['repositories'] = [ + ['type' => 'path', 'url' => $hostRoot, 'options' => ['symlink' => false]], + ]; + $decoded['require']['pestphp/pest'] = '*@dev'; + + file_put_contents( + $composerJson, + json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n", + ); + } + + private static function composerInstall(string $path): void + { + // Invoke composer via the *same* PHP binary that's running this + // process. On macOS multi-version setups (Herd, brew, asdf, etc.) + // the `composer` shebang often points at the system PHP, which + // may not match the version the test suite booted with — leading + // to "your PHP version does not satisfy the requirement" errors + // even when the interpreter in use would satisfy it. Going + // through `PHP_BINARY` + the located composer binary/phar + // sidesteps that entirely. + $composer = self::locateComposer(); + $args = $composer === null + ? ['composer', 'install'] + : [PHP_BINARY, $composer, 'install']; + + $process = new Process( + [...$args, '--no-interaction', '--prefer-dist', '--no-progress', '--quiet'], + $path, + ); + $process->setTimeout(600.0); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException( + "composer install failed in template:\n".$process->getOutput().$process->getErrorOutput(), + ); + } + } + + /** + * Resolves the composer binary to a real path PHP can execute. Returns + * `null` when composer isn't findable, in which case the caller falls + * back to invoking plain `composer` via `$PATH` (and hopes for the + * best — usually fine on CI Linux runners). + */ + private static function locateComposer(): ?string + { + $probe = new Process(['command', '-v', 'composer']); + $probe->run(); + + $path = trim($probe->getOutput()); + + if ($path === '' || ! is_file($path)) { + return null; + } + + // `composer` may be a shell-script wrapper (Herd does this) — + // resolve the actual phar it invokes. Heuristic: parse out the + // last `.phar` argument from the wrapper, fall back to the file + // itself if no wrapper is detected. + $content = @file_get_contents($path); + + if ($content !== false && preg_match('/\S+\.phar/', $content, $m) === 1) { + $phar = $m[0]; + + if (is_file($phar)) { + return $phar; + } + } + + return $path; + } + + private static function bootstrapGit(string $path): void + { + // Each clone needs its own repo — TIA's SHA / branch / diff logic + // all rely on `.git/`. The template has no git dir so clones start + // from a clean slate. + $run = function (array $args) use ($path): void { + $process = new Process(['git', ...$args], $path); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException('git '.implode(' ', $args).' failed: '.$process->getErrorOutput()); + } + }; + + // `.git` may have been cloned from the template if we ever add one + // there — nuke it just in case so every sandbox starts fresh. + if (is_dir($path.'/.git')) { + self::rrmdir($path.'/.git'); + } + + // Keep `vendor/` and composer lock out of the sandbox's git repo + // entirely. With ~thousands of files `git add .` takes tens of + // seconds; TIA also ignores vendor paths via `shouldIgnore()` so + // tracking them buys nothing except slowness. + file_put_contents($path.'/.gitignore', "vendor/\ncomposer.lock\n"); + + $run(['init', '-q', '-b', 'main']); + $run(['config', 'user.email', 'sandbox@pest.test']); + $run(['config', 'user.name', 'Pest Sandbox']); + $run(['config', 'commit.gpgsign', 'false']); + $run(['add', '.']); + $run(['commit', '-q', '-m', 'initial']); + } + + /** + * Short hash derived from the host Pest source that the template is + * built against. Hashing the newest mtime across `src/`, `overrides/`, + * and `composer.json` is cheap (one stat each) and catches every edit + * that could alter TIA behaviour. + */ + private static function hostFingerprint(): string + { + $hostRoot = realpath(__DIR__.'/../..'); + + if ($hostRoot === false) { + return 'unknown'; + } + + $newest = 0; + + foreach ([$hostRoot.'/src', $hostRoot.'/overrides'] as $dir) { + if (! is_dir($dir)) { + continue; + } + + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iter as $file) { + if ($file->isFile()) { + $newest = max($newest, $file->getMTime()); + } + } + } + + if (is_file($hostRoot.'/composer.json')) { + $newest = max($newest, (int) filemtime($hostRoot.'/composer.json')); + } + + return substr(sha1($hostRoot.'|'.PHP_VERSION.'|'.$newest), 0, 12); + } + + private static function rcopy(string $src, string $dest): void + { + if (! is_dir($dest) && ! @mkdir($dest, 0755, true) && ! is_dir($dest)) { + throw new RuntimeException("Cannot create {$dest}"); + } + + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iter as $item) { + $target = $dest.DIRECTORY_SEPARATOR.$iter->getSubPathname(); + + if ($item->isDir()) { + @mkdir($target, 0755, true); + } else { + copy($item->getPathname(), $target); + } + } + } + + private static function rrmdir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + // `rm -rf` shells out but handles symlinks, read-only files, and + // the composer-vendor quirks (lock files, .bin symlinks) that + // PHP's own recursive delete stumbles on. Non-fatal on failure. + $process = new Process(['rm', '-rf', $dir]); + $process->setTimeout(60.0); + $process->run(); + } +} diff --git a/tests-tia/bootstrap.php b/tests-tia/bootstrap.php new file mode 100644 index 00000000..65f4ee11 --- /dev/null +++ b/tests-tia/bootstrap.php @@ -0,0 +1,65 @@ +destroy(); + } +} + +/** + * Strip ANSI escapes so assertions are terminal-agnostic. + */ +function tiaOutput(Process $process): string +{ + $output = $process->getOutput().$process->getErrorOutput(); + + return preg_replace('/\e\[[0-9;]*m/', '', $output) ?? $output; +} diff --git a/tests-tia/phpunit.xml b/tests-tia/phpunit.xml new file mode 100644 index 00000000..99c042e8 --- /dev/null +++ b/tests-tia/phpunit.xml @@ -0,0 +1,17 @@ + + + + + . + Fixtures + Support + + +