This commit is contained in:
nuno maduro
2026-08-06 22:05:15 +01:00
parent 09699847a2
commit b49ba062d5
19 changed files with 1589 additions and 41 deletions
+50
View File
@@ -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;
+7 -1
View File
@@ -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
View File
@@ -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>>
*/
+78
View File
@@ -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) {