This commit is contained in:
nuno maduro
2026-05-02 15:03:44 +01:00
parent 7d51601120
commit 4a8c2d7d78
12 changed files with 57 additions and 456 deletions

View File

@ -9,7 +9,6 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Collectors; use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\Replay; use Pest\Plugins\Tia\Replay;
use Pest\Preset; use Pest\Preset;
@ -300,10 +299,6 @@ trait Testable
$recorder->beginTest($this::class, $this->name(), self::$__filename); $recorder->beginTest($this::class, $this->name(), self::$__filename);
} }
$autoloadBeforeSetUp = $recorder->isActive()
? AutoloadEdges::snapshot()
: [];
parent::setUp(); parent::setUp();
Collectors::armAll($recorder); Collectors::armAll($recorder);
@ -315,18 +310,6 @@ trait Testable
} }
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
$recorder->linkSourcesForTest(
self::$__filename,
AutoloadEdges::newProjectFiles(
$autoloadBeforeSetUp,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
self::$__filename,
),
);
}
} }
private function __shortCircuitCachedPass(): void private function __shortCircuitCachedPass(): void

View File

@ -16,7 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace final class TiaRequiresPestTests extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{ {
public function __construct(private readonly string $className, private readonly string $filename) public function __construct(private readonly string $className, string $filename)
{ {
parent::__construct(sprintf( parent::__construct(sprintf(
'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].', 'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',

View File

@ -32,7 +32,7 @@ final class Kernel
/** /**
* Either the kernel is terminated or not. * Either the kernel is terminated or not.
*/ */
private bool $terminated; private bool $terminated = false;
/** /**
* The Kernel bootstrappers. * The Kernel bootstrappers.
@ -64,12 +64,7 @@ final class Kernel
/** /**
* Creates a new Kernel instance. * Creates a new Kernel instance.
*/ */
public function __construct( public function __construct(private readonly Application $application, private readonly OutputInterface $output) {}
private Application $application,
private OutputInterface $output,
) {
$this->terminated = false;
}
/** /**
* Boots the Kernel. * Boots the Kernel.

View File

@ -631,6 +631,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! $graph instanceof Graph if (! $graph instanceof Graph
&& ! $forceRebuild && ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift && ! $this->baselineFetchAttemptedForDrift
&& $this->watchPatterns->isBaselined()
&& $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) { && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
$this->baselineFetchAttemptedForDrift = true; $this->baselineFetchAttemptedForDrift = true;
$graph = $this->loadGraph($projectRoot); $graph = $this->loadGraph($projectRoot);
@ -1415,10 +1416,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
foreach ($arguments as $index => $arg) { foreach ($arguments as $index => $arg) {
if ($arg === '' || str_starts_with($arg, '-')) { if ($arg === '') {
continue;
}
if (str_starts_with($arg, '-')) {
continue; continue;
} }
if ($index > 0) { if ($index > 0) {
$previous = $arguments[$index - 1] ?? ''; $previous = $arguments[$index - 1] ?? '';
if (in_array($previous, $valueTakingFlags, true)) { if (in_array($previous, $valueTakingFlags, true)) {
@ -1507,6 +1510,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$this->baselineFetchAttemptedForDrift = true; $this->baselineFetchAttemptedForDrift = true;
if (! $this->watchPatterns->isBaselined()) {
return null;
}
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) { if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
return null; return null;
} }

View File

@ -283,7 +283,7 @@ YAML;
/** /**
* @param-out string|null $failureKind * @param-out string|null $failureKind
* *
* @return array{graph: string, coverage: ?string}|null * @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/ */
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
{ {

View File

@ -48,6 +48,18 @@ final class Configuration
return $this; return $this;
} }
/**
* @return $this
*/
public function baselined(): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->markBaselined();
return $this;
}
/** /**
* @param array<string, string> $patterns glob → project-relative test dir * @param array<string, string> $patterns glob → project-relative test dir
* @return $this * @return $this

View File

@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
/**
* @internal
*/
final readonly class AutoloadEdges
{
/**
* @return array<string, true>
*/
public static function snapshot(): array
{
$files = [];
foreach (get_included_files() as $file) {
if ($file !== '') {
$files[$file] = true;
}
}
return $files;
}
/**
* @param array<string, true> $before
* @param array<string, true> $after
* @return list<string>
*/
public static function newProjectFiles(array $before, array $after, string $projectRoot, ?string $testFile = null): array
{
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$testReal = is_string($testFile) && $testFile !== '' ? @realpath($testFile) : false;
$out = [];
foreach (array_keys($after) as $file) {
if (isset($before[$file])) {
continue;
}
$real = @realpath($file);
if ($real === false) {
$real = $file;
}
if ($testReal !== false && $real === $testReal) {
continue;
}
if (! str_starts_with($real, $root)) {
continue;
}
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
if (self::ignored($relative)) {
continue;
}
if (! str_ends_with($relative, '.php')) {
continue;
}
$out[$real] = true;
}
return array_keys($out);
}
private static function ignored(string $relative): bool
{
static $prefixes = [
'vendor/',
'node_modules/',
'storage/framework/',
'bootstrap/cache/',
];
foreach ($prefixes as $prefix) {
if (str_starts_with($relative, (string) $prefix)) {
return true;
}
}
return false;
}
}

View File

@ -522,7 +522,7 @@ final class Graph
$files = []; $files = [];
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
if (! self::shouldRerun($result['status'])) { if (! $this->shouldRerun($result['status'])) {
continue; continue;
} }
@ -549,7 +549,7 @@ final class Graph
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
if (! self::shouldRerun($result['status'])) { if (! $this->shouldRerun($result['status'])) {
continue; continue;
} }
@ -563,14 +563,20 @@ final class Graph
return false; return false;
} }
private static function shouldRerun(int $status): bool private function shouldRerun(int $status): bool
{ {
$testStatus = TestStatus::from($status); $testStatus = TestStatus::from($status);
if ($testStatus->isFailure()) {
return true;
}
if ($testStatus->isError()) {
return true;
}
if ($testStatus->isIncomplete()) {
return true;
}
return $testStatus->isFailure() return $testStatus->isRisky();
|| $testStatus->isError()
|| $testStatus->isIncomplete()
|| $testStatus->isRisky();
} }
/** /**

View File

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\TestSuite; use Pest\TestSuite;
use ReflectionClass; use ReflectionClass;
@ -33,21 +32,6 @@ final class Recorder
/** @var array<string, bool> */ /** @var array<string, bool> */
private array $classUsesDatabaseCache = []; private array $classUsesDatabaseCache = [];
/** @var array<string, list<string>> */
private array $fileToClassNames = [];
/** @var array<string, true> */
private array $indexedClassNames = [];
/** @var array<string, list<string>> */
private array $classDependencyCache = [];
/** @var array<string, list<string>> */
private array $testImportFileCache = [];
/** @var array<string, true> */
private array $includedFilesAtTestStart = [];
private bool $active = false; private bool $active = false;
private bool $driverChecked = false; private bool $driverChecked = false;
@ -89,13 +73,6 @@ final class Recorder
return $this->driverAvailable; return $this->driverAvailable;
} }
public function driver(): string
{
$this->driverAvailable();
return $this->driver;
}
public function beginTest(string $className, string $methodName, string $fallbackFile): void public function beginTest(string $className, string $methodName, string $fallbackFile): void
{ {
if (! $this->active || ! $this->driverAvailable()) { if (! $this->active || ! $this->driverAvailable()) {
@ -113,15 +90,11 @@ final class Recorder
} }
$this->currentTestFile = $file; $this->currentTestFile = $file;
$this->includedFilesAtTestStart = AutoloadEdges::snapshot();
if ($this->classUsesDatabase($className)) { if ($this->classUsesDatabase($className)) {
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
} }
// $this->linkAncestorFiles($className);
// $this->linkImportedFiles($file);
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\clear(); \pcov\clear();
\pcov\start(); \pcov\start();
@ -166,19 +139,7 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
foreach (AutoloadEdges::newProjectFiles(
$this->includedFilesAtTestStart,
AutoloadEdges::snapshot(),
TestSuite::getInstance()->rootPath,
$this->currentTestFile,
) as $sourceFile) {
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
}
// $this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null; $this->currentTestFile = null;
$this->includedFilesAtTestStart = [];
} }
public function linkSource(string $sourceFile): void public function linkSource(string $sourceFile): void
@ -198,295 +159,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
/** @param iterable<int, string> $sourceFiles */
public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void
{
if (! $this->active) {
return;
}
if ($testFile === '') {
return;
}
foreach ($sourceFiles as $sourceFile) {
if ($sourceFile === '') {
continue;
}
$this->perTestFiles[$testFile][$sourceFile] = true;
}
}
/** @param array<int, string> $coveredFiles */
private function linkSourceDependencies(array $coveredFiles): void
{
if ($this->currentTestFile === null) {
return;
}
$this->refreshClassMap();
foreach ($coveredFiles as $coveredFile) {
if (! isset($this->fileToClassNames[$coveredFile])) {
continue;
}
foreach ($this->fileToClassNames[$coveredFile] as $name) {
foreach ($this->classDependencies($name) as $depFile) {
$this->perTestFiles[$this->currentTestFile][$depFile] = true;
}
}
}
}
private function refreshClassMap(): void
{
$names = array_merge(
get_declared_classes(),
get_declared_interfaces(),
get_declared_traits(),
);
foreach ($names as $name) {
if (isset($this->indexedClassNames[$name])) {
continue;
}
$this->indexedClassNames[$name] = true;
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
continue;
}
$reflection = new ReflectionClass($name);
if ($reflection->isInternal()) {
continue;
}
$file = $reflection->getFileName();
if (! is_string($file)) {
continue;
}
if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
continue;
}
$this->fileToClassNames[$file][] = $name;
}
}
/** @return list<string> */
private function classDependencies(string $className): array
{
if (isset($this->classDependencyCache[$className])) {
return $this->classDependencyCache[$className];
}
if (! class_exists($className, false)
&& ! interface_exists($className, false)
&& ! trait_exists($className, false)) {
return $this->classDependencyCache[$className] = [];
}
$reflection = new ReflectionClass($className);
$files = [];
$linkSymbol = static function (string $name) use (&$files): void {
if (! class_exists($name, false)
&& ! interface_exists($name, false)
&& ! trait_exists($name, false)) {
return;
}
$r = new ReflectionClass($name);
if ($r->isInternal()) {
return;
}
$f = $r->getFileName();
if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
return;
}
$files[$f] = true;
};
foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname);
}
foreach ($reflection->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $reflection->getParentClass();
while ($parent !== false && ! $parent->isInternal()) {
$f = $parent->getFileName();
if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$f] = true;
}
foreach ($parent->getTraitNames() as $tname) {
$linkSymbol($tname);
}
$parent = $parent->getParentClass();
}
return $this->classDependencyCache[$className] = array_keys($files);
}
private function linkAncestorFiles(string $className): void
{
if (! class_exists($className, false)) {
return;
}
$reflection = new ReflectionClass($className);
$parent = $reflection->getParentClass();
while ($parent !== false) {
if ($parent->isInternal()) {
break;
}
$file = $parent->getFileName();
if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$this->perTestFiles[(string) $this->currentTestFile][$file] = true;
}
$parent = $parent->getParentClass();
}
}
private function linkImportedFiles(string $testFile): void
{
if ($this->currentTestFile === null) {
return;
}
foreach ($this->importedFilesFor($testFile) as $file) {
$this->perTestFiles[$this->currentTestFile][$file] = true;
}
}
/**
* @return list<string>
*/
private function importedFilesFor(string $testFile): array
{
if (array_key_exists($testFile, $this->testImportFileCache)) {
return $this->testImportFileCache[$testFile];
}
$source = @file_get_contents($testFile);
if ($source === false) {
return $this->testImportFileCache[$testFile] = [];
}
$files = [];
foreach ($this->importedClassNames($source) as $className) {
$file = $this->findAutoloadFile($className);
if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) {
$files[$file] = true;
}
}
return $this->testImportFileCache[$testFile] = array_keys($files);
}
/**
* @return list<string>
*/
private function importedClassNames(string $source): array
{
preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches);
$classes = [];
foreach ($matches[1] as $import) {
$import = trim($import);
if ($import === '') {
continue;
}
$open = strpos($import, '{');
$close = strrpos($import, '}');
if ($open !== false && $close !== false && $close > $open) {
$prefix = trim(trim(substr($import, 0, $open)), '\\');
$items = explode(',', substr($import, $open + 1, $close - $open - 1));
foreach ($items as $item) {
$class = $this->normaliseImportedClass($prefix.'\\'.trim($item));
if ($class !== null) {
$classes[$class] = true;
}
}
continue;
}
$class = $this->normaliseImportedClass($import);
if ($class !== null) {
$classes[$class] = true;
}
}
return array_keys($classes);
}
private function normaliseImportedClass(string $import): ?string
{
$import = trim(trim($import), '\\');
if ($import === '') {
return null;
}
$parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false) {
return null;
}
$class = trim(trim($parts[0]), '\\');
return $class === '' ? null : $class;
}
private function findAutoloadFile(string $className): ?string
{
foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader)) {
continue;
}
if (! is_object($loader[0])) {
continue;
}
if (! method_exists($loader[0], 'findFile')) {
continue;
}
/** @var mixed $file */
$file = $loader[0]->findFile($className);
if (is_string($file) && $file !== '') {
$real = @realpath($file);
return $real === false ? $file : $real;
}
}
return null;
}
private function classUsesDatabase(string $className): bool private function classUsesDatabase(string $className): bool
{ {
if (array_key_exists($className, $this->classUsesDatabaseCache)) { if (array_key_exists($className, $this->classUsesDatabaseCache)) {
@ -691,9 +363,6 @@ final class Recorder
$this->perTestUsesDatabase = []; $this->perTestUsesDatabase = [];
$this->classFileCache = []; $this->classFileCache = [];
$this->classUsesDatabaseCache = []; $this->classUsesDatabaseCache = [];
$this->fileToClassNames = [];
$this->indexedClassNames = [];
$this->classDependencyCache = [];
$this->sourceScope = null; $this->sourceScope = null;
$this->active = false; $this->active = false;
} }

View File

@ -35,6 +35,8 @@ final class WatchPatterns
private bool $filtered = false; private bool $filtered = false;
private bool $baselined = false;
public function useDefaults(string $projectRoot): void public function useDefaults(string $projectRoot): void
{ {
$testPath = TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
@ -156,12 +158,23 @@ final class WatchPatterns
return $this->filtered; return $this->filtered;
} }
public function markBaselined(): void
{
$this->baselined = true;
}
public function isBaselined(): bool
{
return $this->baselined;
}
public function reset(): void public function reset(): void
{ {
$this->patterns = []; $this->patterns = [];
$this->enabled = false; $this->enabled = false;
$this->locally = false; $this->locally = false;
$this->filtered = false; $this->filtered = false;
$this->baselined = false;
} }
private function globMatches(string $pattern, string $file): bool private function globMatches(string $pattern, string $file): bool

View File

@ -45,6 +45,9 @@ final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscrib
Panic::with(new TiaRequiresPestTests($className, $test->file())); Panic::with(new TiaRequiresPestTests($className, $test->file()));
} }
/**
* @param class-string $className
*/
private function usesTestableTrait(string $className): bool private function usesTestableTrait(string $className): bool
{ {
$reflection = new ReflectionClass($className); $reflection = new ReflectionClass($className);

View File

@ -11,6 +11,9 @@ use Fidry\CpuCoreCounter\CpuCoreCounter;
*/ */
final class Cpu final class Cpu
{ {
/**
* @param int<1, max> $fallback
*/
public static function cores(int $fallback = 4): int public static function cores(int $fallback = 4): int
{ {
return (new CpuCoreCounter)->getCountWithFallback($fallback); return (new CpuCoreCounter)->getCountWithFallback($fallback);