This commit is contained in:
nuno maduro
2026-05-01 20:28:39 +01:00
parent bed5e5b54a
commit a725e774c0
19 changed files with 281 additions and 222 deletions

View File

@ -59,7 +59,7 @@
] ]
}, },
"require-dev": { "require-dev": {
"mrpunyapal/peststan": "^0.2.5", "mrpunyapal/peststan": "^0.2.9",
"pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4", "pestphp/pest-plugin-type-coverage": "^4.0.4",

View File

@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], 'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
}; };
?> ?>

View File

@ -8,11 +8,10 @@ use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\AutoloadEdges; use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\BladeEdges; use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\Plugins\Tia\InertiaEdges;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\TableTracker; use Pest\Plugins\Tia\Replay;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container; use Pest\Support\Container;
@ -286,41 +285,23 @@ trait Testable
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); $cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name());
if ($cached !== null) { if ($cached !== null) {
if ($cached->isSuccess()) { // Risky has no public PHPUnit hook to replay as-risky, so we
$this->__cachedPass = true; // collapse it into Pass — the test doesn't misreport as a
$this->__ran = true; // 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; 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');
}
$recorder = Container::getInstance()->get(Recorder::class); $recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder); assert($recorder instanceof Recorder);
@ -334,15 +315,13 @@ trait Testable
parent::setUp(); parent::setUp();
// TIA blade-edge + table-edge recording (Laravel-only). Runs // TIA edge collectors (Laravel-only). Runs right after
// right after `parent::setUp()` so the Laravel app exists and // `parent::setUp()` so the Laravel app exists and the View /
// the View / DB facades are bound; each arm call is // DB facades are bound; each arm call is idempotent against
// idempotent against the current app instance so the 774-test // the current app instance so the 774-test suite doesn't stack
// suite doesn't stack 774 composers / listeners when Laravel // 774 composers / listeners when Laravel keeps the same app
// keeps the same app across tests. // across tests.
BladeEdges::arm($recorder); Collectors::armAll($recorder);
TableTracker::arm($recorder);
InertiaEdges::arm($recorder);
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $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. * Initialize test case properties from TestSuite.
*/ */

View File

@ -7,6 +7,7 @@ namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor; use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace; use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable; use Pest\Contracts\Panicable;
use Pest\Support\View;
use RuntimeException; use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@ -25,12 +26,13 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt
public function render(OutputInterface $output): void public function render(OutputInterface $output): void
{ {
$output->writeln([ View::renderUsing($output);
'',
' <fg=white;options=bold;bg=red> TIA </> '.$this->headline, View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
' <fg=gray>'.$this->hint.'</>', View::render('components.two-column-detail', ['left' => $this->hint, 'right' => '']);
' <fg=gray>Bypass with</> <fg=cyan>--fresh</> <fg=gray>to record locally and skip the baseline fetch.</>', View::render('components.two-column-detail', [
'', 'left' => 'Bypass with --fresh to record locally and skip the baseline fetch.',
'right' => '',
]); ]);
} }

View File

@ -8,6 +8,8 @@ use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable; use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Panic;
use Pest\Plugins\Tia\BaselineSync; use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
@ -20,9 +22,8 @@ use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\Storage; use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Panic;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
@ -125,6 +126,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private readonly BaselineSync $baselineSync, 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 private function loadGraph(string $projectRoot): ?Graph
{ {
$json = $this->state->read(self::KEY_GRAPH); $json = $this->state->read(self::KEY_GRAPH);
@ -165,8 +176,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return true; return true;
} }
$watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class); $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
assert($watchPatterns instanceof Tia\WatchPatterns); assert($watchPatterns instanceof WatchPatterns);
if (! $watchPatterns->isEnabled()) { if (! $watchPatterns->isEnabled()) {
return false; return false;
@ -176,11 +187,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// only after Environment's own handleArguments has run, which // only after Environment's own handleArguments has run, which
// hasn't happened at the restart-decision point — so check argv // hasn't happened at the restart-decision point — so check argv
// directly here. // directly here.
if ($watchPatterns->isLocally() && in_array('--ci', $arguments, true)) { return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
return false;
}
return true;
} }
public function getCachedResult(string $filename, string $testId): ?TestStatus 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'; $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
/** @var Tia\WatchPatterns $watchPatterns */ /** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class); $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments); $cliEnabled = $this->hasArgument(self::OPTION, $arguments);
$alwaysEnabled = $watchPatterns->isEnabled() $alwaysEnabled = $watchPatterns->isEnabled()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL); && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
@ -350,15 +357,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->seedResultsInto($graph); $this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.'); $this->renderBadge('ERROR', 'TIA could not write the dependency graph.');
$recorder->reset(); $recorder->reset();
return; return;
} }
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=green>TIA</> graph recorded (%d test files).', 'TIA recorded the dependency graph (%d test file%s).',
count($perTest), count($perTest),
count($perTest) === 1 ? '' : 's',
)); ));
$recorder->reset(); $recorder->reset();
@ -480,12 +488,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode; return $exitCode;
} }
$this->output->writeln([ $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.');
' <fg=white;bg=red> ERROR </> TIA recorded zero edges — coverage driver likely missing.',
' Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and retry.',
'',
]);
return $exitCode; return $exitCode;
} }
@ -500,15 +504,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.'); $this->renderBadge('ERROR', 'TIA could not write the dependency graph.');
return $exitCode; return $exitCode;
} }
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials).', 'TIA recorded the dependency graph (%d test file%s, %d worker partial%s).',
count($finalised), count($finalised),
count($finalised) === 1 ? '' : 's',
count($partialKeys), count($partialKeys),
count($partialKeys) === 1 ? '' : 's',
)); ));
$this->snapshotTestResults(); $this->snapshotTestResults();
@ -530,8 +536,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! Fingerprint::structuralMatches($stored, $current)) { if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current); $drift = Fingerprint::structuralDrift($stored, $current);
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> graph structure outdated (%s).', 'TIA graph structure outdated (%s).',
$this->formatStructuralDrift($drift), $this->formatStructuralDrift($drift),
)); ));
@ -543,7 +549,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$branchSha, $branchSha,
); );
if ($summary !== '') { if ($summary !== '') {
$this->output->writeln(' <fg=gray>'.$summary.'</>'); $this->renderDetail($summary);
} }
} }
} }
@ -554,7 +560,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->reconcileFingerprint($rebuilt, $current); return $this->reconcileFingerprint($rebuilt, $current);
} }
$this->output->writeln(' <fg=yellow>TIA</> rebuilding graph from scratch.'); $this->renderBadge('WARN', 'TIA rebuilding graph from scratch.');
$this->state->delete(self::KEY_GRAPH); $this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE); $this->state->delete(self::KEY_COVERAGE_CACHE);
@ -565,8 +571,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$drift = Fingerprint::environmentalDrift($stored, $current); $drift = Fingerprint::environmentalDrift($stored, $current);
if ($drift !== []) { if ($drift !== []) {
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> env differs from baseline (%s) — results dropped, edges reused.', 'TIA env differs from baseline (%s) — results dropped, edges reused.',
implode(', ', $drift), implode(', ', $drift),
)); ));
@ -615,9 +621,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($changedFiles->gitAvailable() if ($changedFiles->gitAvailable()
&& $branchSha !== null && $branchSha !== null
&& $changedFiles->since($branchSha) === null) { && $changedFiles->since($branchSha) === null) {
$this->output->writeln( $this->renderBadge('WARN', 'TIA recorded commit is no longer reachable — graph will be rebuilt.');
' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.',
);
$graph = null; $graph = null;
} }
} }
@ -790,9 +794,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
if (! $changedFiles->gitAvailable()) { if (! $changedFiles->gitAvailable()) {
$this->output->writeln( $this->renderBadge('WARN', 'TIA git unavailable — running full suite.');
' <fg=yellow>TIA</> git unavailable — running full suite.',
);
return $arguments; return $arguments;
} }
@ -803,19 +805,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changed = $changedFiles->filterUnchangedSinceLastRun( $changed = $changedFiles->filterUnchangedSinceLastRun(
$changed, $changed,
$graph->lastRunTree($this->branch), $graph->lastRunTree($this->branch),
$branchSha,
); );
$hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed); $hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed);
$coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable(); $coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) { if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
$this->output->writeln([ $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.');
' <fg=black;bg=yellow> WARNING </> TIA detected PHP source changes but no coverage driver is available.', $this->renderDetail('Install / enable pcov or xdebug (mode: coverage) so TIA can safely refresh edges after PHP refactors.');
' Running the full suite to avoid using a stale dependency graph. Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) so TIA can safely refresh edges after PHP refactors.',
'',
]);
return $arguments; return $arguments;
} }
@ -869,9 +867,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! $this->persistAffectedSet($affected)) { if (! $this->persistAffectedSet($affected)) {
$this->output->writeln( $this->renderBadge('ERROR', 'TIA could not persist affected set — running full suite.');
' <fg=red>TIA</> failed to persist affected set — running full suite.',
);
return $arguments; return $arguments;
} }
@ -953,8 +949,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
); );
} }
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=cyan>TIA</> %d affected test file%s%s.', '%d affected test file%s%s.',
count($affected), count($affected),
count($affected) === 1 ? '' : 's', count($affected) === 1 ? '' : 's',
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')', $reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
@ -975,10 +971,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$remainder = count($sorted) - count($preview); $remainder = count($sorted) - count($preview);
if ($remainder > 0) { if ($remainder > 0) {
$this->output->writeln(sprintf( $this->output->writeln(sprintf(' <fg=gray> … +%d more</>', $remainder));
' <fg=gray> … +%d more</>',
$remainder,
));
} }
} }
@ -1019,11 +1012,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1'); Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
} }
$this->output->writeln($this->piggybackCoverage $this->renderBadge('INFO', $this->piggybackCoverage
? ' <fg=cyan>TIA</> recording dependency graph in parallel via `--coverage` (first run) — '. ? 'TIA recording dependency graph in parallel via --coverage (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.' 'subsequent --tia runs will only re-execute affected tests.'
: ' <fg=cyan>TIA</> recording dependency graph in parallel (first run) — '. : 'TIA recording dependency graph in parallel (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.'); 'subsequent --tia runs will only re-execute affected tests.');
return $arguments; return $arguments;
} }
@ -1031,10 +1024,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($this->piggybackCoverage) { if ($this->piggybackCoverage) {
$this->recordingActive = true; $this->recordingActive = true;
$this->output->writeln( $this->renderBadge('INFO', 'TIA recording dependency graph via --coverage (first run) — '.
' <fg=cyan>TIA</> recording dependency graph via `--coverage` (first run) — '. 'subsequent --tia runs will only re-execute affected tests.');
'subsequent `--tia` runs will only re-execute affected tests.',
);
return $arguments; return $arguments;
} }
@ -1042,9 +1033,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recorder->activate(); $recorder->activate();
$this->recordingActive = true; $this->recordingActive = true;
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=cyan>TIA</> recording dependency graph via %s (first run) — '. 'TIA recording dependency graph via %s (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.', 'subsequent --tia runs will only re-execute affected tests.',
$recorder->driver(), $recorder->driver(),
)); ));
@ -1053,14 +1044,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function emitCoverageDriverMissing(): void private function emitCoverageDriverMissing(): void
{ {
$this->output->writeln([ $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.');
' <fg=black;bg=yellow> WARNING </> No coverage driver is available — TIA skipped.', $this->renderDetail('Install or enable one and rerun with --tia.');
'',
' TIA needs <fg=cyan>ext-pcov</> or <fg=cyan>Xdebug</> with <fg=cyan>coverage</> mode enabled to record',
' the dependency graph. Install or enable one and rerun with `--tia`.',
'',
]);
} }
/** /**
@ -1100,11 +1086,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->delete($key); $this->state->delete($key);
} }
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> %d worker(s) had no coverage driver — their per-test edges and results were dropped. ' '%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
.'Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and rerun.',
count($keys), count($keys),
)); ));
$this->renderDetail('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
} }
private function purgeWorkerPartials(): void 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 // filtered runs lose the ability to re-run only the failing test
// next time. // next time.
if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) { if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) {
$file = self::resolveFailedTestFile($testId); $file = $this->resolveFailedTestFile($testId);
} }
$graph->setResult( $graph->setResult(
@ -1416,7 +1402,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
* walking up the parent class chain — a plain PHPUnit test * walking up the parent class chain — a plain PHPUnit test
* lives in a real file at the top of that chain. * 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); $class = strstr($testId, '::', true);
@ -1491,11 +1477,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (str_ends_with($rel, '.blade.php')) { if (str_ends_with($rel, '.blade.php')) {
continue; continue;
} }
if (str_starts_with($rel, 'tests/')) {
if (str_starts_with($rel, 'tests/') continue;
|| str_starts_with($rel, 'vendor/') }
|| str_starts_with($rel, 'storage/framework/') if (str_starts_with($rel, 'vendor/')) {
|| str_starts_with($rel, 'bootstrap/cache/')) { continue;
}
if (str_starts_with($rel, 'storage/framework/')) {
continue;
}
if (str_starts_with($rel, 'bootstrap/cache/')) {
continue; continue;
} }
@ -1532,16 +1523,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) { if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
$this->output->writeln( $this->renderBadge('WARN', 'TIA fetched baseline still drifts — discarding.');
' <fg=yellow>TIA</> fetched baseline still drifts — discarding.',
);
return null; return null;
} }
$this->output->writeln( $this->renderBadge('SUCCESS', 'TIA fetched baseline matches — skipping local rebuild.');
' <fg=green>TIA</> fetched baseline matches — skipping local rebuild.',
);
return $fetched; return $fetched;
} }

View File

@ -9,6 +9,7 @@ use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
@ -46,6 +47,16 @@ final readonly class BaselineSync
private OutputInterface $output, 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 public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
{ {
$repo = $this->detectGitHubRepo($projectRoot); $repo = $this->detectGitHubRepo($projectRoot);
@ -55,9 +66,8 @@ final readonly class BaselineSync
} }
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. ' 'TIA last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
.'Override with <fg=cyan>--refetch</>.',
$this->formatDuration($remaining), $this->formatDuration($remaining),
)); ));
@ -91,8 +101,8 @@ final readonly class BaselineSync
$this->clearCooldown(); $this->clearCooldown();
$this->output->writeln(sprintf( $this->renderBadge('SUCCESS', sprintf(
' <fg=green>TIA</> baseline ready (%s).', 'TIA baseline ready (%s).',
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
)); ));
@ -146,9 +156,7 @@ final readonly class BaselineSync
private function emitPublishInstructions(string $repo): void private function emitPublishInstructions(string $repo): void
{ {
if ($this->isCi()) { if ($this->isCi()) {
$this->output->writeln( $this->renderBadge('INFO', 'TIA no baseline yet — this run will produce one.');
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
);
return; return;
} }
@ -157,28 +165,21 @@ final readonly class BaselineSync
? $this->laravelWorkflowYaml() ? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml(); : $this->genericWorkflowYaml();
$preamble = [ $this->renderBadge('WARN', 'TIA no baseline published yet — recording locally.');
' <fg=yellow>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');
' To share the baseline with your team, add this workflow to the repo:',
'',
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
'',
];
// YAML stays as a raw indented block — Termwind would mangle the
// verbatim whitespace.
$indentedYaml = array_map( $indentedYaml = array_map(
static fn (string $line): string => ' '.$line, static fn (string $line): string => ' '.$line,
explode("\n", $yaml), explode("\n", $yaml),
); );
$trailer = [ $this->output->writeln(['', ...$indentedYaml, '']);
'',
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
'',
];
$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. // `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 // Tier 2 — transient (network, rate-limit, unknown). Surface
// the diagnostic but let the suite fall through to record mode. // the diagnostic but let the suite fall through to record mode.
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> failed to query baseline runs — %s', 'TIA failed to query baseline runs — %s',
$listError['message'], $listError['message'],
)); ));
@ -362,8 +363,8 @@ YAML;
// id as recently used and doesn't evict it later. // id as recently used and doesn't evict it later.
@touch($runCacheDir); @touch($runCacheDir);
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=cyan>TIA</> using cached baseline from <fg=white>%s</> (run %s).', 'TIA using cached baseline from %s (run %s).',
$repo, $repo,
$runId, $runId,
)); ));
@ -377,14 +378,14 @@ YAML;
$artifactSize = $this->artifactSize($repo, $runId); $artifactSize = $this->artifactSize($repo, $runId);
$this->output->writeln($artifactSize !== null $this->renderBadge('INFO', $artifactSize !== null
? sprintf( ? sprintf(
' <fg=cyan>TIA</> fetching baseline (%s) from <fg=white>%s</>…', 'TIA fetching baseline (%s) from %s…',
$this->formatSize($artifactSize), $this->formatSize($artifactSize),
$repo, $repo,
) )
: sprintf( : sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…', 'TIA fetching baseline from %s…',
$repo, $repo,
)); ));
@ -422,8 +423,8 @@ YAML;
} }
// Tier 2 — transient. Diagnostic + fall through to record mode. // Tier 2 — transient. Diagnostic + fall through to record mode.
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> baseline download failed — %s', 'TIA baseline download failed — %s',
$diagnosis['message'], $diagnosis['message'],
)); ));
@ -599,10 +600,12 @@ YAML;
$candidates = []; $candidates = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
$path = $root.DIRECTORY_SEPARATOR.$entry; $path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) { if (! is_dir($path)) {

View File

@ -18,7 +18,7 @@ final readonly class ChangedFiles
* @param array<string, string> $lastRunTree path → content hash from last run. * @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string> * @return array<int, string>
*/ */
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{ {
if ($lastRunTree === []) { if ($lastRunTree === []) {
return $files; return $files;

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\BladeEdges;
use Pest\Plugins\Tia\Edges\InertiaEdges;
/**
* @internal
*/
final class Collectors
{
/** @var list<class-string> */
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);
}
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia\Edges;
/** /**
* @internal * @internal
@ -17,7 +17,7 @@ final readonly class AutoloadEdges
$files = []; $files = [];
foreach (get_included_files() as $file) { foreach (get_included_files() as $file) {
if (is_string($file) && $file !== '') { if ($file !== '') {
$files[$file] = true; $files[$file] = true;
} }
} }
@ -80,7 +80,7 @@ final readonly class AutoloadEdges
]; ];
foreach ($prefixes as $prefix) { foreach ($prefixes as $prefix) {
if (str_starts_with($relative, $prefix)) { if (str_starts_with($relative, (string) $prefix)) {
return true; return true;
} }
} }

View File

@ -2,7 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/** /**
* @internal * @internal

View File

@ -2,7 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/** /**
* @internal * @internal

View File

@ -4,11 +4,12 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
/** /**
* @internal * @internal
@ -230,13 +231,13 @@ final class Graph
if ($freshMap === null) { if ($freshMap === null) {
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user // 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. // knows precision was downgraded rather than leaving the slower replay unexplained.
$output = Container::getInstance()->get(OutputInterface::class); View::render('components.badge', [
if ($output instanceof OutputInterface) { 'type' => 'WARN',
$output->writeln(sprintf( 'content' => sprintf(
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).', 'TIA Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles), count($newJsFiles),
)); ),
} ]);
} else { } else {
foreach ($newJsFiles as $rel) { foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? []; $pages = $freshMap[$rel] ?? [];
@ -538,8 +539,10 @@ final class Graph
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if (! is_string($file)) {
if (! is_string($file) || $file === '') { continue;
}
if ($file === '') {
continue; continue;
} }
@ -767,7 +770,7 @@ final class Graph
]; ];
foreach ($prefixes as $prefix) { foreach ($prefixes as $prefix) {
if (str_starts_with($rel, $prefix)) { if (str_starts_with($rel, (string) $prefix)) {
return true; return true;
} }
} }
@ -805,7 +808,7 @@ final class Graph
foreach ($repo->getFilenames() as $filename) { foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename); $factory = $repo->get($filename);
if ($factory === null) { if (! $factory instanceof TestCaseFactory) {
continue; continue;
} }
@ -850,7 +853,10 @@ final class Graph
if (! is_object($attribute)) { if (! is_object($attribute)) {
continue; continue;
} }
if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) { if (! property_exists($attribute, 'name')) {
continue;
}
if ($attribute->name !== Group::class) {
continue; continue;
} }
if (! property_exists($attribute, 'arguments')) { if (! property_exists($attribute, 'arguments')) {
@ -989,10 +995,12 @@ final class Graph
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (! $file instanceof \SplFileInfo || ! $file->isFile()) { if (! $file instanceof \SplFileInfo) {
continue;
}
if (! $file->isFile()) {
continue; continue;
} }
$path = $file->getPathname(); $path = $file->getPathname();
if (! str_ends_with($path, '.blade.php')) { if (! str_ends_with($path, '.blade.php')) {
continue; continue;

View File

@ -42,7 +42,7 @@ final class JsModuleGraph
*/ */
public static function warmInBackground(string $projectRoot): void public static function warmInBackground(string $projectRoot): void
{ {
if (self::$warmer !== null || self::$warmerCacheHit) { if (self::$warmer instanceof Process || self::$warmerCacheHit) {
return; return;
} }
@ -62,7 +62,7 @@ final class JsModuleGraph
$process = self::buildNodeProcess($projectRoot); $process = self::buildNodeProcess($projectRoot);
if ($process === null) { if (! $process instanceof Process) {
return; return;
} }
@ -164,7 +164,7 @@ final class JsModuleGraph
} }
} }
if (self::$warmer !== null if (self::$warmer instanceof Process
&& self::$warmerFingerprint === $fingerprint && self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot) { && self::$warmerProjectRoot === $projectRoot) {
$process = self::$warmer; $process = self::$warmer;
@ -179,7 +179,7 @@ final class JsModuleGraph
$process = null; $process = null;
} }
if ($process !== null && $process->isSuccessful()) { if ($process instanceof Process && $process->isSuccessful()) {
$result = self::parseNodeOutput($process->getOutput()); $result = self::parseNodeOutput($process->getOutput());
if ($result !== null) { if ($result !== null) {
@ -212,7 +212,7 @@ final class JsModuleGraph
{ {
$process = self::buildNodeProcess($projectRoot); $process = self::buildNodeProcess($projectRoot);
if ($process === null) { if (! $process instanceof Process) {
return null; return null;
} }
@ -319,7 +319,7 @@ final class JsModuleGraph
self::$warmerProjectRoot = null; self::$warmerProjectRoot = null;
self::$warmerCacheHit = false; self::$warmerCacheHit = false;
if ($process === null) { if (! $process instanceof Process) {
return; return;
} }
@ -446,10 +446,12 @@ final class JsModuleGraph
$out = []; $out = [];
foreach ($graph as $key => $value) { foreach ($graph as $key => $value) {
if (! is_string($key) || ! is_array($value)) { if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue; continue;
} }
$names = []; $names = [];
foreach ($value as $name) { foreach ($value as $name) {

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\TestSuite; use Pest\TestSuite;
use ReflectionClass; use ReflectionClass;
@ -169,7 +170,7 @@ final class Recorder
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor); $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = self::filesWithExecutedLines($data); $coveredFiles = $this->filesWithExecutedLines($data);
} else { } else {
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage(); $data = \xdebug_get_code_coverage();
@ -484,10 +485,15 @@ final class Recorder
private function findAutoloadFile(string $className): ?string private function findAutoloadFile(string $className): ?string
{ {
foreach (spl_autoload_functions() as $loader) { 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; continue;
} }
if (! method_exists($loader[0], 'findFile')) { if (! method_exists($loader[0], 'findFile')) {
continue; continue;
} }
@ -678,15 +684,17 @@ final class Recorder
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return list<string> * @return list<string>
*/ */
private static function filesWithExecutedLines(array $data): array private function filesWithExecutedLines(array $data): array
{ {
$out = []; $out = [];
foreach ($data as $file => $lines) { foreach ($data as $file => $lines) {
if (! is_string($file) || ! is_array($lines)) { if (! is_string($file)) {
continue;
}
if (! is_array($lines)) {
continue; continue;
} }
$covered = []; $covered = [];
foreach ($lines as $line => $count) { foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) { if (is_int($count) && $count > 0) {

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
enum Replay
{
case Pass;
case Skipped;
case Incomplete;
case Failure;
public static function from(TestStatus $cached): self
{
return match (true) {
$cached->isSuccess(), $cached->isRisky() => self::Pass,
$cached->isSkipped() => self::Skipped,
$cached->isIncomplete() => self::Incomplete,
default => self::Failure,
};
}
}

View File

@ -7,7 +7,7 @@ namespace Pest\Plugins\Tia;
/** /**
* @internal * @internal
*/ */
final class SourceScope final readonly class SourceScope
{ {
/** /**
* Top-level directory names always treated as out-of-scope. These * Top-level directory names always treated as out-of-scope. These
@ -44,9 +44,8 @@ final class SourceScope
* @param list<string> $excludes Absolute, normalised directory paths. * @param list<string> $excludes Absolute, normalised directory paths.
*/ */
public function __construct( public function __construct(
private readonly string $projectRoot, private array $includes,
private readonly array $includes, private array $excludes,
private readonly array $excludes,
) {} ) {}
public static function fromProjectRoot(string $projectRoot): self public static function fromProjectRoot(string $projectRoot): self
@ -94,13 +93,13 @@ final class SourceScope
$candidate = self::normalise($candidate); $candidate = self::normalise($candidate);
foreach ($this->excludes as $excluded) { foreach ($this->excludes as $excluded) {
if (self::startsWithDir($candidate, $excluded)) { if ($this->startsWithDir($candidate, $excluded)) {
return false; return false;
} }
} }
foreach ($this->includes as $included) { foreach ($this->includes as $included) {
if (self::startsWithDir($candidate, $included)) { if ($this->startsWithDir($candidate, $included)) {
return true; return true;
} }
} }
@ -184,10 +183,12 @@ final class SourceScope
$out = []; $out = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) { if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
continue; continue;
} }
@ -223,7 +224,7 @@ final class SourceScope
return $out; 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] === '/' $isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|| (strlen($path) >= 2 && $path[1] === ':')); || (strlen($path) >= 2 && $path[1] === ':'));
@ -246,7 +247,7 @@ final class SourceScope
return rtrim($path, '/\\'); return rtrim($path, '/\\');
} }
private static function startsWithDir(string $candidate, string $dir): bool private function startsWithDir(string $candidate, string $dir): bool
{ {
if ($candidate === $dir) { if ($candidate === $dir) {
return true; return true;

View File

@ -57,10 +57,12 @@ final class Storage
} }
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
$path = $dir.DIRECTORY_SEPARATOR.$entry; $path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) { if (is_dir($path) && ! is_link($path)) {

View File

@ -79,7 +79,7 @@ final class PcovRestarter implements Restarter
$env = []; $env = [];
foreach (getenv() as $name => $value) { foreach (getenv() as $name => $value) {
if (is_string($name) && is_string($value)) { if (is_string($value)) {
$env[$name] = $value; $env[$name] = $value;
} }
} }

View File

@ -12,7 +12,6 @@ use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage; use Pest\Plugins\Tia\Storage;
/** /**
*
* @internal * @internal
*/ */
final class XdebugRestarter implements Restarter final class XdebugRestarter implements Restarter