Compare commits

...

39 Commits

Author SHA1 Message Date
7bea819978 wip 2026-05-02 18:47:26 +01:00
4280233b40 wip 2026-05-02 18:37:24 +01:00
d6db3a8a20 wip 2026-05-02 18:32:05 +01:00
51c8ce4df6 wip 2026-05-02 18:31:32 +01:00
5b8393b925 wip 2026-05-02 18:25:41 +01:00
e4d9b61fdf wip 2026-05-02 18:25:27 +01:00
e2d940cd53 wip 2026-05-02 18:25:21 +01:00
380ccd30b4 wip 2026-05-02 18:03:25 +01:00
31c200716d wip 2026-05-02 18:03:14 +01:00
6add4da543 wip 2026-05-02 18:02:20 +01:00
8ddcd3e853 wip 2026-05-02 18:02:13 +01:00
e3e178fd94 wip 2026-05-02 17:59:21 +01:00
7b1ec9f003 wip 2026-05-02 17:59:13 +01:00
1e48c5d473 wip 2026-05-02 17:59:00 +01:00
d00ec95dd9 wip 2026-05-02 17:58:55 +01:00
89f3d6cb39 wip 2026-05-02 17:45:54 +01:00
a07a2e512a wip 2026-05-02 17:39:15 +01:00
57eecb2b3d wip 2026-05-02 17:38:12 +01:00
9f804dc954 wip 2026-05-02 17:38:08 +01:00
7cbad4c589 wip 2026-05-02 17:38:01 +01:00
5cae93b059 wip 2026-05-02 17:37:56 +01:00
df829ad19d wip 2026-05-02 17:37:47 +01:00
635460653c wip 2026-05-02 17:37:34 +01:00
1aa80dc398 wip 2026-05-02 17:18:35 +01:00
8a14056111 wip 2026-05-02 17:15:46 +01:00
f247dd8e7b wip 2026-05-02 17:11:49 +01:00
1c7c9754fd wip 2026-05-02 17:07:08 +01:00
5f37939fda wip 2026-05-02 17:02:11 +01:00
28305fcb7a wip 2026-05-02 16:35:52 +01:00
5242803694 wip 2026-05-02 15:54:00 +01:00
925935a7e8 wip 2026-05-02 15:33:38 +01:00
460401c379 wip 2026-05-02 15:26:58 +01:00
348b439172 wip 2026-05-02 15:15:53 +01:00
a4e77766c5 wip 2026-05-02 15:07:51 +01:00
4a8c2d7d78 wip 2026-05-02 15:03:44 +01:00
7d51601120 wip 2026-05-02 14:15:37 +01:00
631bbe318b wip 2026-05-02 13:43:32 +01:00
9b7c15d5b6 wip 2026-05-02 12:03:35 +01:00
872796bd9b wip 2026-05-02 12:00:47 +01:00
38 changed files with 1324 additions and 1642 deletions

View File

@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\ApplicationForWrapperWorker;
use ParaTest\WrapperRunner\WrapperWorker; use ParaTest\WrapperRunner\WrapperWorker;
use Pest\Kernel; use Pest\Kernel;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\ConsoleOutput;
@ -58,6 +59,15 @@ $bootPest = (static function (): void {
} }
} }
$container = Container::getInstance();
$rootPath = dirname(PHPUNIT_COMPOSER_INSTALL, 2);
foreach (Kernel::RESTARTERS as $restarterClass) {
$restarter = $container->get($restarterClass);
$restarter->maybeRestart($rootPath, $_SERVER['argv']);
}
assert(isset($getopt['status-file']) && is_string($getopt['status-file'])); assert(isset($getopt['status-file']) && is_string($getopt['status-file']));
$statusFile = fopen($getopt['status-file'], 'wb'); $statusFile = fopen($getopt['status-file'], 'wb');
assert(is_resource($statusFile)); assert(is_resource($statusFile));

View File

