diff --git a/composer.json b/composer.json index c9dc5eb3..dc21ae86 100644 --- a/composer.json +++ b/composer.json @@ -59,7 +59,7 @@ ] }, "require-dev": { - "mrpunyapal/peststan": "^0.2.5", + "mrpunyapal/peststan": "^0.2.9", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", diff --git a/resources/views/components/badge.php b/resources/views/components/badge.php index 39d31b9f..7e6ebee3 100644 --- a/resources/views/components/badge.php +++ b/resources/views/components/badge.php @@ -5,6 +5,8 @@ [$bgBadgeColor, $bgBadgeText] = match ($type) { 'INFO' => ['blue', 'INFO'], 'ERROR' => ['red', 'ERROR'], + 'WARN' => ['yellow', 'WARN'], + 'SUCCESS' => ['green', 'SUCCESS'], }; ?> diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index ced76540..1fdda42d 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -8,11 +8,10 @@ use Closure; use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Panic; use Pest\Plugins\Tia; -use Pest\Plugins\Tia\AutoloadEdges; -use Pest\Plugins\Tia\BladeEdges; -use Pest\Plugins\Tia\InertiaEdges; +use Pest\Plugins\Tia\Collectors; +use Pest\Plugins\Tia\Edges\AutoloadEdges; use Pest\Plugins\Tia\Recorder; -use Pest\Plugins\Tia\TableTracker; +use Pest\Plugins\Tia\Replay; use Pest\Preset; use Pest\Support\ChainableClosure; use Pest\Support\Container; @@ -286,39 +285,21 @@ trait Testable $cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); if ($cached !== null) { - if ($cached->isSuccess()) { - $this->__cachedPass = true; - $this->__ran = true; + // Risky has no public PHPUnit hook to replay as-risky, so we + // collapse it into Pass — the test doesn't misreport as a + // failure, at the cost of losing aggregate risky totals on + // replay (accepted until PHPUnit grows a programmatic + // risky-marker API). Skipped/Incomplete throw the matching + // PHPUnit exception so the runner marks the status exactly + // as it did on the recorded run. + match (Replay::from($cached)) { + Replay::Pass => $this->__shortCircuitCachedPass(), + Replay::Skipped => $this->markTestSkipped($cached->message()), + Replay::Incomplete => $this->markTestIncomplete($cached->message()), + Replay::Failure => throw new AssertionFailedError($cached->message() ?: 'Cached failure'), + }; - return; - } - - // Risky tests have no public PHPUnit hook to replay as-risky. - // Best available: short-circuit as a pass so the test doesn't - // misreport as a failure. Aggregate risky totals won't - // survive replay — accepted trade-off until PHPUnit grows a - // programmatic risky-marker API. - if ($cached->isRisky()) { - $this->__cachedPass = true; - $this->__ran = true; - - return; - } - - // Non-success: throw the matching PHPUnit exception. Runner - // catches it and marks the test with the correct status so - // skips, failures, incompletes and todos appear in output - // exactly as they did in the cached run. - if ($cached->isSkipped()) { - $this->markTestSkipped($cached->message()); - } - - if ($cached->isIncomplete()) { - $this->markTestIncomplete($cached->message()); - $this->__ran = true; - } - - throw new AssertionFailedError($cached->message() ?: 'Cached failure'); + return; } $recorder = Container::getInstance()->get(Recorder::class); @@ -334,15 +315,13 @@ trait Testable parent::setUp(); - // TIA blade-edge + table-edge recording (Laravel-only). Runs - // right after `parent::setUp()` so the Laravel app exists and - // the View / DB facades are bound; each arm call is - // idempotent against the current app instance so the 774-test - // suite doesn't stack 774 composers / listeners when Laravel - // keeps the same app across tests. - BladeEdges::arm($recorder); - TableTracker::arm($recorder); - InertiaEdges::arm($recorder); + // TIA edge collectors (Laravel-only). Runs right after + // `parent::setUp()` so the Laravel app exists and the View / + // DB facades are bound; each arm call is idempotent against + // the current app instance so the 774-test suite doesn't stack + // 774 composers / listeners when Laravel keeps the same app + // across tests. + Collectors::armAll($recorder); $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; @@ -365,6 +344,12 @@ trait Testable } } + private function __shortCircuitCachedPass(): void + { + $this->__cachedPass = true; + $this->__ran = true; + } + /** * Initialize test case properties from TestSuite. */ diff --git a/src/Exceptions/BaselineFetchFailed.php b/src/Exceptions/BaselineFetchFailed.php index 12b7caf9..eeb3e095 100644 --- a/src/Exceptions/BaselineFetchFailed.php +++ b/src/Exceptions/BaselineFetchFailed.php @@ -7,6 +7,7 @@ namespace Pest\Exceptions; use NunoMaduro\Collision\Contracts\RenderlessEditor; use NunoMaduro\Collision\Contracts\RenderlessTrace; use Pest\Contracts\Panicable; +use Pest\Support\View; use RuntimeException; use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -25,12 +26,13 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt public function render(OutputInterface $output): void { - $output->writeln([ - '', - ' TIA '.$this->headline, - ' '.$this->hint.'', - ' Bypass with --fresh to record locally and skip the baseline fetch.', - '', + View::renderUsing($output); + + View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]); + View::render('components.two-column-detail', ['left' => $this->hint, 'right' => '']); + View::render('components.two-column-detail', [ + 'left' => 'Bypass with --fresh to record locally and skip the baseline fetch.', + 'right' => '', ]); } diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 061d8e0c..ed8abbb6 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -8,6 +8,8 @@ use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter; use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; +use Pest\Exceptions\NoAffectedTestsFound; +use Pest\Panic; use Pest\Plugins\Tia\BaselineSync; use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\Contracts\State; @@ -20,9 +22,8 @@ use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\Storage; use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\WatchPatterns; -use Pest\Exceptions\NoAffectedTestsFound; -use Pest\Panic; use Pest\Support\Container; +use Pest\Support\View; use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestSuite; use PHPUnit\Framework\TestStatus\TestStatus; @@ -125,6 +126,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private readonly BaselineSync $baselineSync, ) {} + private function renderBadge(string $type, string $content): void + { + View::render('components.badge', ['type' => $type, 'content' => $content]); + } + + private function renderDetail(string $left, string $right = ''): void + { + View::render('components.two-column-detail', ['left' => $left, 'right' => $right]); + } + private function loadGraph(string $projectRoot): ?Graph { $json = $this->state->read(self::KEY_GRAPH); @@ -165,8 +176,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return true; } - $watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class); - assert($watchPatterns instanceof Tia\WatchPatterns); + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + assert($watchPatterns instanceof WatchPatterns); if (! $watchPatterns->isEnabled()) { return false; @@ -176,11 +187,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable // only after Environment's own handleArguments has run, which // hasn't happened at the restart-decision point — so check argv // directly here. - if ($watchPatterns->isLocally() && in_array('--ci', $arguments, true)) { - return false; - } - - return true; + return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true)); } public function getCachedResult(string $filename, string $testId): ?TestStatus @@ -241,8 +248,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; - /** @var Tia\WatchPatterns $watchPatterns */ - $watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class); + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); $cliEnabled = $this->hasArgument(self::OPTION, $arguments); $alwaysEnabled = $watchPatterns->isEnabled() && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL); @@ -350,15 +357,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->seedResultsInto($graph); if (! $this->saveGraph($graph)) { - $this->output->writeln(' TIA failed to write graph.'); + $this->renderBadge('ERROR', 'TIA could not write the dependency graph.'); $recorder->reset(); return; } - $this->output->writeln(sprintf( - ' TIA graph recorded (%d test files).', + $this->renderBadge('INFO', sprintf( + 'TIA recorded the dependency graph (%d test file%s).', count($perTest), + count($perTest) === 1 ? '' : 's', )); $recorder->reset(); @@ -480,12 +488,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $exitCode; } - $this->output->writeln([ - '', - ' ERROR TIA recorded zero edges — coverage driver likely missing.', - ' Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.', - '', - ]); + $this->renderBadge('ERROR', 'TIA recorded zero edges — coverage driver likely missing.'); + $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.'); return $exitCode; } @@ -500,15 +504,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } if (! $this->saveGraph($graph)) { - $this->output->writeln(' TIA failed to write graph.'); + $this->renderBadge('ERROR', 'TIA could not write the dependency graph.'); return $exitCode; } - $this->output->writeln(sprintf( - ' TIA graph recorded (%d test files, %d worker partials).', + $this->renderBadge('INFO', sprintf( + 'TIA recorded the dependency graph (%d test file%s, %d worker partial%s).', count($finalised), + count($finalised) === 1 ? '' : 's', count($partialKeys), + count($partialKeys) === 1 ? '' : 's', )); $this->snapshotTestResults(); @@ -530,8 +536,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if (! Fingerprint::structuralMatches($stored, $current)) { $drift = Fingerprint::structuralDrift($stored, $current); - $this->output->writeln(sprintf( - ' TIA graph structure outdated (%s).', + $this->renderBadge('WARN', sprintf( + 'TIA graph structure outdated (%s).', $this->formatStructuralDrift($drift), )); @@ -543,7 +549,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $branchSha, ); if ($summary !== '') { - $this->output->writeln(' '.$summary.''); + $this->renderDetail($summary); } } } @@ -554,7 +560,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $this->reconcileFingerprint($rebuilt, $current); } - $this->output->writeln(' TIA rebuilding graph from scratch.'); + $this->renderBadge('WARN', 'TIA rebuilding graph from scratch.'); $this->state->delete(self::KEY_GRAPH); $this->state->delete(self::KEY_COVERAGE_CACHE); @@ -565,8 +571,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $drift = Fingerprint::environmentalDrift($stored, $current); if ($drift !== []) { - $this->output->writeln(sprintf( - ' TIA env differs from baseline (%s) — results dropped, edges reused.', + $this->renderBadge('WARN', sprintf( + 'TIA env differs from baseline (%s) — results dropped, edges reused.', implode(', ', $drift), )); @@ -615,9 +621,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if ($changedFiles->gitAvailable() && $branchSha !== null && $changedFiles->since($branchSha) === null) { - $this->output->writeln( - ' TIA recorded commit is no longer reachable — graph will be rebuilt.', - ); + $this->renderBadge('WARN', 'TIA recorded commit is no longer reachable — graph will be rebuilt.'); $graph = null; } } @@ -790,9 +794,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $changedFiles = new ChangedFiles($projectRoot); if (! $changedFiles->gitAvailable()) { - $this->output->writeln( - ' TIA git unavailable — running full suite.', - ); + $this->renderBadge('WARN', 'TIA git unavailable — running full suite.'); return $arguments; } @@ -803,19 +805,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $changed = $changedFiles->filterUnchangedSinceLastRun( $changed, $graph->lastRunTree($this->branch), - $branchSha, ); $hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed); $coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable(); if ($hasProjectPhpSourceChanges && ! $coverageAvailable) { - $this->output->writeln([ - '', - ' WARNING TIA detected PHP source changes but no coverage driver is available.', - ' Running the full suite to avoid using a stale dependency graph. Install / enable pcov or xdebug (mode: coverage) so TIA can safely refresh edges after PHP refactors.', - '', - ]); + $this->renderBadge('WARN', 'TIA detected PHP source changes but no coverage driver is available.'); + $this->renderDetail('Running the full suite to avoid using a stale dependency graph.'); + $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) so TIA can safely refresh edges after PHP refactors.'); return $arguments; } @@ -869,9 +867,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } if (! $this->persistAffectedSet($affected)) { - $this->output->writeln( - ' TIA failed to persist affected set — running full suite.', - ); + $this->renderBadge('ERROR', 'TIA could not persist affected set — running full suite.'); return $arguments; } @@ -953,8 +949,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable ); } - $this->output->writeln(sprintf( - ' TIA %d affected test file%s%s.', + $this->renderBadge('INFO', sprintf( + '%d affected test file%s%s.', count($affected), count($affected) === 1 ? '' : 's', $reasons === [] ? '' : ' ('.implode(', ', $reasons).')', @@ -975,10 +971,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $remainder = count($sorted) - count($preview); if ($remainder > 0) { - $this->output->writeln(sprintf( - ' … +%d more', - $remainder, - )); + $this->output->writeln(sprintf(' … +%d more', $remainder)); } } @@ -1019,11 +1012,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1'); } - $this->output->writeln($this->piggybackCoverage - ? ' TIA recording dependency graph in parallel via `--coverage` (first run) — '. - 'subsequent `--tia` runs will only re-execute affected tests.' - : ' TIA recording dependency graph in parallel (first run) — '. - 'subsequent `--tia` runs will only re-execute affected tests.'); + $this->renderBadge('INFO', $this->piggybackCoverage + ? 'TIA recording dependency graph in parallel via --coverage (first run) — '. + 'subsequent --tia runs will only re-execute affected tests.' + : 'TIA recording dependency graph in parallel (first run) — '. + 'subsequent --tia runs will only re-execute affected tests.'); return $arguments; } @@ -1031,10 +1024,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if ($this->piggybackCoverage) { $this->recordingActive = true; - $this->output->writeln( - ' TIA recording dependency graph via `--coverage` (first run) — '. - 'subsequent `--tia` runs will only re-execute affected tests.', - ); + $this->renderBadge('INFO', 'TIA recording dependency graph via --coverage (first run) — '. + 'subsequent --tia runs will only re-execute affected tests.'); return $arguments; } @@ -1042,9 +1033,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $recorder->activate(); $this->recordingActive = true; - $this->output->writeln(sprintf( - ' TIA recording dependency graph via %s (first run) — '. - 'subsequent `--tia` runs will only re-execute affected tests.', + $this->renderBadge('INFO', sprintf( + 'TIA recording dependency graph via %s (first run) — '. + 'subsequent --tia runs will only re-execute affected tests.', $recorder->driver(), )); @@ -1053,14 +1044,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private function emitCoverageDriverMissing(): void { - $this->output->writeln([ - '', - ' WARNING No coverage driver is available — TIA skipped.', - '', - ' TIA needs ext-pcov or Xdebug with coverage mode enabled to record', - ' the dependency graph. Install or enable one and rerun with `--tia`.', - '', - ]); + $this->renderBadge('WARN', 'No coverage driver is available — TIA skipped.'); + $this->renderDetail('TIA needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.'); + $this->renderDetail('Install or enable one and rerun with --tia.'); } /** @@ -1100,11 +1086,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $this->state->delete($key); } - $this->output->writeln(sprintf( - ' TIA %d worker(s) had no coverage driver — their per-test edges and results were dropped. ' - .'Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.', + $this->renderBadge('WARN', sprintf( + '%d worker(s) had no coverage driver — their per-test edges and results were dropped.', count($keys), )); + $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.'); } private function purgeWorkerPartials(): void @@ -1382,7 +1368,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable // filtered runs lose the ability to re-run only the failing test // next time. if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) { - $file = self::resolveFailedTestFile($testId); + $file = $this->resolveFailedTestFile($testId); } $graph->setResult( @@ -1416,7 +1402,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable * walking up the parent class chain — a plain PHPUnit test * lives in a real file at the top of that chain. */ - private static function resolveFailedTestFile(string $testId): ?string + private function resolveFailedTestFile(string $testId): ?string { $class = strstr($testId, '::', true); @@ -1491,11 +1477,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if (str_ends_with($rel, '.blade.php')) { continue; } - - if (str_starts_with($rel, 'tests/') - || str_starts_with($rel, 'vendor/') - || str_starts_with($rel, 'storage/framework/') - || str_starts_with($rel, 'bootstrap/cache/')) { + if (str_starts_with($rel, 'tests/')) { + continue; + } + if (str_starts_with($rel, 'vendor/')) { + continue; + } + if (str_starts_with($rel, 'storage/framework/')) { + continue; + } + if (str_starts_with($rel, 'bootstrap/cache/')) { continue; } @@ -1532,16 +1523,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) { - $this->output->writeln( - ' TIA fetched baseline still drifts — discarding.', - ); + $this->renderBadge('WARN', 'TIA fetched baseline still drifts — discarding.'); return null; } - $this->output->writeln( - ' TIA fetched baseline matches — skipping local rebuild.', - ); + $this->renderBadge('SUCCESS', 'TIA fetched baseline matches — skipping local rebuild.'); return $fetched; } diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index c8cc6d60..40028213 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -9,6 +9,7 @@ use Pest\Exceptions\BaselineFetchFailed; use Pest\Panic; use Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\State; +use Pest\Support\View; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -46,6 +47,16 @@ final readonly class BaselineSync private OutputInterface $output, ) {} + private function renderBadge(string $type, string $content): void + { + View::render('components.badge', ['type' => $type, 'content' => $content]); + } + + private function renderDetail(string $left, string $right = ''): void + { + View::render('components.two-column-detail', ['left' => $left, 'right' => $right]); + } + public function fetchIfAvailable(string $projectRoot, bool $force = false): bool { $repo = $this->detectGitHubRepo($projectRoot); @@ -55,9 +66,8 @@ final readonly class BaselineSync } if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { - $this->output->writeln(sprintf( - ' TIA last fetch found no baseline — next auto-retry in %s. ' - .'Override with --refetch.', + $this->renderBadge('WARN', sprintf( + 'TIA last fetch found no baseline — next auto-retry in %s. Override with --refetch.', $this->formatDuration($remaining), )); @@ -91,8 +101,8 @@ final readonly class BaselineSync $this->clearCooldown(); - $this->output->writeln(sprintf( - ' TIA baseline ready (%s).', + $this->renderBadge('SUCCESS', sprintf( + 'TIA baseline ready (%s).', $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), )); @@ -146,9 +156,7 @@ final readonly class BaselineSync private function emitPublishInstructions(string $repo): void { if ($this->isCi()) { - $this->output->writeln( - ' TIA no baseline yet — this run will produce one.', - ); + $this->renderBadge('INFO', 'TIA no baseline yet — this run will produce one.'); return; } @@ -157,28 +165,21 @@ final readonly class BaselineSync ? $this->laravelWorkflowYaml() : $this->genericWorkflowYaml(); - $preamble = [ - ' 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', - '', - ]; + $this->renderBadge('WARN', 'TIA no baseline published yet — recording locally.'); + $this->renderDetail('To share the baseline with your team, add this workflow to the repo:'); + $this->renderDetail('.github/workflows/tia-baseline.yml'); + // YAML stays as a raw indented block — Termwind would mangle the + // verbatim whitespace. $indentedYaml = array_map( static fn (string $line): string => ' '.$line, explode("\n", $yaml), ); - $trailer = [ - '', - sprintf(' Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo), - ' Details: https://pestphp.com/docs/tia/ci', - '', - ]; + $this->output->writeln(['', ...$indentedYaml, '']); - $this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]); + $this->renderDetail(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo)); + $this->renderDetail('Details: https://pestphp.com/docs/tia/ci'); } // `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var. @@ -337,8 +338,8 @@ YAML; // 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', + $this->renderBadge('WARN', sprintf( + 'TIA failed to query baseline runs — %s', $listError['message'], )); @@ -362,8 +363,8 @@ YAML; // id as recently used and doesn't evict it later. @touch($runCacheDir); - $this->output->writeln(sprintf( - ' TIA using cached baseline from %s (run %s).', + $this->renderBadge('INFO', sprintf( + 'TIA using cached baseline from %s (run %s).', $repo, $runId, )); @@ -377,14 +378,14 @@ YAML; $artifactSize = $this->artifactSize($repo, $runId); - $this->output->writeln($artifactSize !== null + $this->renderBadge('INFO', $artifactSize !== null ? sprintf( - ' TIA fetching baseline (%s) from %s…', + 'TIA fetching baseline (%s) from %s…', $this->formatSize($artifactSize), $repo, ) : sprintf( - ' TIA fetching baseline from %s…', + 'TIA fetching baseline from %s…', $repo, )); @@ -422,8 +423,8 @@ YAML; } // Tier 2 — transient. Diagnostic + fall through to record mode. - $this->output->writeln(sprintf( - ' TIA baseline download failed — %s', + $this->renderBadge('WARN', sprintf( + 'TIA baseline download failed — %s', $diagnosis['message'], )); @@ -599,10 +600,12 @@ YAML; $candidates = []; foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { + if ($entry === '.') { + continue; + } + if ($entry === '..') { continue; } - $path = $root.DIRECTORY_SEPARATOR.$entry; if (! is_dir($path)) { diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index ee81bd0b..68b15959 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -18,7 +18,7 @@ final readonly class ChangedFiles * @param array $lastRunTree path → content hash from last run. * @return array */ - public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array + public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array { if ($lastRunTree === []) { return $files; diff --git a/src/Plugins/Tia/Collectors.php b/src/Plugins/Tia/Collectors.php new file mode 100644 index 00000000..c182f7b7 --- /dev/null +++ b/src/Plugins/Tia/Collectors.php @@ -0,0 +1,28 @@ + */ + private const array COLLECTORS = [ + BladeEdges::class, + TableTracker::class, + InertiaEdges::class, + ]; + + public static function armAll(Recorder $recorder): void + { + foreach (self::COLLECTORS as $collector) { + $collector::arm($recorder); + } + } +} diff --git a/src/Plugins/Tia/AutoloadEdges.php b/src/Plugins/Tia/Edges/AutoloadEdges.php similarity index 93% rename from src/Plugins/Tia/AutoloadEdges.php rename to src/Plugins/Tia/Edges/AutoloadEdges.php index de9a3293..fcc14e6a 100644 --- a/src/Plugins/Tia/AutoloadEdges.php +++ b/src/Plugins/Tia/Edges/AutoloadEdges.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Plugins\Tia; +namespace Pest\Plugins\Tia\Edges; /** * @internal @@ -17,7 +17,7 @@ final readonly class AutoloadEdges $files = []; foreach (get_included_files() as $file) { - if (is_string($file) && $file !== '') { + if ($file !== '') { $files[$file] = true; } } @@ -80,7 +80,7 @@ final readonly class AutoloadEdges ]; foreach ($prefixes as $prefix) { - if (str_starts_with($relative, $prefix)) { + if (str_starts_with($relative, (string) $prefix)) { return true; } } diff --git a/src/Plugins/Tia/BladeEdges.php b/src/Plugins/Tia/Edges/BladeEdges.php similarity index 96% rename from src/Plugins/Tia/BladeEdges.php rename to src/Plugins/Tia/Edges/BladeEdges.php index 1a5681bb..fac1ea40 100644 --- a/src/Plugins/Tia/BladeEdges.php +++ b/src/Plugins/Tia/Edges/BladeEdges.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace Pest\Plugins\Tia; +namespace Pest\Plugins\Tia\Edges; + +use Pest\Plugins\Tia\Recorder; /** * @internal diff --git a/src/Plugins/Tia/InertiaEdges.php b/src/Plugins/Tia/Edges/InertiaEdges.php similarity index 99% rename from src/Plugins/Tia/InertiaEdges.php rename to src/Plugins/Tia/Edges/InertiaEdges.php index 46992255..162f591c 100644 --- a/src/Plugins/Tia/InertiaEdges.php +++ b/src/Plugins/Tia/Edges/InertiaEdges.php @@ -2,7 +2,9 @@ declare(strict_types=1); -namespace Pest\Plugins\Tia; +namespace Pest\Plugins\Tia\Edges; + +use Pest\Plugins\Tia\Recorder; /** * @internal diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 8bbb9b7e..8d516a66 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -4,11 +4,12 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; +use Pest\Factories\TestCaseFactory; use Pest\Support\Container; +use Pest\Support\View; use Pest\TestSuite; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestStatus\TestStatus; -use Symfony\Component\Console\Output\OutputInterface; /** * @internal @@ -230,13 +231,13 @@ final class Graph if ($freshMap === null) { // Vite resolver unavailable — falling back to watch pattern; surface a line so the user // knows precision was downgraded rather than leaving the slower replay unexplained. - $output = Container::getInstance()->get(OutputInterface::class); - if ($output instanceof OutputInterface) { - $output->writeln(sprintf( - ' TIA Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', + View::render('components.badge', [ + 'type' => 'WARN', + 'content' => sprintf( + 'TIA Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', count($newJsFiles), - )); - } + ), + ]); } else { foreach ($newJsFiles as $rel) { $pages = $freshMap[$rel] ?? []; @@ -538,8 +539,10 @@ final class Graph } $file = $result['file'] ?? null; - - if (! is_string($file) || $file === '') { + if (! is_string($file)) { + continue; + } + if ($file === '') { continue; } @@ -767,7 +770,7 @@ final class Graph ]; foreach ($prefixes as $prefix) { - if (str_starts_with($rel, $prefix)) { + if (str_starts_with($rel, (string) $prefix)) { return true; } } @@ -805,7 +808,7 @@ final class Graph foreach ($repo->getFilenames() as $filename) { $factory = $repo->get($filename); - if ($factory === null) { + if (! $factory instanceof TestCaseFactory) { continue; } @@ -850,7 +853,10 @@ final class Graph if (! is_object($attribute)) { continue; } - if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) { + if (! property_exists($attribute, 'name')) { + continue; + } + if ($attribute->name !== Group::class) { continue; } if (! property_exists($attribute, 'arguments')) { @@ -989,10 +995,12 @@ final class Graph ); foreach ($iterator as $file) { - if (! $file instanceof \SplFileInfo || ! $file->isFile()) { + if (! $file instanceof \SplFileInfo) { + continue; + } + if (! $file->isFile()) { continue; } - $path = $file->getPathname(); if (! str_ends_with($path, '.blade.php')) { continue; diff --git a/src/Plugins/Tia/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php index c92be14b..01db8f72 100644 --- a/src/Plugins/Tia/JsModuleGraph.php +++ b/src/Plugins/Tia/JsModuleGraph.php @@ -42,7 +42,7 @@ final class JsModuleGraph */ public static function warmInBackground(string $projectRoot): void { - if (self::$warmer !== null || self::$warmerCacheHit) { + if (self::$warmer instanceof Process || self::$warmerCacheHit) { return; } @@ -62,7 +62,7 @@ final class JsModuleGraph $process = self::buildNodeProcess($projectRoot); - if ($process === null) { + if (! $process instanceof Process) { return; } @@ -164,7 +164,7 @@ final class JsModuleGraph } } - if (self::$warmer !== null + if (self::$warmer instanceof Process && self::$warmerFingerprint === $fingerprint && self::$warmerProjectRoot === $projectRoot) { $process = self::$warmer; @@ -179,7 +179,7 @@ final class JsModuleGraph $process = null; } - if ($process !== null && $process->isSuccessful()) { + if ($process instanceof Process && $process->isSuccessful()) { $result = self::parseNodeOutput($process->getOutput()); if ($result !== null) { @@ -212,7 +212,7 @@ final class JsModuleGraph { $process = self::buildNodeProcess($projectRoot); - if ($process === null) { + if (! $process instanceof Process) { return null; } @@ -319,7 +319,7 @@ final class JsModuleGraph self::$warmerProjectRoot = null; self::$warmerCacheHit = false; - if ($process === null) { + if (! $process instanceof Process) { return; } @@ -446,10 +446,12 @@ final class JsModuleGraph $out = []; foreach ($graph as $key => $value) { - if (! is_string($key) || ! is_array($value)) { + if (! is_string($key)) { + continue; + } + if (! is_array($value)) { continue; } - $names = []; foreach ($value as $name) { @@ -465,7 +467,7 @@ final class JsModuleGraph } /** - * @param array> $graph + * @param array> $graph */ private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void { diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index e28dfe80..2695f1e1 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Pest\Plugins\Tia; +use Pest\Plugins\Tia\Edges\AutoloadEdges; use Pest\TestSuite; use ReflectionClass; @@ -169,7 +170,7 @@ final class Recorder /** @var array $data */ $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor); - $coveredFiles = self::filesWithExecutedLines($data); + $coveredFiles = $this->filesWithExecutedLines($data); } else { /** @var array $data */ $data = \xdebug_get_code_coverage(); @@ -484,10 +485,15 @@ final class Recorder private function findAutoloadFile(string $className): ?string { foreach (spl_autoload_functions() as $loader) { - if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) { + if (! is_array($loader)) { + continue; + } + if (! isset($loader[0])) { + continue; + } + if (! is_object($loader[0])) { continue; } - if (! method_exists($loader[0], 'findFile')) { continue; } @@ -678,15 +684,17 @@ final class Recorder * @param array $data * @return list */ - private static function filesWithExecutedLines(array $data): array + private function filesWithExecutedLines(array $data): array { $out = []; foreach ($data as $file => $lines) { - if (! is_string($file) || ! is_array($lines)) { + if (! is_string($file)) { + continue; + } + if (! is_array($lines)) { continue; } - $covered = []; foreach ($lines as $line => $count) { if (is_int($count) && $count > 0) { diff --git a/src/Plugins/Tia/Replay.php b/src/Plugins/Tia/Replay.php new file mode 100644 index 00000000..7944405a --- /dev/null +++ b/src/Plugins/Tia/Replay.php @@ -0,0 +1,28 @@ +isSuccess(), $cached->isRisky() => self::Pass, + $cached->isSkipped() => self::Skipped, + $cached->isIncomplete() => self::Incomplete, + default => self::Failure, + }; + } +} diff --git a/src/Plugins/Tia/SourceScope.php b/src/Plugins/Tia/SourceScope.php index 0ea469dc..4459f66c 100644 --- a/src/Plugins/Tia/SourceScope.php +++ b/src/Plugins/Tia/SourceScope.php @@ -7,7 +7,7 @@ namespace Pest\Plugins\Tia; /** * @internal */ -final class SourceScope +final readonly class SourceScope { /** * Top-level directory names always treated as out-of-scope. These @@ -44,9 +44,8 @@ final class SourceScope * @param list $excludes Absolute, normalised directory paths. */ public function __construct( - private readonly string $projectRoot, - private readonly array $includes, - private readonly array $excludes, + private array $includes, + private array $excludes, ) {} public static function fromProjectRoot(string $projectRoot): self @@ -94,13 +93,13 @@ final class SourceScope $candidate = self::normalise($candidate); foreach ($this->excludes as $excluded) { - if (self::startsWithDir($candidate, $excluded)) { + if ($this->startsWithDir($candidate, $excluded)) { return false; } } foreach ($this->includes as $included) { - if (self::startsWithDir($candidate, $included)) { + if ($this->startsWithDir($candidate, $included)) { return true; } } @@ -184,10 +183,12 @@ final class SourceScope $out = []; foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { + if ($entry === '.') { + continue; + } + if ($entry === '..') { continue; } - if (in_array($entry, self::TOP_LEVEL_NOISE, true)) { continue; } @@ -223,7 +224,7 @@ final class SourceScope return $out; } - private static function resolveRelative(string $path, string $configDir): ?string + private static function resolveRelative(string $path, string $configDir): string { $isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/' || (strlen($path) >= 2 && $path[1] === ':')); @@ -246,7 +247,7 @@ final class SourceScope return rtrim($path, '/\\'); } - private static function startsWithDir(string $candidate, string $dir): bool + private function startsWithDir(string $candidate, string $dir): bool { if ($candidate === $dir) { return true; diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php index 6d691b99..463761e4 100644 --- a/src/Plugins/Tia/Storage.php +++ b/src/Plugins/Tia/Storage.php @@ -57,10 +57,12 @@ final class Storage } foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { + if ($entry === '.') { + continue; + } + if ($entry === '..') { continue; } - $path = $dir.DIRECTORY_SEPARATOR.$entry; if (is_dir($path) && ! is_link($path)) { diff --git a/src/Restarters/PcovRestarter.php b/src/Restarters/PcovRestarter.php index 75d6aaa7..21d9539c 100644 --- a/src/Restarters/PcovRestarter.php +++ b/src/Restarters/PcovRestarter.php @@ -79,7 +79,7 @@ final class PcovRestarter implements Restarter $env = []; foreach (getenv() as $name => $value) { - if (is_string($name) && is_string($value)) { + if (is_string($value)) { $env[$name] = $value; } } diff --git a/src/Restarters/XdebugRestarter.php b/src/Restarters/XdebugRestarter.php index 905a132a..8a3ad827 100644 --- a/src/Restarters/XdebugRestarter.php +++ b/src/Restarters/XdebugRestarter.php @@ -12,7 +12,6 @@ use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Storage; /** - * * @internal */ final class XdebugRestarter implements Restarter