From 6ac6c1518ebfba43847fe45380eab3568e01f4dc Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 1 May 2026 17:17:33 +0100 Subject: [PATCH] wip --- .github/workflows/tests.yml | 18 - src/Exceptions/BaselineFetchFailed.php | 48 ++ src/Plugins/Tia/BaselineSync.php | 173 ++++++- tests-tia/AffectedSetTest.php | 63 --- tests-tia/FingerprintDriftTest.php | 52 -- .../Fixtures/sample-project/composer.json | 27 -- tests-tia/Fixtures/sample-project/phpunit.xml | 22 - .../Fixtures/sample-project/src/Greeter.php | 13 - .../Fixtures/sample-project/src/Math.php | 13 - .../sample-project/tests/GreeterTest.php | 9 - .../sample-project/tests/MathTest.php | 13 - .../Fixtures/sample-project/tests/Pest.php | 7 - tests-tia/RebuildTest.php | 28 -- tests-tia/RecordReplayTest.php | 42 -- tests-tia/SourceRevertTest.php | 46 -- tests-tia/StatusReplayTest.php | 53 --- tests-tia/Support/Sandbox.php | 447 ------------------ tests-tia/bootstrap.php | 65 --- tests-tia/phpunit.xml | 17 - 19 files changed, 210 insertions(+), 946 deletions(-) create mode 100644 src/Exceptions/BaselineFetchFailed.php delete mode 100644 tests-tia/AffectedSetTest.php delete mode 100644 tests-tia/FingerprintDriftTest.php delete mode 100644 tests-tia/Fixtures/sample-project/composer.json delete mode 100644 tests-tia/Fixtures/sample-project/phpunit.xml delete mode 100644 tests-tia/Fixtures/sample-project/src/Greeter.php delete mode 100644 tests-tia/Fixtures/sample-project/src/Math.php delete mode 100644 tests-tia/Fixtures/sample-project/tests/GreeterTest.php delete mode 100644 tests-tia/Fixtures/sample-project/tests/MathTest.php delete mode 100644 tests-tia/Fixtures/sample-project/tests/Pest.php delete mode 100644 tests-tia/RebuildTest.php delete mode 100644 tests-tia/RecordReplayTest.php delete mode 100644 tests-tia/SourceRevertTest.php delete mode 100644 tests-tia/StatusReplayTest.php delete mode 100644 tests-tia/Support/Sandbox.php delete mode 100644 tests-tia/bootstrap.php delete mode 100644 tests-tia/phpunit.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fa653501..ce3b3349 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,21 +76,3 @@ 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/src/Exceptions/BaselineFetchFailed.php b/src/Exceptions/BaselineFetchFailed.php new file mode 100644 index 00000000..204b6836 --- /dev/null +++ b/src/Exceptions/BaselineFetchFailed.php @@ -0,0 +1,48 @@ +writeln([ + '', + ' TIA '.$this->headline, + ' '.$this->hint.'', + ' Bypass with --fresh to record locally and skip the baseline fetch.', + '', + ]); + } + + public function exitCode(): int + { + return 1; + } +} diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 9fb9ae17..847a1d75 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; use Composer\InstalledVersions; +use Pest\Exceptions\BaselineFetchFailed; +use Pest\Panic; use Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\State; use Symfony\Component\Console\Output\OutputInterface; @@ -69,11 +71,19 @@ final readonly class BaselineSync return false; } - $payload = $this->download($repo, $projectRoot); + $failureKind = null; + $payload = $this->download($repo, $projectRoot, $failureKind); if ($payload === null) { - $this->startCooldown(); - $this->emitPublishInstructions($repo); + // Genuine "no baseline published yet" → cool down and show + // the publish-instructions YAML so the user can wire CI. + // Anything else (missing gh, auth, network, mid-download + // error) is transient and gets a one-line diagnostic + // instead — no cooldown, no noisy YAML. + if ($failureKind === 'no-runs' || $failureKind === null) { + $this->startCooldown(); + $this->emitPublishInstructions($repo); + } return false; } @@ -294,16 +304,58 @@ YAML; return null; } - /** @return array{graph: string, coverage: ?string}|null */ - private function download(string $repo, string $projectRoot): ?array + /** + * @param-out string|null $failureKind + * + * @return array{graph: string, coverage: ?string}|null + */ + private function download(string $repo, string $projectRoot, ?string &$failureKind = null): ?array { + $failureKind = null; + if (! $this->commandExists('gh')) { + Panic::with(new BaselineFetchFailed( + 'GitHub CLI (gh) not found — cannot fetch baseline.', + 'Install it from https://cli.github.com.', + )); + } + + if (! $this->ghAuthenticated()) { + Panic::with(new BaselineFetchFailed( + 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', + 'Run `gh auth login` and retry.', + )); + } + + [$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo); + + if ($listError !== null) { + $failureKind = $listError['kind']; + + // Tier 1 — actionable misconfiguration. Stop the suite and + // tell the user what to fix; a silent fall-through to a + // full record would just paper over the bug. + if (in_array($failureKind, ['forbidden', 'not-found'], true)) { + Panic::with(new BaselineFetchFailed( + sprintf('Failed to query baseline runs — %s', $listError['message']), + 'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.', + )); + } + + // Tier 2 — transient (network, rate-limit, unknown). Surface + // the diagnostic but let the suite fall through to record mode. + $this->output->writeln(sprintf( + ' TIA failed to query baseline runs — %s', + $listError['message'], + )); + return null; } - $runId = $this->latestSuccessfulRunId($repo); - if ($runId === null) { + // Genuine missing baseline — caller emits publish instructions. + $failureKind = 'no-runs'; + return null; } @@ -365,6 +417,23 @@ YAML; if (! $process->isSuccessful()) { $this->cleanup($runCacheDir); + $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); + $failureKind = $diagnosis['kind']; + + // Tier 1 — actionable. Stop hard with a clear diagnostic. + if (in_array($failureKind, ['forbidden', 'not-found'], true)) { + Panic::with(new BaselineFetchFailed( + sprintf('Baseline download failed — %s', $diagnosis['message']), + 'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.', + )); + } + + // Tier 2 — transient. Diagnostic + fall through to record mode. + $this->output->writeln(sprintf( + ' TIA baseline download failed — %s', + $diagnosis['message'], + )); + return null; } @@ -373,7 +442,13 @@ YAML; if ($payload === null) { $this->cleanup($runCacheDir); - return null; + // Artifact present but malformed — CI's publish step is + // broken. Falling through would silently waste the next + // run; surface the bug instead. + Panic::with(new BaselineFetchFailed( + 'Baseline downloaded but the artifact is missing expected files (graph.json).', + 'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.', + )); } $this->trimDownloadCache($projectRoot); @@ -559,7 +634,16 @@ YAML; } } - private function latestSuccessfulRunId(string $repo): ?string + /** + * Returns `[runId|null, errorOrNull]`. Distinguishes "no runs yet" + * (runId null, error null) from "couldn't ask GitHub" (error + * populated with kind + message). Lets the caller pick between + * showing publish instructions and emitting a transient-failure + * diagnostic. + * + * @return array{0: ?string, 1: ?array{kind: string, message: string}} + */ + private function latestSuccessfulRunIdWithError(string $repo): array { $process = new Process([ 'gh', 'run', 'list', @@ -574,12 +658,79 @@ YAML; $process->run(); if (! $process->isSuccessful()) { - return null; + return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())]; } $runId = trim($process->getOutput()); - return $runId === '' ? null : $runId; + return [$runId === '' ? null : $runId, null]; + } + + private function ghAuthenticated(): bool + { + $process = new Process(['gh', 'auth', 'status']); + $process->setTimeout(10.0); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * Maps a chunk of `gh` stderr/stdout to a coarse kind + a short, + * actionable message. Falls back to the first non-empty line of + * the output so even unrecognised errors aren't reduced to "unknown". + * + * @return array{kind: string, message: string} + */ + private function classifyGhError(string $output): array + { + $output = trim($output); + + if ($output === '') { + return ['kind' => 'unknown', 'message' => 'unknown error']; + } + + if (preg_match('/(could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo)/i', $output) === 1) { + return [ + 'kind' => 'network', + 'message' => 'network error (offline or DNS unreachable). Try again when connected.', + ]; + } + + if (preg_match('/(authentication failed|not logged in|requires authentication|bad credentials|401)/i', $output) === 1) { + return [ + 'kind' => 'gh-auth', + 'message' => 'authentication failed — run `gh auth login` and retry.', + ]; + } + + if (preg_match('/(rate limit|too many requests|secondary rate limit)/i', $output) === 1) { + return [ + 'kind' => 'rate-limit', + 'message' => 'GitHub API rate limit hit — try again later.', + ]; + } + + if (preg_match('/(404|not found|repository not found)/i', $output) === 1) { + return [ + 'kind' => 'not-found', + 'message' => 'workflow or artifact not found in repo.', + ]; + } + + if (preg_match('/(403|forbidden|access denied)/i', $output) === 1) { + return [ + 'kind' => 'forbidden', + 'message' => 'access denied — check that your `gh` token has repo + actions read scope.', + ]; + } + + // Unknown — surface the first informative line so the user has + // *something* to act on. + $first = strtok($output, "\n"); + $message = is_string($first) ? trim($first) : 'unknown error'; + + return ['kind' => 'unknown', 'message' => $message]; } private function commandExists(string $cmd): bool diff --git a/tests-tia/AffectedSetTest.php b/tests-tia/AffectedSetTest.php deleted file mode 100644 index 288b2227..00000000 --- a/tests-tia/AffectedSetTest.php +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 5ac2df51..00000000 --- a/tests-tia/FingerprintDriftTest.php +++ /dev/null @@ -1,52 +0,0 @@ -pest(['--tia']); - - $graphPath = $sandbox->path().'/.pest/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().'/.pest/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 deleted file mode 100644 index a72a1f25..00000000 --- a/tests-tia/Fixtures/sample-project/composer.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 0c3e5935..00000000 --- a/tests-tia/Fixtures/sample-project/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - tests - - - - - src - - - diff --git a/tests-tia/Fixtures/sample-project/src/Greeter.php b/tests-tia/Fixtures/sample-project/src/Greeter.php deleted file mode 100644 index e952204e..00000000 --- a/tests-tia/Fixtures/sample-project/src/Greeter.php +++ /dev/null @@ -1,13 +0,0 @@ -toBe('Hello, Nuno!'); -}); diff --git a/tests-tia/Fixtures/sample-project/tests/MathTest.php b/tests-tia/Fixtures/sample-project/tests/MathTest.php deleted file mode 100644 index 609d97ea..00000000 --- a/tests-tia/Fixtures/sample-project/tests/MathTest.php +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index f987b070..00000000 --- a/tests-tia/Fixtures/sample-project/tests/Pest.php +++ /dev/null @@ -1,7 +0,0 @@ -pest(['--tia']); - expect($sandbox->hasGraph())->toBeTrue(); - - $graphBefore = $sandbox->graph(); - - $process = $sandbox->pest(['--tia', '--fresh']); - - 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 deleted file mode 100644 index b4498e5a..00000000 --- a/tests-tia/RecordReplayTest.php +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index c32fb50c..00000000 --- a/tests-tia/SourceRevertTest.php +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index d1eb4f0f..00000000 --- a/tests-tia/StatusReplayTest.php +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 33843f98..00000000 --- a/tests-tia/Support/Sandbox.php +++ /dev/null @@ -1,447 +0,0 @@ -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' => '', - // Force TIA's Storage to fall back to the sandbox-local - // `.pest/tia/` layout. Without this, every sandbox run - // would dump state into the developer's real home dir - // (`~/.pest/tia/`), polluting it and making tests - // non-hermetic. - 'HOME' => '', - 'USERPROFILE' => '', - ], - ); - $process->setTimeout(120.0); - $process->run(); - - return $process; - } - - /** - * @return array|null - */ - public function graph(): ?array - { - $path = $this->path.'/.pest/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 deleted file mode 100644 index 65f4ee11..00000000 --- a/tests-tia/bootstrap.php +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 99c042e8..00000000 --- a/tests-tia/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - . - Fixtures - Support - - -