@ -20,7 +20,6 @@
"php": "^8.3.0", "php": "^8.3.0",
"brianium/paratest": "^7.20.0", "brianium/paratest": "^7.20.0",
"composer/xdebug-handler": "^3.0.5", "composer/xdebug-handler": "^3.0.5",
"fidry/cpu-core-counter": "^1.3",
"nunomaduro/collision": "^8.9.4", "nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin": "^4.0.0",
@ -94,7 +93,6 @@
"test:inline": "php bin/pest --configuration=phpunit.inline.xml", "test:inline": "php bin/pest --configuration=phpunit.inline.xml",
"test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3", "test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3",
"test:integration": "php bin/pest --group=integration -v", "test:integration": "php bin/pest --group=integration -v",
"test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml",
"update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots",
"test": [ "test": [
"@test:lint", "@test:lint",
@ -102,8 +100,7 @@
"@test:type:coverage", "@test:type:coverage",
"@test:unit", "@test:unit",
"@test:parallel", "@test:parallel",
"@test:integration", "@test:integration"
"@test:tia"
] ]
}, },
"extra": { "extra": {

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use PHPUnit\TextUI\Configuration\Builder;
/**
* @internal
*/
final class BootPhpUnitConfiguration implements Bootstrapper
{
public function boot(): void
{
(new Builder)->build(['pest']);
}
}

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;
@ -83,10 +82,15 @@ trait Testable
public bool $__ran = false; public bool $__ran = false;
/** /**
* Set when a `BeforeEachable` plugin returns a cached success result. * The active replay mode for this test, set in `setUp()` and checked
* Checked in `__runTest` and `tearDown` to skip body + cleanup. * in `__runTest()` / `tearDown()` to skip the body and after-each.
*/ */
private bool $__cachedPass = false; private Replay $__replay = Replay::No;
/**
* The cached assertion count to replay, captured when entering replay mode.
*/
private int $__replayAssertions = 0;
/** /**
* The test's test closure. * The test's test closure.
@ -240,8 +244,6 @@ trait Testable
{ {
TestSuite::getInstance()->test = $this; TestSuite::getInstance()->test = $this;
$this->__cachedPass = false;
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description; $description = $method->description;
@ -283,11 +285,10 @@ trait Testable
assert($status !== null); assert($status !== null);
match ($replay) { match ($replay) {
Replay::Pass => $this->__shortCircuitCachedPass(), Replay::Pass, Replay::Risky => $this->__beginReplay($replay, $tia),
Replay::Skipped => $this->markTestSkipped($status->message()), Replay::Skipped => $this->markTestSkipped($status->message()),
Replay::Incomplete => $this->markTestIncomplete($status->message()), Replay::Incomplete => $this->markTestIncomplete($status->message()),
Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'), Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
Replay::No => null,
}; };
return; return;
@ -300,10 +301,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,23 +312,12 @@ 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 __beginReplay(Replay $replay, Tia $tia): void
{ {
$this->__cachedPass = true; $this->__replay = $replay;
$this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name());
$this->__ran = true; $this->__ran = true;
} }
@ -367,7 +353,7 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
if ($this->__cachedPass) { if ($this->__replay !== Replay::No) {
TestSuite::getInstance()->test = null; TestSuite::getInstance()->test = null;
return; return;
@ -398,19 +384,12 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__cachedPass) { if ($this->__replay === Replay::Pass || $this->__replay === Replay::Risky) {
// Feed the exact assertion count captured during the recorded if ($this->__replay === Replay::Pass && $this->__replayAssertions === 0) {
// run so Pest's "Tests: N passed (M assertions)" banner stays
// accurate on replay instead of collapsing to 1-per-test.
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->getAssertionCount($this::class.'::'.$this->name());
if ($assertions === 0) {
$this->expectNotToPerformAssertions(); $this->expectNotToPerformAssertions();
} }
$this->addToAssertionCount($assertions); $this->addToAssertionCount($this->__replayAssertions);
return null; return null;
} }

View File

@ -16,12 +16,12 @@ 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 $file) public function __construct(private readonly string $className, string $filename)
{ {
parent::__construct(sprintf( parent::__construct(sprintf(
'Tia mode requires Pest tests, but encountered PHPUnit class [%s] in [%s].', 'Tia mode requires only functional based Pest tests, but encountered PHPUnit class [%s] in [%s].',
$className, $className,
$file, $filename,
)); ));
} }

View File

@ -166,7 +166,7 @@ final class TestCaseFactory
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
$traitsCode $traitsCode
private static \$__filename = '$filename'; public static \$__filename = '$filename';
$methodsCode $methodsCode
} }

View File

@ -27,8 +27,13 @@ use Whoops\Exception\Inspector;
/** /**
* @internal * @internal
*/ */
final readonly class Kernel final class Kernel
{ {
/**
* Either the kernel is terminated or not.
*/
private bool $terminated = false;
/** /**
* The Kernel bootstrappers. * The Kernel bootstrappers.
* *
@ -36,6 +41,7 @@ final readonly class Kernel
*/ */
private const array BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Bootstrappers\BootPhpUnitConfiguration::class,
Plugins\Tia\Bootstrapper::class, Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,
@ -59,12 +65,7 @@ final readonly 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,
) {
//
}
/** /**
* Boots the Kernel. * Boots the Kernel.
@ -125,9 +126,13 @@ final readonly class Kernel
$configuration = Registry::get(); $configuration = Registry::get();
$result = Facade::result(); $result = Facade::result();
return CallsAddsOutput::execute( $result = CallsAddsOutput::execute(
Result::exitCode($configuration, $result), Result::exitCode($configuration, $result),
); );
$this->terminate();
return $result;
} }
/** /**
@ -135,6 +140,12 @@ final readonly class Kernel
*/ */
public function terminate(): void public function terminate(): void
{ {
if ($this->terminated) {
return;
}
$this->terminated = true;
$preBufferOutput = Container::getInstance()->get(KernelDump::class); $preBufferOutput = Container::getInstance()->get(KernelDump::class);
assert($preBufferOutput instanceof KernelDump); assert($preBufferOutput instanceof KernelDump);

View File

@ -19,6 +19,7 @@ use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\JsModuleGraph; use Pest\Plugins\Tia\JsModuleGraph;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\SourceScope;
use Pest\Plugins\Tia\Storage; use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
@ -29,7 +30,6 @@ use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
use Throwable;
/** /**
* @internal * @internal
@ -40,12 +40,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string OPTION = '--tia'; private const string OPTION = '--tia';
private const string NO_OPTION = '--no-tia';
private const string FRESH_OPTION = '--fresh'; private const string FRESH_OPTION = '--fresh';
private const string REFETCH_OPTION = '--refetch'; private const string REFETCH_OPTION = '--refetch';
private const string FILTERED_OPTION = '--filtered'; private const string FILTERED_OPTION = '--filtered';
private const string LOCALLY_OPTION = '--locally';
private const string BASELINED_OPTION = '--baselined';
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_GRAPH = 'graph.json';
public const string KEY_AFFECTED = 'affected.json'; public const string KEY_AFFECTED = 'affected.json';
@ -70,6 +84,25 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
/**
* 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-php', '--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
private bool $graphWritten = false; private bool $graphWritten = false;
private bool $replayRan = false; private bool $replayRan = false;
@ -93,17 +126,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */ /** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
private ?array $startFingerprint = null; private ?array $startFingerprint = null;
private function workerEdgesKey(string $token): string private bool $piggybackCoverage = false;
{
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
}
private function workerResultsKey(string $token): string
{
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
}
private bool $piggybackCoverage = false;
private bool $recordingActive = false; private bool $recordingActive = false;
@ -171,13 +194,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
public static function isEnabledForRun(array $arguments): bool public static function isEnabledForRun(array $arguments): bool
{ {
if (in_array(self::OPTION, $arguments, true)) {
return true;
}
$watchPatterns = Container::getInstance()->get(WatchPatterns::class); $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
assert($watchPatterns instanceof WatchPatterns); assert($watchPatterns instanceof WatchPatterns);
self::applyWatchPatternMarks($arguments, $watchPatterns);
if (in_array(self::OPTION, $arguments, true) || self::envFlagEnabled(self::ENV_TIA)) {
return true;
}
if (! $watchPatterns->isEnabled()) { if (! $watchPatterns->isEnabled()) {
return false; return false;
} }
@ -185,6 +210,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true)); return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
} }
/**
* @param array<int, string> $arguments
*/
private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void
{
if (in_array(self::LOCALLY_OPTION, $arguments, true) || self::envFlagEnabled(self::ENV_LOCALLY)) {
$watchPatterns->markEnabled();
$watchPatterns->markLocally();
}
if (in_array(self::BASELINED_OPTION, $arguments, true) || self::envFlagEnabled(self::ENV_BASELINED)) {
$watchPatterns->markBaselined();
}
}
private static function envFlagEnabled(string $name): bool
{
return filter_var(getenv($name), FILTER_VALIDATE_BOOL);
}
public function getStatus(string $filename, string $testId): ?TestStatus public function getStatus(string $filename, string $testId): ?TestStatus
{ {
if (! $this->replayGraph instanceof Graph) { if (! $this->replayGraph instanceof Graph) {
@ -245,19 +290,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var WatchPatterns $watchPatterns */ /** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class); $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments); 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() $alwaysEnabled = $watchPatterns->isEnabled()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL); && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
$enabled = $cliEnabled || $alwaysEnabled; $enabled = ! $disabled && ($cliEnabled || $alwaysEnabled);
$this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered()) $this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered())
&& ! $this->hasExplicitPathArgument($arguments); && ! $this->hasExplicitPathArgument($arguments);
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::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::FRESH_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
$arguments = $this->popArgument(self::FILTERED_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) {
$this->forceRefetch = false;
$this->filteredMode = false;
$this->freshRebuild = false;
return $arguments;
}
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
$this->freshRebuild = $forceRebuild; $this->freshRebuild = $forceRebuild;
@ -429,67 +487,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
); );
$mergedFiles = []; [$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys);
$mergedTables = [];
$mergedInertia = [];
foreach ($partialKeys as $key) {
$data = $this->readPartial($key);
if ($data === null) {
continue;
}
foreach ($data['files'] as $testFile => $sources) {
if (! isset($mergedFiles[$testFile])) {
$mergedFiles[$testFile] = [];
}
foreach ($sources as $source) {
$mergedFiles[$testFile][$source] = true;
}
}
foreach ($data['tables'] as $testFile => $tables) {
if (! isset($mergedTables[$testFile])) {
$mergedTables[$testFile] = [];
}
foreach ($tables as $table) {
$mergedTables[$testFile][$table] = true;
}
}
foreach ($data['inertia'] as $testFile => $components) {
if (! isset($mergedInertia[$testFile])) {
$mergedInertia[$testFile] = [];
}
foreach ($components as $component) {
$mergedInertia[$testFile][$component] = true;
}
}
$this->state->delete($key);
}
$finalised = [];
foreach ($mergedFiles as $testFile => $sourceSet) {
$finalised[$testFile] = array_keys($sourceSet);
}
$finalisedTables = [];
foreach ($mergedTables as $testFile => $tableSet) {
$finalisedTables[$testFile] = array_keys($tableSet);
}
$finalisedInertia = [];
foreach ($mergedInertia as $testFile => $componentSet) {
$finalisedInertia[$testFile] = array_keys($componentSet);
}
if ($finalised === []) { if ($finalised === []) {
if ($this->replayRan) { if ($this->replayRan) {
@ -534,7 +532,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! Fingerprint::structuralMatches($stored, $current)) { if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current); $drift = Fingerprint::structuralDrift($stored, $current);
$this->renderBadge('INFO', sprintf( $this->renderChild(sprintf(
'Graph structure outdated (%s).', 'Graph structure outdated (%s).',
$this->formatStructuralDrift($drift), $this->formatStructuralDrift($drift),
)); ));
@ -607,8 +605,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
$branchSha = $graph->recordedAtSha($this->branch); $branchSha = $graph->recordedAtSha($this->branch);
if ($changedFiles->gitAvailable() if ($branchSha !== null
&& $branchSha !== null
&& $changedFiles->since($branchSha) === null) { && $changedFiles->since($branchSha) === null) {
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.'); $this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
$graph = null; $graph = null;
@ -618,6 +615,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);
@ -761,12 +759,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
{ {
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
if (! $changedFiles->gitAvailable()) {
$this->renderBadge('WARN', 'Git unavailable — running full suite.');
return $arguments;
}
$branchSha = $graph->recordedAtSha($this->branch); $branchSha = $graph->recordedAtSha($this->branch);
$changed = $changedFiles->since($branchSha) ?? []; $changed = $changedFiles->since($branchSha) ?? [];
@ -1009,7 +1001,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
$this->state->write($this->workerEdgesKey($this->workerToken()), $json); $this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json);
} }
/** /**
@ -1071,7 +1063,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
$this->state->write($this->workerResultsKey($this->workerToken()), $json); $this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json);
} }
/** /**
@ -1157,6 +1149,43 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $token; 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) {
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 * @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
*/ */
@ -1334,21 +1363,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return null; return null;
} }
$reflection = new \ReflectionClass($class); assert(property_exists($class, '__filename') && is_string($class::$__filename));
if ($reflection->hasProperty('__filename')) { $filename = $class::$__filename;
try {
$filename = $reflection->getStaticPropertyValue('__filename');
} catch (\ReflectionException) {
$filename = null;
}
if (is_string($filename) && $filename !== '' && ! str_contains($filename, "eval()'d")) { if ($filename !== '' && ! str_contains($filename, "eval()'d")) {
return $filename; return $filename;
} }
}
$current = $reflection; $current = new \ReflectionClass($class);
while ($current !== false) { while ($current !== false) {
$file = $current->getFileName(); $file = $current->getFileName();
@ -1365,12 +1388,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function coverageReportActive(): bool private function coverageReportActive(): bool
{ {
try {
/** @var Coverage $coverage */
$coverage = Container::getInstance()->get(Coverage::class); $coverage = Container::getInstance()->get(Coverage::class);
} catch (Throwable) { assert($coverage instanceof Coverage);
return false;
}
return $coverage->coverage === true; return $coverage->coverage === true;
} }
@ -1380,35 +1399,23 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function hasExplicitPathArgument(array $arguments): bool private function hasExplicitPathArgument(array $arguments): bool
{ {
static $valueTakingFlags = [
'-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-php', '--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$testPaths = \Pest\Plugins\Tia\SourceScope::testPaths($projectRoot); $testPaths = SourceScope::testPaths();
if ($testPaths === []) { if ($testPaths === []) {
return false; return false;
} }
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, self::VALUE_TAKING_FLAGS, true)) {
continue; continue;
} }
} }
@ -1494,6 +1501,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

@ -32,6 +32,29 @@ final readonly class BaselineSync
private const int FETCH_COOLDOWN_SECONDS = 86400; private const int FETCH_COOLDOWN_SECONDS = 86400;
private const array DIAGNOSES = [
'network' => [
'pattern' => '/could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo/i',
'message' => 'network error (offline or DNS unreachable). Try again when connected.',
],
'gh-auth' => [
'pattern' => '/authentication failed|not logged in|requires authentication|bad credentials|401/i',
'message' => 'authentication failed — run `gh auth login` and retry.',
],
'rate-limit' => [
'pattern' => '/rate limit|too many requests|secondary rate limit/i',
'message' => 'GitHub API rate limit hit — try again later.',
],
'not-found' => [
'pattern' => '/404|not found|repository not found/i',
'message' => 'workflow or artifact not found in repo.',
],
'forbidden' => [
'pattern' => '/403|forbidden|access denied/i',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
],
];
public function __construct( public function __construct(
private State $state, private State $state,
private OutputInterface $output, private OutputInterface $output,
@ -64,8 +87,9 @@ final readonly class BaselineSync
return false; return false;
} }
$failureKind = null; $result = $this->download($repo, $projectRoot, $hasAnchor);
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor); $payload = $result['payload'];
$failureKind = $result['failureKind'];
if ($payload === null) { if ($payload === null) {
if ($failureKind === 'no-runs' || $failureKind === null) { if ($failureKind === 'no-runs' || $failureKind === null) {
@ -162,7 +186,7 @@ final readonly class BaselineSync
$this->output->writeln(['', ...$indentedYaml, '']); $this->output->writeln(['', ...$indentedYaml, '']);
$this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo)); $this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
$this->renderChild('Details: https://pestphp.com/docs/tia/ci'); $this->renderChild('Details: https://pestphp.com/docs/tia');
} }
private function isCi(): bool private function isCi(): bool
@ -281,14 +305,78 @@ YAML;
} }
/** /**
* @param-out string|null $failureKind * @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string}
*
* @return array{graph: string, coverage: ?string}|null
*/ */
private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array
{ {
$failureKind = null; $this->validateGhDependencies($hasAnchor);
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$this->panicOnClassifiedError($listError, 'Failed to query baseline runs', $hasAnchor);
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return ['payload' => null, 'failureKind' => $listError['kind']];
}
if ($runId === null) {
return ['payload' => null, 'failureKind' => 'no-runs'];
}
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderBadge('INFO', sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return ['payload' => $this->readArtifact($runCacheDir), 'failureKind' => null];
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return ['payload' => null, 'failureKind' => null];
}
$download = $this->downloadArtifact($repo, $runId, $runCacheDir, $hasAnchor);
if (! $download['success']) {
return ['payload' => null, 'failureKind' => $download['failureKind']];
}
$payload = $this->validateDownloadedArtifact($runCacheDir, $hasAnchor);
$this->trimDownloadCache($projectRoot);
return ['payload' => $payload, 'failureKind' => null];
}
/**
* @param array{kind: string, message: string} $diagnosis
*/
private function panicOnClassifiedError(array $diagnosis, string $contextPrefix, bool $hasAnchor): void
{
if (! in_array($diagnosis['kind'], ['forbidden', 'not-found'], true)) {
return;
}
Panic::with(new BaselineFetchFailed(
sprintf('%s — %s', $contextPrefix, $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
private function validateGhDependencies(bool $hasAnchor): void
{
if (! $this->commandExists('gh')) { if (! $this->commandExists('gh')) {
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.', 'GitHub CLI (gh) not found — cannot fetch baseline.',
@ -304,52 +392,13 @@ YAML;
$hasAnchor, $hasAnchor,
)); ));
} }
[$runId, $listError] = $this->latestSuccessfulRunIdWithError($repo);
if ($listError !== null) {
$failureKind = $listError['kind'];
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed(
sprintf('Failed to query baseline runs — %s', $listError['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
$this->renderBadge('WARN', sprintf(
'Failed to query baseline runs — %s',
$listError['message'],
));
return null;
}
if ($runId === null) {
$failureKind = 'no-runs';
return null;
}
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
@touch($runCacheDir);
$this->renderBadge('INFO', sprintf(
'Using cached baseline from %s (run %s).',
$repo,
$runId,
));
return $this->readArtifact($runCacheDir);
}
if (! @mkdir($runCacheDir, 0755, true) && ! is_dir($runCacheDir)) {
return null;
} }
/**
* @return array{success: bool, failureKind: ?string}
*/
private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array
{
$artifactSize = $this->artifactSize($repo, $runId); $artifactSize = $this->artifactSize($repo, $runId);
$this->renderBadge('INFO', $artifactSize !== null $this->renderBadge('INFO', $artifactSize !== null
@ -382,28 +431,29 @@ YAML;
$process->wait(); $process->wait();
$this->clearProgressLine(); $this->clearProgressLine();
if (! $process->isSuccessful()) { if ($process->isSuccessful()) {
return ['success' => true, 'failureKind' => null];
}
$this->cleanup($runCacheDir); $this->cleanup($runCacheDir);
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$failureKind = $diagnosis['kind'];
if (in_array($failureKind, ['forbidden', 'not-found'], true)) { $this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor);
Panic::with(new BaselineFetchFailed(
sprintf('Baseline download failed — %s', $diagnosis['message']),
'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
));
}
$this->renderBadge('WARN', sprintf( $this->renderBadge('WARN', sprintf(
'Baseline download failed — %s', 'Baseline download failed — %s',
$diagnosis['message'], $diagnosis['message'],
)); ));
return null; return ['success' => false, 'failureKind' => $diagnosis['kind']];
} }
/**
* @return array{graph: string, coverage: ?string, sizeOnDisk: int}
*/
private function validateDownloadedArtifact(string $runCacheDir, bool $hasAnchor): array
{
$payload = $this->readArtifact($runCacheDir); $payload = $this->readArtifact($runCacheDir);
if ($payload === null) { if ($payload === null) {
@ -416,8 +466,6 @@ YAML;
)); ));
} }
$this->trimDownloadCache($projectRoot);
return $payload; return $payload;
} }
@ -548,12 +596,10 @@ YAML;
$candidates = []; $candidates = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.') { if (in_array($entry, ['.', '..'], true)) {
continue;
}
if ($entry === '..') {
continue; continue;
} }
$path = $root.DIRECTORY_SEPARATOR.$entry; $path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) { if (! is_dir($path)) {
@ -624,59 +670,21 @@ YAML;
return ['kind' => 'unknown', 'message' => 'unknown error']; return ['kind' => 'unknown', 'message' => 'unknown error'];
} }
if (preg_match('/(could not resolve host|connection refused|connection reset|temporary failure in name resolution|network is unreachable|no route to host|i\/o timeout|tls handshake|getaddrinfo)/i', $output) === 1) { foreach (self::DIAGNOSES as $kind => $diagnosis) {
return [ if (preg_match($diagnosis['pattern'], $output) === 1) {
'kind' => 'network', return ['kind' => $kind, 'message' => $diagnosis['message']];
'message' => 'network error (offline or DNS unreachable). Try again when connected.', }
];
} }
if (preg_match('/(authentication failed|not logged in|requires authentication|bad credentials|401)/i', $output) === 1) { return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))];
return [
'kind' => 'gh-auth',
'message' => 'authentication failed — run `gh auth login` and retry.',
];
}
if (preg_match('/(rate limit|too many requests|secondary rate limit)/i', $output) === 1) {
return [
'kind' => 'rate-limit',
'message' => 'GitHub API rate limit hit — try again later.',
];
}
if (preg_match('/(404|not found|repository not found)/i', $output) === 1) {
return [
'kind' => 'not-found',
'message' => 'workflow or artifact not found in repo.',
];
}
if (preg_match('/(403|forbidden|access denied)/i', $output) === 1) {
return [
'kind' => 'forbidden',
'message' => 'access denied — check that your `gh` token has repo + actions read scope.',
];
}
$message = trim(strtok($output, "\n"));
return ['kind' => 'unknown', 'message' => $message];
} }
private function commandExists(string $cmd): bool private function commandExists(string $cmd): bool
{ {
$probe = new Process(['command', '-v', $cmd]); $process = new Process(['which', $cmd]);
$probe->run(); $process->run();
if ($probe->isSuccessful()) { return $process->isSuccessful();
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
} }
private function cleanup(string $dir): void private function cleanup(string $dir): void
@ -685,13 +693,17 @@ YAML;
return; return;
} }
$entries = glob($dir.DIRECTORY_SEPARATOR.'*'); $iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST,
);
if ($entries !== false) { /** @var \SplFileInfo $entry */
foreach ($entries as $entry) { foreach ($iterator as $entry) {
if (is_file($entry)) { if ($entry->isDir()) {
@unlink($entry); @rmdir($entry->getPathname());
} } else {
@unlink($entry->getPathname());
} }
} }

