mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
wq
This commit is contained in:
+119
-23
@@ -159,6 +159,16 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
/** @var array<string, int> */
|
||||
private array $cachedAssertionsByTestId = [];
|
||||
|
||||
/**
|
||||
* The status a replayed test was replayed *as*, so the write-back records
|
||||
* what was cached rather than what the replay looked like from the
|
||||
* outside. A cached deprecation replays as a pass — recording that pass
|
||||
* would erase the deprecation from the baseline on the very next run.
|
||||
*
|
||||
* @var array<string, array{status: int, message: string}>
|
||||
*/
|
||||
private array $cachedStatusByTestId = [];
|
||||
|
||||
/**
|
||||
* @var array<string, float>
|
||||
*/
|
||||
@@ -188,8 +198,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
|
||||
private bool $baselineFetchAttemptedForDrift = false;
|
||||
|
||||
private bool $freshRebuild = false;
|
||||
|
||||
private bool $filteredMode = false;
|
||||
|
||||
private bool $writesSuppressed = false;
|
||||
@@ -272,7 +280,9 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
return;
|
||||
}
|
||||
|
||||
$this->state->delete(self::KEY_GRAPH);
|
||||
if (! $this->deleteState(self::KEY_GRAPH)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->unreadableGraphReported) {
|
||||
return;
|
||||
@@ -284,6 +294,25 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$this->renderBadge('WARN', 'The dependency graph could not be read — it will be rebuilt.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a state file, unless this checkout may not write.
|
||||
*
|
||||
* A detached HEAD names no branch, so {@see self::saveGraph()} refuses to
|
||||
* write — which means anything deleted here could never be rebuilt from
|
||||
* this checkout. Read-only has to mean deletes too, or a drifted
|
||||
* `composer.lock` on a detached CI checkout wipes the whole team's baseline.
|
||||
*
|
||||
* @return bool Whether the delete happened.
|
||||
*/
|
||||
private function deleteState(string $key): bool
|
||||
{
|
||||
if ($this->detachedHead) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->state->delete($key);
|
||||
}
|
||||
|
||||
private function saveGraph(Graph $graph): bool
|
||||
{
|
||||
// A detached HEAD names no branch of its own, so `$this->branch` is the
|
||||
@@ -412,6 +441,10 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
}
|
||||
|
||||
$this->replayedCount++;
|
||||
$this->cachedStatusByTestId[$testId] = [
|
||||
'status' => $result->asInt(),
|
||||
'message' => $result->message(),
|
||||
];
|
||||
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
|
||||
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
|
||||
|
||||
@@ -502,7 +535,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
|
||||
$this->forceRefetch = false;
|
||||
$this->filteredMode = false;
|
||||
$this->freshRebuild = false;
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@@ -515,7 +547,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
}
|
||||
|
||||
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
||||
$this->freshRebuild = $forceRebuild;
|
||||
|
||||
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
|
||||
$this->requestWorkerResults();
|
||||
@@ -546,7 +577,10 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$this->flushWorkerReplay();
|
||||
}
|
||||
|
||||
if ($this->writesSuppressed || $this->resultsOnlyWrites) {
|
||||
// `terminate()` also runs from the shutdown handler, which is how a run
|
||||
// that `exit()`s inside a test gets here — with a test prepared and
|
||||
// never finished, and so with no right to a complete write.
|
||||
if ($this->writesSuppressed || $this->resultsOnlyWrites || $this->hasUnfinishedTest()) {
|
||||
$this->recorder->reset();
|
||||
$this->coverageCollector->reset();
|
||||
|
||||
@@ -619,10 +653,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$graph->replaceTestInertiaComponents($perTestInertia);
|
||||
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||
|
||||
if ($this->freshRebuild) {
|
||||
$graph->pruneMissingTests();
|
||||
}
|
||||
|
||||
$this->seedResultsInto($graph);
|
||||
|
||||
if (! $this->saveGraph($graph)) {
|
||||
@@ -642,7 +672,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
if (Only::isEnabled() || $this->stoppedEarly()) {
|
||||
if (Only::isEnabled() || $this->stoppedEarly() || $this->hasUnfinishedTest()) {
|
||||
$this->resultsOnlyWrites = true;
|
||||
}
|
||||
|
||||
@@ -727,10 +757,6 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$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.');
|
||||
|
||||
@@ -773,8 +799,8 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
return $this->reconcileFingerprint($rebuilt, $current);
|
||||
}
|
||||
|
||||
$this->state->delete(self::KEY_GRAPH);
|
||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||
$this->deleteState(self::KEY_GRAPH);
|
||||
$this->deleteState(self::KEY_COVERAGE_CACHE);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -790,7 +816,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$graph->clearResults($this->branch);
|
||||
$graph->setFingerprint($current);
|
||||
$this->saveGraph($graph);
|
||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||
$this->deleteState(self::KEY_COVERAGE_CACHE);
|
||||
}
|
||||
|
||||
return $graph;
|
||||
@@ -821,7 +847,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
$this->startFingerprint = $fingerprint;
|
||||
|
||||
if ($forceRebuild) {
|
||||
if ($forceRebuild && ! $this->detachedHead) {
|
||||
Storage::purge($projectRoot);
|
||||
}
|
||||
|
||||
@@ -1362,7 +1388,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
}
|
||||
|
||||
foreach ($results as $testId => $result) {
|
||||
$results[$testId]['time'] = $this->resultTime($testId, $result['time']);
|
||||
$results[$testId] = $this->replayedAsRecorded($testId, $result);
|
||||
}
|
||||
|
||||
$json = json_encode([
|
||||
@@ -1370,7 +1396,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
'replayed' => $this->replayedCount,
|
||||
'affected' => $this->affectedCount,
|
||||
'executed' => $this->executedCount,
|
||||
'truncated' => $this->stoppedEarly(),
|
||||
'truncated' => $this->stoppedEarly() || $collector->hasUnfinishedTest(),
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
@@ -1639,6 +1665,24 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
return $this->cachedTimeByTestId[$testId] ?? $time;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{status: int, message: string, time: float, assertions: int, file?: string} $result
|
||||
* @return array{status: int, message: string, time: float, assertions: int, file?: string}
|
||||
*/
|
||||
private function replayedAsRecorded(string $testId, array $result): array
|
||||
{
|
||||
$result['time'] = $this->resultTime($testId, $result['time']);
|
||||
|
||||
$cached = $this->cachedStatusByTestId[$testId] ?? null;
|
||||
|
||||
if ($cached !== null) {
|
||||
$result['status'] = $cached['status'];
|
||||
$result['message'] = $cached['message'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function seedResultsInto(Graph $graph): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
@@ -1658,12 +1702,14 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
$touchedFiles[$file] = true;
|
||||
}
|
||||
|
||||
$result = $this->replayedAsRecorded($testId, $result);
|
||||
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
$result['status'],
|
||||
$result['message'],
|
||||
$this->resultTime($testId, $result['time']),
|
||||
$result['time'],
|
||||
$result['assertions'],
|
||||
$file,
|
||||
);
|
||||
@@ -1671,10 +1717,45 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
|
||||
$graph->markKnownTestFiles(array_keys($touchedFiles));
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
$this->reclaim($graph);
|
||||
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back what the graph no longer needs. Only ever called from a
|
||||
* complete write — the RESULTS-ONLY and HARD-SUPPRESSED tiers may not
|
||||
* remove an entry, and a narrowed run has not seen enough to judge.
|
||||
*/
|
||||
private function reclaim(Graph $graph): void
|
||||
{
|
||||
// The fallback branch never layers under itself, so marking it would
|
||||
// write the graph for no reader's benefit — and cost a clean green run
|
||||
// its "wrote nothing at all".
|
||||
if ($this->branch !== $this->fallbackBranch) {
|
||||
$graph->markBaselineComplete($this->branch);
|
||||
}
|
||||
|
||||
$graph->pruneMissingTests();
|
||||
$graph->pruneResultsForMissingFiles($this->branch);
|
||||
|
||||
$branches = new ChangedFiles(TestSuite::getInstance()->rootPath)->branchNames();
|
||||
|
||||
if ($branches === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A shallow, single-branch CI checkout can see almost no refs, and
|
||||
// "git has never heard of it" would then mean "this clone is narrow",
|
||||
// not "that branch is gone". Only reclaim from a checkout that can at
|
||||
// least see the branch everything else falls back to.
|
||||
if (! in_array($this->fallbackBranch, $branches, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$graph->pruneMissingBranches([...$branches, $this->branch, $this->fallbackBranch]);
|
||||
}
|
||||
|
||||
private function snapshotTestResults(bool $markKnownTestFiles = false, bool $complete = true): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
@@ -1720,12 +1801,14 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->replayedAsRecorded($testId, $result);
|
||||
|
||||
$graph->setResult(
|
||||
$this->branch,
|
||||
$testId,
|
||||
$result['status'],
|
||||
$result['message'],
|
||||
$this->resultTime($testId, $result['time']),
|
||||
$result['time'],
|
||||
$result['assertions'],
|
||||
$file,
|
||||
);
|
||||
@@ -1737,6 +1820,7 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
|
||||
if ($complete) {
|
||||
$graph->pruneStaleResults($this->branch, array_keys($touchedFiles), array_keys($results));
|
||||
$this->reclaim($graph);
|
||||
}
|
||||
|
||||
$this->saveGraph($graph);
|
||||
@@ -1832,6 +1916,18 @@ final class Tia implements AddsOutput, HandlesArguments, HandlesOriginalArgument
|
||||
return TestResultFacade::shouldStop();
|
||||
}
|
||||
|
||||
/**
|
||||
* A test that was prepared and never finished means this process is being
|
||||
* torn down mid-file, so it has not seen enough of that file to prune it.
|
||||
*/
|
||||
private function hasUnfinishedTest(): bool
|
||||
{
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
assert($collector instanceof ResultCollector);
|
||||
|
||||
return $collector->hasUnfinishedTest();
|
||||
}
|
||||
|
||||
private function resolveBranch(string $projectRoot): void
|
||||
{
|
||||
if ($this->branchResolved) {
|
||||
|
||||
@@ -243,6 +243,56 @@ final readonly class ChangedFiles
|
||||
return $exists ? $configured : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every branch name this checkout knows, local and remote alike. Remotes
|
||||
* count: a branch that only lives on the origin is still a branch someone
|
||||
* will check out, and its baseline must survive.
|
||||
*
|
||||
* @return list<string>|null `null` when git cannot answer.
|
||||
*/
|
||||
public function branchNames(): ?array
|
||||
{
|
||||
$process = new Process(
|
||||
['git', 'for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes'],
|
||||
$this->projectRoot,
|
||||
);
|
||||
$process->setTimeout(5.0);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($this->splitLines($process->getOutput()) as $ref) {
|
||||
if (str_starts_with($ref, 'refs/heads/')) {
|
||||
$names[substr($ref, strlen('refs/heads/'))] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($ref, 'refs/remotes/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tail = substr($ref, strlen('refs/remotes/'));
|
||||
$slash = strpos($tail, '/');
|
||||
|
||||
if ($slash === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$branch = substr($tail, $slash + 1);
|
||||
|
||||
if ($branch !== '' && $branch !== 'HEAD') {
|
||||
$names[$branch] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($names);
|
||||
}
|
||||
|
||||
public function hasRemote(): bool
|
||||
{
|
||||
return $this->gitOutput(['git', 'remote']) !== null;
|
||||
|
||||
@@ -29,7 +29,13 @@ enum ReplayType
|
||||
$status->isRisky() => self::Risky,
|
||||
$status->isSkipped() => self::Skipped,
|
||||
$status->isIncomplete() => self::Incomplete,
|
||||
default => self::Failure,
|
||||
// A recorded notice, deprecation or warning only reaches replay when
|
||||
// the configured failOn* / displayDetailsOn* policies say it is not
|
||||
// worth re-running — which means the test passed. Folding it into
|
||||
// Failure below would turn a green run red on cache alone.
|
||||
$status->isNotice(), $status->isDeprecation(), $status->isWarning() => self::Pass,
|
||||
$status->isFailure(), $status->isError() => self::Failure,
|
||||
default => self::None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+289
-10
@@ -43,6 +43,7 @@ final class Graph
|
||||
* @var array<string, array{
|
||||
* sha: ?string,
|
||||
* tree: array<string, string>,
|
||||
* complete?: bool,
|
||||
* results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
* }>
|
||||
*/
|
||||
@@ -650,6 +651,9 @@ final class Graph
|
||||
|
||||
$r = $baseline['results'][$testId];
|
||||
|
||||
// A status this build does not know — a graph written by a newer Pest,
|
||||
// or a corrupt one — is not a result. Returning null re-executes the
|
||||
// test rather than replaying an outcome nobody can interpret.
|
||||
return match ($r['status']) {
|
||||
0 => TestStatus::success(),
|
||||
1 => TestStatus::skipped($r['message']),
|
||||
@@ -660,7 +664,7 @@ final class Graph
|
||||
6 => TestStatus::warning($r['message']),
|
||||
7 => TestStatus::failure($r['message']),
|
||||
8 => TestStatus::error($r['message']),
|
||||
default => TestStatus::unknown(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -687,7 +691,9 @@ final class Graph
|
||||
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel !== null) {
|
||||
// A test file that is no longer on disk cannot be re-run by anyone,
|
||||
// so selecting it would only widen the run for nothing.
|
||||
if ($rel !== null && is_file($this->projectRoot.'/'.$rel)) {
|
||||
$files[$rel] = true;
|
||||
}
|
||||
}
|
||||
@@ -695,6 +701,15 @@ final class Graph
|
||||
return array_keys($files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cached result due a re-run names a test file this project
|
||||
* cannot address — an empty path, or one that resolves outside the project
|
||||
* root. Those are genuinely lost, so the caller widens to the full suite.
|
||||
*
|
||||
* A path that resolves fine but is simply absent is *deleted*, not lost:
|
||||
* widening would not run it either, and treating it as unlocated used to
|
||||
* strand `--filtered` on a full replay for good.
|
||||
*/
|
||||
public function hasUnlocatedTestsToRerun(string $branch, ?string $fallbackBranch = null): bool
|
||||
{
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
@@ -710,9 +725,7 @@ final class Graph
|
||||
return true;
|
||||
}
|
||||
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel === null || ! is_file($this->projectRoot.'/'.$rel)) {
|
||||
if ($this->relative($file) === null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -736,6 +749,10 @@ final class Graph
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($testStatus->isUnknown()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$configuration = Registry::get();
|
||||
|
||||
if ($testStatus->isRisky()) {
|
||||
@@ -813,10 +830,21 @@ final class Graph
|
||||
* default branch's, so a key minted by a narrowed run — which only holds the
|
||||
* handful of tests that ran — does not shadow the fallback for everything else.
|
||||
*
|
||||
* Once this branch has had a complete run, the layering becomes per *file*
|
||||
* rather than per test id: the branch's entries for a file it executed are
|
||||
* the whole truth, so the fallback's entries for that same file are dropped
|
||||
* rather than merged. Without that, a test the branch renamed or removed —
|
||||
* and {@see self::pruneStaleResults()} therefore unset — is resurrected by
|
||||
* the default branch on the very next read, and never stops coming back.
|
||||
*
|
||||
* A branch whose key was minted by a *narrowed* run holds only the handful
|
||||
* of tests that ran, and has no business speaking for the rest of their
|
||||
* file, so it keeps the per-test-id merge.
|
||||
*
|
||||
* Read-only: the layering never reaches `$this->baselines`, so writes stay on
|
||||
* the branch that ran.
|
||||
*
|
||||
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
|
||||
* @return array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}
|
||||
*/
|
||||
private function baselineFor(string $branch, ?string $fallbackBranch): array
|
||||
{
|
||||
@@ -833,13 +861,49 @@ final class Graph
|
||||
return $own;
|
||||
}
|
||||
|
||||
$under = ($own['complete'] ?? false) === true
|
||||
? $this->withoutFilesCoveredBy($fallback['results'], $own['results'])
|
||||
: $fallback['results'];
|
||||
|
||||
return [
|
||||
'sha' => $own['sha'] ?? $fallback['sha'],
|
||||
'tree' => $own['tree'] !== [] ? $own['tree'] : $fallback['tree'],
|
||||
'results' => array_replace($fallback['results'], $own['results']),
|
||||
'results' => array_replace($under, $own['results']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $results
|
||||
* @param array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}> $authoritative
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
*/
|
||||
private function withoutFilesCoveredBy(array $results, array $authoritative): array
|
||||
{
|
||||
$covered = [];
|
||||
|
||||
foreach ($authoritative as $entry) {
|
||||
$file = $entry['file'] ?? null;
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$covered[$file] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($covered === []) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
foreach ($results as $testId => $entry) {
|
||||
$file = $entry['file'] ?? null;
|
||||
|
||||
if (is_string($file) && isset($covered[$file])) {
|
||||
unset($results[$testId]);
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function ensureBaseline(string $branch): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch])) {
|
||||
@@ -1422,6 +1486,67 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that this branch has run the whole suite at least once, which is
|
||||
* what lets {@see self::baselineFor()} treat its entries as authoritative
|
||||
* for the files they cover. Never mints a key: a run that recorded nothing
|
||||
* has nothing to be authoritative about.
|
||||
*/
|
||||
public function markBaselineComplete(string $branch): void
|
||||
{
|
||||
if (isset($this->baselines[$branch])) {
|
||||
$this->baselines[$branch]['complete'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop this branch's result entries whose test file is no longer on disk.
|
||||
*
|
||||
* Without this nothing but `--fresh` ever reclaims them, and a *failing*
|
||||
* one keeps `--filtered` widened to a full replay on every later run.
|
||||
*/
|
||||
public function pruneResultsForMissingFiles(string $branch): void
|
||||
{
|
||||
if (! isset($this->baselines[$branch]['results'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
foreach ($this->baselines[$branch]['results'] as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel === null || is_file($root.$rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->baselines[$branch]['results'][$testId]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop baselines for branches git no longer knows, so the graph does not
|
||||
* carry one full copy of the suite per branch ever created.
|
||||
*
|
||||
* @param array<int, string> $keep Branch names that must survive.
|
||||
*/
|
||||
public function pruneMissingBranches(array $keep): void
|
||||
{
|
||||
$survivors = array_fill_keys($keep, true);
|
||||
|
||||
foreach (array_keys($this->baselines) as $branch) {
|
||||
if (! isset($survivors[$branch])) {
|
||||
unset($this->baselines[$branch]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune baseline result entries whose test files were just executed but whose
|
||||
* test IDs are no longer present (e.g. the test method was removed or renamed).
|
||||
@@ -1499,10 +1624,10 @@ final class Graph
|
||||
|
||||
$graph = new self($projectRoot);
|
||||
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
|
||||
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
|
||||
$graph->files = self::decodeFiles($data['files'] ?? null);
|
||||
$graph->fileIds = array_flip($graph->files);
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
$graph->edges = self::decodeEdges($data['edges'] ?? null);
|
||||
$graph->baselines = self::decodeBaselines($data['baselines'] ?? null);
|
||||
|
||||
$graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
|
||||
$graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
|
||||
@@ -1511,6 +1636,160 @@ final class Graph
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function decodeFiles(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
foreach ($section as $path) {
|
||||
if (is_string($path) && $path !== '') {
|
||||
$files[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, int>>
|
||||
*/
|
||||
private static function decodeEdges(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$edges = [];
|
||||
|
||||
foreach ($section as $key => $ids) {
|
||||
$testFile = (string) $key;
|
||||
|
||||
if ($testFile === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$clean[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$edges[$testFile] = $clean;
|
||||
}
|
||||
|
||||
return $edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* A graph is state on disk that any process may have written: a newer Pest,
|
||||
* a half-finished write, a hand edit. Every branch, every entry and every
|
||||
* field is checked here so that a malformed one is dropped rather than
|
||||
* reaching a read path and taking the run down with it.
|
||||
*
|
||||
* @return array<string, array{sha: ?string, tree: array<string, string>, complete?: bool, results: array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>}>
|
||||
*/
|
||||
private static function decodeBaselines(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baselines = [];
|
||||
|
||||
foreach ($section as $key => $baseline) {
|
||||
// A branch named `12345` decodes as an integer key, and must not be
|
||||
// mistaken for a malformed one.
|
||||
$branch = (string) $key;
|
||||
|
||||
if ($branch === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($baseline)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sha = $baseline['sha'] ?? null;
|
||||
$tree = [];
|
||||
|
||||
if (is_array($baseline['tree'] ?? null)) {
|
||||
foreach ($baseline['tree'] as $path => $hash) {
|
||||
if (is_string($path) && is_string($hash)) {
|
||||
$tree[$path] = $hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$baselines[$branch] = [
|
||||
'sha' => is_string($sha) ? $sha : null,
|
||||
'tree' => $tree,
|
||||
'results' => self::decodeResults($baseline['results'] ?? null),
|
||||
];
|
||||
|
||||
if (($baseline['complete'] ?? null) === true) {
|
||||
$baselines[$branch]['complete'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{status: int, message: string, time: float, assertions?: int, file?: string}>
|
||||
*/
|
||||
private static function decodeResults(mixed $section): array
|
||||
{
|
||||
if (! is_array($section)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($section as $key => $entry) {
|
||||
$testId = (string) $key;
|
||||
|
||||
if ($testId === '') {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_int($entry['status'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$time = $entry['time'] ?? null;
|
||||
|
||||
$result = [
|
||||
'status' => $entry['status'],
|
||||
'message' => is_string($entry['message'] ?? null) ? $entry['message'] : '',
|
||||
'time' => is_int($time) || is_float($time) ? (float) $time : 0.0,
|
||||
];
|
||||
|
||||
if (is_int($entry['assertions'] ?? null)) {
|
||||
$result['assertions'] = $entry['assertions'];
|
||||
}
|
||||
|
||||
if (is_string($entry['file'] ?? null) && $entry['file'] !== '') {
|
||||
$result['file'] = $entry['file'];
|
||||
}
|
||||
|
||||
$results[$testId] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,9 @@ final class ResultCollector
|
||||
*/
|
||||
private array $results = [];
|
||||
|
||||
/** @var array<string, true> */
|
||||
private array $triggered = [];
|
||||
|
||||
private ?string $currentTestId = null;
|
||||
|
||||
private ?string $currentTestFile = null;
|
||||
@@ -35,9 +38,35 @@ final class ResultCollector
|
||||
return;
|
||||
}
|
||||
|
||||
// PHPUnit reports a test that triggered a notice, deprecation or
|
||||
// warning as passed, and emits Passed for it. Recording success here
|
||||
// would erase the issue from the baseline, and a later replay under
|
||||
// --fail-on-deprecation (and friends) would come back green where a
|
||||
// fresh run fails. Keep the issue; only refresh what it cannot know.
|
||||
if (isset($this->triggered[$this->currentTestId])) {
|
||||
$this->refreshTime();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->record(TestStatus::success());
|
||||
}
|
||||
|
||||
public function testTriggeredNotice(string $message): void
|
||||
{
|
||||
$this->recordIssue(TestStatus::notice($message));
|
||||
}
|
||||
|
||||
public function testTriggeredDeprecation(string $message): void
|
||||
{
|
||||
$this->recordIssue(TestStatus::deprecation($message));
|
||||
}
|
||||
|
||||
public function testTriggeredWarning(string $message): void
|
||||
{
|
||||
$this->recordIssue(TestStatus::warning($message));
|
||||
}
|
||||
|
||||
public function testFailed(string $message): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
@@ -91,6 +120,17 @@ final class ResultCollector
|
||||
return $this->results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a test was prepared but never finished — the process is being
|
||||
* torn down in the middle of it (an `exit()` inside a test, a killed
|
||||
* worker). What it collected is therefore a partial view of that test
|
||||
* file, and must not license pruning the siblings it never reached.
|
||||
*/
|
||||
public function hasUnfinishedTest(): bool
|
||||
{
|
||||
return $this->currentTestId !== null;
|
||||
}
|
||||
|
||||
public function recordAssertions(string $testId, int $assertions): void
|
||||
{
|
||||
if (isset($this->results[$testId])) {
|
||||
@@ -111,6 +151,7 @@ final class ResultCollector
|
||||
public function reset(): void
|
||||
{
|
||||
$this->results = [];
|
||||
$this->triggered = [];
|
||||
$this->currentTestId = null;
|
||||
$this->currentTestFile = null;
|
||||
$this->startTime = null;
|
||||
@@ -123,6 +164,43 @@ final class ResultCollector
|
||||
$this->startTime = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an issue raised while the test was running. The most important
|
||||
* one wins, exactly as PHPUnit ranks them, so a deprecation does not
|
||||
* shadow the warning that followed it — or the failure.
|
||||
*/
|
||||
private function recordIssue(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $this->results[$this->currentTestId]['status'] ?? null;
|
||||
|
||||
if (is_int($existing) && $existing >= $status->asInt()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->triggered[$this->currentTestId] = true;
|
||||
|
||||
$this->record($status);
|
||||
}
|
||||
|
||||
private function refreshTime(): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
return;
|
||||
}
|
||||
if (! isset($this->results[$this->currentTestId])) {
|
||||
return;
|
||||
}
|
||||
if ($this->startTime === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->results[$this->currentTestId]['time'] = round(microtime(true) - $this->startTime, 3);
|
||||
}
|
||||
|
||||
private function record(TestStatus $status): void
|
||||
{
|
||||
if ($this->currentTestId === null) {
|
||||
|
||||
Reference in New Issue
Block a user