mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
39 Commits
c38d32ae86
...
7bea819978
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bea819978 | |||
| 4280233b40 | |||
| d6db3a8a20 | |||
| 51c8ce4df6 | |||
| 5b8393b925 | |||
| e4d9b61fdf | |||
| e2d940cd53 | |||
| 380ccd30b4 | |||
| 31c200716d | |||
| 6add4da543 | |||
| 8ddcd3e853 | |||
| e3e178fd94 | |||
| 7b1ec9f003 | |||
| 1e48c5d473 | |||
| d00ec95dd9 | |||
| 89f3d6cb39 | |||
| a07a2e512a | |||
| 57eecb2b3d | |||
| 9f804dc954 | |||
| 7cbad4c589 | |||
| 5cae93b059 | |||
| df829ad19d | |||
| 635460653c | |||
| 1aa80dc398 | |||
| 8a14056111 | |||
| f247dd8e7b | |||
| 1c7c9754fd | |||
| 5f37939fda | |||
| 28305fcb7a | |||
| 5242803694 | |||
| 925935a7e8 | |||
| 460401c379 | |||
| 348b439172 | |||
| a4e77766c5 | |||
| 4a8c2d7d78 | |||
| 7d51601120 | |||
| 631bbe318b | |||
| 9b7c15d5b6 | |||
| 872796bd9b |
@ -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));
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
19
src/Bootstrappers/BootPhpUnitConfiguration.php
Normal file
19
src/Bootstrappers/BootPhpUnitConfiguration.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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());
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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, '/\\');
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 = [];
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
261
tests/Unit/Plugins/Tia/ContentHash.php
Normal file
261
tests/Unit/Plugins/Tia/ContentHash.php
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user