View File

@ -17,18 +17,12 @@ final readonly class Bootstrapper implements BootstrapperContract
public function __construct(private Container $container) {} public function __construct(private Container $container) {}
public function boot(): void public function boot(): void
{
$this->container->add(State::class, new FileState($this->tempDir()));
}
/**
* across worktrees of the same repo. See {@see Storage} for the key
*/
private function tempDir(): string
{ {
$testSuite = $this->container->get(TestSuite::class); $testSuite = $this->container->get(TestSuite::class);
assert($testSuite instanceof TestSuite); assert($testSuite instanceof TestSuite);
return Storage::tempDir($testSuite->rootPath); $tempDir = Storage::tempDir($testSuite->rootPath);
$this->container->add(State::class, new FileState($tempDir));
} }
} }

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Exceptions\MissingDependency;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
@ -34,37 +35,27 @@ final readonly class ChangedFiles
foreach (array_keys($candidates) as $file) { foreach (array_keys($candidates) as $file) {
$snapshot = $lastRunTree[$file] ?? null; $snapshot = $lastRunTree[$file] ?? null;
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $current = $this->currentHash($file);
$exists = is_file($absolute);
if ($snapshot === null) { if ($snapshot === null || $current === null || $current !== $snapshot) {
$remaining[] = $file; $remaining[] = $file;
}
continue;
} }
if (! $exists) { return $remaining;
$remaining[] = $file; }
continue; private function currentHash(string $relativePath): ?string
{
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! is_file($absolute)) {
return null;
} }
$hash = ContentHash::of($absolute); $hash = ContentHash::of($absolute);
if ($hash === false) { return $hash === false ? null : $hash;
$remaining[] = $file;
continue;
}
if ($hash === $snapshot) {
continue;
}
$remaining[] = $file;
}
return $remaining;
} }
/** /**
@ -99,10 +90,6 @@ final readonly class ChangedFiles
*/ */
public function since(?string $sha): ?array public function since(?string $sha): ?array
{ {
if (! $this->gitAvailable()) {
return null;
}
$files = []; $files = [];
if ($sha !== null && $sha !== '') { if ($sha !== null && $sha !== '') {
@ -121,13 +108,10 @@ final readonly class ChangedFiles
if ($file === '') { if ($file === '') {
continue; continue;
} }
if ($this->shouldIgnore($file)) {
continue;
}
$unique[$file] = true; $unique[$file] = true;
} }
$candidates = array_keys($unique); $candidates = array_keys($this->filterIgnored($unique));
if ($sha !== null && $sha !== '') { if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha); return $this->filterBehaviourallyUnchanged($candidates, $sha);
@ -145,17 +129,9 @@ final readonly class ChangedFiles
$remaining = []; $remaining = [];
foreach ($files as $file) { foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $currentHash = $this->currentHash($file);
if (! is_file($absolute)) { if ($currentHash === null) {
$remaining[] = $file;
continue;
}
$currentHash = ContentHash::of($absolute);
if ($currentHash === false) {
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -169,9 +145,7 @@ final readonly class ChangedFiles
continue; continue;
} }
$baselineHash = ContentHash::ofContent($file, $baselineContent); if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) {
if ($currentHash !== $baselineHash) {
$remaining[] = $file; $remaining[] = $file;
} }
} }
@ -192,37 +166,52 @@ final readonly class ChangedFiles
return $process->getOutput(); return $process->getOutput();
} }
private function shouldIgnore(string $path): bool /**
* @param array<string, true> $candidates
* @return array<string, true>
*/
private function filterIgnored(array $candidates): array
{ {
static $prefixes = [ if ($candidates === []) {
'.pest/', return $candidates;
'.phpunit.cache/', }
'.phpunit.result.cache',
'vendor/',
'node_modules/',
'bootstrap/cache/',
];
foreach ($prefixes as $prefix) { $process = new Process(
if (str_starts_with($path, (string) $prefix)) { ['git', 'check-ignore', '--no-index', '-z', '--stdin'],
return true; $this->projectRoot,
);
$process->setTimeout(5.0);
$process->setInput(implode("\x00", array_keys($candidates)));
$process->run();
$exitCode = $process->getExitCode();
if ($exitCode !== 0 && $exitCode !== 1) {
throw new MissingDependency('Tia mode', 'git');
}
$output = $process->getOutput();
if ($output === '') {
return $candidates;
}
foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) {
if ($ignored !== '') {
unset($candidates[$ignored]);
} }
} }
return false; return $candidates;
} }
public function currentBranch(): ?string public function currentBranch(): ?string
{ {
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return null; throw new MissingDependency('Tia mode', 'git');
} }
$branch = trim($process->getOutput()); $branch = trim($process->getOutput());
@ -230,14 +219,6 @@ final readonly class ChangedFiles
return $branch === '' || $branch === 'HEAD' ? null : $branch; return $branch === '' || $branch === 'HEAD' ? null : $branch;
} }
public function gitAvailable(): bool
{
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
$process->run();
return $process->isSuccessful();
}
private function shaIsReachable(string $sha): bool private function shaIsReachable(string $sha): bool
{ {
$process = new Process( $process = new Process(
@ -261,7 +242,7 @@ final readonly class ChangedFiles
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return []; throw new MissingDependency('Tia mode', 'git');
} }
return $this->splitLines($process->getOutput()); return $this->splitLines($process->getOutput());
@ -279,7 +260,7 @@ final readonly class ChangedFiles
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return []; throw new MissingDependency('Tia mode', 'git');
} }
$output = $process->getOutput(); $output = $process->getOutput();
@ -321,15 +302,11 @@ final readonly class ChangedFiles
public function currentSha(): ?string public function currentSha(): ?string
{ {
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot);
$process->run(); $process->run();
if (! $process->isSuccessful()) { if (! $process->isSuccessful()) {
return null; throw new MissingDependency('Tia mode', 'git');
} }
$sha = trim($process->getOutput()); $sha = trim($process->getOutput());

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

@ -104,22 +104,8 @@ final class CoverageCollector
return null; return null;
} }
$reflection = new ReflectionClass($className); assert(property_exists($className, '__filename') && is_string($className::$__filename));
if ($reflection->hasProperty('__filename')) { return $className::$__filename;
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
} }
} }

