Files
pest/src/Plugins/Tia.php
T
nuno maduro d112582857 wip
2026-08-05 19:58:57 +01:00

2356 lines
78 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Pest\Plugins;
use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\HandlesOriginalArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption;
use Pest\Exceptions\MissingDependency;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Exceptions\TiaRequiresRemote;
use Pest\Exceptions\TiaRequiresRepositoryRoot;
use Pest\Panic;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageCollector;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\JsModuleGraph;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\SourceScope;
use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* @internal
*/
final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArguments, Terminable
{
use HandleArguments;
private const string OPTION = '--tia';
private const string NO_OPTION = '--no-tia';
private const string FRESH_OPTION = '--fresh';
private const string REFETCH_OPTION = '--refetch';
private const string FILTERED_OPTION = '--filtered';
private const string LOCALLY_OPTION = '--locally';
private const string BASELINED_OPTION = '--baselined';
private const string BASELINE_PATH_OPTION = '--baseline';
/**
* Set by the mutation plugin on the subprocess running a single mutant,
* and nowhere else. Its own `--mutate` flag is popped before the argv is
* handed to that subprocess, so the flag cannot be matched instead.
*/
private const string ENV_MUTATION_TESTING = 'PEST_MUTATION_TESTING';
private const string ENV_TIA = 'PEST_TIA';
private const string ENV_FILTERED = 'PEST_TIA_FILTERED';
private const string ENV_LOCALLY = 'PEST_TIA_LOCALLY';
private const string ENV_BASELINED = 'PEST_TIA_BASELINED';
public const string KEY_GRAPH = 'graph.json';
public const string KEY_AFFECTED = 'affected.json';
private const string KEY_WORKER_EDGES_PREFIX = 'worker-edges-';
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-';
public const string KEY_COVERAGE_CACHE = 'coverage.bin.gz';
public const string KEY_COVERAGE_MARKER = 'coverage.marker';
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
private const string RECORDING_GLOBAL = 'TIA_RECORDING';
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
private const string FILTERED_GLOBAL = 'TIA_FILTERED';
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
/**
* The parent's resolved fallback branch, handed to the workers.
*
* A worker cannot resolve it for itself: the restarters run before
* `tests/Pest.php` is loaded, so a `defaultBranch()` declared there is
* invisible to it — and autodetecting again would spend a git call per
* worker to reach the answer the parent already has.
*/
private const string FALLBACK_BRANCH_GLOBAL = 'TIA_FALLBACK_BRANCH';
/**
* The branch assumed when a repository cannot name its own default.
*/
private const string DEFAULT_BRANCH = 'main';
/**
* PHPUnit/Pest CLI flags whose subsequent argument is a value, not a path.
*
* @var list<string>
*/
private const array VALUE_TAKING_FLAGS = [
'-c', '--configuration', '--bootstrap', '--cache-directory',
'--filter', '--group', '--exclude-group', '--covers', '--uses',
'--test-suffix', '--testsuite', '--exclude-testsuite',
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
'--include-path', '--whitelist',
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-openclover', '--coverage-php',
'--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
/**
* PHPUnit flags that make this run produce a coverage report.
*
* Pest's own `--coverage` is tracked by the Coverage plugin, but a raw
* PHPUnit report flag never reaches it. A run that reports coverage must
* not be narrowed to the affected tests — the report would then describe a
* subset of the suite — and must let PHPUnit own the coverage driver rather
* than have the TIA recorder clear it mid-collection.
*
* Flags that only shape collection or an existing report — `--coverage-filter`,
* `--path-coverage`, `--warm-coverage-cache`, `--only-summary-for-coverage-text`,
* `--show-uncovered-for-coverage-text`, `--disable-coverage-ignore` — produce no
* report on their own, so they are deliberately absent.
*
* @var list<string>
*/
private const array COVERAGE_REPORT_FLAGS = [
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-openclover', '--coverage-php',
'--coverage-text', '--coverage-xml',
];
/**
* Flags that narrow this run to a subset of the suite.
*
* Only user-supplied, per-run narrowing belongs here. A filter that is
* always in force — `<groups>` in phpunit.xml, or a plugin registering a
* test case filter from `boot()` — applies equally to the runs that build
* the baseline, so it does not make this run narrower than the baseline
* and must not disable baseline writes.
*
* `--shard` is rewritten to `--filter` before this plugin sees the
* arguments, so it is covered here too. The `bin/pest`-only flags are
* stripped from the handled arguments, so they are matched against the
* original argv instead.
*
* Flags that cut a run short instead of narrowing it — `--bail`, `--retry`,
* `--stop-on-*` — do not belong here either. They only narrow the run when
* something actually fails, and that is not known until it is over, so they
* are handled by stoppedEarly() from addOutput().
*
* @var list<string>
*/
private const array PARTIAL_SELECTION_FLAGS = [
'--filter', '--exclude-filter', '--group', '--exclude-group',
'--covers', '--uses', '--testsuite', '--exclude-testsuite', '--test-suffix',
'--dirty', '--todo', '--todos', '--flaky', '--notes',
'--assignee', '--issue', '--ticket', '--pr', '--pull-request',
];
/**
* Options that cannot be combined with Tia mode.
*
* `--covers` and `--uses` select on coverage metadata Tia does not model,
* so they resolve to no tests at all rather than to the ones the user meant.
* `--random-order-seed` exits non-zero on its own, with or without Tia.
* Either way the run cannot honour both things it was asked for, so it says
* so instead of silently dropping Tia and running something else.
*/
private const array UNSUPPORTED_OPTIONS = [
'--covers', '--uses', '--random-order-seed',
];
private bool $graphWritten = false;
private bool $replayRan = false;
private int $replayedCount = 0;
private int $affectedCount = 0;
private int $executedCount = 0;
/** @var array<string, int> */
private array $cachedAssertionsByTestId = [];
/**
* Recorded durations of the tests this run replayed rather than executed.
*
* A replayed test never runs, so the duration PHPUnit reports for it is the
* cost of replaying it — near zero. Writing that back would decay every
* cached timing toward zero one run at a time.
*
* @var array<string, float>
*/
private array $cachedTimeByTestId = [];
private ?Graph $replayGraph = null;
/**
* The baseline this run reads from and writes to.
*
* The repository's default branch is only the fallback for a checkout whose
* branch cannot be read — a detached HEAD. It is also the branch every
* other baseline falls back to reading, so writing there by accident
* corrupts the shared baseline. Resolved through resolveBranch() rather
* than at every use site, because the git call it needs is not free.
*/
private string $branch = self::DEFAULT_BRANCH;
/**
* The baseline branches with none of their own read from.
*
* Read-only, and the whole point of the exercise: without it the first run
* on every new branch re-runs a suite whose results the default branch
* already holds.
*/
private string $fallbackBranch = self::DEFAULT_BRANCH;
private bool $branchResolved = false;
/** @var array<string, true> */
private array $affectedFiles = [];
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
private ?array $startFingerprint = null;
private bool $piggybackCoverage = false;
private bool $recordingActive = false;
private bool $forceRefetch = false;
private bool $baselineFetchAttemptedForDrift = false;
private bool $freshRebuild = false;
private bool $filteredMode = false;
/**
* Bars this run from touching the graph at all, results included.
*
* Reserved for runs whose results describe something other than the code
* in the working tree, which is nothing the baseline can ever use.
*/
private bool $writesSuppressed = false;
/**
* Narrows this run's writes to the results of the tests it actually ran.
*
* A run that covered only part of the suite still learns something true
* about the tests it did reach. What it cannot do is speak for the rest:
* pruning results, advancing the recorded sha and replacing the edge map
* all claim the whole suite reported, so they stay behind a complete run.
*/
private bool $resultsOnlyWrites = false;
/** @var array<int, string> */
private array $originalArguments = [];
private ?string $driftLabel = null;
private ?string $driftDetails = null;
private ?string $freshGraphReason = null;
public function __construct(
private readonly OutputInterface $output,
private readonly Recorder $recorder,
private readonly CoverageCollector $coverageCollector,
private readonly WatchPatterns $watchPatterns,
private readonly State $state,
private readonly BaselineSync $baselineSync,
) {}
private function renderBadge(string $type, string $content): void
{
View::render('components.badge', ['type' => $type, 'content' => $content]);
}
private function renderChild(string $text): void
{
$this->output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
/**
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/
private function structuralFingerprintShifted(array $current): bool
{
assert($this->startFingerprint !== null);
return ! Fingerprint::structuralMatches($this->startFingerprint, $current);
}
private function loadGraph(string $projectRoot): ?Graph
{
$json = $this->state->read(self::KEY_GRAPH);
if ($json === null) {
return null;
}
$graph = Graph::decode($json, $projectRoot);
// Every read of a baseline goes through a graph loaded here, so this is
// the one place the resolved fallback has to reach.
$graph?->setFallbackBranch($this->fallbackBranch);
return $graph;
}
private function saveGraph(Graph $graph): bool
{
$json = $graph->encode();
if ($json === null) {
return false;
}
return $this->state->write(self::KEY_GRAPH, $json);
}
/**
* @param array<int, string> $arguments
*/
public static function isEnabledForRun(array $arguments): bool
{
if (self::argumentPresent(self::NO_OPTION, $arguments)) {
return false;
}
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
assert($watchPatterns instanceof WatchPatterns);
self::applyWatchPatternMarks($arguments, $watchPatterns);
if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) {
return true;
}
if (! $watchPatterns->isEnabled()) {
return false;
}
if (! $watchPatterns->isLocally()) {
return true;
}
return ! self::argumentPresent('--ci', $arguments);
}
/**
* Whether the workers of this run record their own coverage edges.
*
* Stamped by the parent before paratest spawns anything, because a worker
* cannot tell on its own: its argv carries no `--tia`, and the restarters
* run before `tests/Pest.php` is loaded, so {@see self::isEnabledForRun()}
* sees an empty {@see WatchPatterns} too. Left unanswered, pcov keeps its
* default scope — a single auto-detected source directory — and every edge
* outside it, test self-edges included, is silently dropped.
*
* Piggyback runs are excluded: their edges come from PHPUnit's own coverage
* session, so widening pcov there costs time and buys nothing.
*/
public static function recordsEdgesInWorkers(): bool
{
return (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'
&& (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) !== '1';
}
/**
* @param array<int, string> $arguments
*/
private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
{
if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) {
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
}
if (self::argumentPresent(self::BASELINED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_BASELINED)) {
$watchPatterns->markBaselined();
}
}
/**
* Mirrors {@see HandleArguments::hasArgument()} for
* use from static contexts — matches both `--flag` and `--flag=value`.
*
* @param array<int, string> $arguments
*/
private static function argumentPresent(string $argument, array $arguments): bool
{
foreach ($arguments as $arg) {
if ($arg === $argument) {
return true;
}
if (str_starts_with((string) $arg, "$argument=")) { // @phpstan-ignore-line
return true;
}
}
return false;
}
private static function envFlagEnabled(string $name): bool
{
return filter_var(getenv($name), FILTER_VALIDATE_BOOL);
}
public function getStatus(string $filename, string $testId): ?TestStatus
{
if (! $this->replayGraph instanceof Graph) {
return null;
}
$projectRoot = TestSuite::getInstance()->rootPath;
$real = @realpath($filename);
$rel = $real !== false
? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR)))
: null;
if ($rel !== null && isset($this->affectedFiles[$rel])) {
$this->affectedCount++;
$this->executedCount++;
return null;
}
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
$this->executedCount++;
return null;
}
$result = $this->replayGraph->getResult($this->branch, $testId);
if ($result instanceof TestStatus) {
if ($this->replayGraph->shouldRerunStatus($result)) {
$this->executedCount++;
return null;
}
$this->replayedCount++;
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
$time = $this->replayGraph->getTime($this->branch, $testId);
if ($time !== null) {
$this->cachedTimeByTestId[$testId] = $time;
}
} else {
$this->executedCount++;
}
return $result;
}
public function getAssertionCount(string $testId): int
{
return $this->cachedAssertionsByTestId[$testId] ?? 0;
}
/**
* {@inheritDoc}
*/
public function handleOriginalArguments(array $arguments): void
{
$this->originalArguments = $arguments;
}
/**
* {@inheritDoc}
*/
public function handleArguments(array $arguments): array
{
if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) {
$this->output->writeln(Storage::tempDir(TestSuite::getInstance()->rootPath));
exit(0);
}
$isWorker = Parallel::isWorker();
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
self::applyWatchPatternMarks($arguments, $watchPatterns);
$disabled = $this->hasArgument(self::NO_OPTION, $arguments);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA);
$alwaysEnabled = $watchPatterns->isEnabled()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
if (! $isWorker && ! $disabled && ($cliEnabled || $alwaysEnabled)) {
$this->guardUnsupportedOptions($arguments);
}
$hasExplicitPath = $this->hasExplicitPathArgument($arguments);
$partial = ! $isWorker && ($hasExplicitPath || $this->hasPartialSelection($arguments));
$disabled = $disabled || $partial;
// A mutation subprocess runs the suite against source the mutation
// plugin has deliberately broken. Its failures describe the mutant, not
// the working tree, so unlike every other narrowed run there is nothing
// in its results worth keeping. The parent `--mutate` run is untouched
// by this: it runs the whole suite against real source.
if (getenv(self::ENV_MUTATION_TESTING) !== false) {
$this->writesSuppressed = true;
}
$enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
&& ! $hasExplicitPath
&& ! $this->coverageReportActive();
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::NO_OPTION, $arguments);
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::FILTERED_OPTION, $arguments);
$arguments = $this->popArgument(self::LOCALLY_OPTION, $arguments);
$arguments = $this->popArgument(self::BASELINED_OPTION, $arguments);
if ($disabled) {
if ($partial) {
// TIA cannot choose what runs here — the user already did — but
// the tests they picked still report honestly, so their results
// are kept and everything that would speak for the excluded ones
// is not. `--no-tia` needs none of this: it still runs the whole
// suite, so it remains a complete run.
$this->resultsOnlyWrites = true;
// `$this->filteredMode` counts as asking for it: reaching here
// means the narrowing came from the command line while filtered
// mode came from the environment or the config, and a run that
// silently declines what the config asked for is the one most
// in need of the explanation.
if ($cliEnabled || $freshRequested || $this->forceRefetch || $this->filteredMode) {
$this->output->writeln('');
$this->renderChild('TIA does not apply to partial runs — running the selected tests directly.');
}
}
$this->forceRefetch = false;
$this->filteredMode = false;
$this->freshRebuild = false;
return $arguments;
}
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild;
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
return $arguments;
}
$this->piggybackCoverage = $isWorker
? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1'
: $this->coverageReportActive();
$projectRoot = TestSuite::getInstance()->rootPath;
if ($isWorker) {
return $this->handleWorker($arguments, $projectRoot, $recordingGlobal, $replayingGlobal);
}
return $this->handleParent($arguments, $projectRoot, $forceRebuild);
}
public function terminate(): void
{
if ($this->graphWritten) {
return;
}
if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) {
$this->flushWorkerReplay();
}
// Both only ever set for the parent — addOutput() returns early in
// workers, whose partials are ephemeral and only reach the baseline if
// the parent consumes them. Everything this method goes on to write is
// whole-suite by nature — the edge map above all — so a narrowed run
// stops here too, its results already persisted by addOutput().
if ($this->writesSuppressed || $this->resultsOnlyWrites) {
$this->recorder->reset();
$this->coverageCollector->reset();
return;
}
$recorder = $this->recorder;
if (! $this->recordingActive && ! $recorder->isActive()) {
return;
}
$this->graphWritten = true;
$projectRoot = TestSuite::getInstance()->rootPath;
$perTest = $this->piggybackCoverage
? $this->mergePerTestFiles($this->coverageCollector->perTestFiles(), $recorder->perTestFiles())
: $recorder->perTestFiles();
if ($perTest === []) {
$recorder->reset();
$this->coverageCollector->reset();
return;
}
$perTestTables = $recorder->perTestTables();
$perTestInertia = $recorder->perTestInertiaComponents();
$perTestUsesDatabase = $recorder->perTestUsesDatabase();
if ($perTestUsesDatabase !== []) {
$perTestTables = $this->augmentDatabaseTestTables(
$perTestTables,
$perTestUsesDatabase,
$projectRoot,
);
}
if (Parallel::isWorker()) {
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
$recorder->reset();
$this->coverageCollector->reset();
return;
}
$changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha();
$currentFingerprint = Fingerprint::compute($projectRoot);
if ($this->structuralFingerprintShifted($currentFingerprint)) {
$this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.');
$this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.');
$recorder->reset();
$this->coverageCollector->reset();
return;
}
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint($currentFingerprint);
$graph->setRecordedAtSha($this->branch, $currentSha);
$graph->setLastRunTree(
$this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
);
$graph->replaceEdges($perTest, keepExisting: $this->piggybackCoverage);
$graph->replaceTestTables($perTestTables);
$graph->replaceTestInertiaComponents($perTestInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
if ($this->freshRebuild) {
$graph->pruneMissingTests();
}
$this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) {
$this->renderBadge('ERROR', 'Could not write the dependency graph.');
$recorder->reset();
return;
}
$recorder->reset();
$this->coverageCollector->reset();
}
public function addOutput(int $exitCode): int
{
if (Parallel::isWorker()) {
return $exitCode;
}
// `->only()` narrows the executed set exactly like `--filter` does, but
// is only knowable once the suite has been collected — too late to turn
// TIA off up front. Sampled in addOutput() because Only's lock file is
// already gone by the time terminate() runs (its plugin terminates
// first). Whether the run was cut short is likewise only knowable now.
if (Only::isEnabled() || $this->stoppedEarly()) {
$this->resultsOnlyWrites = true;
}
$this->reportMissingWorkerDrivers();
// Runs before the checks below: it is what fills the parent's result
// collector in parallel, and a worker that stopped early narrows the
// whole run.
if (Parallel::isEnabled()) {
$this->mergeWorkerReplayPartials();
}
if ($this->writesSuppressed) {
return $exitCode;
}
if ($this->resultsOnlyWrites) {
$this->snapshotTestResults(complete: false);
return $exitCode;
}
if ($this->replayRan) {
$this->bumpRecordedSha();
}
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
$this->snapshotTestResults();
return $exitCode;
}
$projectRoot = TestSuite::getInstance()->rootPath;
$partialKeys = $this->collectWorkerEdgesPartials();
if ($partialKeys === []) {
if ($this->replayRan) {
$this->snapshotTestResults();
}
return $exitCode;
}
$changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha();
$currentFingerprint = Fingerprint::compute($projectRoot);
if ($this->structuralFingerprintShifted($currentFingerprint)) {
$this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.');
$this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.');
foreach ($partialKeys as $key) {
$this->state->delete($key);
}
return $exitCode;
}
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint($currentFingerprint);
$graph->setRecordedAtSha($this->branch, $currentSha);
$graph->setLastRunTree(
$this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
);
[$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys);
if ($finalised === []) {
if ($this->replayRan) {
$this->snapshotTestResults();
return $exitCode;
}
$this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.');
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.');
return $exitCode;
}
$graph->replaceEdges($finalised, keepExisting: $this->piggybackCoverage);
$graph->replaceTestTables($finalisedTables);
$graph->replaceTestInertiaComponents($finalisedInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
if ($this->freshRebuild) {
$graph->pruneMissingTests();
}
if (! $this->saveGraph($graph)) {
$this->renderBadge('ERROR', 'Could not write the dependency graph.');
return $exitCode;
}
$this->snapshotTestResults(markKnownTestFiles: true);
return $exitCode;
}
/**
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/
private function reconcileFingerprint(Graph $graph, array $current): ?Graph
{
$stored = $graph->fingerprint();
if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current);
$this->driftLabel = $this->formatStructuralDrift($drift);
if (in_array('composer_lock', $drift, true)) {
$branchSha = $graph->recordedAtSha($this->branch);
if ($branchSha !== null) {
$summary = $this->composerLockDelta(
TestSuite::getInstance()->rootPath,
$branchSha,
);
if ($summary !== '') {
$this->driftDetails = $summary;
}
}
}
$rebuilt = $this->tryRemoteBaselineForDrift($current);
if ($rebuilt instanceof Graph) {
return $this->reconcileFingerprint($rebuilt, $current);
}
$this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE);
return null;
}
$drift = Fingerprint::environmentalDrift($stored, $current);
if ($drift !== []) {
$this->renderBadge('WARN', sprintf(
'Env differs from baseline (%s) — results dropped, edges reused.',
implode(', ', $drift),
));
$graph->clearResults($this->branch);
$graph->setFingerprint($current);
$this->saveGraph($graph);
$this->state->delete(self::KEY_COVERAGE_CACHE);
}
return $graph;
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
{
$this->watchPatterns->useDefaults($projectRoot);
$subdirectoryPrefix = $this->gitSubdirectoryPrefix($projectRoot);
if ($subdirectoryPrefix !== null) {
Panic::with(new TiaRequiresRepositoryRoot($subdirectoryPrefix));
}
$this->resolveBranch($projectRoot);
// After resolveBranch(), so a directory that is no repository at all
// still reports the missing git dependency rather than a missing remote.
// Skipped once the default branch is configured by hand: there is then
// nothing left for a remote to answer.
if ($this->watchPatterns->defaultBranch() === null && ! new ChangedFiles($projectRoot)->hasRemote()) {
Panic::with(new TiaRequiresRemote);
}
$fingerprint = Fingerprint::compute($projectRoot);
$this->startFingerprint = $fingerprint;
if ($forceRebuild) {
Storage::purge($projectRoot);
}
$graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot);
if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint);
}
if ($graph instanceof Graph) {
$changedFiles = new ChangedFiles($projectRoot);
$branchSha = $graph->recordedAtSha($this->branch);
if ($branchSha !== null
&& $changedFiles->since($branchSha) === null) {
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
$graph = null;
}
}
if (! $graph instanceof Graph
&& ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift
&& $this->watchPatterns->isBaselined()
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
$this->baselineFetchAttemptedForDrift = true;
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint);
}
}
// Both of these belong to the coverage cache, which only Pest's own
// `--coverage` ever writes or merges. A raw PHPUnit report flag takes
// the piggyback path — it must not drive the driver itself — but must
// not leave a marker behind, nor force a recording run to prime a cache
// that nothing on its path will fill.
$coverageCacheOwned = $this->piggybackCoverage && $this->pestCoverageActive();
if ($coverageCacheOwned) {
$this->state->write(self::KEY_COVERAGE_MARKER, '');
}
if ($coverageCacheOwned && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
if ($graph instanceof Graph && $this->driftLabel === null) {
$this->freshGraphReason = 'recording a coverage baseline';
}
return $this->enterRecordMode($arguments);
}
if ($graph instanceof Graph) {
return $this->enterReplayMode($graph, $projectRoot, $arguments);
}
return $this->enterRecordMode($arguments);
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
{
$this->resolveBranch($projectRoot);
if ($replayingGlobal) {
$this->installWorkerReplay($projectRoot);
if ($recordingGlobal) {
return $this->activateWorkerRecorderForReplay($arguments);
}
return $arguments;
}
if (! $recordingGlobal) {
return $arguments;
}
if ($this->piggybackCoverage) {
$this->recorder->activateLinkTracking();
$this->recordingActive = true;
return $arguments;
}
$recorder = $this->recorder;
if (! $recorder->driverAvailable()) {
$this->state->write(
self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json',
'{}',
);
return $arguments;
}
$recorder->activate();
$this->recordingActive = true;
return $arguments;
}
private function installWorkerReplay(string $projectRoot): void
{
$graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) {
return;
}
$raw = $this->state->read(self::KEY_AFFECTED);
if ($raw === null) {
return;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return;
}
$affectedSet = [];
foreach ($decoded as $rel) {
if (is_string($rel)) {
$affectedSet[$rel] = true;
}
}
$this->replayGraph = $graph;
$this->affectedFiles = $affectedSet;
if ((string) Parallel::getGlobal(self::FILTERED_GLOBAL) === '1') {
TestSuite::getInstance()->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
}
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function activateWorkerRecorderForReplay(array $arguments): array
{
if ($this->piggybackCoverage) {
$this->recorder->activateLinkTracking();
$this->recordingActive = true;
return $arguments;
}
$recorder = $this->recorder;
if (! $recorder->driverAvailable()) {
$this->state->write(
self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json',
'{}',
);
return $arguments;
}
$recorder->activate();
$this->recordingActive = true;
return $arguments;
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function enterReplayMode(Graph $graph, string $projectRoot, array $arguments): array
{
$changedFiles = new ChangedFiles($projectRoot);
$branchSha = $graph->recordedAtSha($this->branch);
$changed = $changedFiles->since($branchSha) ?? [];
$changed = $changedFiles->filterUnchangedSinceLastRun(
$changed,
$graph->lastRunTree($this->branch),
);
$hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed);
$coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
$this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.');
$this->renderChild('Running the full suite to avoid using a stale dependency graph.');
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
return $arguments;
}
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
$rerunFromCache = [];
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
$this->filteredMode = false;
$this->renderBadge('WARN', 'Some cached tests due a re-run could not be located on disk.');
$this->renderChild('Running the full suite with replay instead of a filtered run.');
}
if ($this->filteredMode) {
$rerunFromCache = $graph->testFilesToRerun($this->branch);
}
$affected = array_values(array_unique([
...$affectedFromChanges,
...$rerunFromCache,
]));
$this->reportAffectedSummary($changed, $affectedFromChanges, $rerunFromCache, $affected);
$affectedSet = array_fill_keys($affected, true);
$canRefreshReplayEdges = $affected !== [] && $coverageAvailable;
$this->replayRan = true;
$this->replayGraph = $graph;
$this->affectedFiles = $affectedSet;
$this->registerRecap();
if ($this->filteredMode) {
if ($affected === []) {
Panic::with(new NoAffectedTestsFound);
}
TestSuite::getInstance()->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
}
if (! Parallel::isEnabled()) {
if ($canRefreshReplayEdges) {
// Piggyback runs read PHPUnit's own coverage session. Driving
// the driver alongside it would clear the data PHPUnit is about
// to read, so only link tracking may run here.
if ($this->piggybackCoverage) {
$this->recorder->activateLinkTracking();
} else {
$this->recorder->activate();
}
$this->recordingActive = true;
}
return $arguments;
}
if (! $this->persistAffectedSet($affected)) {
$this->renderBadge('ERROR', 'Could not persist affected set — running full suite.');
return $arguments;
}
$this->purgeWorkerPartials();
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
if ($canRefreshReplayEdges) {
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
if ($this->piggybackCoverage) {
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
}
}
if ($this->filteredMode) {
Parallel::setGlobal(self::FILTERED_GLOBAL, '1');
}
return $arguments;
}
/**
* @param array<int, string> $changedFiles
* @param array<int, string> $affectedFromChanges
* @param array<int, string> $rerunFromCache
* @param array<int, string> $affected
*/
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $rerunFromCache, array $affected): void
{
$this->output->writeln('');
if ($affected === []) {
$this->renderChild('Experimental TIA mode enabled.');
return;
}
$newReruns = $rerunFromCache === []
? 0
: count(array_diff($rerunFromCache, $affectedFromChanges));
$reasons = [];
$singleReason = (int) ($affectedFromChanges !== []) + (int) ($newReruns > 0) === 1;
if ($affectedFromChanges !== []) {
$reasons[] = $singleReason
? sprintf(
'from %d changed file%s',
count($changedFiles),
count($changedFiles) === 1 ? '' : 's',
)
: sprintf(
'%d from %d changed file%s',
count($affectedFromChanges),
count($changedFiles),
count($changedFiles) === 1 ? '' : 's',
);
}
if ($newReruns > 0) {
$reasons[] = $singleReason
? sprintf(
'from %d previously unsuccessful test%s',
$newReruns,
$newReruns === 1 ? '' : 's',
)
: sprintf(
'%d from previously unsuccessful test%s',
$newReruns,
$newReruns === 1 ? '' : 's',
);
}
$this->renderChild(sprintf(
'Experimental TIA mode enabled / %d affected test file%s%s.',
count($affected),
count($affected) === 1 ? '' : 's',
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
));
$sorted = $affected;
sort($sorted);
$previewLimit = $this->output->isVerbose() ? count($sorted) : 10;
$preview = array_slice($sorted, 0, $previewLimit);
foreach ($preview as $file) {
$this->output->writeln(sprintf(' <fg=gray>%s</>', $file));
}
$remainder = count($sorted) - count($preview);
if ($remainder > 0) {
$this->output->writeln(sprintf(' <fg=gray>… +%d more</>', $remainder));
}
}
/**
* @param array<int, string> $affected Project-relative paths.
*/
private function persistAffectedSet(array $affected): bool
{
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
if ($json === false) {
return false;
}
return $this->state->write(self::KEY_AFFECTED, $json);
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
*/
private function enterRecordMode(array $arguments): array
{
$recorder = $this->recorder;
if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) {
$this->emitCoverageDriverMissing();
return $arguments;
}
if (Parallel::isEnabled()) {
$this->purgeWorkerPartials();
Parallel::setGlobal(self::RECORDING_GLOBAL, '1');
if ($this->piggybackCoverage) {
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
}
$this->output->writeln('');
$this->renderFreshGraph();
return $arguments;
}
if ($this->piggybackCoverage) {
$recorder->activateLinkTracking();
$this->recordingActive = true;
$this->output->writeln('');
$this->renderFreshGraph();
return $arguments;
}
$recorder->activate();
$this->recordingActive = true;
// Why this run is rebuilding is worth saying whenever there is a reason
// for it — the parallel and piggyback branches above already do. Runs
// that are simply recording for the first time have nothing to explain.
if ($this->driftLabel !== null || $this->freshGraphReason !== null) {
$this->output->writeln('');
$this->renderFreshGraph();
return $arguments;
}
$this->renderChild('Running in TIA mode.');
return $arguments;
}
private function renderFreshGraph(): void
{
if ($this->driftLabel === null && $this->freshGraphReason !== null) {
// The reason is only ever set for a run that keeps its graph and
// records alongside it, so "fresh graph" would be a lie here.
$headline = sprintf('Experimental TIA mode enabled / %s.', $this->freshGraphReason);
} else {
$headline = 'Experimental TIA mode enabled / fresh graph';
if ($this->driftLabel !== null) {
$headline .= sprintf(' (%s changed)', $this->driftLabel);
} else {
$headline .= '.';
}
}
$this->renderChild($headline);
if ($this->driftDetails !== null) {
foreach (explode(', ', $this->driftDetails) as $detail) {
$this->output->writeln(sprintf(' <fg=gray>%s</>', $detail));
}
}
}
private function emitCoverageDriverMissing(): void
{
$this->output->writeln('');
$this->renderChild('Running in TIA mode, however TIA is skipped as it needs ext-pcov or Xdebug.');
}
/**
* @param array<string, array<int, string>> $perTestFiles
* @param array<string, array<int, string>> $perTestTables
* @param array<string, array<int, string>> $perTestInertiaComponents
*/
private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void
{
$json = json_encode([
'files' => $perTestFiles,
'tables' => $perTestTables,
'inertia' => $perTestInertiaComponents,
], JSON_UNESCAPED_SLASHES);
if ($json === false) {
return;
}
$this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json);
}
/**
* @return list<string>
*/
private function collectWorkerEdgesPartials(): array
{
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
}
private function reportMissingWorkerDrivers(): void
{
$keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX);
if ($keys === []) {
return;
}
foreach ($keys as $key) {
$this->state->delete($key);
}
$this->renderBadge('WARN', sprintf(
'%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
count($keys),
));
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
}
private function purgeWorkerPartials(): void
{
foreach ($this->collectWorkerEdgesPartials() as $key) {
$this->state->delete($key);
}
foreach ($this->collectWorkerReplayPartials() as $key) {
$this->state->delete($key);
}
}
private function flushWorkerReplay(): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
$results = $collector->all();
if ($results === [] && $this->replayedCount === 0 && $this->affectedCount === 0 && $this->executedCount === 0) {
return;
}
// A replayed result carries the duration PHPUnit measured for a test
// that never ran — near zero. Unlike the assertion count, which the
// replay injects into the result itself, the cached duration lives only
// in this process: the parent replayed nothing of its own, so once the
// partial is written the real value is unrecoverable. Launder it here
// and the parent's verbatim read is correct by construction.
foreach ($results as $testId => $result) {
$results[$testId]['time'] = $this->resultTime($testId, $result['time']);
}
$json = json_encode([
'results' => $results,
'replayed' => $this->replayedCount,
'affected' => $this->affectedCount,
'executed' => $this->executedCount,
// Only the worker knows it stopped early — the parent runs no tests
// of its own, so its own check would always come back clean.
'truncated' => $this->stoppedEarly(),
], JSON_UNESCAPED_SLASHES);
if ($json === false) {
return;
}
$this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json);
}
/**
* @return list<string>
*/
private function collectWorkerReplayPartials(): array
{
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
}
private function mergeWorkerReplayPartials(): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
foreach ($this->collectWorkerReplayPartials() as $key) {
$raw = $this->state->read($key);
$this->state->delete($key);
if ($raw === null) {
continue;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
continue;
}
// One worker stopping early leaves the whole suite incomplete: the
// tests it never reached are missing from the merged result set just
// as if they had been filtered out.
if (($decoded['truncated'] ?? false) === true) {
$this->resultsOnlyWrites = true;
}
if (isset($decoded['replayed']) && is_int($decoded['replayed'])) {
$this->replayedCount += $decoded['replayed'];
}
if (isset($decoded['affected']) && is_int($decoded['affected'])) {
$this->affectedCount += $decoded['affected'];
}
if (isset($decoded['executed']) && is_int($decoded['executed'])) {
$this->executedCount += $decoded['executed'];
}
if (isset($decoded['results']) && is_array($decoded['results'])) {
$normalised = [];
/** @var mixed $result */
foreach ($decoded['results'] as $testId => $result) {
if (! is_string($testId)) {
continue;
}
if (! is_array($result)) {
continue;
}
$normalised[$testId] = [
'status' => is_int($result['status'] ?? null) ? $result['status'] : 0,
'message' => is_string($result['message'] ?? null) ? $result['message'] : '',
'time' => is_float($result['time'] ?? null) || is_int($result['time'] ?? null) ? (float) $result['time'] : 0.0,
'assertions' => is_int($result['assertions'] ?? null) ? $result['assertions'] : 0,
];
if (isset($result['file']) && is_string($result['file'])) {
$normalised[$testId]['file'] = $result['file'];
}
}
if ($normalised !== []) {
$collector->merge($normalised);
}
}
}
}
private function workerToken(): string
{
$raw = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? null;
$token = is_scalar($raw) ? (string) $raw : (string) getmypid();
$token = preg_replace('/[^A-Za-z0-9_-]/', '', $token);
if ($token === null || $token === '') {
return (string) getmypid();
}
return $token;
}
/**
* @param list<string> $partialKeys
* @return array{0: array<string, list<string>>, 1: array<string, list<string>>, 2: array<string, list<string>>}
*/
private function consumePartials(array $partialKeys): array
{
$merged = ['files' => [], 'tables' => [], 'inertia' => []];
foreach ($partialKeys as $key) {
$data = $this->readPartial($key);
if ($data === null) {
$this->state->delete($key);
continue;
}
foreach (['files', 'tables', 'inertia'] as $section) {
foreach ($data[$section] as $testFile => $values) {
if (! isset($merged[$section][$testFile])) {
$merged[$section][$testFile] = [];
}
foreach ($values as $value) {
$merged[$section][$testFile][$value] = true;
}
}
}
$this->state->delete($key);
}
return [
array_map(array_keys(...), $merged['files']),
array_map(array_keys(...), $merged['tables']),
array_map(array_keys(...), $merged['inertia']),
];
}
/**
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
*/
private function readPartial(string $key): ?array
{
$raw = $this->state->read($key);
if ($raw === null) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data)) {
return null;
}
$filesSource = is_array($data['files'] ?? null) ? $data['files'] : [];
$tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : [];
$inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : [];
return [
'files' => $this->cleanPartialSection($filesSource),
'tables' => $this->cleanPartialSection($tablesSource),
'inertia' => $this->cleanPartialSection($inertiaSource),
];
}
/**
* @param array<mixed, mixed> $section
* @return array<string, array<int, string>>
*/
private function cleanPartialSection(array $section): array
{
$out = [];
foreach ($section as $test => $items) {
if (! is_string($test)) {
continue;
}
if (! is_array($items)) {
continue;
}
$clean = [];
foreach ($items as $item) {
if (is_string($item)) {
$clean[] = $item;
}
}
$out[$test] = $clean;
}
return $out;
}
private function registerRecap(): void
{
DefaultPrinter::addRecap(function (): string {
if (Parallel::isEnabled() && ! Parallel::isWorker()) {
$this->mergeWorkerReplayPartials();
}
$fragments = [];
if ($this->affectedCount > 0) {
$fragments[] = $this->affectedCount.' affected';
}
$uncachedCount = max(0, $this->executedCount - $this->affectedCount);
if ($uncachedCount > 0) {
$fragments[] = $uncachedCount.' uncached';
}
if ($this->replayedCount > 0) {
$fragments[] = $this->replayedCount.' replayed';
}
return implode(', ', $fragments);
});
}
private function bumpRecordedSha(): void
{
$projectRoot = TestSuite::getInstance()->rootPath;
$graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) {
return;
}
$changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha();
if ($currentSha !== null) {
$graph->setRecordedAtSha($this->branch, $currentSha);
}
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
$this->saveGraph($graph);
}
/**
* Union of two per-test edge maps — piggybacked line-coverage edges plus
* the recorder's link-tracked edges (rendered Blade views, ...), which
* never appear in line coverage.
*
* @param array<string, array<int, string>> $coverage
* @param array<string, array<int, string>> $linked
* @return array<string, array<int, string>>
*/
private function mergePerTestFiles(array $coverage, array $linked): array
{
foreach ($linked as $testFile => $sources) {
$existing = $coverage[$testFile] ?? [];
$coverage[$testFile] = array_values(array_unique([...$existing, ...$sources]));
}
return $coverage;
}
/**
* The duration to record for a test: its own, unless it was replayed rather
* than executed, in which case the duration it was recorded with stands.
*/
private function resultTime(string $testId, float $time): float
{
return $this->cachedTimeByTestId[$testId] ?? $time;
}
private function seedResultsInto(Graph $graph): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
$results = $collector->all();
$touchedFiles = [];
foreach ($results as $testId => $result) {
$file = $result['file'] ?? null;
if ($file === null || str_contains($file, "eval()'d")) {
$file = $this->resolveFailedTestFile($testId);
}
if (is_string($file) && $file !== '') {
$touchedFiles[$file] = true;
}
$graph->setResult(
$this->branch,
$testId,
$result['status'],
$result['message'],
$this->resultTime($testId, $result['time']),
$result['assertions'],
$file,
);
}
$graph->markKnownTestFiles(array_keys($touchedFiles));
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
$collector->reset();
}
/**
* Folds the run's results into the existing graph.
*
* An incomplete run passes `$complete: false`, which keeps the additive
* half — the results of the tests it did run — and drops the half that
* speaks for the suite as a whole.
*/
private function snapshotTestResults(bool $markKnownTestFiles = false, bool $complete = true): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
$results = $collector->all();
if ($results === []) {
return;
}
$projectRoot = TestSuite::getInstance()->rootPath;
$graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) {
return;
}
try {
$this->resolveBranch($projectRoot);
} catch (MissingDependency) {
// This run never asked for TIA, so a missing git must not turn it
// into a failure the way it does on the TIA path. Writing to the
// fallback baseline is the lesser of the two evils.
}
// The graph above was loaded before the branch was known — this path
// only writes, but a graph carrying an unresolved fallback is the exact
// bug this whole change is about.
$graph->setFallbackBranch($this->fallbackBranch);
$touchedFiles = [];
// Whether this run is the one that records the edges its results will be
// invalidated through. A recording run's edges are written after this
// (terminate() runs last), and a parallel one's arrive with the worker
// partials that ask for $markKnownTestFiles — either way the graph on
// disk cannot be asked yet, so the run is taken at its word.
$recordsEdges = $complete && ($markKnownTestFiles || $this->recordingActive);
foreach ($results as $testId => $result) {
$file = $result['file'] ?? null;
if ($file === null || str_contains($file, "eval()'d")) {
$file = $this->resolveFailedTestFile($testId);
}
if (is_string($file) && $file !== '') {
$touchedFiles[$file] = true;
}
// A result is only ever invalidated through the edges of the test
// that produced it, so one recorded for a test the graph has no
// edges for could never be invalidated again — it would be replayed
// as settled however far the code around it moved. A run that
// records no edges leaves such a test exactly as unknown as it
// found it, whether or not it ran the whole suite.
if (! $recordsEdges && (! is_string($file) || ! $graph->knowsTest($file))) {
continue;
}
$graph->setResult(
$this->branch,
$testId,
$result['status'],
$result['message'],
$this->resultTime($testId, $result['time']),
$result['assertions'],
$file,
);
}
if ($markKnownTestFiles) {
$graph->markKnownTestFiles(array_keys($touchedFiles));
}
// Pruning reads the absence of a test from this run as the test being
// gone. That only holds if every test was invited to report.
if ($complete) {
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
}
$this->saveGraph($graph);
$collector->reset();
}
private function resolveFailedTestFile(string $testId): ?string
{
$class = strstr($testId, '::', true);
if (! is_string($class) || $class === '' || ! class_exists($class)) {
return null;
}
assert(property_exists($class, '__filename') && is_string($class::$__filename));
$filename = $class::$__filename;
if ($filename !== '' && ! str_contains($filename, "eval()'d")) {
return $filename;
}
$current = new \ReflectionClass($class);
while ($current !== false) {
$file = $current->getFileName();
if ($file !== false && ! str_contains($file, "eval()'d")) {
return $file;
}
$current = $current->getParentClass();
}
return null;
}
/**
* Whether this run produces a coverage report, however it was asked for.
*
* The original argv, not the handled arguments: Pest's own Coverage plugin
* appends `--coverage-php <path>` to those and runs before this one, and a
* paratest worker's arguments always carry it too. `bin/worker.php` never
* hands over the original argv, so a worker sees `[]` here and keeps taking
* this from {@see self::PIGGYBACK_COVERAGE_GLOBAL} instead.
*/
private function coverageReportActive(): bool
{
if ($this->pestCoverageActive()) {
return true;
}
return array_any(self::COVERAGE_REPORT_FLAGS, fn (string $flag): bool => $this->hasArgument($flag, $this->originalArguments));
}
/**
* Whether Pest's own `--coverage` was given — the only entry point that
* writes the coverage cache these two flags read and clean up.
*/
private function pestCoverageActive(): bool
{
$coverage = Container::getInstance()->get(Coverage::class);
assert($coverage instanceof Coverage);
return $coverage->coverage;
}
/**
* Panics when the run asks for Tia alongside an option Tia cannot honour.
*
* Checked against the original argv as well, because `bin/pest` consumes
* some of these itself before PHPUnit ever sees them.
*
* @param array<int, string> $arguments
*/
private function guardUnsupportedOptions(array $arguments): void
{
foreach (self::UNSUPPORTED_OPTIONS as $option) {
if (! $this->hasArgument($option, $arguments) && ! $this->hasArgument($option, $this->originalArguments)) {
continue;
}
Panic::with(new InvalidOption(sprintf(
'The [%s] option cannot be combined with [%s].',
$option,
self::OPTION,
)));
}
}
/**
* Whether a selection-narrowing flag was given, either among the arguments
* PHPUnit receives or — for the flags `bin/pest` consumes itself — among
* the original argv. Explicit path arguments and `->only()` are detected
* separately.
*
* @param array<int, string> $arguments
*/
private function hasPartialSelection(array $arguments): bool
{
foreach (self::PARTIAL_SELECTION_FLAGS as $flag) {
if ($this->hasArgument($flag, $arguments)) {
return true;
}
if ($this->hasArgument($flag, $this->originalArguments)) {
return true;
}
}
return false;
}
/**
* Whether the run stopped before reaching every test it had queued.
*
* Covers `--bail`, `--retry` and every `--stop-on-*` flag, the equivalent
* `phpunit.xml` attributes, and an interrupted run — none of which narrow
* the selection up front, so hasPartialSelection() cannot see them. The
* tests queued behind the defect that halted the run never reported, and
* folding what did report into the baseline prunes the cached results of
* their siblings in every file the run had already entered.
*
* Deliberately unguarded. Both callers run only once PHPUnit's
* configuration is registered — the kernel reads it unguarded itself just
* before dispatching addOutput(), and flushWorkerReplay() bails out unless
* the worker actually executed something. Swallowing a failure here would
* report every truncated run as complete, which is the corruption this
* guards against in the first place.
*/
private function stoppedEarly(): bool
{
return TestResultFacade::shouldStop();
}
/**
* Resolves the baselines this run reads from and writes to, once.
*
* Results are written on runs where TIA itself took no part, and those
* never reach handleParent(). Without this the default would stand and
* every such run would write its results to `main`, whatever branch it
* actually ran on.
*/
private function resolveBranch(string $projectRoot): void
{
if ($this->branchResolved) {
return;
}
$this->branchResolved = true;
$changedFiles = new ChangedFiles($projectRoot);
// Resolved before the current branch, which throws where git is
// missing: the fallback is advisory, so a run that cannot name its
// branch at all should still carry the best answer available.
$this->fallbackBranch = $this->resolveFallbackBranch($changedFiles);
Parallel::setGlobal(self::FALLBACK_BRANCH_GLOBAL, $this->fallbackBranch);
// A detached HEAD has no branch of its own to write to. The default
// branch is the honest key there — it is the commit the checkout most
// likely sits on, and it keeps a phantom baseline from being minted
// under a branch name the repository never had.
$this->branch = $changedFiles->currentBranch() ?? $this->fallbackBranch;
}
private function resolveFallbackBranch(ChangedFiles $changedFiles): string
{
$inherited = Parallel::getGlobal(self::FALLBACK_BRANCH_GLOBAL);
if (is_string($inherited) && $inherited !== '') {
return $inherited;
}
// Configuration wins over autodetection: it is the escape hatch for a
// repository whose `origin/HEAD` is unset and whose `init.defaultBranch`
// says something else than its branches do.
return $this->watchPatterns->defaultBranch()
?? $changedFiles->defaultBranch()
?? self::DEFAULT_BRANCH;
}
/**
* @param array<int, string> $arguments
*/
private function hasExplicitPathArgument(array $arguments): bool
{
$projectRoot = TestSuite::getInstance()->rootPath;
$testPaths = SourceScope::testPaths();
if ($testPaths === []) {
return false;
}
foreach ($arguments as $index => $arg) {
$arg = (string) $arg; // @phpstan-ignore-line
if ($arg === '') {
continue;
}
if (str_starts_with($arg, '-')) {
continue;
}
if ($index > 0) {
$previous = $arguments[$index - 1] ?? '';
if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) {
continue;
}
}
$candidate = $this->resolveArgumentPath($arg, $projectRoot);
if ($candidate === null) {
continue;
}
foreach ($testPaths as $testPath) {
if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) {
return true;
}
}
}
return false;
}
private function resolveArgumentPath(string $arg, string $projectRoot): ?string
{
$candidates = [$arg, rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim($arg, DIRECTORY_SEPARATOR)];
foreach ($candidates as $candidate) {
if (! is_file($candidate) && ! is_dir($candidate)) {
continue;
}
$real = @realpath($candidate);
return rtrim($real === false ? $candidate : $real, '/\\');
}
return null;
}
/**
* @param array<int, string> $changedFiles
*/
private function hasProjectPhpSourceChanges(array $changedFiles): bool
{
foreach ($changedFiles as $rel) {
if (! str_ends_with($rel, '.php')) {
continue;
}
if (str_ends_with($rel, '.blade.php')) {
continue;
}
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;
}
if (! is_file(TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.$rel)) {
continue;
}
return true;
}
return false;
}
/**
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/
private function tryRemoteBaselineForDrift(array $current): ?Graph
{
if ($this->baselineFetchAttemptedForDrift) {
return null;
}
$projectRoot = TestSuite::getInstance()->rootPath;
$this->baselineFetchAttemptedForDrift = true;
if (! $this->watchPatterns->isBaselined()) {
return null;
}
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
return null;
}
$fetched = $this->loadGraph($projectRoot);
if (! $fetched instanceof Graph) {
return null;
}
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
$this->output->writeln(' <fg=gray> However, baseline still drifts — discarding.</>');
return null;
}
$this->renderBadge('SUCCESS', 'Fetched baseline matches — skipping local rebuild.');
return $fetched;
}
/**
* @param list<string> $drift
*/
private function formatStructuralDrift(array $drift): string
{
static $labels = [
'composer_lock' => 'composer.lock',
'composer_json' => 'composer.json',
'phpunit_xml' => 'phpunit.xml',
'phpunit_xml_dist' => 'phpunit.xml.dist',
'vite_config' => 'vite.config',
'package_json' => 'package.json',
'package_lock' => 'Node lockfile',
'js_config' => 'JS/TS config',
'pest_factory' => 'Pest internals',
'pest_method_factory' => 'Pest internals',
];
$seen = [];
foreach ($drift as $key) {
$seen[$labels[$key] ?? $key] = true;
}
if ($seen === []) {
return 'unknown';
}
return implode(', ', array_keys($seen));
}
/**
* The path from the git repository root down to $projectRoot (e.g.
* `laravel-app`) when the project is nested inside a larger repo, or `null`
* when the project root is itself the repo root (or git is unavailable).
* TIA requires the two to coincide: git reports and addresses paths
* relative to the repo root, while the dependency graph is project-relative.
*/
private function gitSubdirectoryPrefix(string $projectRoot): ?string
{
$process = new Process(['git', 'rev-parse', '--show-prefix'], $projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$prefix = trim($process->getOutput());
return $prefix === '' ? null : rtrim(str_replace(DIRECTORY_SEPARATOR, '/', $prefix), '/');
}
private function composerLockDelta(string $projectRoot, string $sha): string
{
$current = @file_get_contents($projectRoot.'/composer.lock');
if ($current === false) {
return '';
}
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
$process->setTimeout(5.0);
$process->run();
if (! $process->isSuccessful()) {
return '';
}
$oldVersions = $this->lockVersions($process->getOutput());
$newVersions = $this->lockVersions($current);
if ($oldVersions === [] && $newVersions === []) {
return '';
}
$changes = [];
foreach ($newVersions as $name => $version) {
if (! isset($oldVersions[$name])) {
$changes[] = '+ '.$name.' '.$version;
} elseif ($oldVersions[$name] !== $version) {
$changes[] = $name.' '.$oldVersions[$name].' → '.$version;
}
}
foreach ($oldVersions as $name => $version) {
if (! isset($newVersions[$name])) {
$changes[] = ' '.$name.' '.$version;
}
}
if ($changes === []) {
return '';
}
sort($changes);
$maxShown = 8;
if (count($changes) > $maxShown) {
$extra = count($changes) - $maxShown;
$changes = array_slice($changes, 0, $maxShown);
$changes[] = sprintf('… +%d more', $extra);
}
return implode(', ', $changes);
}
/**
* @param array<string, array<int, string>> $perTestTables
* @param array<string, true> $perTestUsesDatabase
* @return array<string, array<int, string>>
*/
private function augmentDatabaseTestTables(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array
{
$migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations';
if (! is_dir($migrationDir)) {
return $perTestTables;
}
$allTables = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($migrationDir, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $fileInfo) {
if (! $fileInfo->isFile()) {
continue;
}
if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) {
continue;
}
$content = @file_get_contents((string) $fileInfo->getPathname());
if ($content === false) {
continue;
}
foreach (TableExtractor::fromMigrationSource($content) as $table) {
$allTables[strtolower($table)] = true;
}
}
if ($allTables === []) {
return $perTestTables;
}
foreach (array_keys($perTestUsesDatabase) as $testFile) {
$existing = $perTestTables[$testFile] ?? [];
$merged = array_fill_keys($existing, true) + $allTables;
$names = array_keys($merged);
sort($names);
$perTestTables[$testFile] = $names;
}
return $perTestTables;
}
/**
* @return array<string, string> package name → version
*/
private function lockVersions(string $json): array
{
$data = json_decode($json, true);
if (! is_array($data)) {
return [];
}
$out = [];
foreach (['packages', 'packages-dev'] as $section) {
if (! isset($data[$section])) {
continue;
}
if (! is_array($data[$section])) {
continue;
}
foreach ($data[$section] as $package) {
if (! is_array($package)) {
continue;
}
$name = $package['name'] ?? null;
$version = $package['version'] ?? null;
if (is_string($name) && is_string($version)) {
$out[$name] = $version;
}
}
}
return $out;
}
}