This commit is contained in:
nuno maduro
2026-04-22 08:07:52 -07:00
parent 856a370032
commit c6a42a2b28
22 changed files with 1259 additions and 4 deletions

View File

@ -74,6 +74,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REBUILD_OPTION = '--tia-rebuild';
/**
* Bypasses `BaselineSync`'s post-failure cooldown. After a failed
* baseline fetch, subsequent `--tia` runs skip the fetch for 24h; this
* flag forces an immediate retry (e.g. right after publishing a
* baseline from CI for the first time).
*/
private const string REFETCH_OPTION = '--tia-refetch';
/**
* State keys under which TIA persists its blobs. Kept here as constants
* (rather than scattered strings) so the storage layout is visible in
@ -103,6 +111,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
public const string KEY_COVERAGE_MARKER = 'coverage.marker';
/**
* Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
* `{"until": <unix>}` — subsequent runs within the window skip the
* fetch attempt (and its `gh run list` network hop) until the
* cooldown expires or the user passes `--tia-refetch`.
*/
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
/**
* Global flag toggled by the parent process so workers know to record.
*/
@ -199,6 +215,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/
private bool $recordingActive = false;
/**
* True when `--tia-refetch` is in the current argv — `BaselineSync`
* uses it to bypass the post-failure fetch cooldown.
*/
private bool $forceRefetch = false;
public function __construct(
private readonly OutputInterface $output,
private readonly Recorder $recorder,
@ -310,13 +332,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$enabled = $this->hasArgument(self::OPTION, $arguments);
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) {
if (! $enabled && ! $forceRebuild && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
return $arguments;
}
$arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::REBUILD_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
// When `--coverage` is active, piggyback on PHPUnit's CodeCoverage
// instead of starting our own PCOV / Xdebug session. Running two
@ -401,6 +425,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceEdges($perTest);
$graph->pruneMissingTests();
// Fold in the results collected during this same record run. The
// `AddsOutput` pass that runs `snapshotTestResults` fires *before*
// `terminate()` in the shutdown chain, so by the time the graph
// lands on disk, the snapshot pass has already returned empty.
// Writing results here means a first `--tia` invocation produces
// a graph with edges *and* results — the immediate next run hits
// cache for every unchanged test rather than needing a "warm-up"
// pass.
$this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
$recorder->reset();
@ -635,7 +669,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// to pull a team-shared baseline so fresh checkouts (new devs, CI
// containers) don't pay the full record cost. If the pull succeeds
// the graph is re-read and reconciled against the local env.
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) {
if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint);
@ -1147,6 +1181,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->saveGraph($graph);
}
/**
* In-memory equivalent of `snapshotTestResults()` — transfers the
* collected results straight into the given graph instance without a
* load/save round-trip. Used on the record path where the graph
* hasn't hit disk yet and a separate `loadGraph()` would find nothing.
*/
private function seedResultsInto(Graph $graph): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
foreach ($collector->all() as $testId => $result) {
$graph->setResult(
$this->branch,
$testId,
$result['status'],
$result['message'],
$result['time'],
$result['assertions'],
);
}
$collector->reset();
}
/**
* Merges per-test status + message from the `ResultCollector` into the
* TIA graph. Runs after every `--tia` invocation so the graph always has

View File

@ -62,6 +62,15 @@ final readonly class BaselineSync
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
/**
* Cooldown (in seconds) applied after a failed baseline fetch.
* Rationale: when the remote workflow hasn't published yet, every
* `pest --tia` invocation would otherwise re-hit `gh run list` and
* re-print the publish instructions — noisy + slow. Back off for a
* day, let the user override with `--tia-refetch`.
*/
private const int FETCH_COOLDOWN_SECONDS = 86400;
public function __construct(
private State $state,
private OutputInterface $output,
@ -72,8 +81,12 @@ final readonly class BaselineSync
* contents into the TIA state store. Returns true when the graph blob
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
* never reads it.
*
* `$force = true` (driven by `--tia-refetch`) ignores the post-failure
* cooldown so the user can retry on demand without waiting out the
* 24h window.
*/
public function fetchIfAvailable(string $projectRoot): bool
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
@ -81,6 +94,16 @@ final readonly class BaselineSync
return false;
}
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
.'Override with <fg=cyan>--tia-refetch</>.',
$this->formatDuration($remaining),
));
return false;
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
$repo,
@ -89,6 +112,7 @@ final readonly class BaselineSync
$payload = $this->download($repo);
if ($payload === null) {
$this->startCooldown();
$this->emitPublishInstructions($repo);
return false;
@ -102,6 +126,11 @@ final readonly class BaselineSync
$this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']);
}
// Successful fetch wipes any stale cooldown so the next failure
// (say, weeks later) starts a fresh 24h timer rather than inheriting
// one from the deep past.
$this->clearCooldown();
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
@ -110,6 +139,54 @@ final readonly class BaselineSync
return true;
}
/**
* Seconds left on the cooldown, or `null` when the cooldown is cleared
* / expired / unreadable.
*/
private function cooldownRemaining(): ?int
{
$raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) {
return null;
}
$remaining = $decoded['until'] - time();
return $remaining > 0 ? $remaining : null;
}
private function startCooldown(): void
{
$this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([
'until' => time() + self::FETCH_COOLDOWN_SECONDS,
]));
}
private function clearCooldown(): void
{
$this->state->delete(Tia::KEY_FETCH_COOLDOWN);
}
private function formatDuration(int $seconds): string
{
if ($seconds >= 3600) {
return (int) round($seconds / 3600).'h';
}
if ($seconds >= 60) {
return (int) round($seconds / 60).'m';
}
return $seconds.'s';
}
/**
* Prints actionable instructions for publishing a first baseline when
* the consumer-side fetch finds nothing.

179
src/Support/XdebugGuard.php Normal file
View File

@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
/**
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
* behaviour of composer, phpstan, rector, psalm and pint.
*
* Xdebug imposes a 3050% runtime tax on every PHP process that loads it —
* even when nothing is actively tracing, profiling or breaking. Plain `pest`
* users might rely on Xdebug being loaded (IDE breakpoints, step-through
* debugging, custom tooling), so we intentionally leave non-TIA runs alone.
*
* The guard engages only when ALL of these hold:
* 1. `--tia` is present in argv.
* 2. No `--tia-rebuild` flag (forced record always drives the coverage
* driver; dropping Xdebug would break the recording).
* 3. No `--coverage*` flag (coverage runs need the driver regardless).
* 4. A valid graph already exists on disk AND its structural fingerprint
* matches the current environment — i.e. TIA will replay rather than
* record. Record runs need the driver.
* 5. Xdebug's configured mode is either empty or exactly `['coverage']`.
* Any other mode (debug, develop, trace, profile, gcstats) signals the
* user wants Xdebug for reasons unrelated to coverage, so we leave it
* alone even on replay.
*
* `PEST_ALLOW_XDEBUG=1` remains an explicit manual override; it is honoured
* natively by `composer/xdebug-handler`.
*
* @internal
*/
final class XdebugGuard
{
/**
* Call as early as possible after composer autoload, before any Pest
* class beyond the autoloader is touched. Safe when Xdebug is not
* loaded (returns immediately) and when `composer/xdebug-handler` is
* unavailable (defensive `class_exists` check).
*/
public static function maybeDrop(string $projectRoot): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! self::xdebugIsCoverageOnly()) {
return;
}
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
if (! self::runLooksDroppable($argv, $projectRoot)) {
return;
}
(new XdebugHandler('pest'))->check();
}
/**
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
* for older Xdebug without `xdebug_info` — be conservative and leave it
* loaded; we can't prove the mode is safe to drop.
*/
private static function xdebugIsCoverageOnly(): bool
{
if (! function_exists('xdebug_info')) {
return false;
}
$modes = @xdebug_info('mode');
if (! is_array($modes)) {
return false;
}
$modes = array_values(array_filter($modes, is_string(...)));
if ($modes === []) {
return true;
}
return $modes === ['coverage'];
}
/**
* Encodes the argv-based rules: `--tia` must be present, no coverage
* flag, no forced rebuild, and TIA must be about to replay rather than
* record. Plain `pest` (and anything else without `--tia`) keeps Xdebug
* loaded so non-TIA users aren't surprised by behaviour changes.
*
* @param array<int, mixed> $argv
*/
private static function runLooksDroppable(array $argv, string $projectRoot): bool
{
$hasTia = false;
foreach ($argv as $value) {
if (! is_string($value)) {
continue;
}
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--tia-rebuild') {
return false;
}
if ($value === '--tia') {
$hasTia = true;
}
}
if (! $hasTia) {
return false;
}
return self::tiaWillReplay($projectRoot);
}
/**
* True when a valid TIA graph already lives on disk AND its structural
* fingerprint matches the current environment. Any other outcome
* (missing graph, unreadable JSON, structural drift) means TIA will
* record and the driver must stay loaded.
*/
private static function tiaWillReplay(string $projectRoot): bool
{
$path = self::graphPath();
if (! is_file($path)) {
return false;
}
$json = @file_get_contents($path);
if ($json === false) {
return false;
}
$graph = Graph::decode($json, $projectRoot);
if (! $graph instanceof Graph) {
return false;
}
return Fingerprint::structuralMatches(
$graph->fingerprint(),
Fingerprint::compute($projectRoot),
);
}
/**
* On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()`
* so both writer and reader stay in sync without a runtime container
* lookup (the container isn't booted yet at this point).
*/
private static function graphPath(): string
{
return dirname(__DIR__, 2)
.DIRECTORY_SEPARATOR.'.temp'
.DIRECTORY_SEPARATOR.'tia'
.DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
}
}