View File

@ -19,7 +19,7 @@ final class CoverageMerger
{ {
$state = self::state(); $state = self::state();
if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) { if (! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
return; return;
} }
@ -131,15 +131,12 @@ final class CoverageMerger
return array_keys($ids); return array_keys($ids);
} }
private static function state(): ?State private static function state(): State
{ {
try {
$state = Container::getInstance()->get(State::class); $state = Container::getInstance()->get(State::class);
} catch (Throwable) { assert($state instanceof State);
return null;
}
return $state instanceof State ? $state : null; return $state;
} }
private static function requireCoverage(string $reportPath): ?CodeCoverage private static function requireCoverage(string $reportPath): ?CodeCoverage

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

@ -34,11 +34,7 @@ final class BladeEdges
return; return;
} }
if ($app->bound(self::MARKER)) { if ($app->bound(self::MARKER) || ! $app->bound('view')) {
return;
}
if (! $app->bound('view')) {
return; return;
} }

View File

@ -36,11 +36,7 @@ final class InertiaEdges
return; return;
} }
if ($app->bound(self::MARKER)) { if ($app->bound(self::MARKER) || ! $app->bound('events')) {
return;
}
if (! $app->bound('events')) {
return; return;
} }
@ -54,18 +50,11 @@ final class InertiaEdges
} }
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void { $events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
if (! property_exists($event, 'response')) { if (! property_exists($event, 'response') || ! is_object($event->response)) {
return; return;
} }
/** @var mixed $response */ $component = self::extractComponent($event->response);
$response = $event->response;
if (! is_object($response)) {
return;
}
$component = self::extractComponent($response);
if ($component !== null) { if ($component !== null) {
$recorder->linkInertiaComponent($component); $recorder->linkInertiaComponent($component);
@ -75,32 +64,16 @@ final class InertiaEdges
private static function extractComponent(object $response): ?string private static function extractComponent(object $response): ?string
{ {
if (property_exists($response, 'headers') && is_object($response->headers)) {
$headers = $response->headers;
if (method_exists($headers, 'has') && $headers->has('X-Inertia')) {
$content = self::readContent($response);
if ($content !== null) {
/** @var mixed $decoded */
$decoded = json_decode($content, true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
}
}
}
$content = self::readContent($response); $content = self::readContent($response);
if ($content === null) { if ($content === null) {
return null; return null;
} }
if (self::isInertiaJsonResponse($response)) {
return self::componentFromJson($content);
}
if (str_contains($content, 'type="application/json"') if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) { && preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1])); $component = self::componentFromJson(html_entity_decode($match[1]));
@ -112,16 +85,23 @@ final class InertiaEdges
if (str_contains($content, 'data-page=') if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) { && preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1])); return self::componentFromJson(html_entity_decode($match[1]));
if ($component !== null) {
return $component;
}
} }
return null; return null;
} }
private static function isInertiaJsonResponse(object $response): bool
{
if (! property_exists($response, 'headers') || ! is_object($response->headers)) {
return false;
}
$headers = $response->headers;
return method_exists($headers, 'has') && $headers->has('X-Inertia') === true;
}
private static function componentFromJson(string $json): ?string private static function componentFromJson(string $json): ?string
{ {
/** @var mixed $decoded */ /** @var mixed $decoded */

View File

@ -9,9 +9,11 @@ use Pest\Plugins\Tia\Contracts\State;
/** /**
* @internal * @internal
*/ */
final readonly class FileState implements State final class FileState implements State
{ {
private string $rootDir; private readonly string $rootDir;
private ?string $resolvedRoot = null;
public function __construct(string $rootDir) public function __construct(string $rootDir)
{ {
@ -100,9 +102,17 @@ final readonly class FileState implements State
private function resolvedRoot(): ?string private function resolvedRoot(): ?string
{ {
if ($this->resolvedRoot !== null) {
return $this->resolvedRoot;
}
$resolved = @realpath($this->rootDir); $resolved = @realpath($this->rootDir);
return $resolved === false ? null : $resolved; if ($resolved === false) {
return null;
}
return $this->resolvedRoot = $resolved;
} }
private function ensureRoot(): bool private function ensureRoot(): bool

View File

@ -9,12 +9,12 @@ namespace Pest\Plugins\Tia;
*/ */
final readonly class Fingerprint final readonly class Fingerprint
{ {
private const int SCHEMA_VERSION = 14; private const int SCHEMA_VERSION = 15;
/** /**
* @return array{ * @return array{
* structural: array<string, int|string|null>, * structural: array<string, int|string|null>,
* environmental: array<string, string|null>, * environmental: array<string, int|string|null>,
* } * }
*/ */
public static function compute(string $projectRoot): array public static function compute(string $projectRoot): array
@ -22,6 +22,7 @@ final readonly class Fingerprint
return [ return [
'structural' => [ 'structural' => [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
@ -33,6 +34,10 @@ final readonly class Fingerprint
'composer_json' => self::composerJsonHash($projectRoot), 'composer_json' => self::composerJsonHash($projectRoot),
], ],
'environmental' => [ 'environmental' => [
'php_minor' => PHP_MAJOR_VERSION,
// 'extensions' => self::extensionsFingerprint($projectRoot),
// 'env_files' => self::envFilesHash($projectRoot),
], ],
]; ];
} }
@ -59,30 +64,11 @@ final readonly class Fingerprint
*/ */
public static function structuralDrift(array $stored, array $current): array public static function structuralDrift(array $stored, array $current): array
{ {
$a = self::structuralOnly($stored); return self::detectDrift(
$b = self::structuralOnly($current); self::structuralOnly($stored),
self::structuralOnly($current),
$drifts = []; 'schema',
);
foreach ($a as $key => $value) {
if ($key === 'schema') {
continue;
}
if (($b[$key] ?? null) !== $value) {
$drifts[] = $key;
}
}
foreach ($b as $key => $value) {
if ($key === 'schema') {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key;
}
}
return array_values(array_unique($drifts));
} }
/** /**
@ -92,18 +78,34 @@ final readonly class Fingerprint
*/ */
public static function environmentalDrift(array $stored, array $current): array public static function environmentalDrift(array $stored, array $current): array
{ {
$a = self::environmentalOnly($stored); return self::detectDrift(
$b = self::environmentalOnly($current); self::environmentalOnly($stored),
self::environmentalOnly($current),
);
}
/**
* @param array<string, mixed> $a
* @param array<string, mixed> $b
* @return list<string>
*/
private static function detectDrift(array $a, array $b, ?string $skipKey = null): array
{
$drifts = []; $drifts = [];
foreach ($a as $key => $value) { foreach ($a as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (($b[$key] ?? null) !== $value) { if (($b[$key] ?? null) !== $value) {
$drifts[] = $key; $drifts[] = $key;
} }
} }
foreach ($b as $key => $value) { foreach ($b as $key => $value) {
if ($key === $skipKey) {
continue;
}
if (! array_key_exists($key, $a) && $value !== null) { if (! array_key_exists($key, $a) && $value !== null) {
$drifts[] = $key; $drifts[] = $key;
} }
@ -157,7 +159,7 @@ final readonly class Fingerprint
{ {
$parts = []; $parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) {
$hash = self::contentHashOrNull($projectRoot.'/'.$name); $hash = self::contentHashOrNull($projectRoot.'/'.$name);
if ($hash !== null) { if ($hash !== null) {
@ -226,6 +228,11 @@ final readonly class Fingerprint
return $json === false ? null : hash('xxh128', $json); return $json === false ? null : hash('xxh128', $json);
} }
private static function composerLockHash(string $projectRoot): ?string
{
return self::hashIfExists($projectRoot.'/composer.lock');
}
private static function packageLockHash(string $projectRoot): ?string private static function packageLockHash(string $projectRoot): ?string
{ {
$parts = []; $parts = [];

View File

@ -4,12 +4,15 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Factories\Attribute;
use Pest\Factories\TestCaseFactory; use Pest\Factories\TestCaseFactory;
use Pest\Factories\TestCaseMethodFactory;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\View; use Pest\Support\View;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use PHPUnit\TextUI\Configuration\Registry;
/** /**
* @internal * @internal
@ -51,6 +54,9 @@ final class Graph
/** @var array<string, true>|null */ /** @var array<string, true>|null */
private ?array $archTestFiles = null; private ?array $archTestFiles = null;
/** @var array<string, string|false> */
private array $realpathCache = [];
public function __construct(string $projectRoot) public function __construct(string $projectRoot)
{ {
$real = @realpath($projectRoot); $real = @realpath($projectRoot);
@ -82,37 +88,76 @@ final class Graph
*/ */
public function affected(array $changedFiles): array public function affected(array $changedFiles): array
{ {
$normalised = []; [$migrationPaths, $nonMigrationPaths] = $this->partitionChangedPaths($changedFiles);
$affectedSet = [];
$unparseableMigrations = $this->applyMigrationChanges($migrationPaths, $affectedSet);
[$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]
= $this->applyInertiaChanges($nonMigrationPaths, $affectedSet);
$unknownSourceDirs = $this->applyPhpEdgeChanges($nonMigrationPaths, $affectedSet);
$this->applyTestFileChanges($nonMigrationPaths, $affectedSet);
$staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet);
$this->applyWatchPatternFallback(
$nonMigrationPaths,
$unparseableMigrations,
$preciselyHandledPages,
$sharedFilesResolved,
$staticallyHandledBlade,
$affectedSet,
);
$this->applyUnknownSourceDirs($unknownSourceDirs, $affectedSet);
return array_keys($affectedSet);
}
/**
* @param array<int, string> $changedFiles
* @return array{0: list<string>, 1: list<string>}
*/
private function partitionChangedPaths(array $changedFiles): array
{
$migrations = [];
$nonMigrations = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel !== null) { if ($rel === null) {
$normalised[] = $rel; continue;
}
} }
$affectedSet = [];
$migrationPaths = [];
$nonMigrationPaths = [];
foreach ($normalised as $rel) {
if ($this->isMigrationPath($rel)) { if ($this->isMigrationPath($rel)) {
$migrationPaths[] = $rel; $migrations[] = $rel;
} else { } else {
$nonMigrationPaths[] = $rel; $nonMigrations[] = $rel;
} }
} }
return [$migrations, $nonMigrations];
}
/**
* @param list<string> $migrationPaths
* @param array<string, true> $affectedSet
* @return list<string> Unparseable migrations (caller treats as unknown-to-graph).
*/
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
{
$changedTables = []; $changedTables = [];
$unparseableMigrations = []; $unparseable = [];
foreach ($migrationPaths as $rel) { foreach ($migrationPaths as $rel) {
$tables = $this->tablesForMigration($rel); $tables = $this->tablesForMigration($rel);
if ($tables === []) { if ($tables === []) {
$unparseableMigrations[] = $rel; $unparseable[] = $rel;
continue; continue;
} }
@ -138,6 +183,17 @@ final class Graph
} }
} }
return $unparseable;
}
/**
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array{0: array<string, true>, 1: array<string, true>, 2: array<string, true>}
* globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved
*/
private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$globalFrontendRuntimeFiles = []; $globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
@ -169,6 +225,7 @@ final class Graph
} }
$sharedFilesResolved = []; $sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
continue; continue;
@ -176,12 +233,12 @@ final class Graph
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
continue; continue;
} }
if (! isset($this->jsFileToComponents[$rel])) { if (! isset($this->jsFileToComponents[$rel])) {
continue; continue;
} }
$touchedAny = false; $touchedAny = false;
foreach ($this->jsFileToComponents[$rel] as $pageComponent) { foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true; $changedComponents[$pageComponent] = true;
@ -195,6 +252,7 @@ final class Graph
} }
$newJsFiles = []; $newJsFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
continue; continue;
@ -215,39 +273,7 @@ final class Graph
} }
if ($newJsFiles !== []) { if ($newJsFiles !== []) {
$freshMap = JsModuleGraph::buildStrict($this->projectRoot); $this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved);
if ($freshMap === null) {
View::render('components.badge', [
'type' => 'WARN',
'content' => sprintf(
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
),
]);
} else {
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
} }
if ($changedComponents !== []) { if ($changedComponents !== []) {
@ -266,6 +292,61 @@ final class Graph
} }
} }
return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved];
}
/**
* @param list<string> $newJsFiles
* @param array<string, true> $changedComponents
* @param array<string, true> $sharedFilesResolved
*/
private function resolveNewJsFiles(array $newJsFiles, array &$changedComponents, array &$sharedFilesResolved): void
{
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) {
View::render('components.badge', [
'type' => 'WARN',
'content' => sprintf(
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles),
),
]);
return;
}
foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? [];
if ($pages === []) {
$sharedFilesResolved[$rel] = true;
continue;
}
$touchedAny = false;
foreach ($pages as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
}
/**
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true> Unknown source dirs (sibling-heuristic).
*/
private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$changedIds = []; $changedIds = [];
$unknownSourceDirs = []; $unknownSourceDirs = [];
$sourcePhpChanged = false; $sourcePhpChanged = false;
@ -282,9 +363,7 @@ final class Graph
} }
if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) { if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
$absolute = $this->projectRoot.'/'.$rel; if (! is_file($this->projectRoot.'/'.$rel)) {
if (! is_file($absolute)) {
continue; continue;
} }
@ -316,8 +395,18 @@ final class Graph
} }
} }
// A changed file inside the configured test suites is itself the unit return $unknownSourceDirs;
// of work — always run it (new untracked tests, edited tests, renames). }
/**
* A changed file inside the configured test suites is itself the unit of
* work — always run it (new untracked tests, edited tests, renames).
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
*/
private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void
{
$testPaths = TestPaths::fromProjectRoot($this->projectRoot); $testPaths = TestPaths::fromProjectRoot($this->projectRoot);
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
@ -332,9 +421,19 @@ final class Graph
} }
$affectedSet[$rel] = true; $affectedSet[$rel] = true;
} }
}
/**
* Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered.
*
* @param list<string> $nonMigrationPaths
* @param array<string, true> $affectedSet
* @return array<string, true>
*/
private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array
{
$staticallyHandled = [];
// Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
$staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) { if (isset($this->fileIds[$rel])) {
continue; continue;
@ -353,13 +452,33 @@ final class Graph
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
$staticallyHandledBlade[$rel] = true; $staticallyHandled[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) { } elseif ($this->isBladeComponentPath($rel)) {
$staticallyHandledBlade[$rel] = true; $staticallyHandled[$rel] = true;
} }
} }
return $staticallyHandled;
}
/**
* @param list<string> $nonMigrationPaths
* @param list<string> $unparseableMigrations
* @param array<string, true> $preciselyHandledPages
* @param array<string, true> $sharedFilesResolved
* @param array<string, true> $staticallyHandledBlade
* @param array<string, true> $affectedSet
*/
private function applyWatchPatternFallback(
array $nonMigrationPaths,
array $unparseableMigrations,
array $preciselyHandledPages,
array $sharedFilesResolved,
array $staticallyHandledBlade,
array &$affectedSet,
): void {
$unknownToGraph = $unparseableMigrations; $unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
continue; continue;
@ -388,8 +507,18 @@ final class Graph
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
}
/**
* @param array<string, true> $unknownSourceDirs
* @param array<string, true> $affectedSet
*/
private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void
{
if ($unknownSourceDirs === []) {
return;
}
if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) { foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) { if (isset($affectedSet[$testFile])) {
continue; continue;
@ -411,9 +540,6 @@ final class Graph
} }
} }
return array_keys($affectedSet);
}
public function knowsTest(string $testFile): bool public function knowsTest(string $testFile): bool
{ {
$rel = $this->relative($testFile); $rel = $this->relative($testFile);
@ -522,7 +648,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 +675,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 +689,61 @@ 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);
return $testStatus->isFailure() if ($testStatus->isFailure() || $testStatus->isError()) {
|| $testStatus->isError() return true;
|| $testStatus->isIncomplete() }
|| $testStatus->isRisky();
$configuration = Registry::get();
if ($testStatus->isRisky()) {
return $configuration->failOnRisky();
}
if ($testStatus->isWarning()) {
if ($configuration->failOnWarning()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerWarnings();
}
if ($testStatus->isNotice()) {
if ($configuration->failOnNotice()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerNotices();
}
if ($testStatus->isDeprecation()) {
if ($configuration->failOnDeprecation()) {
return true;
}
return $configuration->displayDetailsOnTestsThatTriggerDeprecations();
}
if ($testStatus->isIncomplete()) {
if ($configuration->failOnIncomplete()) {
return true;
}
return $configuration->displayDetailsOnIncompleteTests();
}
if ($testStatus->isSkipped()) {
if ($configuration->failOnSkipped()) {
return true;
}
return $configuration->displayDetailsOnSkippedTests();
}
return false;
} }
/** /**
@ -832,27 +1005,14 @@ final class Graph
return $this->archTestFiles; return $this->archTestFiles;
} }
private function methodHasGroup(object $method, string $group): bool private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool
{ {
if (property_exists($method, 'groups') && is_array($method->groups) && in_array($group, $method->groups, true)) { if (in_array($group, $method->groups, true)) {
return true; return true;
} }
if (! property_exists($method, 'attributes') || ! is_array($method->attributes)) {
return false;
}
foreach ($method->attributes as $attribute) { foreach ($method->attributes as $attribute) {
if (! is_object($attribute)) { if (! $attribute instanceof Attribute || $attribute->name !== Group::class) {
continue;
}
if (! property_exists($attribute, 'name')) {
continue;
}
if ($attribute->name !== Group::class) {
continue;
}
if (! property_exists($attribute, 'arguments')) {
continue; continue;
} }
@ -988,9 +1148,8 @@ final class Graph
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (! $file instanceof \SplFileInfo) { assert($file instanceof \SplFileInfo);
continue;
}
if (! $file->isFile()) { if (! $file->isFile()) {
continue; continue;
} }
@ -1178,78 +1337,51 @@ final class Graph
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : []; $graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
if (isset($data['test_tables']) && is_array($data['test_tables'])) { $graph->testTables = self::decodeStringMap($data['test_tables'] ?? null);
foreach ($data['test_tables'] as $testRel => $tables) { $graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null);
if (! is_string($testRel)) { $graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null);
continue;
}
if (! is_array($tables)) {
continue;
}
$names = [];
foreach ($tables as $table) {
if (is_string($table) && $table !== '') {
$names[] = $table;
}
}
if ($names !== []) {
$graph->testTables[$testRel] = $names;
}
}
}
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
foreach ($data['test_inertia_components'] as $testRel => $components) {
if (! is_string($testRel)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->testInertiaComponents[$testRel] = $names;
}
}
}
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
foreach ($data['js_file_to_components'] as $path => $components) {
if (! is_string($path)) {
continue;
}
if ($path === '') {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->jsFileToComponents[$path] = $names;
}
}
}
return $graph; return $graph;
} }
/**
* @return array<string, list<string>>
*/
private static function decodeStringMap(mixed $section): array
{
if (! is_array($section)) {
return [];
}
$out = [];
foreach ($section as $key => $values) {
if (! is_string($key)) {
continue;
}
if ($key === '') {
continue;
}
if (! is_array($values)) {
continue;
}
$names = [];
foreach ($values as $value) {
if (is_string($value) && $value !== '') {
$names[] = $value;
}
}
if ($names !== []) {
$out[$key] = $names;
}
}
return $out;
}
public function encode(): ?string public function encode(): ?string
{ {
$payload = [ $payload = [
@ -1283,7 +1415,11 @@ final class Graph
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|| (strlen($path) >= 2 && $path[1] === ':'); || (strlen($path) >= 2 && $path[1] === ':');
if ($isAbsolute) { if ($isAbsolute) {
$real = @realpath($path); if (array_key_exists($path, $this->realpathCache)) {
$real = $this->realpathCache[$path];
} else {
$real = $this->realpathCache[$path] = @realpath($path);
}
if ($real === false) { if ($real === false) {
$real = $path; $real = $path;

View File

@ -1,234 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* @internal
*/
final class JsImportParser
{
private const array PAGE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte'];
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
private const string JS_DIR = 'resources/js';
/**
* @return array<string, list<string>>
*/
public static function parse(string $projectRoot): array
{
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
$pagesRoot = null;
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
if (is_dir($abs)) {
$pagesRoot = $abs;
break;
}
}
if ($pagesRoot === null) {
return [];
}
$reverse = [];
foreach (self::collectPages($pagesRoot) as $pageAbs) {
$component = self::componentName($pagesRoot, $pageAbs);
if ($component === null) {
continue;
}
$visited = [];
self::collectTransitive($pageAbs, $projectRoot, $jsRoot, $visited);
foreach (array_keys($visited) as $depAbs) {
if ($depAbs === $pageAbs) {
continue;
}
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($depAbs, strlen($projectRoot) + 1));
$reverse[$rel][$component] = true;
}
}
$out = [];
foreach ($reverse as $path => $components) {
$names = array_keys($components);
sort($names);
$out[$path] = $names;
}
ksort($out);
return $out;
}
/**
* @return list<string>
*/
private static function collectPages(string $pagesRoot): array
{
$out = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $fileInfo) {
if (! $fileInfo->isFile()) {
continue;
}
$ext = strtolower((string) $fileInfo->getExtension());
if (in_array($ext, self::PAGE_EXTENSIONS, true)) {
$out[] = $fileInfo->getPathname();
}
}
return $out;
}
private static function componentName(string $pagesRoot, string $pageAbs): ?string
{
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($pageAbs, strlen($pagesRoot) + 1));
$dot = strrpos($rel, '.');
if ($dot === false) {
return null;
}
$name = substr($rel, 0, $dot);
return $name === '' ? null : $name;
}
/**
* @param array<string, true> $visited
*/
private static function collectTransitive(string $fileAbs, string $projectRoot, string $jsRoot, array &$visited): void
{
if (isset($visited[$fileAbs])) {
return;
}
$visited[$fileAbs] = true;
$source = self::loadSource($fileAbs);
if ($source === null) {
return;
}
foreach (self::extractImports($source) as $spec) {
$resolved = self::resolveImport($spec, $fileAbs, $jsRoot);
if ($resolved === null) {
continue;
}
if (! is_file($resolved)) {
continue;
}
self::collectTransitive($resolved, $projectRoot, $jsRoot, $visited);
}
}
private static function loadSource(string $fileAbs): ?string
{
$content = @file_get_contents($fileAbs);
if ($content === false) {
return null;
}
if (str_ends_with(strtolower($fileAbs), '.vue')) {
$scripts = [];
if (preg_match_all('/<script[^>]*>(.*?)<\/script>/si', $content, $m) !== false) {
foreach ($m[1] as $block) {
$scripts[] = $block;
}
}
return implode("\n", $scripts);
}
return $content;
}
/**
* @return list<string>
*/
private static function extractImports(string $source): array
{
$stripped = preg_replace('#//[^\n]*#', '', $source) ?? $source;
$stripped = preg_replace('#/\*.*?\*/#s', '', $stripped) ?? $stripped;
$specs = [];
if (preg_match_all('/\bimport\s+(?:[^\'"()]*?\s+from\s+)?[\'"]([^\'"]+)[\'"]/', $stripped, $matches) !== false) {
foreach ($matches[1] as $spec) {
$specs[] = $spec;
}
}
if (preg_match_all('/\bimport\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $stripped, $matches) !== false) {
foreach ($matches[1] as $spec) {
$specs[] = $spec;
}
}
return $specs;
}
private static function resolveImport(string $spec, string $importerAbs, string $jsRoot): ?string
{
if ($spec === '' || $spec[0] === '.' || $spec[0] === '/') {
return self::resolveRelative($spec, $importerAbs);
}
if (str_starts_with($spec, '@/') || str_starts_with($spec, '~/')) {
$tail = substr($spec, 2);
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
}
return null;
}
private static function resolveRelative(string $spec, string $importerAbs): ?string
{
if ($spec === '' || $spec[0] === '/') {
return null;
}
$base = dirname($importerAbs);
$path = $base.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $spec);
return self::withExtension($path);
}
private static function withExtension(string $path): ?string
{
if (is_file($path)) {
return realpath($path) ?: $path;
}
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
$candidate = $path.'.'.$ext;
if (is_file($candidate)) {
return realpath($candidate) ?: $candidate;
}
}
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
$candidate = $path.DIRECTORY_SEPARATOR.'index.'.$ext;
if (is_file($candidate)) {
return realpath($candidate) ?: $candidate;
}
}
return null;
}
}

View File

@ -16,6 +16,17 @@ final class JsModuleGraph
private const string CACHE_FILE = 'js-module-graph.cache.json'; private const string CACHE_FILE = 'js-module-graph.cache.json';
/**
* @var list<string>
*/
public const array VITE_CONFIG_NAMES = [
'vite.config.ts',
'vite.config.js',
'vite.config.mjs',
'vite.config.cjs',
'vite.config.mts',
];
/** /**
* @return array<string, list<string>> * @return array<string, list<string>>
*/ */
@ -155,13 +166,9 @@ final class JsModuleGraph
private static function fingerprint(string $projectRoot): ?string private static function fingerprint(string $projectRoot): ?string
{ {
if (! self::hasViteConfig($projectRoot)) {
return null;
}
$parts = []; $parts = [];
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { foreach (self::VITE_CONFIG_NAMES as $name) {
$path = $projectRoot.DIRECTORY_SEPARATOR.$name; $path = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (! is_file($path)) { if (! is_file($path)) {
@ -177,6 +184,10 @@ final class JsModuleGraph
.':'.($bytes === false ? '' : hash('sha256', $bytes)); .':'.($bytes === false ? '' : hash('sha256', $bytes));
} }
if ($parts === []) {
return null;
}
foreach (['Pages', 'pages'] as $dir) { foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) { if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
$parts[] = 'pagesDir:'.$dir; $parts[] = 'pagesDir:'.$dir;
@ -310,7 +321,7 @@ final class JsModuleGraph
private static function hasViteConfig(string $projectRoot): bool private static function hasViteConfig(string $projectRoot): bool
{ {
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) { foreach (self::VITE_CONFIG_NAMES as $name) {
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) { if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true; return true;
} }

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)) {
@ -624,23 +296,9 @@ final class Recorder
return null; return null;
} }
$reflection = new ReflectionClass($className); assert(property_exists($className, '__filename') && is_string($className::$__filename));
if ($reflection->hasProperty('__filename')) { return $className::$__filename;
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) {
$value = $property->getValue();
if (is_string($value)) {
return $value;
}
}
}
$file = $reflection->getFileName();
return is_string($file) ? $file : null;
} }
/** /**
@ -691,9 +349,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

@ -13,6 +13,7 @@ enum Replay
{ {
case No; case No;
case Pass; case Pass;
case Risky;
case Skipped; case Skipped;
case Incomplete; case Incomplete;
case Failure; case Failure;
@ -24,7 +25,8 @@ enum Replay
} }
return match (true) { return match (true) {
$status->isSuccess(), $status->isRisky() => self::Pass, $status->isSuccess() => self::Pass,
$status->isRisky() => self::Risky,
$status->isSkipped() => self::Skipped, $status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete, $status->isIncomplete() => self::Incomplete,
default => self::Failure, default => self::Failure,

View File

@ -4,11 +4,17 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/** /**
* @internal * @internal
*/ */
final readonly class SourceScope final class SourceScope
{ {
/** @var array<string, bool> */
private array $containsCache = [];
private const array TOP_LEVEL_NOISE = [ private const array TOP_LEVEL_NOISE = [
'vendor', 'vendor',
'node_modules', 'node_modules',
@ -32,25 +38,27 @@ final readonly class SourceScope
* @param list<string> $excludes Absolute, normalised directory paths. * @param list<string> $excludes Absolute, normalised directory paths.
*/ */
public function __construct( public function __construct(
private array $includes, private readonly array $includes,
private array $excludes, private readonly array $excludes,
) {} ) {}
public static function fromProjectRoot(string $projectRoot): self public static function fromProjectRoot(string $projectRoot): self
{ {
$configPath = self::configPath($projectRoot);
$phpunitIncludes = []; $phpunitIncludes = [];
$phpunitExcludes = []; $phpunitExcludes = [];
if ($configPath !== null) { try {
$xml = @simplexml_load_file($configPath); $source = Registry::get()->source();
if ($xml !== false) { foreach ($source->includeDirectories() as $dir) {
$configDir = dirname($configPath); $phpunitIncludes[] = self::normalise($dir->path());
$phpunitIncludes = self::extractDirectories($xml, 'source/include/directory', $configDir);
$phpunitExcludes = self::extractDirectories($xml, 'source/exclude/directory', $configDir);
} }
foreach ($source->excludeDirectories() as $dir) {
$phpunitExcludes[] = self::normalise($dir->path());
}
} catch (Throwable) {
// Registry not initialized — fall back to project-root scanning.
} }
$rootIncludes = self::topLevelProjectDirs($projectRoot); $rootIncludes = self::topLevelProjectDirs($projectRoot);
@ -71,94 +79,50 @@ final readonly class SourceScope
/** /**
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml. * @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
*/ */
public static function testPaths(string $projectRoot): array public static function testPaths(): array
{ {
$configPath = self::configPath($projectRoot); try {
$suites = Registry::get()->testSuite();
if ($configPath === null) { } catch (Throwable) {
return []; return [];
} }
$out = [];
$xml = @simplexml_load_file($configPath); foreach ($suites as $suite) {
foreach ($suite->directories() as $directory) {
if ($xml === false) { $out[] = self::normalise($directory->path());
return [];
} }
$configDir = dirname($configPath); foreach ($suite->files() as $file) {
$out[] = self::normalise($file->path());
}
}
return array_values(array_unique([ return array_values(array_unique($out));
...self::extractDirectories($xml, 'testsuites/testsuite/directory', $configDir),
...self::extractDirectories($xml, 'testsuites/testsuite/file', $configDir),
]));
} }
public function contains(string $absoluteFile): bool public function contains(string $absoluteFile): bool
{ {
if (isset($this->containsCache[$absoluteFile])) {
return $this->containsCache[$absoluteFile];
}
$real = @realpath($absoluteFile); $real = @realpath($absoluteFile);
$candidate = $real === false ? $absoluteFile : $real; $candidate = $real === false ? $absoluteFile : $real;
$candidate = self::normalise($candidate); $candidate = self::normalise($candidate);
foreach ($this->excludes as $excluded) { foreach ($this->excludes as $excluded) {
if ($this->startsWithDir($candidate, $excluded)) { if ($this->startsWithDir($candidate, $excluded)) {
return false; return $this->containsCache[$absoluteFile] = false;
} }
} }
foreach ($this->includes as $included) { foreach ($this->includes as $included) {
if ($this->startsWithDir($candidate, $included)) { if ($this->startsWithDir($candidate, $included)) {
return true; return $this->containsCache[$absoluteFile] = true;
} }
} }
return false; return $this->containsCache[$absoluteFile] = false;
}
/**
* @return list<string>
*/
public function includes(): array
{
return $this->includes;
}
private static function configPath(string $projectRoot): ?string
{
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
/**
* @return list<string>
*/
private static function extractDirectories(\SimpleXMLElement $xml, string $xpath, string $configDir): array
{
$nodes = $xml->xpath($xpath);
if (! is_array($nodes)) {
return [];
}
$out = [];
foreach ($nodes as $node) {
$value = trim((string) $node);
if ($value === '') {
continue;
}
$out[] = self::resolveRelative($value, $configDir);
}
return array_values(array_unique($out));
} }
/** /**
@ -216,22 +180,6 @@ final readonly class SourceScope
return $out; return $out;
} }
private static function resolveRelative(string $path, string $configDir): string
{
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|| (strlen($path) >= 2 && $path[1] === ':'));
$combined = $isAbsolute ? $path : $configDir.DIRECTORY_SEPARATOR.$path;
$real = @realpath($combined);
if ($real === false) {
return self::normalise($combined);
}
return self::normalise($real);
}
private static function normalise(string $path): string private static function normalise(string $path): string
{ {
return rtrim($path, '/\\'); return rtrim($path, '/\\');

View File

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\TextUI\Configuration\Registry;
use Throwable;
/** /**
* Resolves the set of project-relative paths that are considered test files, * Resolves the set of project-relative paths that are considered test files,
@ -28,39 +30,48 @@ final readonly class TestPaths
public static function fromProjectRoot(string $projectRoot): self public static function fromProjectRoot(string $projectRoot): self
{ {
$configPath = self::configPath($projectRoot);
$directories = []; $directories = [];
$files = []; $files = [];
$suffixes = ['.php']; $suffixes = [];
if ($configPath !== null) { try {
$xml = @simplexml_load_file($configPath); $configuration = Registry::get();
if ($xml !== false) { foreach ($configuration->testSuite() as $suite) {
$configDir = dirname($configPath); foreach ($suite->directories() as $directory) {
$rel = self::toRelative($directory->path(), $projectRoot);
foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) {
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
if ($rel !== null) { if ($rel !== null) {
$directories[] = $rel; $directories[] = $rel;
} }
$suffix = (string) ($node['suffix'] ?? ''); $suffix = $directory->suffix();
if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) {
if ($suffix !== '') {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix; $suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
} }
} }
foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) { foreach ($suite->files() as $file) {
$rel = self::toRelative((string) $node, $configDir, $projectRoot); $rel = self::toRelative($file->path(), $projectRoot);
if ($rel !== null) { if ($rel !== null) {
$files[] = $rel; $files[] = $rel;
} }
} }
} }
if ($suffixes === []) {
foreach ($configuration->testSuffixes() as $suffix) {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
} catch (Throwable) {
// Registry not initialized — fall through to defaults.
}
if ($suffixes === []) {
$suffixes = ['.php'];
} }
if ($directories === [] && $files === []) { if ($directories === [] && $files === []) {
@ -109,20 +120,7 @@ final readonly class TestPaths
return false; return false;
} }
private static function configPath(string $projectRoot): ?string private static function toRelative(string $value, string $projectRoot): ?string
{
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
private static function toRelative(string $value, string $configDir, string $projectRoot): ?string
{ {
$value = trim($value); $value = trim($value);
@ -130,13 +128,8 @@ final readonly class TestPaths
return null; return null;
} }
$isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR $real = @realpath($value);
|| (strlen($value) >= 2 && $value[1] === ':'); $resolved = $real === false ? $value : $real;
$combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value;
$real = @realpath($combined);
$resolved = $real === false ? $combined : $real;
$resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved); $resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved);
$root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/'; $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/';
@ -152,7 +145,7 @@ final readonly class TestPaths
{ {
try { try {
$testPath = TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
} catch (\Throwable) { } catch (Throwable) {
return null; return null;
} }

View File

@ -25,29 +25,10 @@ final readonly class Browser implements WatchDefault
$browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath); $browserTargets = self::detectBrowserTestTargets($projectRoot, $testPath);
$globs = [ $globs = [
'resources/js/**/*.js', 'resources/js/** !*.php',
'resources/js/**/*.ts', 'resources/css/** !*.php',
'resources/js/**/*.tsx', 'public/hot !*.php',
'resources/js/**/*.jsx', 'public/** !*.php',
'resources/js/**/*.vue',
'resources/js/**/*.svelte',
'resources/css/**/*.css',
'resources/css/**/*.scss',
'resources/css/**/*.less',
'public/build/**/*.js',
'public/build/**/*.css',
'public/**/*.js',
'public/**/*.css',
'public/**/*.svg',
'public/**/*.png',
'public/**/*.jpg',
'public/**/*.jpeg',
'public/**/*.webp',
'public/**/*.ico',
'public/**/*.txt',
'public/**/*.json',
'public/**/*.xml',
'public/hot',
]; ];
$patterns = []; $patterns = [];

View File

@ -20,27 +20,8 @@ final readonly class Inertia implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath); return [
'resources/js/** !*.php' => [$testPath],
$patterns = []; ];
foreach (['Pages', 'pages'] as $pages) {
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$pages}/**/*.{$ext}"] = $browserTargets;
}
}
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
$patterns["resources/js/{$shared}/**/*.{$ext}"] = $browserTargets;
}
}
$patterns['resources/js/ssr.js'] = $browserTargets;
$patterns['resources/js/ssr.ts'] = $browserTargets;
$patterns['resources/js/app.js'] = $browserTargets;
$patterns['resources/js/app.ts'] = $browserTargets;
return $patterns;
} }
} }

