Compare commits

..

2 Commits

Author SHA1 Message Date
07416a3c61 wip 2026-05-01 03:30:28 +01:00
30b94e3034 qdw 2026-05-01 02:10:08 +01:00
3 changed files with 116 additions and 26 deletions

View File

@ -1250,22 +1250,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// The collector occasionally hands us nothing usable: PHPUnit's
// Prepared event can miss the file for Pest-generated classes,
// and an eval'd class path (".../IndexTest.php(1) : eval()'d code")
// would be rejected later by Graph::relative(). Reflect on the
// class embedded in the test ID as a fallback so the failure
// gets stored *with* a file — without it, filtered runs lose
// the ability to re-run only the failing test next time and
// bail out to the full suite.
// would be rejected later by Graph::relative(). Recover the real
// path from the class embedded in the test ID — without it,
// filtered runs lose the ability to re-run only the failing test
// next time.
if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) {
$class = strstr($testId, '::', true);
if (is_string($class) && $class !== '') {
try {
$reflected = (new \ReflectionClass($class))->getFileName();
$file = $reflected === false ? null : $reflected;
} catch (\ReflectionException) {
$file = null;
}
}
$file = self::resolveFailedTestFile($testId);
}
$graph->setResult(
@ -1283,6 +1273,63 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$collector->reset();
}
/**
* Resolves the source file for a Pest-generated test class.
*
* Pest synthesises a per-test class via `eval()` and writes the
* original test file path to a `private static $__filename` property
* (see `src/Factories/TestCaseFactory.php`). Reflecting on the class
* with `getFileName()` would return the eval'd location, which
* `Graph::relative()` rejects — losing the file mapping.
*
* Strategy:
* 1. Read the `__filename` static if the class declares it (Pest
* tests).
* 2. Otherwise use `getFileName()` and skip eval'd frames by
* walking up the parent class chain — a plain PHPUnit test
* lives in a real file at the top of that chain.
*/
private static function resolveFailedTestFile(string $testId): ?string
{
$class = strstr($testId, '::', true);
if (! is_string($class) || $class === '') {
return null;
}
try {
$reflection = new \ReflectionClass($class);
} catch (\ReflectionException) {
return null;
}
if ($reflection->hasProperty('__filename')) {
try {
$filename = $reflection->getStaticPropertyValue('__filename');
} catch (\ReflectionException) {
$filename = null;
}
if (is_string($filename) && $filename !== '' && ! str_contains($filename, "eval()'d")) {
return $filename;
}
}
$current = $reflection;
while ($current !== false) {
$file = $current->getFileName();
if (is_string($file) && $file !== '' && ! str_contains($file, "eval()'d")) {
return $file;
}
$current = $current->getParentClass();
}
return null;
}
private function coverageReportActive(): bool
{
try {

View File

@ -30,12 +30,12 @@ final readonly class BaselineSync
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
// Project-local directory where artifacts from previous downloads are
// kept (one subfolder per workflow run id). Hitting the same run id on
// a later fetch skips the `gh run download` round trip entirely —
// artifacts are immutable per run id, so the cached bytes are exactly
// what gh would re-download.
private const string DOWNLOAD_CACHE_REL_DIR = '.pest/artifacts';
// Subdirectory under the per-project state dir (`~/.pest/tia/<project>/`)
// where artifacts from previous downloads are kept (one subfolder per
// workflow run id). Hitting the same run id on a later fetch skips
// the `gh run download` round trip entirely — artifacts are immutable
// per run id, so the cached bytes are exactly what gh would re-download.
private const string DOWNLOAD_CACHE_DIR = 'artifacts';
// Most recently downloaded artifacts to retain on disk. Branch
// switches and partial baseline rollouts hop across run ids — keeping
@ -381,9 +381,7 @@ YAML;
private function downloadCacheDir(string $projectRoot): string
{
return rtrim($projectRoot, '/\\')
.DIRECTORY_SEPARATOR
.str_replace('/', DIRECTORY_SEPARATOR, self::DOWNLOAD_CACHE_REL_DIR);
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
}
/**

View File

@ -150,14 +150,28 @@ final class Recorder
\pcov\stop();
/** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\all);
// pcov returns every executable line in every file it
// tracked: positive values for executed lines, `-1` for
// executable-but-not-run. A file with no positives was
// loaded but nothing in it ran during this test's window
// — typically a declaration-only file (Mailables, Enums,
// DTOs) pulled in by some service-provider's static `use`
// at framework boot. Including those attributes every
// globally-bootstrapped class to whichever test triggered
// the boot, blowing up the affected set on edits to those
// files.
$coveredFiles = self::filesWithExecutedLines($data);
} else {
/** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage();
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
\xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data);
}
foreach (array_keys($data) as $sourceFile) {
foreach ($coveredFiles as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
@ -172,7 +186,7 @@ final class Recorder
// Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
// so a signature change would leave implementing-class tests stale without this walk.
$this->linkSourceDependencies(array_keys($data));
$this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
@ -641,6 +655,37 @@ final class Recorder
return is_string($file) ? $file : null;
}
/**
* Filters pcov's `file => line => executionCount` map to the files
* that actually had at least one executed line. pcov reports `-1`
* for "executable but not run" and a positive count for executed
* lines; a file with no positives was loaded but contributed no
* executed code to this test.
*
* @param array<string, mixed> $data
* @return list<string>
*/
private static function filesWithExecutedLines(array $data): array
{
$out = [];
foreach ($data as $file => $lines) {
if (! is_string($file) || ! is_array($lines)) {
continue;
}
foreach ($lines as $count) {
if (is_int($count) && $count > 0) {
$out[] = $file;
continue 2;
}
}
}
return $out;
}
public function reset(): void
{
$this->currentTestFile = null;