View File

@ -20,46 +20,21 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath],
'routes/*.php' => [$testPath],
'routes/**/*.php' => [$testPath],
'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath],
'database/migrations/**/*.php' => [$testPath], 'database/migrations/**/*.php' => [$testPath],
'database/seeders/**/*.php' => [$testPath],
'database/factories/**/*.php' => [$testPath],
'storage/fixtures/**/*' => [$testPath], 'storage/fixtures/**/*' => [$testPath],
'app/**/*.tpl' => [$testPath], 'app/** !*.php' => [$testPath],
'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath],
'app/**/*.yaml' => [$testPath],
'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath],
'resources/views/**/*.blade.php' => [$testPath], 'resources/views/**' => [$testPath],
'resources/views/**/*.css' => [$testPath],
'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$testPath],
'lang/**/*.php' => [$testPath], 'lang/**' => [$testPath],
'lang/**/*.json' => [$testPath], 'resources/lang/**' => [$testPath],
'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$testPath],
'vite.config.js' => [$testPath], 'vite.config.* !*.php' => [$testPath],
'vite.config.ts' => [$testPath], 'webpack.mix.* !*.php' => [$testPath],
'webpack.mix.js' => [$testPath], 'tailwind.config.* !*.php' => [$testPath],
'tailwind.config.js' => [$testPath], 'postcss.config.* !*.php' => [$testPath],
'tailwind.config.ts' => [$testPath],
'postcss.config.js' => [$testPath],
]; ];
} }
} }

View File

@ -25,11 +25,7 @@ final readonly class Php implements WatchDefault
'docker-compose.yml' => [$testPath], 'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath], 'docker-compose.yaml' => [$testPath],
'phpunit.xml.dist' => [$testPath], 'phpunit.xml*' => [$testPath],
$testPath.'/Pest.php' => [$testPath],
$testPath.'/Datasets/**/*.php' => [$testPath],
$testPath.'/Fixtures/**/*' => [$testPath], $testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath], $testPath.'/**/Fixtures/**/*' => [$testPath],

View File

@ -20,43 +20,22 @@ final readonly class Symfony implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
'config/*.yaml' => [$testPath], 'config/** !*.php' => [$testPath],
'config/*.yml' => [$testPath], 'config/routes/** !*.php' => [$testPath],
'config/*.php' => [$testPath],
'config/*.xml' => [$testPath],
'config/**/*.yaml' => [$testPath],
'config/**/*.yml' => [$testPath],
'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$testPath],
'config/routes/*.yaml' => [$testPath],
'config/routes/*.php' => [$testPath],
'config/routes/*.xml' => [$testPath],
'config/routes/**/*.yaml' => [$testPath],
'src/Kernel.php' => [$testPath],
'migrations/**/*.php' => [$testPath], 'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath], 'src/Migrations/**/*.php' => [$testPath],
'templates/**/*.html.twig' => [$testPath], 'templates/** !*.php' => [$testPath],
'templates/**/*.twig' => [$testPath],
'translations/**/*.yaml' => [$testPath], 'translations/** !*.php' => [$testPath],
'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath],
'config/doctrine/**/*.xml' => [$testPath], 'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath], 'config/doctrine/**/*.yaml' => [$testPath],
'webpack.config.js' => [$testPath], 'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath], 'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath], 'assets/** !*.php' => [$testPath],
'assets/**/*.ts' => [$testPath],
'assets/**/*.vue' => [$testPath],
'assets/**/*.css' => [$testPath],
'assets/**/*.scss' => [$testPath],
]; ];
} }
} }

View File

@ -12,7 +12,7 @@ interface WatchDefault
public function applicable(): bool; public function applicable(): bool;
/** /**
* @return array<string, array<int, string>> glob → list of project-relative test dirs * @return array<string, array<int, string>> pattern → list of project-relative test dirs
*/ */
public function defaults(string $projectRoot, string $testPath): array; public function defaults(string $projectRoot, string $testPath): array;
} }

View File

@ -24,17 +24,26 @@ final class WatchPatterns
WatchDefaults\Browser::class, WatchDefaults\Browser::class,
]; ];
private const array VCS_DIRS = ['.git', '.svn', '.hg'];
/** /**
* @var array<string, array<int, string>> glob → list of project-relative test dirs/files * @var array<string, array<int, string>> raw pattern key → list of project-relative test dirs/files
*/ */
private array $patterns = []; private array $patterns = [];
/**
* @var array<string, array{include: string, excludes: array<int, string>, allowDotfiles: bool}>
*/
private array $parsed = [];
private bool $enabled = false; private bool $enabled = false;
private bool $locally = false; private bool $locally = false;
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;
@ -46,22 +55,22 @@ final class WatchPatterns
continue; continue;
} }
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) { foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) {
$this->patterns[$glob] = array_values(array_unique( $this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], $dirs), array_merge($this->patterns[$key] ?? [], $dirs),
)); ));
} }
} }
} }
/** /**
* @param array<string, string> $patterns glob → project-relative test dir/file * @param array<string, string> $patterns pattern key → project-relative test dir/file
*/ */
public function add(array $patterns): void public function add(array $patterns): void
{ {
foreach ($patterns as $glob => $dir) { foreach ($patterns as $key => $dir) {
$this->patterns[$glob] = array_values(array_unique( $this->patterns[$key] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], [$dir]), array_merge($this->patterns[$key] ?? [], [$dir]),
)); ));
} }
} }
@ -80,14 +89,16 @@ final class WatchPatterns
$matched = []; $matched = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {
foreach ($this->patterns as $glob => $dirs) { foreach ($this->patterns as $key => $dirs) {
if ($this->globMatches($glob, $file)) { if (! $this->keyMatches($key, $file)) {
continue;
}
foreach ($dirs as $dir) { foreach ($dirs as $dir) {
$matched[$dir] = true; $matched[$dir] = true;
} }
} }
} }
}
return array_keys($matched); return array_keys($matched);
} }
@ -156,12 +167,132 @@ 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->parsed = [];
$this->enabled = false; $this->enabled = false;
$this->locally = false; $this->locally = false;
$this->filtered = false; $this->filtered = false;
$this->baselined = false;
}
private function keyMatches(string $key, string $file): bool
{
$rule = $this->parse($key);
if (! $this->globMatches($rule['include'], $file)) {
return false;
}
$file = str_replace('\\', '/', $file);
if ($this->touchesVcs($file)) {
return false;
}
if (! $rule['allowDotfiles'] && $this->touchesDotfile($file)) {
return false;
}
foreach ($rule['excludes'] as $exclude) {
if ($this->excludeMatches($exclude, $file)) {
return false;
}
}
return true;
}
/**
* @return array{include: string, excludes: array<int, string>, allowDotfiles: bool}
*/
private function parse(string $key): array
{
if (isset($this->parsed[$key])) {
return $this->parsed[$key];
}
$tokens = preg_split('/\s+/', trim($key)) ?: [];
$include = '';
$excludes = [];
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
if ($token[0] === '!') {
$excludes[] = substr($token, 1);
continue;
}
if ($include === '') {
$include = $token;
}
}
return $this->parsed[$key] = [
'include' => $include,
'excludes' => $excludes,
'allowDotfiles' => $this->patternTargetsDotfiles($include),
];
}
private function patternTargetsDotfiles(string $pattern): bool
{
foreach (explode('/', str_replace('\\', '/', $pattern)) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
}
private function touchesVcs(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if (in_array($segment, self::VCS_DIRS, true)) {
return true;
}
}
return false;
}
private function touchesDotfile(string $file): bool
{
foreach (explode('/', $file) as $segment) {
if ($segment !== '' && $segment[0] === '.') {
return true;
}
}
return false;
}
private function excludeMatches(string $exclude, string $file): bool
{
$pattern = str_contains($exclude, '/') ? $exclude : '**/'.$exclude;
if ($this->globMatches($pattern, $file)) {
return true;
}
return $this->globMatches($exclude, basename($file));
} }
private function globMatches(string $pattern, string $file): bool private function globMatches(string $pattern, string $file): bool

View File

@ -4,14 +4,12 @@ declare(strict_types=1);
namespace Pest\Subscribers; namespace Pest\Subscribers;
use Pest\Concerns\Testable;
use Pest\Exceptions\TiaRequiresPestTests; use Pest\Exceptions\TiaRequiresPestTests;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber; use PHPUnit\Event\Test\PreparedSubscriber;
use ReflectionClass;
/** /**
* @internal * @internal
@ -38,27 +36,10 @@ final readonly class EnsureTiaIsRunningPestTestsOnly implements PreparedSubscrib
return; return;
} }
if ($this->usesTestableTrait($className)) { if (method_exists($className, '__initializeTestCase')) {
return; return;
} }
Panic::with(new TiaRequiresPestTests($className, $test->file())); Panic::with(new TiaRequiresPestTests($className, $test->file()));
} }
private function usesTestableTrait(string $className): bool
{
$reflection = new ReflectionClass($className);
do {
foreach ($reflection->getTraitNames() as $trait) {
if ($trait === Testable::class) {
return true;
}
}
$reflection = $reflection->getParentClass();
} while ($reflection !== false);
return false;
}
} }

View File

@ -6,17 +6,17 @@ namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\PreparationStarted;
use PHPUnit\Event\Test\PreparedSubscriber; use PHPUnit\Event\Test\PreparationStartedSubscriber;
/** /**
* @internal * @internal
*/ */
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber final readonly class EnsureTiaResultsAreCollected implements PreparationStartedSubscriber
{ {
public function __construct(private ResultCollector $collector) {} public function __construct(private ResultCollector $collector) {}
public function notify(Prepared $event): void public function notify(PreparationStarted $event): void
{ {
$test = $event->test(); $test = $event->test();

View File

@ -89,10 +89,6 @@ final class Coverage
throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath)); throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath));
} }
// If TIA's marker is present, this run executed only the affected
// tests. Merge their fresh coverage slice into the cached full-run
// snapshot (stored by the previous `--tia --coverage` pass) so the
// report reflects the entire suite, not just what re-ran.
CoverageMerger::applyIfMarked($reportPath); CoverageMerger::applyIfMarked($reportPath);
/** @var CodeCoverage $codeCoverage */ /** @var CodeCoverage $codeCoverage */

View File

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Fidry\CpuCoreCounter\CpuCoreCounter;
/**
* @internal
*/
final class Cpu
{
public static function cores(int $fallback = 4): int
{
return (new CpuCoreCounter)->getCountWithFallback($fallback);
}
}

View File

@ -0,0 +1,261 @@
<?php
use Pest\Plugins\Tia\ContentHash;
describe('of()', function () {
it('returns false when file does not exist', function () {
expect(ContentHash::of('/path/that/does/not/exist.php'))->toBeFalse();
});
it('hashes an existing file', function () {
$path = tempnam(sys_get_temp_dir(), 'pest_').'.php';
file_put_contents($path, "<?php echo 'hi';");
try {
expect(ContentHash::of($path))->toBeString()->not->toBeEmpty();
} finally {
@unlink($path);
}
});
});
describe('PHP files', function () {
it('produces the same hash regardless of whitespace differences', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;\n\necho \$foo;");
$b = ContentHash::ofContent('a.php', "<?php \$foo=1; echo \$foo;");
expect($a)->toBe($b);
});
it('ignores single-line comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n// this is a comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores hash-style comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n# hash comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores multi-line comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n/* a multi\n line comment */\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
it('ignores doc comments', function () {
$a = ContentHash::ofContent('a.php', "<?php\n/**\n * @return int\n */\nfunction foo() { return 1; }");
$b = ContentHash::ofContent('a.php', "<?php\nfunction foo() { return 1; }");
expect($a)->toBe($b);
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php \$foo = 2;");
expect($a)->not->toBe($b);
});
it('preserves whitespace inside string literals', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 'hello world';");
$b = ContentHash::ofContent('a.php', "<?php \$foo = 'helloworld';");
expect($a)->not->toBe($b);
});
it('treats variable renames as a change', function () {
$a = ContentHash::ofContent('a.php', "<?php \$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php \$bar = 1;");
expect($a)->not->toBe($b);
});
it('falls back to a raw hash for unparseable PHP', function () {
$hash = ContentHash::ofContent('a.php', 'not valid php at all');
expect($hash)->toBeString()->not->toBeEmpty();
});
it('is case-insensitive on the file extension', function () {
$a = ContentHash::ofContent('a.PHP', "<?php\n// comment\n\$foo = 1;");
$b = ContentHash::ofContent('a.php', "<?php\n\$foo = 1;");
expect($a)->toBe($b);
});
});
describe('Blade files', function () {
it('strips blade comments', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>{{-- a comment --}}Hello</div>");
$b = ContentHash::ofContent('a.blade.php', "<div>Hello</div>");
expect($a)->toBe($b);
});
it('strips multi-line blade comments', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>\n{{--\n multi\n line\n--}}\nHello\n</div>");
$b = ContentHash::ofContent('a.blade.php', "<div> Hello </div>");
expect($a)->toBe($b);
});
it('collapses whitespace', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>\n Hello\n World\n</div>");
$b = ContentHash::ofContent('a.blade.php', "<div> Hello World </div>");
expect($a)->toBe($b);
});
it('detects content changes', function () {
$a = ContentHash::ofContent('a.blade.php', "<div>Hello</div>");
$b = ContentHash::ofContent('a.blade.php', "<div>Goodbye</div>");
expect($a)->not->toBe($b);
});
it('keeps blade directives intact', function () {
$a = ContentHash::ofContent('a.blade.php', "@if(\$user)Hi @endif");
$b = ContentHash::ofContent('a.blade.php', "@if(\$user)Bye @endif");
expect($a)->not->toBe($b);
});
it('does not use the PHP tokenizer for blade files', function () {
$a = ContentHash::ofContent('a.blade.php', "<?php // not stripped ?> hello");
$b = ContentHash::ofContent('a.blade.php', "<?php ?> hello");
expect($a)->not->toBe($b);
});
});
describe('JavaScript-like files', function () {
it('strips line comments', function () {
$a = ContentHash::ofContent('a.js', "// a comment\nconst foo = 1;");
$b = ContentHash::ofContent('a.js', "const foo = 1;");
expect($a)->toBe($b);
});
it('strips block comments on their own lines', function () {
$a = ContentHash::ofContent('a.js', "/* block */\nconst foo = 1;");
$b = ContentHash::ofContent('a.js', "const foo = 1;");
expect($a)->toBe($b);
});
it('collapses whitespace', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1;\n\nconst bar = 2;");
$b = ContentHash::ofContent('a.js', "const foo = 1; const bar = 2;");
expect($a)->toBe($b);
});
it('detects code changes', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1;");
$b = ContentHash::ofContent('a.js', "const foo = 2;");
expect($a)->not->toBe($b);
});
it('does not strip inline trailing comments', function () {
$a = ContentHash::ofContent('a.js', "const foo = 1; // inline");
$b = ContentHash::ofContent('a.js', "const foo = 1;");
expect($a)->not->toBe($b);
});
it('applies the same rules to .ts files', function () {
$a = ContentHash::ofContent('a.ts', "// comment\nconst foo: number = 1;");
$b = ContentHash::ofContent('a.ts', "const foo: number = 1;");
expect($a)->toBe($b);
});
it('applies the same rules to .tsx files', function () {
$a = ContentHash::ofContent('a.tsx', "// comment\nconst Foo = () => <div/>;");
$b = ContentHash::ofContent('a.tsx', "const Foo = () => <div/>;");
expect($a)->toBe($b);
});
it('applies the same rules to .jsx files', function () {
$a = ContentHash::ofContent('a.jsx', "// comment\nconst Foo = () => <div/>;");
$b = ContentHash::ofContent('a.jsx', "const Foo = () => <div/>;");
expect($a)->toBe($b);
});
it('applies the same rules to .vue files', function () {
$a = ContentHash::ofContent('a.vue', "<script>\n// comment\nexport default {}\n</script>");
$b = ContentHash::ofContent('a.vue', "<script> export default {} </script>");
expect($a)->toBe($b);
});
it('applies the same rules to .svelte files', function () {
$a = ContentHash::ofContent('a.svelte', "<script>\n// comment\nlet foo = 1;\n</script>");
$b = ContentHash::ofContent('a.svelte', "<script> let foo = 1; </script>");
expect($a)->toBe($b);
});
it('applies the same rules to .mjs, .cjs, and .mts files', function () {
foreach (['mjs', 'cjs', 'mts'] as $ext) {
$a = ContentHash::ofContent("a.$ext", "// comment\nexport const foo = 1;");
$b = ContentHash::ofContent("a.$ext", "export const foo = 1;");
expect($a)->toBe($b);
}
});
});
describe('unknown extensions', function () {
it('hashes the raw content for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "hello world");
$b = ContentHash::ofContent('a.txt', "hello world");
expect($a)->toBe($b);
});
it('does not normalise whitespace for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "hello world");
$b = ContentHash::ofContent('a.txt', "hello world");
expect($a)->not->toBe($b);
});
it('does not strip comments for unknown extensions', function () {
$a = ContentHash::ofContent('a.txt', "// not a comment here\nhello");
$b = ContentHash::ofContent('a.txt', "hello");
expect($a)->not->toBe($b);
});
it('hashes files with no extension as raw content', function () {
$a = ContentHash::ofContent('Makefile', "all:\n\techo hi");
$b = ContentHash::ofContent('Makefile', "all:\n\techo hi");
expect($a)->toBe($b);
});
});
describe('output format', function () {
it('returns a 32-character hex xxh128 hash', function () {
$hash = ContentHash::ofContent('a.php', '<?php $foo = 1;');
expect($hash)->toMatch('/^[a-f0-9]{32}$/');
});
it('returns a stable hash for empty content', function () {
$a = ContentHash::ofContent('a.php', '');
$b = ContentHash::ofContent('a.php', '');
expect($a)->toBe($b);
});
});