diff --git a/bin/pest b/bin/pest index 8cd277887..a6f874928 100755 --- a/bin/pest +++ b/bin/pest @@ -3,8 +3,10 @@ declare(strict_types=1); +use Pest\Contracts\Restarter; use Pest\Kernel; use Pest\Panic; +use Pest\Support\Container; use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter; @@ -142,6 +144,7 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Get $rootPath based on $autoloadPath $rootPath = dirname($autoloadPath, 2); + $input = new ArgvInput; $testSuite = TestSuite::getInstance( @@ -192,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput; try { $kernel = Kernel::boot($testSuite, $input, $output); + $container = Container::getInstance(); + + foreach (Kernel::RESTARTERS as $restarterClass) { + $restarter = $container->get($restarterClass); + assert($restarter instanceof Restarter); + + $restarter->maybeRestart($rootPath, $originalArguments); + } + $result = $kernel->handle($originalArguments, $arguments); $kernel->terminate(); diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs new file mode 100644 index 000000000..49133249a --- /dev/null +++ b/bin/pest-tia-vite-deps.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import { readdir, readFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve, relative, extname, sep, join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const PAGE_EXTENSIONS = new Set([ + '.vue', '.svelte', + '.tsx', '.jsx', + '.ts', '.js', + '.mts', '.cts', '.mjs', '.cjs', +]) +const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i +const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd()) +const PAGE_DIR_CANDIDATES = [ + 'resources/js/Pages', + 'resources/js/pages', + 'assets/js/Pages', + 'assets/js/pages', + 'assets/Pages', + 'assets/pages', +] + +async function loadRolldown() { + const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) + const path = projectRequire.resolve('rolldown') + return await import(pathToFileURL(path).href) +} + +async function readJsonWithComments(path) { + const raw = await readFile(path, 'utf8') + const stripped = raw + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1') + return JSON.parse(stripped) +} + +async function loadAliasFromTsconfig() { + const alias = {} + for (const name of ['tsconfig.json', 'jsconfig.json']) { + const p = join(PROJECT_ROOT, name) + if (!existsSync(p)) continue + let cfg + try { cfg = await readJsonWithComments(p) } catch { continue } + const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.') + const paths = cfg?.compilerOptions?.paths ?? {} + for (const [key, targets] of Object.entries(paths)) { + if (!key.endsWith('/*')) continue + const t0 = Array.isArray(targets) ? targets[0] : null + if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue + const aliasKey = key.slice(0, -2) + if (alias[aliasKey] !== undefined) continue + alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2)) + } + } + return alias +} + +async function listPageFiles(pagesDir) { + if (!existsSync(pagesDir)) return [] + + const out = [] + const walk = async (dir) => { + let entries + try { entries = await readdir(dir, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) { await walk(full); continue } + if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full) + } + } + + await walk(pagesDir) + return out +} + +async function discoverPagesDir() { + const override = process.env.TIA_VITE_PAGES_DIR + if (override && override.length > 0) { + return resolve(PROJECT_ROOT, override.replace(/\\/g, '/')) + } + + for (const rel of PAGE_DIR_CANDIDATES) { + const abs = resolve(PROJECT_ROOT, rel) + if (!existsSync(abs)) continue + const files = await listPageFiles(abs) + if (files.length > 0) return abs + } + + return null +} + +function componentNameFor(pageAbs, pagesDir) { + const rel = relative(pagesDir, pageAbs).split(sep).join('/') + const ext = extname(rel) + return rel.slice(0, rel.length - ext.length) +} + +function isLocalSpecifier(source, aliasKeys) { + if (source.startsWith('.') || source.startsWith('/')) return true + for (const key of aliasKeys) { + if (source === key || source.startsWith(key + '/')) return true + } + return false +} + +async function main() { + const pagesDir = await discoverPagesDir() + + if (pagesDir === null) { + process.stdout.write('{}') + return + } + + const pages = await listPageFiles(pagesDir) + + if (pages.length === 0) { + process.stdout.write('{}') + return + } + + const { rolldown } = await loadRolldown() + const alias = await loadAliasFromTsconfig() + const aliasKeys = Object.keys(alias) + + const graph = new Map() + + const collector = { + name: 'pest-tia-collector', + moduleParsed(info) { + const id = info.id + if (!id || id.startsWith('\0')) return + const deps = new Set() + for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i) + for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i) + graph.set(id, deps) + }, + } + + const externalBare = { + name: 'pest-tia-external-bare', + resolveId(source) { + if (!source) return null + if (isLocalSpecifier(source, aliasKeys)) return null + return { id: source, external: true } + }, + } + + const assetStub = { + name: 'pest-tia-asset-stub', + load(id) { + if (!id) return null + if (ASSET_EXT_RE.test(id)) { + return { code: 'export default null', moduleSideEffects: false } + } + return null + }, + } + + const input = Object.create(null) + for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i] + + const bundle = await rolldown({ + input, + cwd: PROJECT_ROOT, + resolve: { + alias, + extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'], + }, + transform: { jsx: 'preserve' }, + treeshake: false, + plugins: [externalBare, assetStub, collector], + logLevel: 'silent', + onLog: () => {}, + }) + + try { + await bundle.generate({ format: 'esm' }) + } finally { + await bundle.close() + } + + const reverse = new Map() + const transitiveCache = new Map() + + const computeTransitive = (id, stack) => { + const cached = transitiveCache.get(id) + if (cached) return cached + if (stack.has(id)) return null + + stack.add(id) + const acc = new Set() + const deps = graph.get(id) + if (deps) { + for (const dep of deps) { + if (!dep || dep.startsWith('\0')) continue + if (dep.startsWith(PROJECT_ROOT)) { + const rel = relative(PROJECT_ROOT, dep).split(sep).join('/') + acc.add(rel) + } + if (stack.has(dep)) continue + const child = computeTransitive(dep, stack) + if (child) for (const r of child) acc.add(r) + } + } + stack.delete(id) + transitiveCache.set(id, acc) + return acc + } + + for (const page of pages) { + const pageComponent = componentNameFor(page, pagesDir) + const reachable = computeTransitive(page, new Set()) + if (!reachable) continue + for (const rel of reachable) { + const bucket = reverse.get(rel) ?? new Set() + bucket.add(pageComponent) + reverse.set(rel, bucket) + } + } + + const payload = Object.create(null) + const keys = [...reverse.keys()].sort() + for (const key of keys) { + payload[key] = [...reverse.get(key)].sort() + } + + process.stdout.write(JSON.stringify(payload)) +} + +try { + void pathToFileURL + await main() +} catch (err) { + process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) + process.exit(1) +} diff --git a/bin/worker.php b/bin/worker.php index dc69d67e3..7b374785d 100644 --- a/bin/worker.php +++ b/bin/worker.php @@ -6,6 +6,7 @@ use ParaTest\WrapperRunner\WrapperWorker; use Pest\Kernel; use Pest\Plugins\Actions\CallsHandleArguments; +use Pest\Support\Container; use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -58,6 +59,15 @@ } } + $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'])); $statusFile = fopen($getopt['status-file'], 'wb'); assert(is_resource($statusFile)); diff --git a/composer.json b/composer.json index 6557a253c..957e68ebe 100644 --- a/composer.json +++ b/composer.json @@ -19,18 +19,19 @@ "require": { "php": "^8.3.0", "brianium/paratest": "^7.20.0", - "nunomaduro/collision": "^8.9.3", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", - "phpunit/phpunit": "^12.5.23", + "phpunit/phpunit": "^12.5.24", "symfony/process": "^7.4.8|^8.0.8" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.23", + "phpunit/phpunit": ">12.5.24", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -58,7 +59,7 @@ ] }, "require-dev": { - "mrpunyapal/peststan": "^0.2.5", + "mrpunyapal/peststan": "^0.2.9", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", @@ -123,6 +124,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, diff --git a/resources/views/components/badge.php b/resources/views/components/badge.php index 39d31b9f1..7e6ebee3a 100644 --- a/resources/views/components/badge.php +++ b/resources/views/components/badge.php @@ -5,6 +5,8 @@ [$bgBadgeColor, $bgBadgeText] = match ($type) { 'INFO' => ['blue', 'INFO'], 'ERROR' => ['red', 'ERROR'], + 'WARN' => ['yellow', 'WARN'], + 'SUCCESS' => ['green', 'SUCCESS'], }; ?> diff --git a/src/Bootstrappers/BootPhpUnitConfiguration.php b/src/Bootstrappers/BootPhpUnitConfiguration.php new file mode 100644 index 000000000..c9f808127 --- /dev/null +++ b/src/Bootstrappers/BootPhpUnitConfiguration.php @@ -0,0 +1,19 @@ +build(['pest']); + } +} diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 7877b2372..065a5e0fc 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -25,6 +25,17 @@ Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureTeamCityEnabled::class, + Subscribers\EnsureTiaIsRunningPestTestsOnly::class, + Subscribers\EnsureTiaStarts::class, + Subscribers\EnsureTiaEnds::class, + Subscribers\EnsureTiaResultsAreCollected::class, + Subscribers\EnsureTiaResultIsRecordedOnPassed::class, + Subscribers\EnsureTiaResultIsRecordedOnFailed::class, + Subscribers\EnsureTiaResultIsRecordedOnErrored::class, + Subscribers\EnsureTiaResultIsRecordedOnSkipped::class, + Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class, + Subscribers\EnsureTiaResultIsRecordedOnRisky::class, + Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class, ]; /** diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 3f7e3b779..a6f78d25b 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -7,12 +7,18 @@ use Closure; use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Panic; +use Pest\Plugins\Tia; +use Pest\Plugins\Tia\Collectors; +use Pest\Plugins\Tia\Enums\ReplayType; +use Pest\Plugins\Tia\Recorder; use Pest\Preset; use Pest\Support\ChainableClosure; +use Pest\Support\Container; use Pest\Support\ExceptionTrace; use Pest\Support\Reflection; use Pest\Support\Shell; use Pest\TestSuite; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\IncompleteTest; use PHPUnit\Framework\SkippedTest; @@ -75,6 +81,17 @@ trait Testable */ public bool $__ran = false; + /** + * The active replay mode for this test, set in `setUp()` and checked + * in `__runTest()` / `tearDown()` to skip the body and after-each. + */ + private ReplayType $__replay = ReplayType::None; + + /** + * The cached assertion count to replay, captured when entering replay mode. + */ + private int $__replayAssertions = 0; + /** * The test's test closure. */ @@ -259,8 +276,35 @@ protected function setUp(...$arguments): void self::$__latestIssues = $method->issues; self::$__latestPrs = $method->prs; + /** @var Tia $tia */ + $tia = Container::getInstance()->get(Tia::class); + $status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name()); + $replay = ReplayType::fromStatus($status); + + if ($replay !== ReplayType::None) { + assert($status !== null); + + match ($replay) { + ReplayType::Pass, ReplayType::Risky => $this->__beginReplay($replay, $tia), + ReplayType::Skipped => $this->markTestSkipped($status->message()), + ReplayType::Incomplete => $this->markTestIncomplete($status->message()), + ReplayType::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'), + }; + + return; + } + + $recorder = Container::getInstance()->get(Recorder::class); + assert($recorder instanceof Recorder); + + if ($recorder->isActive()) { + $recorder->beginTest($this::class, $this->name(), self::$__filename); + } + parent::setUp(); + Collectors::armAll($recorder); + $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; if ($this->__beforeEach instanceof Closure) { @@ -270,6 +314,13 @@ protected function setUp(...$arguments): void $this->__callClosure($beforeEach, $arguments); } + private function __beginReplay(ReplayType $replay, Tia $tia): void + { + $this->__replay = $replay; + $this->__replayAssertions = $tia->getAssertionCount($this::class.'::'.$this->name()); + $this->__ran = true; + } + /** * Initialize test case properties from TestSuite. */ @@ -302,6 +353,12 @@ public function __initializeTestCase(): void */ protected function tearDown(...$arguments): void { + if ($this->__replay !== ReplayType::None) { + TestSuite::getInstance()->test = null; + + return; + } + $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); if ($this->__afterEach instanceof Closure) { @@ -327,6 +384,16 @@ protected function tearDown(...$arguments): void */ private function __runTest(Closure $closure, ...$args): mixed { + if ($this->__replay === ReplayType::Pass || $this->__replay === ReplayType::Risky) { + if ($this->__replay === ReplayType::Pass && $this->__replayAssertions === 0) { + $this->expectNotToPerformAssertions(); + } + + $this->addToAssertionCount($this->__replayAssertions); + + return null; + } + $arguments = $this->__resolveTestArguments($args); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments); diff --git a/src/Configuration.php b/src/Configuration.php index 4261f3ef3..a37871eef 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -119,6 +119,14 @@ public function browser(): Browser\Configuration return new Browser\Configuration; } + /** + * Gets the TIA (Test Impact Analysis) configuration. + */ + public function tia(): Plugins\Tia\Configuration + { + return new Plugins\Tia\Configuration; + } + /** * Proxies calls to the uses method. * diff --git a/src/Contracts/Restarter.php b/src/Contracts/Restarter.php new file mode 100644 index 000000000..95324301a --- /dev/null +++ b/src/Contracts/Restarter.php @@ -0,0 +1,16 @@ + $arguments + */ + public function maybeRestart(string $projectRoot, array $arguments): void; +} diff --git a/src/Exceptions/BaselineFetchFailed.php b/src/Exceptions/BaselineFetchFailed.php new file mode 100644 index 000000000..301345c24 --- /dev/null +++ b/src/Exceptions/BaselineFetchFailed.php @@ -0,0 +1,54 @@ +hasAnchor) { + View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]); + $this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.'); + $output->writeln(''); + + return; + } + + $this->renderChild($output, $this->headline); + $this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.'); + $output->writeln(''); + } + + public function exitCode(): int + { + return 1; + } + + private function renderChild(OutputInterface $output, string $text): void + { + $output->writeln(sprintf(' ─ %s', $text)); + } +} diff --git a/src/Exceptions/NoAffectedTestsFound.php b/src/Exceptions/NoAffectedTestsFound.php new file mode 100644 index 000000000..162dacc21 --- /dev/null +++ b/src/Exceptions/NoAffectedTestsFound.php @@ -0,0 +1,32 @@ +writeln([ + '', + ' INFO No affected tests found.', + '', + ]); + } + + public function exitCode(): int + { + return 0; + } +} diff --git a/src/Exceptions/TiaRequiresPestTests.php b/src/Exceptions/TiaRequiresPestTests.php new file mode 100644 index 000000000..71d7615ac --- /dev/null +++ b/src/Exceptions/TiaRequiresPestTests.php @@ -0,0 +1,46 @@ +writeln([ + '', + ' ERROR Tia mode requires Pest tests.', + '', + sprintf(' Encountered PHPUnit class %s', $this->className), + sprintf(' in %s.', $this->file), + '', + ' Convert it to a Pest test, or run without Tia.', + '', + ]); + } + + public function exitCode(): int + { + return 1; + } +} diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php index 6f120c274..b23082fd6 100644 --- a/src/Factories/TestCaseFactory.php +++ b/src/Factories/TestCaseFactory.php @@ -166,7 +166,7 @@ public function evaluate(string $filename, array $methods): void final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN { $traitsCode - private static \$__filename = '$filename'; + public static \$__filename = '$filename'; $methodsCode } diff --git a/src/Kernel.php b/src/Kernel.php index 55b9e7a43..28232891d 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -27,8 +27,13 @@ /** * @internal */ -final readonly class Kernel +final class Kernel { + /** + * Either the kernel is terminated or not. + */ + private bool $terminated = false; + /** * The Kernel bootstrappers. * @@ -36,6 +41,8 @@ */ private const array BOOTSTRAPPERS = [ Bootstrappers\BootOverrides::class, + Bootstrappers\BootPhpUnitConfiguration::class, + Plugins\Tia\Bootstrapper::class, Bootstrappers\BootSubscribers::class, Bootstrappers\BootFiles::class, Bootstrappers\BootView::class, @@ -43,15 +50,22 @@ Bootstrappers\BootExcludeList::class, ]; + /** + * The Kernel restarters — resolved and invoked from `bin/pest` + * before any other Pest class is touched, so the list is exposed + * on the Kernel rather than driven from `bin/pest` directly. + * + * @var array> + */ + public const array RESTARTERS = [ + Restarters\XdebugRestarter::class, + Restarters\PcovRestarter::class, + ]; + /** * Creates a new Kernel instance. */ - public function __construct( - private Application $application, - private OutputInterface $output, - ) { - // - } + public function __construct(private readonly Application $application, private readonly OutputInterface $output) {} /** * Boots the Kernel. @@ -112,9 +126,13 @@ public function handle(array $originalArguments, array $arguments): int $configuration = Registry::get(); $result = Facade::result(); - return CallsAddsOutput::execute( + $result = CallsAddsOutput::execute( Result::exitCode($configuration, $result), ); + + $this->terminate(); + + return $result; } /** @@ -122,6 +140,12 @@ public function handle(array $originalArguments, array $arguments): int */ public function terminate(): void { + if ($this->terminated) { + return; + } + + $this->terminated = true; + $preBufferOutput = Container::getInstance()->get(KernelDump::class); assert($preBufferOutput instanceof KernelDump); diff --git a/src/Logging/Converter.php b/src/Logging/Converter.php index e0b69bb0d..1c8b38ccf 100644 --- a/src/Logging/Converter.php +++ b/src/Logging/Converter.php @@ -12,7 +12,9 @@ use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Test\AfterLastTestMethodErrored; +use PHPUnit\Event\Test\AfterLastTestMethodFailed; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; +use PHPUnit\Event\Test\BeforeFirstTestMethodFailed; use PHPUnit\Event\Test\ConsideredRisky; use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Failed; @@ -255,9 +257,11 @@ public function getStateFromResult(PhpUnitTestResult $result): State $numberOfNotPassedTests = count( array_unique( array_map( - function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string { + function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string { if ($event instanceof BeforeFirstTestMethodErrored - || $event instanceof AfterLastTestMethodErrored) { + || $event instanceof AfterLastTestMethodErrored + || $event instanceof BeforeFirstTestMethodFailed + || $event instanceof AfterLastTestMethodFailed) { return $event->testClassName(); } diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php new file mode 100644 index 000000000..e4a64b333 --- /dev/null +++ b/src/Plugins/Tia.php @@ -0,0 +1,1761 @@ + + */ + 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 $replayRan = false; + + private int $replayedCount = 0; + + private int $affectedCount = 0; + + private int $executedCount = 0; + + /** @var array */ + private array $cachedAssertionsByTestId = []; + + private ?Graph $replayGraph = null; + + private string $branch = 'main'; + + /** @var array */ + private array $affectedFiles = []; + + /** @var array{structural: array, environmental: array}|null */ + private ?array $startFingerprint = null; + + private bool $piggybackCoverage = false; + + private bool $recordingActive = false; + + private bool $forceRefetch = false; + + private bool $baselineFetchAttemptedForDrift = false; + + private bool $freshRebuild = false; + + private bool $filteredMode = false; + + private ?string $driftLabel = null; + + private ?string $driftDetails = null; + + private ?string $freshGraphReason = null; + + public function __construct( + private readonly OutputInterface $output, + private readonly Recorder $recorder, + private readonly CoverageCollector $coverageCollector, + private readonly WatchPatterns $watchPatterns, + private readonly State $state, + private readonly BaselineSync $baselineSync, + ) {} + + private function renderBadge(string $type, string $content): void + { + View::render('components.badge', ['type' => $type, 'content' => $content]); + } + + private function renderChild(string $text): void + { + $this->output->writeln(sprintf(' ─ %s', $text)); + } + + /** + * @param array{structural: array, environmental: array} $current + */ + private function structuralFingerprintShifted(array $current): bool + { + assert($this->startFingerprint !== null); + + return ! Fingerprint::structuralMatches($this->startFingerprint, $current); + } + + private function loadGraph(string $projectRoot): ?Graph + { + $json = $this->state->read(self::KEY_GRAPH); + + if ($json === null) { + return null; + } + + return Graph::decode($json, $projectRoot); + } + + private function saveGraph(Graph $graph): bool + { + $json = $graph->encode(); + + if ($json === null) { + return false; + } + + return $this->state->write(self::KEY_GRAPH, $json); + } + + /** + * @param array $arguments + */ + public static function isEnabledForRun(array $arguments): bool + { + if (self::argumentPresent(self::NO_OPTION, $arguments)) { + return false; + } + + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + assert($watchPatterns instanceof WatchPatterns); + + self::applyWatchPatternMarks($arguments, $watchPatterns); + + if (self::argumentPresent(self::OPTION, $arguments) || self::envFlagEnabled(self::ENV_TIA)) { + return true; + } + + if (! $watchPatterns->isEnabled()) { + return false; + } + + return ! ($watchPatterns->isLocally() && self::argumentPresent('--ci', $arguments)); + } + + /** + * @param array $arguments + */ + private static function applyWatchPatternMarks(array $arguments, WatchPatterns $watchPatterns): void + { + if (self::argumentPresent(self::LOCALLY_OPTION, $arguments) || self::envFlagEnabled(self::ENV_LOCALLY)) { + $watchPatterns->markEnabled(); + $watchPatterns->markLocally(); + } + + if (self::argumentPresent(self::BASELINED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_BASELINED)) { + $watchPatterns->markBaselined(); + } + } + + /** + * Mirrors {@see HandleArguments::hasArgument()} for + * use from static contexts — matches both `--flag` and `--flag=value`. + * + * @param array $arguments + */ + private static function argumentPresent(string $argument, array $arguments): bool + { + foreach ($arguments as $arg) { + if ($arg === $argument) { + return true; + } + + if (str_starts_with($arg, "$argument=")) { + return true; + } + } + + return false; + } + + private static function envFlagEnabled(string $name): bool + { + return filter_var(getenv($name), FILTER_VALIDATE_BOOL); + } + + public function getStatus(string $filename, string $testId): ?TestStatus + { + if (! $this->replayGraph instanceof Graph) { + return null; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + $real = @realpath($filename); + $rel = $real !== false + ? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR))) + : null; + + if ($rel !== null && isset($this->affectedFiles[$rel])) { + $this->affectedCount++; + $this->executedCount++; + + return null; + } + + if ($rel === null || ! $this->replayGraph->knowsTest($rel)) { + $this->executedCount++; + + return null; + } + + $result = $this->replayGraph->getResult($this->branch, $testId); + + if ($result instanceof TestStatus) { + if ($result->isFailure() || $result->isError()) { + $this->executedCount++; + + return null; + } + + $this->replayedCount++; + $assertions = $this->replayGraph->getAssertions($this->branch, $testId); + $this->cachedAssertionsByTestId[$testId] = $assertions ?? 0; + } else { + $this->executedCount++; + } + + return $result; + } + + public function getAssertionCount(string $testId): int + { + return $this->cachedAssertionsByTestId[$testId] ?? 0; + } + + /** + * {@inheritDoc} + */ + public function handleArguments(array $arguments): array + { + if ($this->hasArgument(self::BASELINE_PATH_OPTION, $arguments)) { + $this->output->writeln(Storage::tempDir(TestSuite::getInstance()->rootPath)); + + exit(0); + } + + $isWorker = Parallel::isWorker(); + $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; + $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; + + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + 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() + && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL); + $enabled = ! $disabled && ($cliEnabled || $alwaysEnabled); + $this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || self::envFlagEnabled(self::ENV_FILTERED) || $watchPatterns->isFiltered()) + && ! $this->hasExplicitPathArgument($arguments) + && ! $this->coverageReportActive(); + $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); + $this->forceRefetch = $this->hasArgument(self::REFETCH_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::REFETCH_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); + $this->freshRebuild = $forceRebuild; + + if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { + return $arguments; + } + + $this->piggybackCoverage = $isWorker + ? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1' + : $this->coverageReportActive(); + + $projectRoot = TestSuite::getInstance()->rootPath; + + if ($isWorker) { + return $this->handleWorker($arguments, $projectRoot, $recordingGlobal, $replayingGlobal); + } + + return $this->handleParent($arguments, $projectRoot, $forceRebuild); + } + + public function terminate(): void + { + if ($this->graphWritten) { + return; + } + + if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) { + $this->flushWorkerReplay(); + } + + $recorder = $this->recorder; + + if (! $this->recordingActive && ! $recorder->isActive()) { + return; + } + + $this->graphWritten = true; + + $projectRoot = TestSuite::getInstance()->rootPath; + $perTest = $this->piggybackCoverage + ? $this->coverageCollector->perTestFiles() + : $recorder->perTestFiles(); + + if ($perTest === []) { + $recorder->reset(); + $this->coverageCollector->reset(); + + return; + } + + $perTestTables = $recorder->perTestTables(); + $perTestInertia = $recorder->perTestInertiaComponents(); + $perTestUsesDatabase = $recorder->perTestUsesDatabase(); + + if ($perTestUsesDatabase !== []) { + $perTestTables = $this->augmentDatabaseTestTables( + $perTestTables, + $perTestUsesDatabase, + $projectRoot, + ); + } + + if (Parallel::isWorker()) { + $this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia); + $recorder->reset(); + $this->coverageCollector->reset(); + + return; + } + + $changedFiles = new ChangedFiles($projectRoot); + $currentSha = $changedFiles->currentSha(); + + $currentFingerprint = Fingerprint::compute($projectRoot); + + if ($this->structuralFingerprintShifted($currentFingerprint)) { + $this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.'); + $this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.'); + $recorder->reset(); + $this->coverageCollector->reset(); + + return; + } + + $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); + $graph->setFingerprint($currentFingerprint); + $graph->setRecordedAtSha($this->branch, $currentSha); + $graph->setLastRunTree( + $this->branch, + $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), + ); + $graph->replaceEdges($perTest); + $graph->replaceTestTables($perTestTables); + $graph->replaceTestInertiaComponents($perTestInertia); + $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); + + if ($this->freshRebuild) { + $graph->pruneMissingTests(); + } + + $this->seedResultsInto($graph); + + if (! $this->saveGraph($graph)) { + $this->renderBadge('ERROR', 'Could not write the dependency graph.'); + $recorder->reset(); + + return; + } + + $recorder->reset(); + $this->coverageCollector->reset(); + } + + public function addOutput(int $exitCode): int + { + if (Parallel::isWorker()) { + return $exitCode; + } + + $this->reportMissingWorkerDrivers(); + + if (Parallel::isEnabled()) { + $this->mergeWorkerReplayPartials(); + } + + if ($this->replayRan) { + $this->bumpRecordedSha(); + } + + if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { + $this->snapshotTestResults(); + + return $exitCode; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + $partialKeys = $this->collectWorkerEdgesPartials(); + + if ($partialKeys === []) { + if ($this->replayRan) { + $this->snapshotTestResults(); + } + + return $exitCode; + } + + $changedFiles = new ChangedFiles($projectRoot); + $currentSha = $changedFiles->currentSha(); + + $currentFingerprint = Fingerprint::compute($projectRoot); + + if ($this->structuralFingerprintShifted($currentFingerprint)) { + $this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.'); + $this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.'); + + foreach ($partialKeys as $key) { + $this->state->delete($key); + } + + return $exitCode; + } + + $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); + $graph->setFingerprint($currentFingerprint); + $graph->setRecordedAtSha($this->branch, $currentSha); + $graph->setLastRunTree( + $this->branch, + $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), + ); + + [$finalised, $finalisedTables, $finalisedInertia] = $this->consumePartials($partialKeys); + + if ($finalised === []) { + if ($this->replayRan) { + $this->snapshotTestResults(); + + return $exitCode; + } + + $this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.'); + $this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.'); + + return $exitCode; + } + + $graph->replaceEdges($finalised); + $graph->replaceTestTables($finalisedTables); + $graph->replaceTestInertiaComponents($finalisedInertia); + $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); + + if ($this->freshRebuild) { + $graph->pruneMissingTests(); + } + + if (! $this->saveGraph($graph)) { + $this->renderBadge('ERROR', 'Could not write the dependency graph.'); + + return $exitCode; + } + + $this->snapshotTestResults(); + + return $exitCode; + } + + /** + * @param array{structural: array, environmental: array} $current + */ + private function reconcileFingerprint(Graph $graph, array $current): ?Graph + { + $stored = $graph->fingerprint(); + + if (! Fingerprint::structuralMatches($stored, $current)) { + $drift = Fingerprint::structuralDrift($stored, $current); + + $this->driftLabel = $this->formatStructuralDrift($drift); + + if (in_array('composer_lock', $drift, true)) { + $branchSha = $graph->recordedAtSha($this->branch); + if ($branchSha !== null) { + $summary = $this->composerLockDelta( + TestSuite::getInstance()->rootPath, + $branchSha, + ); + if ($summary !== '') { + $this->driftDetails = $summary; + } + } + } + + $rebuilt = $this->tryRemoteBaselineForDrift($current); + + if ($rebuilt instanceof Graph) { + return $this->reconcileFingerprint($rebuilt, $current); + } + + $this->state->delete(self::KEY_GRAPH); + $this->state->delete(self::KEY_COVERAGE_CACHE); + + return null; + } + + $drift = Fingerprint::environmentalDrift($stored, $current); + + if ($drift !== []) { + $this->renderBadge('WARN', sprintf( + 'Env differs from baseline (%s) — results dropped, edges reused.', + implode(', ', $drift), + )); + + $graph->clearResults($this->branch); + $graph->setFingerprint($current); + $this->saveGraph($graph); + $this->state->delete(self::KEY_COVERAGE_CACHE); + } + + return $graph; + } + + /** + * @param array $arguments + * @return array + */ + private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array + { + $this->watchPatterns->useDefaults($projectRoot); + $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; + + $fingerprint = Fingerprint::compute($projectRoot); + $this->startFingerprint = $fingerprint; + + if ($forceRebuild) { + Storage::purge($projectRoot); + } + + $graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot); + + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); + } + + if ($graph instanceof Graph) { + $changedFiles = new ChangedFiles($projectRoot); + $branchSha = $graph->recordedAtSha($this->branch); + + if ($branchSha !== null + && $changedFiles->since($branchSha) === null) { + $this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.'); + $graph = null; + } + } + + if (! $graph instanceof Graph + && ! $forceRebuild + && ! $this->baselineFetchAttemptedForDrift + && $this->watchPatterns->isBaselined() + && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) { + $this->baselineFetchAttemptedForDrift = true; + $graph = $this->loadGraph($projectRoot); + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); + } + } + + if ($this->piggybackCoverage) { + $this->state->write(self::KEY_COVERAGE_MARKER, ''); + } + + if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { + if ($graph instanceof Graph && $this->driftLabel === null) { + $this->freshGraphReason = 'recording coverage baseline'; + } + + return $this->enterRecordMode($arguments); + } + + if ($graph instanceof Graph) { + return $this->enterReplayMode($graph, $projectRoot, $arguments); + } + + return $this->enterRecordMode($arguments); + } + + /** + * @param array $arguments + * @return array + */ + private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array + { + $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; + + if ($replayingGlobal) { + $this->installWorkerReplay($projectRoot); + + if ($recordingGlobal) { + return $this->activateWorkerRecorderForReplay($arguments); + } + + return $arguments; + } + + if (! $recordingGlobal) { + return $arguments; + } + + if ($this->piggybackCoverage) { + $this->recordingActive = true; + + return $arguments; + } + + $recorder = $this->recorder; + + if (! $recorder->driverAvailable()) { + $this->state->write( + self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json', + '{}', + ); + + return $arguments; + } + + $recorder->activate(); + $this->recordingActive = true; + + return $arguments; + } + + private function installWorkerReplay(string $projectRoot): void + { + $graph = $this->loadGraph($projectRoot); + + if (! $graph instanceof Graph) { + return; + } + + $raw = $this->state->read(self::KEY_AFFECTED); + + if ($raw === null) { + return; + } + + $decoded = json_decode($raw, true); + + if (! is_array($decoded)) { + return; + } + + $affectedSet = []; + + foreach ($decoded as $rel) { + if (is_string($rel)) { + $affectedSet[$rel] = true; + } + } + + $this->replayGraph = $graph; + $this->affectedFiles = $affectedSet; + + if ((string) Parallel::getGlobal(self::FILTERED_GLOBAL) === '1') { + TestSuite::getInstance()->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + ); + } + } + + /** + * @param array $arguments + * @return array + */ + private function activateWorkerRecorderForReplay(array $arguments): array + { + if ($this->piggybackCoverage) { + $this->recordingActive = true; + + return $arguments; + } + + $recorder = $this->recorder; + + if (! $recorder->driverAvailable()) { + $this->state->write( + self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json', + '{}', + ); + + return $arguments; + } + + $recorder->activate(); + $this->recordingActive = true; + + return $arguments; + } + + /** + * @param array $arguments + * @return array + */ + private function enterReplayMode(Graph $graph, string $projectRoot, array $arguments): array + { + $changedFiles = new ChangedFiles($projectRoot); + + $branchSha = $graph->recordedAtSha($this->branch); + $changed = $changedFiles->since($branchSha) ?? []; + + $changed = $changedFiles->filterUnchangedSinceLastRun( + $changed, + $graph->lastRunTree($this->branch), + ); + + $hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed); + $coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable(); + + if ($hasProjectPhpSourceChanges && ! $coverageAvailable) { + $this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.'); + $this->renderChild('Running the full suite to avoid using a stale dependency graph.'); + $this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.'); + + return $arguments; + } + + $affectedFromChanges = $changed === [] ? [] : $graph->affected($changed); + $rerunFromCache = []; + + if ($this->filteredMode) { + $rerunFromCache = $graph->testFilesToRerun($this->branch); + } + + $affected = array_values(array_unique([ + ...$affectedFromChanges, + ...$rerunFromCache, + ])); + + $this->reportAffectedSummary($changed, $affectedFromChanges, $rerunFromCache, $affected); + + $affectedSet = array_fill_keys($affected, true); + $canRefreshReplayEdges = $affected !== [] && $coverageAvailable; + + $this->replayRan = true; + $this->replayGraph = $graph; + $this->affectedFiles = $affectedSet; + + $this->registerRecap(); + + if ($this->filteredMode) { + if ($affected === []) { + Panic::with(new NoAffectedTestsFound); + } + + TestSuite::getInstance()->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + ); + } + + if (! Parallel::isEnabled()) { + if ($canRefreshReplayEdges) { + $this->recorder->activate(); + $this->recordingActive = true; + } + + return $arguments; + } + + if (! $this->persistAffectedSet($affected)) { + $this->renderBadge('ERROR', 'Could not persist affected set — running full suite.'); + + return $arguments; + } + + $this->purgeWorkerPartials(); + + Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); + + if ($canRefreshReplayEdges) { + Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); + } + + if ($this->filteredMode) { + Parallel::setGlobal(self::FILTERED_GLOBAL, '1'); + } + + return $arguments; + } + + /** + * @param array $changedFiles + * @param array $affectedFromChanges + * @param array $rerunFromCache + * @param array $affected + */ + private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $rerunFromCache, array $affected): void + { + $this->output->writeln(''); + + if ($affected === []) { + $this->renderChild('Experimental TIA mode enabled.'); + + return; + } + + $newReruns = $rerunFromCache === [] + ? 0 + : count(array_diff($rerunFromCache, $affectedFromChanges)); + + $reasons = []; + $singleReason = (int) ($affectedFromChanges !== []) + (int) ($newReruns > 0) === 1; + + if ($affectedFromChanges !== []) { + $reasons[] = $singleReason + ? sprintf( + 'from %d changed file%s', + count($changedFiles), + count($changedFiles) === 1 ? '' : 's', + ) + : sprintf( + '%d from %d changed file%s', + count($affectedFromChanges), + count($changedFiles), + count($changedFiles) === 1 ? '' : 's', + ); + } + + if ($newReruns > 0) { + $reasons[] = $singleReason + ? sprintf( + 'from %d previously unsuccessful test%s', + $newReruns, + $newReruns === 1 ? '' : 's', + ) + : sprintf( + '%d from previously unsuccessful test%s', + $newReruns, + $newReruns === 1 ? '' : 's', + ); + } + + $this->renderChild(sprintf( + 'Experimental TIA mode enabled / %d affected test file%s%s.', + count($affected), + count($affected) === 1 ? '' : 's', + $reasons === [] ? '' : ' ('.implode(', ', $reasons).')', + )); + + $sorted = $affected; + sort($sorted); + + $previewLimit = $this->output->isVerbose() ? count($sorted) : 10; + $preview = array_slice($sorted, 0, $previewLimit); + + foreach ($preview as $file) { + $this->output->writeln(sprintf(' %s', $file)); + } + + $remainder = count($sorted) - count($preview); + + if ($remainder > 0) { + $this->output->writeln(sprintf(' … +%d more', $remainder)); + } + } + + /** + * @param array $affected Project-relative paths. + */ + private function persistAffectedSet(array $affected): bool + { + $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return false; + } + + return $this->state->write(self::KEY_AFFECTED, $json); + } + + /** + * @param array $arguments + * @return array + */ + private function enterRecordMode(array $arguments): array + { + $recorder = $this->recorder; + + if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) { + $this->emitCoverageDriverMissing(); + + return $arguments; + } + + if (Parallel::isEnabled()) { + $this->purgeWorkerPartials(); + + Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); + + if ($this->piggybackCoverage) { + Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1'); + } + + $this->output->writeln(''); + $this->renderFreshGraph(); + + return $arguments; + } + + if ($this->piggybackCoverage) { + $this->recordingActive = true; + + $this->output->writeln(''); + $this->renderFreshGraph(); + + return $arguments; + } + + $recorder->activate(); + $this->recordingActive = true; + + $this->renderChild('Running in TIA mode.'); + + return $arguments; + } + + private function renderFreshGraph(): void + { + $headline = 'Experimental TIA mode enabled / fresh graph'; + + if ($this->driftLabel !== null) { + $headline .= sprintf(' (%s changed)', $this->driftLabel); + } elseif ($this->freshGraphReason !== null) { + $headline .= sprintf(' (%s)', $this->freshGraphReason); + } else { + $headline .= '.'; + } + + $this->renderChild($headline); + + if ($this->driftDetails !== null) { + foreach (explode(', ', $this->driftDetails) as $detail) { + $this->output->writeln(sprintf(' %s', $detail)); + } + } + } + + private function emitCoverageDriverMissing(): void + { + $this->output->writeln(''); + + $this->renderChild('Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug.'); + } + + /** + * @param array> $perTestFiles + * @param array> $perTestTables + * @param array> $perTestInertiaComponents + */ + private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void + { + $json = json_encode([ + 'files' => $perTestFiles, + 'tables' => $perTestTables, + 'inertia' => $perTestInertiaComponents, + ], JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return; + } + + $this->state->write(self::KEY_WORKER_EDGES_PREFIX.$this->workerToken().'.json', $json); + } + + /** + * @return list + */ + private function collectWorkerEdgesPartials(): array + { + return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); + } + + private function reportMissingWorkerDrivers(): void + { + $keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX); + + if ($keys === []) { + return; + } + + foreach ($keys as $key) { + $this->state->delete($key); + } + + $this->renderBadge('WARN', sprintf( + '%d worker(s) had no coverage driver — their per-test edges and results were dropped.', + count($keys), + )); + $this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.'); + } + + private function purgeWorkerPartials(): void + { + foreach ($this->collectWorkerEdgesPartials() as $key) { + $this->state->delete($key); + } + foreach ($this->collectWorkerReplayPartials() as $key) { + $this->state->delete($key); + } + } + + private function flushWorkerReplay(): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + $results = $collector->all(); + + if ($results === [] && $this->replayedCount === 0 && $this->affectedCount === 0 && $this->executedCount === 0) { + return; + } + + $json = json_encode([ + 'results' => $results, + 'replayed' => $this->replayedCount, + 'affected' => $this->affectedCount, + 'executed' => $this->executedCount, + ], JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return; + } + + $this->state->write(self::KEY_WORKER_RESULTS_PREFIX.$this->workerToken().'.json', $json); + } + + /** + * @return list + */ + private function collectWorkerReplayPartials(): array + { + return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX); + } + + private function mergeWorkerReplayPartials(): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + foreach ($this->collectWorkerReplayPartials() as $key) { + $raw = $this->state->read($key); + $this->state->delete($key); + + if ($raw === null) { + continue; + } + + $decoded = json_decode($raw, true); + + if (! is_array($decoded)) { + continue; + } + + if (isset($decoded['replayed']) && is_int($decoded['replayed'])) { + $this->replayedCount += $decoded['replayed']; + } + + if (isset($decoded['affected']) && is_int($decoded['affected'])) { + $this->affectedCount += $decoded['affected']; + } + + if (isset($decoded['executed']) && is_int($decoded['executed'])) { + $this->executedCount += $decoded['executed']; + } + + if (isset($decoded['results']) && is_array($decoded['results'])) { + $normalised = []; + + /** @var mixed $result */ + foreach ($decoded['results'] as $testId => $result) { + if (! is_string($testId)) { + continue; + } + if (! is_array($result)) { + continue; + } + $normalised[$testId] = [ + 'status' => is_int($result['status'] ?? null) ? $result['status'] : 0, + 'message' => is_string($result['message'] ?? null) ? $result['message'] : '', + 'time' => is_float($result['time'] ?? null) || is_int($result['time'] ?? null) ? (float) $result['time'] : 0.0, + 'assertions' => is_int($result['assertions'] ?? null) ? $result['assertions'] : 0, + ]; + + if (isset($result['file']) && is_string($result['file'])) { + $normalised[$testId]['file'] = $result['file']; + } + } + + if ($normalised !== []) { + $collector->merge($normalised); + } + } + } + } + + private function workerToken(): string + { + $raw = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? null; + + $token = is_scalar($raw) ? (string) $raw : (string) getmypid(); + $token = preg_replace('/[^A-Za-z0-9_-]/', '', $token); + + if ($token === null || $token === '') { + return (string) getmypid(); + } + + return $token; + } + + /** + * @param list $partialKeys + * @return array{0: array>, 1: array>, 2: array>} + */ + 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>, tables: array>, inertia: array>}|null + */ + private function readPartial(string $key): ?array + { + $raw = $this->state->read($key); + + if ($raw === null) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + return null; + } + + $filesSource = is_array($data['files'] ?? null) ? $data['files'] : []; + $tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : []; + $inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : []; + + return [ + 'files' => $this->cleanPartialSection($filesSource), + 'tables' => $this->cleanPartialSection($tablesSource), + 'inertia' => $this->cleanPartialSection($inertiaSource), + ]; + } + + /** + * @param array $section + * @return array> + */ + private function cleanPartialSection(array $section): array + { + $out = []; + + foreach ($section as $test => $items) { + if (! is_string($test)) { + continue; + } + if (! is_array($items)) { + continue; + } + + $clean = []; + + foreach ($items as $item) { + if (is_string($item)) { + $clean[] = $item; + } + } + + $out[$test] = $clean; + } + + return $out; + } + + private function registerRecap(): void + { + DefaultPrinter::addRecap(function (): string { + if (Parallel::isEnabled() && ! Parallel::isWorker()) { + $this->mergeWorkerReplayPartials(); + } + + $fragments = []; + + if ($this->affectedCount > 0) { + $fragments[] = $this->affectedCount.' affected'; + } + + $uncachedCount = max(0, $this->executedCount - $this->affectedCount); + + if ($uncachedCount > 0) { + $fragments[] = $uncachedCount.' uncached'; + } + + if ($this->replayedCount > 0) { + $fragments[] = $this->replayedCount.' replayed'; + } + + return implode(', ', $fragments); + }); + } + + private function bumpRecordedSha(): void + { + $projectRoot = TestSuite::getInstance()->rootPath; + + $graph = $this->loadGraph($projectRoot); + + if (! $graph instanceof Graph) { + return; + } + + $changedFiles = new ChangedFiles($projectRoot); + $currentSha = $changedFiles->currentSha(); + + if ($currentSha !== null) { + $graph->setRecordedAtSha($this->branch, $currentSha); + } + + $workingTreeFiles = $changedFiles->since($currentSha) ?? []; + $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles)); + + $this->saveGraph($graph); + } + + private function seedResultsInto(Graph $graph): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + foreach ($collector->all() as $testId => $result) { + $graph->setResult( + $this->branch, + $testId, + $result['status'], + $result['message'], + $result['time'], + $result['assertions'], + $result['file'] ?? null, + ); + } + + $collector->reset(); + } + + private function snapshotTestResults(): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + $results = $collector->all(); + + if ($results === []) { + return; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + + $graph = $this->loadGraph($projectRoot); + + if (! $graph instanceof Graph) { + return; + } + + foreach ($results as $testId => $result) { + $file = $result['file'] ?? null; + + if ($file === null || str_contains($file, "eval()'d")) { + $file = $this->resolveFailedTestFile($testId); + } + + $graph->setResult( + $this->branch, + $testId, + $result['status'], + $result['message'], + $result['time'], + $result['assertions'], + $file, + ); + } + + $this->saveGraph($graph); + $collector->reset(); + } + + private function resolveFailedTestFile(string $testId): ?string + { + $class = strstr($testId, '::', true); + + if (! is_string($class) || $class === '' || ! class_exists($class)) { + return null; + } + + assert(property_exists($class, '__filename') && is_string($class::$__filename)); + + $filename = $class::$__filename; + + if ($filename !== '' && ! str_contains($filename, "eval()'d")) { + return $filename; + } + + $current = new \ReflectionClass($class); + + while ($current !== false) { + $file = $current->getFileName(); + + if ($file !== false && ! str_contains($file, "eval()'d")) { + return $file; + } + + $current = $current->getParentClass(); + } + + return null; + } + + private function coverageReportActive(): bool + { + $coverage = Container::getInstance()->get(Coverage::class); + assert($coverage instanceof Coverage); + + return $coverage->coverage; + } + + /** + * @param array $arguments + */ + private function hasExplicitPathArgument(array $arguments): bool + { + $projectRoot = TestSuite::getInstance()->rootPath; + $testPaths = SourceScope::testPaths(); + + if ($testPaths === []) { + return false; + } + + foreach ($arguments as $index => $arg) { + if ($arg === '') { + continue; + } + if (str_starts_with($arg, '-')) { + continue; + } + if ($index > 0) { + $previous = $arguments[$index - 1] ?? ''; + if (in_array($previous, self::VALUE_TAKING_FLAGS, true)) { + continue; + } + } + + $candidate = $this->resolveArgumentPath($arg, $projectRoot); + + if ($candidate === null) { + continue; + } + + foreach ($testPaths as $testPath) { + if ($candidate === $testPath || str_starts_with($candidate, $testPath.DIRECTORY_SEPARATOR)) { + return true; + } + } + } + + return false; + } + + private function resolveArgumentPath(string $arg, string $projectRoot): ?string + { + $candidates = [$arg, rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim($arg, DIRECTORY_SEPARATOR)]; + + foreach ($candidates as $candidate) { + if (! is_file($candidate) && ! is_dir($candidate)) { + continue; + } + + $real = @realpath($candidate); + + return rtrim($real === false ? $candidate : $real, '/\\'); + } + + return null; + } + + /** + * @param array $changedFiles + */ + private function hasProjectPhpSourceChanges(array $changedFiles): bool + { + foreach ($changedFiles as $rel) { + if (! str_ends_with($rel, '.php')) { + continue; + } + + if (str_ends_with($rel, '.blade.php')) { + continue; + } + if (str_starts_with($rel, 'tests/')) { + continue; + } + if (str_starts_with($rel, 'vendor/')) { + continue; + } + if (str_starts_with($rel, 'storage/framework/')) { + continue; + } + if (str_starts_with($rel, 'bootstrap/cache/')) { + continue; + } + + if (! is_file(TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.$rel)) { + continue; + } + + return true; + } + + return false; + } + + /** + * @param array{structural: array, environmental: array} $current + */ + private function tryRemoteBaselineForDrift(array $current): ?Graph + { + if ($this->baselineFetchAttemptedForDrift) { + return null; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + $this->baselineFetchAttemptedForDrift = true; + + if (! $this->watchPatterns->isBaselined()) { + return null; + } + + if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) { + return null; + } + + $fetched = $this->loadGraph($projectRoot); + + if (! $fetched instanceof Graph) { + return null; + } + + if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) { + $this->output->writeln(' However, baseline still drifts — discarding.'); + + return null; + } + + $this->renderBadge('SUCCESS', 'Fetched baseline matches — skipping local rebuild.'); + + return $fetched; + } + + /** + * @param list $drift + */ + private function formatStructuralDrift(array $drift): string + { + static $labels = [ + 'composer_lock' => 'composer.lock', + 'composer_json' => 'composer.json', + 'phpunit_xml' => 'phpunit.xml', + 'phpunit_xml_dist' => 'phpunit.xml.dist', + 'vite_config' => 'vite.config', + 'package_json' => 'package.json', + 'package_lock' => 'Node lockfile', + 'js_config' => 'JS/TS config', + 'pest_factory' => 'Pest internals', + 'pest_method_factory' => 'Pest internals', + ]; + + $seen = []; + foreach ($drift as $key) { + $seen[$labels[$key] ?? $key] = true; + } + + if ($seen === []) { + return 'unknown'; + } + + return implode(', ', array_keys($seen)); + } + + private function composerLockDelta(string $projectRoot, string $sha): string + { + $current = @file_get_contents($projectRoot.'/composer.lock'); + if ($current === false) { + return ''; + } + + $process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot); + $process->setTimeout(5.0); + $process->run(); + + if (! $process->isSuccessful()) { + return ''; + } + + $oldVersions = $this->lockVersions($process->getOutput()); + $newVersions = $this->lockVersions($current); + + if ($oldVersions === [] && $newVersions === []) { + return ''; + } + + $changes = []; + foreach ($newVersions as $name => $version) { + if (! isset($oldVersions[$name])) { + $changes[] = '+ '.$name.' '.$version; + } elseif ($oldVersions[$name] !== $version) { + $changes[] = $name.' '.$oldVersions[$name].' → '.$version; + } + } + foreach ($oldVersions as $name => $version) { + if (! isset($newVersions[$name])) { + $changes[] = '− '.$name.' '.$version; + } + } + + if ($changes === []) { + return ''; + } + + sort($changes); + + $maxShown = 8; + if (count($changes) > $maxShown) { + $extra = count($changes) - $maxShown; + $changes = array_slice($changes, 0, $maxShown); + $changes[] = sprintf('… +%d more', $extra); + } + + return implode(', ', $changes); + } + + /** + * @param array> $perTestTables + * @param array $perTestUsesDatabase + * @return array> + */ + private function augmentDatabaseTestTables(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array + { + $migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations'; + + if (! is_dir($migrationDir)) { + return $perTestTables; + } + + $allTables = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($migrationDir, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo->isFile()) { + continue; + } + if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) { + continue; + } + + $content = @file_get_contents((string) $fileInfo->getPathname()); + + if ($content === false) { + continue; + } + + foreach (TableExtractor::fromMigrationSource($content) as $table) { + $allTables[strtolower($table)] = true; + } + } + + if ($allTables === []) { + return $perTestTables; + } + + foreach (array_keys($perTestUsesDatabase) as $testFile) { + $existing = $perTestTables[$testFile] ?? []; + $merged = array_fill_keys($existing, true) + $allTables; + $names = array_keys($merged); + sort($names); + $perTestTables[$testFile] = $names; + } + + return $perTestTables; + } + + /** + * @return array package name → version + */ + private function lockVersions(string $json): array + { + $data = json_decode($json, true); + + if (! is_array($data)) { + return []; + } + + $out = []; + + foreach (['packages', 'packages-dev'] as $section) { + if (! isset($data[$section])) { + continue; + } + if (! is_array($data[$section])) { + continue; + } + foreach ($data[$section] as $package) { + if (! is_array($package)) { + continue; + } + $name = $package['name'] ?? null; + $version = $package['version'] ?? null; + + if (is_string($name) && is_string($version)) { + $out[$name] = $version; + } + } + } + + return $out; + } +} diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php new file mode 100644 index 000000000..c291b6c22 --- /dev/null +++ b/src/Plugins/Tia/BaselineSync.php @@ -0,0 +1,626 @@ + [ + '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( + private State $state, + private OutputInterface $output, + ) {} + + private function renderBadge(string $type, string $content): void + { + View::render('components.badge', ['type' => $type, 'content' => $content]); + } + + private function renderChild(string $text): void + { + $this->output->writeln(sprintf(' ─ %s', $text)); + } + + private function renderChildContinuation(string $text): void + { + $this->output->writeln(sprintf(' %s', $text)); + } + + public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool + { + $repo = $this->detectGitHubRepo($projectRoot); + + if ($repo === null) { + return false; + } + + if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { + $this->renderBadge('WARN', sprintf( + 'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.', + $this->formatDuration($remaining), + )); + + return false; + } + + $result = $this->download($repo, $projectRoot, $hasAnchor); + $payload = $result['payload']; + $failureKind = $result['failureKind']; + + if ($payload === null) { + if ($failureKind === 'no-runs' || $failureKind === null) { + $this->startCooldown(); + $this->emitPublishInstructions(); + } + + return false; + } + + if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) { + return false; + } + + if ($payload['coverage'] !== null) { + $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']); + } + + $this->clearCooldown(); + + return true; + } + + private function cooldownRemaining(): ?int + { + $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN); + + if ($raw === null) { + return null; + } + + $decoded = json_decode($raw, true); + + if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) { + return null; + } + + $remaining = $decoded['until'] - time(); + + return $remaining > 0 ? $remaining : null; + } + + private function startCooldown(): void + { + $this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([ + 'until' => time() + self::FETCH_COOLDOWN_SECONDS, + ])); + } + + private function clearCooldown(): void + { + $this->state->delete(Tia::KEY_FETCH_COOLDOWN); + } + + private function formatDuration(int $seconds): string + { + if ($seconds >= 3600) { + return (int) round($seconds / 3600).'h'; + } + + if ($seconds >= 60) { + return (int) round($seconds / 60).'m'; + } + + return $seconds.'s'; + } + + private function emitPublishInstructions(): void + { + if ($this->isCi()) { + $this->renderBadge('INFO', 'No baseline yet — this run will produce one.'); + + return; + } + + $this->renderBadge('WARN', 'No baseline published yet — recording locally.'); + $this->renderChild('See https://pestphp.com/docs/tia for how to publish one from CI.'); + } + + private function isCi(): bool + { + return getenv('GITHUB_ACTIONS') === 'true' + || getenv('GITLAB_CI') === 'true' + || getenv('CIRCLECI') === 'true'; + } + + private function detectGitHubRepo(string $projectRoot): ?string + { + $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; + + if (! is_file($gitConfig)) { + return null; + } + + $content = @file_get_contents($gitConfig); + + if ($content === false) { + return null; + } + + if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) { + return null; + } + + $url = $match[1]; + + if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) { + return $m[1]; + } + + if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) { + return $m[1]; + } + + if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { + return $m[1]; + } + + return null; + } + + /** + * @return array{payload: array{graph: string, coverage: ?string, sizeOnDisk: int}|null, failureKind: ?string} + */ + private function download(string $repo, string $projectRoot, bool $hasAnchor = false): array + { + $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->renderChild(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')) { + Panic::with(new BaselineFetchFailed( + 'GitHub CLI (gh) not found — cannot fetch baseline.', + 'Install it from https://cli.github.com.', + $hasAnchor, + )); + } + + if (! $this->ghAuthenticated()) { + Panic::with(new BaselineFetchFailed( + 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', + 'Run `gh auth login` and retry.', + $hasAnchor, + )); + } + } + + /** + * @return array{success: bool, failureKind: ?string} + */ + private function downloadArtifact(string $repo, string $runId, string $runCacheDir, bool $hasAnchor): array + { + $artifactSize = $this->artifactSize($repo, $runId); + + $this->output->writeln(''); + $this->renderChild($artifactSize !== null + ? sprintf( + 'Downloading TIA baseline (%s) from %s…', + $this->formatSize($artifactSize), + $repo, + ) + : sprintf( + 'Downloading TIA baseline from %s…', + $repo, + )); + + $process = new Process([ + 'gh', 'run', 'download', $runId, + '-R', $repo, + '-n', self::ARTIFACT_NAME, + '-D', $runCacheDir, + ]); + $process->setTimeout(900.0); + $process->start(); + + $startedAt = microtime(true); + $tick = 0; + + while ($process->isRunning()) { + $this->renderDownloadProgress($startedAt, $tick++); + usleep(120_000); + } + + $process->wait(); + $this->clearProgressLine(); + + if ($process->isSuccessful()) { + return ['success' => true, 'failureKind' => null]; + } + + $this->cleanup($runCacheDir); + + $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); + + $this->panicOnClassifiedError($diagnosis, 'Baseline download failed', $hasAnchor); + + $this->renderBadge('WARN', sprintf( + 'Baseline download failed — %s', + $diagnosis['message'], + )); + + 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); + + if ($payload === null) { + $this->cleanup($runCacheDir); + + Panic::with(new BaselineFetchFailed( + 'Baseline downloaded but the artifact is missing expected files (graph.json).', + 'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.', + $hasAnchor, + )); + } + + return $payload; + } + + private function artifactSize(string $repo, string $runId): ?int + { + $process = new Process([ + 'gh', 'api', + sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId), + '--jq', sprintf( + '.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type + self::ARTIFACT_NAME, + ), + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $size = trim($process->getOutput()); + + return is_numeric($size) ? (int) $size : null; + } + + private function renderDownloadProgress(float $startedAt, int $tick): void + { + static $frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + + $elapsed = max(0.0, microtime(true) - $startedAt); + $frame = $frames[$tick % count($frames)]; + + $this->output->write(sprintf( + "\r\033[K %s %.1fs elapsed", + $frame, + $elapsed, + )); + } + + private function clearProgressLine(): void + { + $this->output->write("\r\033[K"); + } + + private function dirSize(string $dir): int + { + if (! is_dir($dir)) { + return 0; + } + + $total = 0; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + ); + + /** @var \SplFileInfo $entry */ + foreach ($iterator as $entry) { + if ($entry->isFile()) { + $total += $entry->getSize(); + } + } + + return $total; + } + + /** + * @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null + */ + private function readArtifact(string $dir): ?array + { + $graphPath = $dir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET; + $coveragePath = $dir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET; + + $graph = is_file($graphPath) ? @file_get_contents($graphPath) : false; + + if ($graph === false) { + return null; + } + + $coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false; + + return [ + 'graph' => $graph, + 'coverage' => $coverage === false ? null : $coverage, + 'sizeOnDisk' => $this->dirSize($dir), + ]; + } + + private function downloadCacheDir(string $projectRoot): string + { + return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR; + } + + private function safeRunId(string $runId): string + { + $sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? ''; + + return $sanitised === '' ? 'unknown' : $sanitised; + } + + private function trimDownloadCache(string $projectRoot): void + { + $root = $this->downloadCacheDir($projectRoot); + + if (! is_dir($root)) { + return; + } + + $entries = @scandir($root); + + if ($entries === false) { + return; + } + + $candidates = []; + + foreach ($entries as $entry) { + if (in_array($entry, ['.', '..'], true)) { + continue; + } + + $path = $root.DIRECTORY_SEPARATOR.$entry; + + if (! is_dir($path)) { + continue; + } + + $mtime = @filemtime($path); + $candidates[] = ['path' => $path, 'mtime' => $mtime === false ? 0 : $mtime]; + } + + if (count($candidates) <= self::DOWNLOAD_CACHE_MAX_ENTRIES) { + return; + } + + usort( + $candidates, + static fn (array $a, array $b): int => $b['mtime'] <=> $a['mtime'], + ); + + foreach (array_slice($candidates, self::DOWNLOAD_CACHE_MAX_ENTRIES) as $stale) { + $this->cleanup($stale['path']); + } + } + + /** + * @return array{0: ?string, 1: ?array{kind: string, message: string}} + */ + private function latestSuccessfulRunIdWithError(string $repo): array + { + $process = new Process([ + 'gh', 'run', 'list', + '-R', $repo, + '--workflow', self::WORKFLOW_FILE, + '--status', 'success', + '--limit', '1', + '--json', 'databaseId', + '--jq', '.[0].databaseId // empty', + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return [null, $this->classifyGhError($process->getErrorOutput().$process->getOutput())]; + } + + $runId = trim($process->getOutput()); + + return [$runId === '' ? null : $runId, null]; + } + + private function ghAuthenticated(): bool + { + $process = new Process(['gh', 'auth', 'status']); + $process->setTimeout(10.0); + $process->run(); + + return $process->isSuccessful(); + } + + /** + * @return array{kind: string, message: string} + */ + private function classifyGhError(string $output): array + { + $output = trim($output); + + if ($output === '') { + return ['kind' => 'unknown', 'message' => 'unknown error']; + } + + foreach (self::DIAGNOSES as $kind => $diagnosis) { + if (preg_match($diagnosis['pattern'], $output) === 1) { + return ['kind' => $kind, 'message' => $diagnosis['message']]; + } + } + + return ['kind' => 'unknown', 'message' => trim(strtok($output, "\n"))]; + } + + private function commandExists(string $cmd): bool + { + $process = new Process(['which', $cmd]); + $process->run(); + + return $process->isSuccessful(); + } + + private function cleanup(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + /** @var \SplFileInfo $entry */ + foreach ($iterator as $entry) { + if ($entry->isDir()) { + @rmdir($entry->getPathname()); + } else { + @unlink($entry->getPathname()); + } + } + + @rmdir($dir); + } + + private function formatSize(int $bytes): string + { + if ($bytes >= 1024 * 1024) { + return sprintf('%.1f MB', $bytes / 1024 / 1024); + } + + if ($bytes >= 1024) { + return sprintf('%.1f KB', $bytes / 1024); + } + + return $bytes.' B'; + } +} diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php new file mode 100644 index 000000000..b26ecb432 --- /dev/null +++ b/src/Plugins/Tia/Bootstrapper.php @@ -0,0 +1,28 @@ +container->get(TestSuite::class); + assert($testSuite instanceof TestSuite); + + $tempDir = Storage::tempDir($testSuite->rootPath); + + $this->container->add(State::class, new FileState($tempDir)); + } +} diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php new file mode 100644 index 000000000..9ffb680ea --- /dev/null +++ b/src/Plugins/Tia/ChangedFiles.php @@ -0,0 +1,326 @@ + $files project-relative paths. + * @param array $lastRunTree path → content hash from last run. + * @return array + */ + public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array + { + if ($lastRunTree === []) { + return $files; + } + + $candidates = array_fill_keys($files, true); + + foreach (array_keys($lastRunTree) as $snapshotted) { + $candidates[$snapshotted] = true; + } + + $remaining = []; + + foreach (array_keys($candidates) as $file) { + $snapshot = $lastRunTree[$file] ?? null; + $current = $this->currentHash($file); + + if ($snapshot === null || $current === null || $current !== $snapshot) { + $remaining[] = $file; + } + } + + return $remaining; + } + + private function currentHash(string $relativePath): ?string + { + $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$relativePath; + + if (! is_file($absolute)) { + return null; + } + + $hash = ContentHash::of($absolute); + + return $hash === false ? null : $hash; + } + + /** + * @param array $files + * @return array path → xxh128 content hash + */ + public function snapshotTree(array $files): array + { + $out = []; + + foreach ($files as $file) { + $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; + + if (! is_file($absolute)) { + $out[$file] = ''; + + continue; + } + + $hash = ContentHash::of($absolute); + + if ($hash !== false) { + $out[$file] = $hash; + } + } + + return $out; + } + + /** + * @return array|null `null` when git is unavailable, or when + */ + public function since(?string $sha): ?array + { + $files = []; + + if ($sha !== null && $sha !== '') { + if (! $this->shaIsReachable($sha)) { + return null; + } + + $files = array_merge($files, $this->diffSinceSha($sha)); + } + + $files = array_merge($files, $this->workingTreeChanges()); + + $unique = []; + + foreach ($files as $file) { + if ($file === '') { + continue; + } + $unique[$file] = true; + } + + $candidates = array_keys($this->filterIgnored($unique)); + + if ($sha !== null && $sha !== '') { + return $this->filterBehaviourallyUnchanged($candidates, $sha); + } + + return $candidates; + } + + /** + * @param array $files + * @return array + */ + private function filterBehaviourallyUnchanged(array $files, string $sha): array + { + $remaining = []; + + foreach ($files as $file) { + $currentHash = $this->currentHash($file); + + if ($currentHash === null) { + $remaining[] = $file; + + continue; + } + + $baselineContent = $this->contentAtSha($sha, $file); + + if ($baselineContent === null) { + $remaining[] = $file; + + continue; + } + + if ($currentHash !== ContentHash::ofContent($file, $baselineContent)) { + $remaining[] = $file; + } + } + + return $remaining; + } + + private function contentAtSha(string $sha, string $path): ?string + { + $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); + $process->setTimeout(5.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + return $process->getOutput(); + } + + /** + * @param array $candidates + * @return array + */ + private function filterIgnored(array $candidates): array + { + if ($candidates === []) { + return $candidates; + } + + $process = new Process( + ['git', 'check-ignore', '--no-index', '-z', '--stdin'], + $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 $candidates; + } + + public function currentBranch(): ?string + { + $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot); + $process->run(); + + if (! $process->isSuccessful()) { + throw new MissingDependency('Tia mode', 'git'); + } + + $branch = trim($process->getOutput()); + + return $branch === '' || $branch === 'HEAD' ? null : $branch; + } + + private function shaIsReachable(string $sha): bool + { + $process = new Process( + ['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'], + $this->projectRoot, + ); + $process->run(); + + return $process->getExitCode() === 0; + } + + /** + * @return array + */ + private function diffSinceSha(string $sha): array + { + $process = new Process( + ['git', 'diff', '--name-only', $sha.'..HEAD'], + $this->projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + throw new MissingDependency('Tia mode', 'git'); + } + + return $this->splitLines($process->getOutput()); + } + + /** + * @return array + */ + private function workingTreeChanges(): array + { + $process = new Process( + ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], + $this->projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + throw new MissingDependency('Tia mode', 'git'); + } + + $output = $process->getOutput(); + + if ($output === '') { + return []; + } + + $records = explode("\x00", rtrim($output, "\x00")); + $files = []; + $count = count($records); + + for ($i = 0; $i < $count; $i++) { + $record = $records[$i]; + + if (strlen($record) < 4) { + continue; + } + + $status = substr($record, 0, 2); + $path = substr($record, 3); + + if ($status[0] === 'R' || $status[0] === 'C') { + $files[] = $path; + + if (isset($records[$i + 1]) && $records[$i + 1] !== '') { + $files[] = $records[$i + 1]; + $i++; + } + + continue; + } + + $files[] = $path; + } + + return $files; + } + + public function currentSha(): ?string + { + $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); + $process->run(); + + if (! $process->isSuccessful()) { + throw new MissingDependency('Tia mode', 'git'); + } + + $sha = trim($process->getOutput()); + + return $sha === '' ? null : $sha; + } + + /** + * @return array + */ + private function splitLines(string $output): array + { + $lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY); + + return $lines === false ? [] : $lines; + } +} diff --git a/src/Plugins/Tia/Collectors.php b/src/Plugins/Tia/Collectors.php new file mode 100644 index 000000000..c182f7b75 --- /dev/null +++ b/src/Plugins/Tia/Collectors.php @@ -0,0 +1,28 @@ + */ + private const array COLLECTORS = [ + BladeEdges::class, + TableTracker::class, + InertiaEdges::class, + ]; + + public static function armAll(Recorder $recorder): void + { + foreach (self::COLLECTORS as $collector) { + $collector::arm($recorder); + } + } +} diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php new file mode 100644 index 000000000..617898018 --- /dev/null +++ b/src/Plugins/Tia/Configuration.php @@ -0,0 +1,75 @@ +get(WatchPatterns::class); + $watchPatterns->markEnabled(); + + return $this; + } + + /** + * @return $this + */ + public function locally(): self + { + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + $watchPatterns->markEnabled(); + $watchPatterns->markLocally(); + + return $this; + } + + /** + * @return $this + */ + public function filtered(): self + { + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + $watchPatterns->markFiltered(); + + return $this; + } + + /** + * @return $this + */ + public function baselined(): self + { + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + $watchPatterns->markBaselined(); + + return $this; + } + + /** + * @param array $patterns glob → project-relative test dir + * @return $this + */ + public function watch(array $patterns): self + { + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + $watchPatterns->add($patterns); + + return $this; + } +} diff --git a/src/Plugins/Tia/ContentHash.php b/src/Plugins/Tia/ContentHash.php new file mode 100644 index 000000000..f8538bb50 --- /dev/null +++ b/src/Plugins/Tia/ContentHash.php @@ -0,0 +1,90 @@ + + */ + public function keysWithPrefix(string $prefix): array; +} diff --git a/src/Plugins/Tia/Contracts/WatchDefault.php b/src/Plugins/Tia/Contracts/WatchDefault.php new file mode 100644 index 000000000..feb82de46 --- /dev/null +++ b/src/Plugins/Tia/Contracts/WatchDefault.php @@ -0,0 +1,18 @@ +> pattern → list of project-relative test dirs + */ + public function defaults(string $projectRoot, string $testPath): array; +} diff --git a/src/Plugins/Tia/CoverageCollector.php b/src/Plugins/Tia/CoverageCollector.php new file mode 100644 index 000000000..1aaf34cb3 --- /dev/null +++ b/src/Plugins/Tia/CoverageCollector.php @@ -0,0 +1,110 @@ + + */ + private array $classFileCache = []; + + /** + * @return array> + */ + public function perTestFiles(): array + { + if (! PhpUnitCodeCoverage::instance()->isActive()) { + return []; + } + + try { + $lineCoverage = PhpUnitCodeCoverage::instance() + ->codeCoverage() + ->getData() + ->lineCoverage(); + } catch (Throwable) { + return []; + } + + /** @var array> $edges */ + $edges = []; + + foreach ($lineCoverage as $sourceFile => $lines) { + $testIds = []; + + foreach ($lines as $hits) { + if ($hits === null) { + continue; + } + + foreach ($hits as $id) { + $testIds[$id] = true; + } + } + + foreach (array_keys($testIds) as $testId) { + $testFile = $this->testIdToFile($testId); + + if ($testFile === null) { + continue; + } + + $edges[$testFile][$sourceFile] = true; + } + } + + $out = []; + + foreach ($edges as $testFile => $sources) { + $out[$testFile] = array_keys($sources); + } + + return $out; + } + + public function reset(): void + { + $this->classFileCache = []; + } + + private function testIdToFile(string $testId): ?string + { + $hash = strpos($testId, '#'); + $identifier = $hash === false ? $testId : substr($testId, 0, $hash); + + if (! str_contains($identifier, '::')) { + return null; + } + + [$className] = explode('::', $identifier, 2); + + if (array_key_exists($className, $this->classFileCache)) { + return $this->classFileCache[$className]; + } + + $file = $this->resolveClassFile($className); + $this->classFileCache[$className] = $file; + + return $file; + } + + private function resolveClassFile(string $className): ?string + { + if (! class_exists($className, false)) { + return null; + } + + assert(property_exists($className, '__filename') && is_string($className::$__filename)); + + return $className::$__filename; + } +} diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php new file mode 100644 index 000000000..f64f640e3 --- /dev/null +++ b/src/Plugins/Tia/CoverageMerger.php @@ -0,0 +1,177 @@ +exists(Tia::KEY_COVERAGE_MARKER)) { + return; + } + + $state->delete(Tia::KEY_COVERAGE_MARKER); + + $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); + + if ($cachedBytes === null) { + $current = self::requireCoverage($reportPath); + + if ($current instanceof CodeCoverage) { + self::primeUncoveredFiles($current); + $state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current))); + } + + return; + } + + $decoded = self::decompress($cachedBytes); + + if ($decoded === null) { + $state->delete(Tia::KEY_COVERAGE_CACHE); + + return; + } + + $cached = self::unserializeCoverage($decoded); + $current = self::requireCoverage($reportPath); + + if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) { + return; + } + + self::primeUncoveredFiles($cached); + self::primeUncoveredFiles($current); + + self::stripCurrentTestsFromCached($cached, $current); + + $cached->merge($current); + + $serialised = serialize($cached); + + @file_put_contents( + $reportPath, + 'write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised)); + } + + private static function primeUncoveredFiles(CodeCoverage $coverage): void + { + $coverage->getData(false); + } + + private static function compress(string $bytes): string + { + $compressed = @gzencode($bytes); + + return $compressed === false ? $bytes : $compressed; + } + + private static function decompress(string $bytes): ?string + { + $decoded = @gzdecode($bytes); + + return $decoded === false ? null : $decoded; + } + + private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void + { + $currentIds = self::collectTestIds($current); + + if ($currentIds === []) { + return; + } + + $cachedData = $cached->getData(); + $lineCoverage = $cachedData->lineCoverage(); + + foreach ($lineCoverage as $file => $lines) { + foreach ($lines as $line => $ids) { + if ($ids === null) { + continue; + } + if ($ids === []) { + continue; + } + $filtered = array_values(array_diff($ids, $currentIds)); + + if ($filtered !== $ids) { + $lineCoverage[$file][$line] = $filtered; + } + } + } + + $cachedData->setLineCoverage($lineCoverage); + } + + /** + * @return array + */ + private static function collectTestIds(CodeCoverage $coverage): array + { + $ids = []; + + foreach ($coverage->getData()->lineCoverage() as $lines) { + foreach ($lines as $hits) { + if ($hits === null) { + continue; + } + + foreach ($hits as $id) { + $ids[$id] = true; + } + } + } + + return array_keys($ids); + } + + private static function state(): State + { + $state = Container::getInstance()->get(State::class); + assert($state instanceof State); + + return $state; + } + + private static function requireCoverage(string $reportPath): ?CodeCoverage + { + if (! is_file($reportPath)) { + return null; + } + + try { + /** @var mixed $value */ + $value = require $reportPath; + } catch (Throwable) { + return null; + } + + return $value instanceof CodeCoverage ? $value : null; + } + + private static function unserializeCoverage(string $bytes): ?CodeCoverage + { + try { + $value = @unserialize($bytes); + } catch (Throwable) { + return null; + } + + return $value instanceof CodeCoverage ? $value : null; + } +} diff --git a/src/Plugins/Tia/Edges/BladeEdges.php b/src/Plugins/Tia/Edges/BladeEdges.php new file mode 100644 index 000000000..1d9931450 --- /dev/null +++ b/src/Plugins/Tia/Edges/BladeEdges.php @@ -0,0 +1,62 @@ +isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER) || ! $app->bound('view')) { + return; + } + + $app->instance(self::MARKER, true); + + $factory = $app->make('view'); + + if (! is_object($factory) || ! method_exists($factory, 'composer')) { + return; + } + + $factory->composer('*', static function (object $view) use ($recorder): void { + if (! method_exists($view, 'getPath')) { + return; + } + + /** @var mixed $path */ + $path = $view->getPath(); + + if (is_string($path) && $path !== '') { + $recorder->linkSource($path); + } + }); + } +} diff --git a/src/Plugins/Tia/Edges/InertiaEdges.php b/src/Plugins/Tia/Edges/InertiaEdges.php new file mode 100644 index 000000000..038c1d41f --- /dev/null +++ b/src/Plugins/Tia/Edges/InertiaEdges.php @@ -0,0 +1,131 @@ +isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER) || ! $app->bound('events')) { + return; + } + + $app->instance(self::MARKER, true); + + /** @var object $events */ + $events = $app->make('events'); + + if (! method_exists($events, 'listen')) { + return; + } + + $events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void { + if (! property_exists($event, 'response') || ! is_object($event->response)) { + return; + } + + $component = self::extractComponent($event->response); + + if ($component !== null) { + $recorder->linkInertiaComponent($component); + } + }); + } + + private static function extractComponent(object $response): ?string + { + $content = self::readContent($response); + + if ($content === null) { + return null; + } + + if (self::isInertiaJsonResponse($response)) { + return self::componentFromJson($content); + } + + if (str_contains($content, 'type="application/json"') + && preg_match('#]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)#s', $content, $match) === 1) { + $component = self::componentFromJson(html_entity_decode($match[1])); + + if ($component !== null) { + return $component; + } + } + + if (str_contains($content, 'data-page=') + && preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) { + return self::componentFromJson(html_entity_decode($match[1])); + } + + 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 + { + /** @var mixed $decoded */ + $decoded = json_decode($json, true); + + if (is_array($decoded) + && isset($decoded['component']) + && is_string($decoded['component']) + && $decoded['component'] !== '') { + return $decoded['component']; + } + + return null; + } + + private static function readContent(object $response): ?string + { + if (! method_exists($response, 'getContent')) { + return null; + } + + /** @var mixed $content */ + $content = $response->getContent(); + + return is_string($content) ? $content : null; + } +} diff --git a/src/Plugins/Tia/Enums/ReplayType.php b/src/Plugins/Tia/Enums/ReplayType.php new file mode 100644 index 000000000..6b669cc65 --- /dev/null +++ b/src/Plugins/Tia/Enums/ReplayType.php @@ -0,0 +1,35 @@ +isSuccess() => self::Pass, + $status->isRisky() => self::Risky, + $status->isSkipped() => self::Skipped, + $status->isIncomplete() => self::Incomplete, + default => self::Failure, + }; + } +} diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php new file mode 100644 index 000000000..91dc78925 --- /dev/null +++ b/src/Plugins/Tia/FileState.php @@ -0,0 +1,130 @@ +rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR); + } + + public function read(string $key): ?string + { + $path = $this->pathFor($key); + + if (! is_file($path)) { + return null; + } + + $bytes = @file_get_contents($path); + + return $bytes === false ? null : $bytes; + } + + public function write(string $key, string $content): bool + { + if (! $this->ensureRoot()) { + return false; + } + + $path = $this->pathFor($key); + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + if (@file_put_contents($tmp, $content) === false) { + return false; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; + } + + public function delete(string $key): bool + { + $path = $this->pathFor($key); + + if (! is_file($path)) { + return true; + } + + return @unlink($path); + } + + public function exists(string $key): bool + { + return is_file($this->pathFor($key)); + } + + public function keysWithPrefix(string $prefix): array + { + $root = $this->resolvedRoot(); + + if ($root === null) { + return []; + } + + $pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*'; + $matches = glob($pattern); + + if ($matches === false) { + return []; + } + + $keys = []; + + foreach ($matches as $path) { + $keys[] = basename($path); + } + + return $keys; + } + + public function pathFor(string $key): string + { + return $this->rootDir.DIRECTORY_SEPARATOR.$key; + } + + private function resolvedRoot(): ?string + { + if ($this->resolvedRoot !== null) { + return $this->resolvedRoot; + } + + $resolved = @realpath($this->rootDir); + + if ($resolved === false) { + return null; + } + + return $this->resolvedRoot = $resolved; + } + + private function ensureRoot(): bool + { + if (is_dir($this->rootDir)) { + return true; + } + + if (@mkdir($this->rootDir, 0755, true)) { + return true; + } + + return is_dir($this->rootDir); + } +} diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php new file mode 100644 index 000000000..da860b93f --- /dev/null +++ b/src/Plugins/Tia/Fingerprint.php @@ -0,0 +1,389 @@ +, + * environmental: array, + * } + */ + public static function compute(string $projectRoot): array + { + return [ + 'structural' => [ + 'schema' => self::SCHEMA_VERSION, + 'composer_lock' => self::composerLockHash($projectRoot), + 'phpunit_xml' => self::trackedHash($projectRoot, 'phpunit.xml'), + 'phpunit_xml_dist' => self::trackedHash($projectRoot, 'phpunit.xml.dist'), + // 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), + // 'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), + 'vite_config' => self::viteConfigHash($projectRoot), + // 'package_json' => self::packageJsonHash($projectRoot), + 'package_lock' => self::packageLockHash($projectRoot), + 'js_config' => self::jsConfigHash($projectRoot), + // 'composer_json' => self::composerJsonHash($projectRoot), + ], + 'environmental' => [ + 'php_minor' => PHP_MAJOR_VERSION, + + // 'extensions' => self::extensionsFingerprint($projectRoot), + // 'env_files' => self::envFilesHash($projectRoot), + ], + ]; + } + + /** + * @param array $a + * @param array $b + */ + public static function structuralMatches(array $a, array $b): bool + { + $aStructural = self::structuralOnly($a); + $bStructural = self::structuralOnly($b); + + ksort($aStructural); + ksort($bStructural); + + return $aStructural === $bStructural; + } + + /** + * @param array $stored + * @param array $current + * @return list + */ + public static function structuralDrift(array $stored, array $current): array + { + return self::detectDrift( + self::structuralOnly($stored), + self::structuralOnly($current), + 'schema', + ); + } + + /** + * @param array $stored + * @param array $current + * @return list + */ + public static function environmentalDrift(array $stored, array $current): array + { + return self::detectDrift( + self::environmentalOnly($stored), + self::environmentalOnly($current), + ); + } + + /** + * @param array $a + * @param array $b + * @return list + */ + private static function detectDrift(array $a, array $b, ?string $skipKey = null): array + { + $drifts = []; + + foreach ($a as $key => $value) { + if ($key === $skipKey) { + continue; + } + if (($b[$key] ?? null) !== $value) { + $drifts[] = $key; + } + } + + foreach ($b as $key => $value) { + if ($key === $skipKey) { + continue; + } + if (! array_key_exists($key, $a) && $value !== null) { + $drifts[] = $key; + } + } + + return array_values(array_unique($drifts)); + } + + /** + * @param array $fingerprint + * @return array + */ + private static function structuralOnly(array $fingerprint): array + { + return self::bucket($fingerprint, 'structural'); + } + + /** + * @param array $fingerprint + * @return array + */ + private static function environmentalOnly(array $fingerprint): array + { + return self::bucket($fingerprint, 'environmental'); + } + + /** + * @param array $fingerprint + * @return array + */ + private static function bucket(array $fingerprint, string $key): array + { + $raw = $fingerprint[$key] ?? null; + + if (! is_array($raw)) { + return []; + } + + $normalised = []; + + foreach ($raw as $k => $v) { + if (is_string($k)) { + $normalised[$k] = $v; + } + } + + return $normalised; + } + + private static function viteConfigHash(string $projectRoot): ?string + { + $parts = []; + + foreach (JsModuleGraph::VITE_CONFIG_NAMES as $name) { + if (! self::isTrackedByGit($projectRoot, $name)) { + continue; + } + + $hash = self::contentHashOrNull($projectRoot.'/'.$name); + + if ($hash !== null) { + $parts[] = $name.':'.$hash; + } + } + + return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); + } + + private static function jsConfigHash(string $projectRoot): ?string + { + $parts = []; + + foreach (['tsconfig.json', 'tsconfig.app.json', 'jsconfig.json'] as $name) { + if (! self::isTrackedByGit($projectRoot, $name)) { + continue; + } + + $hash = self::hashIfExists($projectRoot.'/'.$name); + + if ($hash !== null) { + $parts[] = $name.':'.$hash; + } + } + + return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); + } + + private static function packageJsonHash(string $projectRoot): ?string + { + $path = $projectRoot.'/package.json'; + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + $hash = @hash_file('xxh128', $path); + + return $hash === false ? null : $hash; + } + + $relevant = [ + 'type' => $data['type'] ?? null, + 'packageManager' => $data['packageManager'] ?? null, + 'dependencies' => $data['dependencies'] ?? null, + 'devDependencies' => $data['devDependencies'] ?? null, + 'optionalDependencies' => $data['optionalDependencies'] ?? null, + 'peerDependencies' => $data['peerDependencies'] ?? null, + 'overrides' => $data['overrides'] ?? null, + 'resolutions' => $data['resolutions'] ?? null, + 'imports' => $data['imports'] ?? null, + 'exports' => $data['exports'] ?? null, + 'browser' => $data['browser'] ?? null, + ]; + + self::sortRecursively($relevant); + + $json = json_encode($relevant); + + return $json === false ? null : hash('xxh128', $json); + } + + private static function composerLockHash(string $projectRoot): ?string + { + return self::trackedHash($projectRoot, 'composer.lock'); + } + + private static function packageLockHash(string $projectRoot): ?string + { + $parts = []; + + foreach (['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'] as $name) { + $hash = self::trackedHash($projectRoot, $name); + + if ($hash !== null) { + $parts[] = $name.':'.$hash; + } + } + + return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); + } + + private static function trackedHash(string $projectRoot, string $relativePath): ?string + { + if (! self::isTrackedByGit($projectRoot, $relativePath)) { + return null; + } + + return self::hashIfExists($projectRoot.'/'.$relativePath); + } + + /** + * Returns true when the file exists and is not gitignored. + * + * Gitignored lockfiles (e.g. `package-lock.json` excluded from the repo) + * regenerate per-machine with OS-specific optional deps, which would + * otherwise force a fingerprint mismatch on every fetched baseline. + */ + private static function isTrackedByGit(string $projectRoot, string $relativePath): bool + { + if (! is_file($projectRoot.'/'.$relativePath)) { + return false; + } + + static $cache = []; + + $key = $projectRoot."\0".$relativePath; + + if (isset($cache[$key])) { + return $cache[$key]; + } + + if (! is_dir($projectRoot.'/.git') && ! is_file($projectRoot.'/.git')) { + return $cache[$key] = true; + } + + $finder = (new Finder()) + ->in($projectRoot) + ->depth('== 0') + ->name($relativePath) + ->ignoreVCSIgnored(true); + + return $cache[$key] = $finder->hasResults(); + } + + private static function composerJsonHash(string $projectRoot): ?string + { + $path = $projectRoot.'/composer.json'; + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + $hash = @hash_file('xxh128', $path); + + return $hash === false ? null : $hash; + } + + $config = is_array($data['config'] ?? null) ? $data['config'] : []; + $relevantConfig = array_intersect_key($config, [ + 'platform' => true, + 'allow-plugins' => true, + ]); + + $relevant = [ + 'autoload' => $data['autoload'] ?? null, + 'autoload-dev' => $data['autoload-dev'] ?? null, + 'require' => $data['require'] ?? null, + 'require-dev' => $data['require-dev'] ?? null, + 'extra' => $data['extra'] ?? null, + 'repositories' => $data['repositories'] ?? null, + 'minimum-stability' => $data['minimum-stability'] ?? null, + 'prefer-stable' => $data['prefer-stable'] ?? null, + 'config' => $relevantConfig === [] ? null : $relevantConfig, + ]; + + self::sortRecursively($relevant); + + $json = json_encode($relevant); + + return $json === false ? null : hash('xxh128', $json); + } + + private static function sortRecursively(mixed &$value): void + { + if (! is_array($value)) { + return; + } + + $isAssoc = ! array_is_list($value); + + if ($isAssoc) { + ksort($value); + } + + foreach ($value as &$child) { + self::sortRecursively($child); + } + } + + private static function contentHashOrNull(string $path): ?string + { + if (! is_file($path)) { + return null; + } + + $hash = ContentHash::of($path); + + return $hash === false ? null : $hash; + } + + private static function hashIfExists(string $path): ?string + { + if (! is_file($path)) { + return null; + } + + $hash = @hash_file('xxh128', $path); + + return $hash === false ? null : $hash; + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php new file mode 100644 index 000000000..6da50f8bc --- /dev/null +++ b/src/Plugins/Tia/Graph.php @@ -0,0 +1,1446 @@ + */ + private array $files = []; + + /** @var array */ + private array $fileIds = []; + + /** @var array> */ + private array $edges = []; + + /** @var array> */ + private array $testTables = []; + + /** @var array> */ + private array $testInertiaComponents = []; + + /** @var array> */ + private array $jsFileToComponents = []; + + /** @var array */ + private array $fingerprint = []; + + /** + * @var array, + * results: array + * }> + */ + private array $baselines = []; + + private readonly string $projectRoot; + + /** @var array|null */ + private ?array $archTestFiles = null; + + /** @var array */ + private array $realpathCache = []; + + public function __construct(string $projectRoot) + { + $real = @realpath($projectRoot); + + $this->projectRoot = $real !== false ? $real : $projectRoot; + } + + public function link(string $testFile, string $sourceFile): void + { + $testRel = $this->relative($testFile); + $sourceRel = $this->relative($sourceFile); + + if ($sourceRel === null || $testRel === null) { + return; + } + + if (! isset($this->fileIds[$sourceRel])) { + $id = count($this->files); + $this->files[$id] = $sourceRel; + $this->fileIds[$sourceRel] = $id; + } + + $this->edges[$testRel][] = $this->fileIds[$sourceRel]; + } + + /** + * @param array $changedFiles Absolute or relative paths. + * @return array + */ + public function affected(array $changedFiles): array + { + [$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 $changedFiles + * @return array{0: list, 1: list} + */ + private function partitionChangedPaths(array $changedFiles): array + { + $migrations = []; + $nonMigrations = []; + + foreach ($changedFiles as $file) { + $rel = $this->relative($file); + + if ($rel === null) { + continue; + } + + if ($this->isMigrationPath($rel)) { + $migrations[] = $rel; + } else { + $nonMigrations[] = $rel; + } + } + + return [$migrations, $nonMigrations]; + } + + /** + * @param list $migrationPaths + * @param array $affectedSet + * @return list Unparseable migrations (caller treats as unknown-to-graph). + */ + private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array + { + $changedTables = []; + $unparseable = []; + + foreach ($migrationPaths as $rel) { + $tables = $this->tablesForMigration($rel); + + if ($tables === []) { + $unparseable[] = $rel; + + continue; + } + + foreach ($tables as $table) { + $changedTables[$table] = true; + } + } + + if ($changedTables !== []) { + foreach ($this->testTables as $testFile => $tables) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($tables as $table) { + if (isset($changedTables[$table])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + } + + return $unparseable; + } + + /** + * @param list $nonMigrationPaths + * @param array $affectedSet + * @return array{0: array, 1: array, 2: array} + * globalFrontendRuntimeFiles, preciselyHandledPages, sharedFilesResolved + */ + private function applyInertiaChanges(array $nonMigrationPaths, array &$affectedSet): array + { + $globalFrontendRuntimeFiles = []; + + foreach ($nonMigrationPaths as $rel) { + if (! $this->isGlobalFrontendRuntimePath($rel)) { + continue; + } + + foreach (array_keys($this->testInertiaComponents) as $testFile) { + $affectedSet[$testFile] = true; + } + + $globalFrontendRuntimeFiles[$rel] = true; + } + + $changedComponents = []; + $preciselyHandledPages = []; + + foreach ($nonMigrationPaths as $rel) { + $component = $this->componentForInertiaPage($rel); + + if ($component === null) { + continue; + } + + if ($this->anyTestUses($this->testInertiaComponents, $component)) { + $changedComponents[$component] = true; + $preciselyHandledPages[$rel] = true; + } + } + + $sharedFilesResolved = []; + + foreach ($nonMigrationPaths as $rel) { + if (isset($globalFrontendRuntimeFiles[$rel])) { + continue; + } + if (isset($preciselyHandledPages[$rel])) { + continue; + } + if (! isset($this->jsFileToComponents[$rel])) { + continue; + } + + $touchedAny = false; + + foreach ($this->jsFileToComponents[$rel] as $pageComponent) { + if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { + $changedComponents[$pageComponent] = true; + $touchedAny = true; + } + } + + if ($touchedAny) { + $sharedFilesResolved[$rel] = true; + } + } + + $newJsFiles = []; + + foreach ($nonMigrationPaths as $rel) { + if (isset($globalFrontendRuntimeFiles[$rel])) { + continue; + } + if (isset($preciselyHandledPages[$rel])) { + continue; + } + if (isset($sharedFilesResolved[$rel])) { + continue; + } + if (isset($this->jsFileToComponents[$rel])) { + continue; + } + if (! str_starts_with($rel, 'resources/js/')) { + continue; + } + $newJsFiles[] = $rel; + } + + if ($newJsFiles !== []) { + $this->resolveNewJsFiles($newJsFiles, $changedComponents, $sharedFilesResolved); + } + + if ($changedComponents !== []) { + foreach ($this->testInertiaComponents as $testFile => $components) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($components as $component) { + if (isset($changedComponents[$component])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + } + + return [$globalFrontendRuntimeFiles, $preciselyHandledPages, $sharedFilesResolved]; + } + + /** + * @param list $newJsFiles + * @param array $changedComponents + * @param array $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 $nonMigrationPaths + * @param array $affectedSet + * @return array Unknown source dirs (sibling-heuristic). + */ + private function applyPhpEdgeChanges(array $nonMigrationPaths, array &$affectedSet): array + { + $changedIds = []; + $unknownSourceDirs = []; + $sourcePhpChanged = false; + + foreach ($nonMigrationPaths as $rel) { + if ($this->isProjectSourcePhp($rel)) { + $sourcePhpChanged = true; + } + + if (isset($this->fileIds[$rel])) { + $changedIds[$this->fileIds[$rel]] = true; + + continue; + } + + if (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) { + if (! is_file($this->projectRoot.'/'.$rel)) { + continue; + } + + if ($this->usesSiblingHeuristicForUnknownPhp($rel)) { + $unknownSourceDirs[dirname($rel)] = true; + } + } + } + + if ($sourcePhpChanged) { + foreach (array_keys($this->edges) as $testFile) { + if ($this->isArchTestFile($testFile)) { + $affectedSet[$testFile] = true; + } + } + } + + foreach ($this->edges as $testFile => $ids) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($ids as $id) { + if (isset($changedIds[$id])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + + return $unknownSourceDirs; + } + + /** + * 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 $nonMigrationPaths + * @param array $affectedSet + */ + private function applyTestFileChanges(array $nonMigrationPaths, array &$affectedSet): void + { + $testPaths = TestPaths::fromProjectRoot($this->projectRoot); + + foreach ($nonMigrationPaths as $rel) { + if (isset($affectedSet[$rel])) { + continue; + } + if (! $testPaths->isTestFile($rel)) { + continue; + } + if (! is_file($this->projectRoot.'/'.$rel)) { + continue; + } + $affectedSet[$rel] = true; + } + } + + /** + * Unknown Blade files: walk static references (@include, @extends, ) up to rendered. + * + * @param list $nonMigrationPaths + * @param array $affectedSet + * @return array + */ + private function applyBladeStaticChanges(array $nonMigrationPaths, array &$affectedSet): array + { + $staticallyHandled = []; + + foreach ($nonMigrationPaths as $rel) { + if (isset($this->fileIds[$rel])) { + continue; + } + if (! $this->isBladePath($rel)) { + continue; + } + if (! is_file($this->projectRoot.'/'.$rel)) { + continue; + } + + $bladeAffected = $this->affectedByStaticBladeUsage($rel); + + if ($bladeAffected !== []) { + foreach ($bladeAffected as $testFile) { + $affectedSet[$testFile] = true; + } + + $staticallyHandled[$rel] = true; + } elseif ($this->isBladeComponentPath($rel)) { + $staticallyHandled[$rel] = true; + } + } + + return $staticallyHandled; + } + + /** + * @param list $nonMigrationPaths + * @param list $unparseableMigrations + * @param array $preciselyHandledPages + * @param array $sharedFilesResolved + * @param array $staticallyHandledBlade + * @param array $affectedSet + */ + private function applyWatchPatternFallback( + array $nonMigrationPaths, + array $unparseableMigrations, + array $preciselyHandledPages, + array $sharedFilesResolved, + array $staticallyHandledBlade, + array &$affectedSet, + ): void { + $unknownToGraph = $unparseableMigrations; + + foreach ($nonMigrationPaths as $rel) { + if (isset($preciselyHandledPages[$rel])) { + continue; + } + if (isset($sharedFilesResolved[$rel])) { + continue; + } + if (isset($staticallyHandledBlade[$rel])) { + continue; + } + if (! isset($this->fileIds[$rel])) { + if (! is_file($this->projectRoot.'/'.$rel)) { + continue; + } + + $unknownToGraph[] = $rel; + } + } + + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); + + $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $unknownToGraph); + $allTestFiles = array_keys($this->edges); + + foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { + $affectedSet[$testFile] = true; + } + } + + /** + * @param array $unknownSourceDirs + * @param array $affectedSet + */ + private function applyUnknownSourceDirs(array $unknownSourceDirs, array &$affectedSet): void + { + if ($unknownSourceDirs === []) { + return; + } + + foreach ($this->edges as $testFile => $ids) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($ids as $id) { + if (! isset($this->files[$id])) { + continue; + } + + $depDir = dirname($this->files[$id]); + + if (isset($unknownSourceDirs[$depDir])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + } + + public function knowsTest(string $testFile): bool + { + $rel = $this->relative($testFile); + + return $rel !== null && isset($this->edges[$rel]); + } + + /** @return array */ + public function allTestFiles(): array + { + return array_keys($this->edges); + } + + /** + * @param array $fingerprint + */ + public function setFingerprint(array $fingerprint): void + { + $this->fingerprint = $fingerprint; + } + + /** + * @return array + */ + public function fingerprint(): array + { + return $this->fingerprint; + } + + public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + + return $baseline['sha']; + } + + public function setRecordedAtSha(string $branch, ?string $sha): void + { + $this->ensureBaseline($branch); + $this->baselines[$branch]['sha'] = $sha; + } + + public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void + { + $this->ensureBaseline($branch); + + $entry = [ + 'status' => $status, + 'message' => $message, + 'time' => $time, + 'assertions' => $assertions, + ]; + + if ($file !== null) { + $rel = $this->relative($file); + + if ($rel !== null) { + $entry['file'] = $rel; + } + } + + $this->baselines[$branch]['results'][$testId] = $entry; + } + + public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + + if (! isset($baseline['results'][$testId]['assertions'])) { + return null; + } + + return $baseline['results'][$testId]['assertions']; + } + + public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + + if (! isset($baseline['results'][$testId])) { + return null; + } + + $r = $baseline['results'][$testId]; + + return match ($r['status']) { + 0 => TestStatus::success(), + 1 => TestStatus::skipped($r['message']), + 2 => TestStatus::incomplete($r['message']), + 3 => TestStatus::notice($r['message']), + 4 => TestStatus::deprecation($r['message']), + 5 => TestStatus::risky($r['message']), + 6 => TestStatus::warning($r['message']), + 7 => TestStatus::failure($r['message']), + 8 => TestStatus::error($r['message']), + default => TestStatus::unknown(), + }; + } + + /** + * @return array + */ + public function testFilesToRerun(string $branch, string $fallbackBranch = 'main'): array + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + $files = []; + + foreach ($baseline['results'] as $result) { + if (! $this->shouldRerun($result['status'])) { + continue; + } + + $file = $result['file'] ?? null; + if ($file === null) { + continue; + } + if ($file === '') { + continue; + } + + $rel = $this->relative($file); + + if ($rel !== null) { + $files[$rel] = true; + } + } + + return array_keys($files); + } + + public function hasUnlocatedTestsToRerun(string $branch, string $fallbackBranch = 'main'): bool + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + + foreach ($baseline['results'] as $result) { + if (! $this->shouldRerun($result['status'])) { + continue; + } + + $file = $result['file'] ?? null; + + if ($file === null || $file === '' || $this->relative($file) === null) { + return true; + } + } + + return false; + } + + private function shouldRerun(int $status): bool + { + $testStatus = TestStatus::from($status); + + if ($testStatus->isFailure() || $testStatus->isError()) { + return true; + } + + $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; + } + + /** + * @param array $tree project-relative path → content hash + */ + public function setLastRunTree(string $branch, array $tree): void + { + $this->ensureBaseline($branch); + $this->baselines[$branch]['tree'] = $tree; + } + + public function clearResults(string $branch): void + { + $this->ensureBaseline($branch); + $this->baselines[$branch]['results'] = []; + } + + /** + * @return array + */ + public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array + { + return $this->baselineFor($branch, $fallbackBranch)['tree']; + } + + /** + * @return array{sha: ?string, tree: array, results: array} + */ + private function baselineFor(string $branch, string $fallbackBranch): array + { + if (isset($this->baselines[$branch])) { + return $this->baselines[$branch]; + } + + if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) { + return $this->baselines[$fallbackBranch]; + } + + return ['sha' => null, 'tree' => [], 'results' => []]; + } + + private function ensureBaseline(string $branch): void + { + if (! isset($this->baselines[$branch])) { + $this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []]; + } + } + + /** + * @param array> $testToFiles + */ + public function replaceEdges(array $testToFiles): void + { + foreach ($testToFiles as $testFile => $sources) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $this->edges[$testRel] = []; + + foreach ($sources as $source) { + $this->link($testFile, $source); + } + + $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel])); + } + } + + /** + * @param array> $testToTables + */ + public function replaceTestTables(array $testToTables): void + { + foreach ($testToTables as $testFile => $tables) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $normalised = []; + + foreach ($tables as $table) { + $lower = strtolower($table); + + if ($lower !== '') { + $normalised[$lower] = true; + } + } + + $names = array_keys($normalised); + sort($names); + + $this->testTables[$testRel] = $names; + } + } + + /** + * @param array> $testToComponents + */ + public function replaceTestInertiaComponents(array $testToComponents): void + { + foreach ($testToComponents as $testFile => $components) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $normalised = []; + + foreach ($components as $component) { + if ($component !== '') { + $normalised[$component] = true; + } + } + + $names = array_keys($normalised); + sort($names); + + $this->testInertiaComponents[$testRel] = $names; + } + } + + /** + * @param array> $fileToComponents + */ + public function replaceJsFileToComponents(array $fileToComponents): void + { + $out = []; + + foreach ($fileToComponents as $path => $components) { + if ($path === '') { + continue; + } + $names = []; + + foreach ($components as $component) { + if ($component !== '') { + $names[$component] = true; + } + } + + if ($names === []) { + continue; + } + + $keys = array_keys($names); + sort($keys); + $out[$path] = $keys; + } + + if ($out === []) { + return; + } + + ksort($out); + + $this->jsFileToComponents = $out; + } + + private function isMigrationPath(string $rel): bool + { + return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php'); + } + + private function usesSiblingHeuristicForUnknownPhp(string $rel): bool + { + static $prefixes = [ + 'app/Providers/', + 'app/Listeners/', + 'app/Events/', + 'app/Observers/', + 'app/Policies/', + 'app/Console/Commands/', + 'app/Mail/', + 'app/Notifications/', + 'app/Nova/Actions/', + 'app/Nova/Dashboards/', + 'app/Nova/Lenses/', + 'app/Nova/Metrics/', + 'app/Nova/Policies/', + 'app/Nova/Resources/', + 'app/Projectors/', + 'app/Reactors/', + 'database/factories/', + 'database/seeders/', + ]; + + foreach ($prefixes as $prefix) { + if (str_starts_with($rel, (string) $prefix)) { + return true; + } + } + + return false; + } + + private function isProjectSourcePhp(string $rel): bool + { + return str_ends_with($rel, '.php') + && ! $this->isBladePath($rel) + && ! str_starts_with($rel, 'tests/') + && ! str_starts_with($rel, 'vendor/') + && ! str_starts_with($rel, 'storage/framework/') + && ! str_starts_with($rel, 'bootstrap/cache/'); + } + + private function isArchTestFile(string $rel): bool + { + return isset($this->archTestFiles()[$rel]); + } + + /** + * @return array + */ + private function archTestFiles(): array + { + if ($this->archTestFiles !== null) { + return $this->archTestFiles; + } + + $this->archTestFiles = []; + $repo = TestSuite::getInstance()->tests; + + foreach ($repo->getFilenames() as $filename) { + $factory = $repo->get($filename); + + if (! $factory instanceof TestCaseFactory) { + continue; + } + + foreach ($factory->methods as $method) { + if (! $this->methodHasGroup($method, 'arch')) { + continue; + } + + $rel = $this->relative($filename); + + if ($rel !== null) { + $this->archTestFiles[$rel] = true; + } + + break; + } + } + + foreach (array_keys($this->edges) as $testFile) { + if (isset($this->archTestFiles[$testFile])) { + continue; + } + if ($this->testSourceDeclaresArchGroup($testFile)) { + $this->archTestFiles[$testFile] = true; + } + } + + return $this->archTestFiles; + } + + private function methodHasGroup(TestCaseMethodFactory $method, string $group): bool + { + if (in_array($group, $method->groups, true)) { + return true; + } + + foreach ($method->attributes as $attribute) { + if ($attribute->name !== Group::class) { + continue; + } + + foreach ($attribute->arguments as $argument) { + if ($argument === $group) { + return true; + } + } + } + + return false; + } + + private function testSourceDeclaresArchGroup(string $rel): bool + { + $source = @file_get_contents($this->projectRoot.'/'.$rel); + + if ($source === false) { + return false; + } + + return preg_match('/\barch\s*\(/', $source) === 1 + || preg_match('/->\s*group\s*\(\s*[\'\"]arch[\'\"]/', $source) === 1 + || preg_match('/#\[\s*(?:\\\\)?(?:PHPUnit\\\\Framework\\\\Attributes\\\\)?Group\s*\(\s*[\'\"]arch[\'\"]/', $source) === 1; + } + + private function isBladePath(string $rel): bool + { + return str_starts_with($rel, 'resources/views/') && str_ends_with($rel, '.blade.php'); + } + + private function isBladeComponentPath(string $rel): bool + { + return str_starts_with($rel, 'resources/views/components/') && str_ends_with($rel, '.blade.php'); + } + + /** + * @return list Project-relative test files. + */ + private function affectedByStaticBladeUsage(string $changedBlade): array + { + $ancestors = $this->bladeAncestorsFor($changedBlade); + + if ($ancestors === []) { + return []; + } + + $ancestorIds = []; + foreach ($ancestors as $ancestor) { + if (isset($this->fileIds[$ancestor])) { + $ancestorIds[$this->fileIds[$ancestor]] = true; + } + } + + if ($ancestorIds === []) { + return []; + } + + $affected = []; + foreach ($this->edges as $testFile => $ids) { + foreach ($ids as $id) { + if (isset($ancestorIds[$id])) { + $affected[$testFile] = true; + + break; + } + } + } + + return array_keys($affected); + } + + /** + * @return list Project-relative Blade files that statically depend on $changedBlade, directly or transitively. + */ + private function bladeAncestorsFor(string $changedBlade): array + { + $allBladeFiles = $this->allBladeFiles(); + + if ($allBladeFiles === []) { + return []; + } + + $targets = [$changedBlade => true]; + $ancestors = []; + $changed = true; + + while ($changed) { + $changed = false; + + foreach ($allBladeFiles as $candidate) { + if (isset($targets[$candidate])) { + continue; + } + if (isset($ancestors[$candidate])) { + continue; + } + + $source = @file_get_contents($this->projectRoot.'/'.$candidate); + if ($source === false) { + continue; + } + + foreach (array_keys($targets) as $target) { + if ($this->bladeSourceReferences($source, $target)) { + $ancestors[$candidate] = true; + $targets[$candidate] = true; + $changed = true; + + break; + } + } + } + } + + return array_keys($ancestors); + } + + /** + * @return list + */ + private function allBladeFiles(): array + { + $views = $this->projectRoot.'/resources/views'; + + if (! is_dir($views)) { + return []; + } + + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($views, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + assert($file instanceof \SplFileInfo); + + if (! $file->isFile()) { + continue; + } + $path = $file->getPathname(); + if (! str_ends_with($path, '.blade.php')) { + continue; + } + + $files[] = str_replace(DIRECTORY_SEPARATOR, '/', substr($path, strlen($this->projectRoot) + 1)); + } + + sort($files); + + return $files; + } + + private function bladeSourceReferences(string $source, string $targetBlade): bool + { + $view = $this->viewNameForBlade($targetBlade); + + if ($view !== null) { + $quoted = preg_quote($view, '#'); + + if (preg_match('#@(include|includeIf|includeWhen|includeUnless|extends|component|each)\s*\([^)]*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) { + return true; + } + + if (preg_match('#\b(view|View::make)\s*\(\s*[\'\"]'.$quoted.'[\'\"]#', $source) === 1) { + return true; + } + } + + foreach ($this->componentNamesForBlade($targetBlade) as $component) { + $quoted = preg_quote($component, '#'); + + if (preg_match('#/.:])#i', $source) === 1) { + return true; + } + } + + return false; + } + + private function viewNameForBlade(string $rel): ?string + { + if (! $this->isBladePath($rel)) { + return null; + } + + $tail = substr($rel, strlen('resources/views/')); + $tail = substr($tail, 0, -strlen('.blade.php')); + + return str_replace('/', '.', $tail); + } + + /** + * @return list + */ + private function componentNamesForBlade(string $rel): array + { + if (! $this->isBladeComponentPath($rel)) { + return []; + } + + $tail = substr($rel, strlen('resources/views/components/')); + $tail = substr($tail, 0, -strlen('.blade.php')); + $name = str_replace('/', '.', $tail); + + return $name === '' ? [] : [$name, str_replace('_', '-', $name)]; + } + + /** @return list */ + private function tablesForMigration(string $rel): array + { + $absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel; + + if (! is_file($absolute)) { + return []; + } + + $content = @file_get_contents($absolute); + + if ($content === false) { + return []; + } + + return TableExtractor::fromMigrationSource($content); + } + + private function componentForInertiaPage(string $rel): ?string + { + foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { + if (! str_starts_with($rel, $prefix)) { + continue; + } + + $tail = substr($rel, strlen($prefix)); + $dot = strrpos($tail, '.'); + + if ($dot === false) { + return null; + } + + $extension = substr($tail, $dot + 1); + + if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) { + return null; + } + + $name = substr($tail, 0, $dot); + + return $name === '' ? null : $name; + } + + return null; + } + + private function isGlobalFrontendRuntimePath(string $rel): bool + { + if (! str_starts_with($rel, 'resources/js/')) { + return false; + } + + $tail = substr($rel, strlen('resources/js/')); + $dot = strrpos($tail, '.'); + + if ($dot === false) { + return false; + } + + $name = substr($tail, 0, $dot); + $extension = substr($tail, $dot + 1); + + return in_array($extension, ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte'], true) + && in_array($name, ['App', 'app', 'bootstrap', 'echo', 'favicon'], true); + } + + /** @param array> $edges */ + private function anyTestUses(array $edges, string $component): bool + { + foreach ($edges as $components) { + if (in_array($component, $components, true)) { + return true; + } + } + + return false; + } + + public function pruneMissingTests(): void + { + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + foreach (array_keys($this->edges) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->edges[$testRel]); + } + } + + foreach (array_keys($this->testInertiaComponents) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->testInertiaComponents[$testRel]); + } + } + + foreach (array_keys($this->testTables) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->testTables[$testRel]); + } + } + } + + public static function decode(string $json, string $projectRoot): ?self + { + $data = json_decode($json, true); + + if (! is_array($data) || ($data['schema'] ?? null) !== 1) { + return null; + } + + $graph = new self($projectRoot); + $graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : []; + $graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : []; + $graph->fileIds = array_flip($graph->files); + $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; + $graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : []; + + $graph->testTables = self::decodeStringMap($data['test_tables'] ?? null); + $graph->testInertiaComponents = self::decodeStringMap($data['test_inertia_components'] ?? null); + $graph->jsFileToComponents = self::decodeStringMap($data['js_file_to_components'] ?? null); + + return $graph; + } + + /** + * @return array> + */ + 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 + { + $payload = [ + 'schema' => 1, + 'fingerprint' => $this->fingerprint, + 'files' => $this->files, + 'edges' => $this->edges, + 'baselines' => $this->baselines, + 'test_tables' => $this->testTables, + 'test_inertia_components' => $this->testInertiaComponents, + 'js_file_to_components' => $this->jsFileToComponents, + ]; + + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + + return $json === false ? null : $json; + } + + private function relative(string $path): ?string + { + if ($path === '' || $path === 'unknown') { + return null; + } + + if (str_contains($path, "eval()'d")) { + return null; + } + + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) + || (strlen($path) >= 2 && $path[1] === ':'); + if ($isAbsolute) { + if (array_key_exists($path, $this->realpathCache)) { + $real = $this->realpathCache[$path]; + } else { + $real = $this->realpathCache[$path] = @realpath($path); + } + + if ($real === false) { + $real = $path; + } + + if (! str_starts_with($real, $root)) { + return null; + } + + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } else { + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); + + while (str_starts_with($relative, './')) { + $relative = substr($relative, 2); + } + } + + if (str_starts_with($relative, 'vendor/')) { + return null; + } + + return $relative; + } +} diff --git a/src/Plugins/Tia/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php new file mode 100644 index 000000000..d0e117faa --- /dev/null +++ b/src/Plugins/Tia/JsModuleGraph.php @@ -0,0 +1,397 @@ + + */ + public const array VITE_CONFIG_NAMES = [ + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cjs', + 'vite.config.mts', + ]; + + /** + * Candidate page directories, in priority order. Must stay in sync with + * `PAGE_DIR_CANDIDATES` in bin/pest-tia-vite-deps.mjs. + * + * @var list + */ + private const array PAGE_DIR_CANDIDATES = [ + 'resources/js/Pages', + 'resources/js/pages', + 'assets/js/Pages', + 'assets/js/pages', + 'assets/Pages', + 'assets/pages', + ]; + + /** + * @var list + */ + private const array PAGE_EXTENSIONS = [ + 'vue', 'svelte', + 'tsx', 'jsx', + 'ts', 'js', + 'mts', 'cts', 'mjs', 'cjs', + ]; + + /** + * @return array> + */ + public static function build(string $projectRoot): array + { + $result = self::resolve($projectRoot); + + return $result ?? []; + } + + /** + * @return array>|null + */ + public static function buildStrict(string $projectRoot): ?array + { + return self::resolve($projectRoot); + } + + public static function isApplicable(string $projectRoot): bool + { + if (! self::hasViteConfig($projectRoot)) { + return false; + } + + return self::firstExistingPagesDir($projectRoot) !== null; + } + + private static function firstExistingPagesDir(string $projectRoot): ?string + { + foreach (self::PAGE_DIR_CANDIDATES as $rel) { + $abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel); + + if (! is_dir($abs)) { + continue; + } + + if (self::dirHasPageFile($abs)) { + return $abs; + } + } + + return null; + } + + private static function dirHasPageFile(string $dir): bool + { + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY, + ); + } catch (\UnexpectedValueException) { + return false; + } + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + if (in_array(strtolower($file->getExtension()), self::PAGE_EXTENSIONS, true)) { + return true; + } + } + + return false; + } + + /** + * @return array>|null + */ + private static function resolve(string $projectRoot): ?array + { + $fingerprint = self::fingerprint($projectRoot); + + if ($fingerprint !== null) { + $cached = self::readCache($projectRoot, $fingerprint); + + if ($cached !== null) { + return $cached; + } + } + + $process = self::buildNodeProcess($projectRoot); + + if (! $process instanceof Process) { + return null; + } + + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $result = self::parseNodeOutput($process->getOutput()); + + if ($result !== null && $fingerprint !== null) { + self::writeCache($projectRoot, $fingerprint, $result); + } + + return $result; + } + + private static function buildNodeProcess(string $projectRoot): ?Process + { + if (! self::hasViteConfig($projectRoot)) { + return null; + } + + if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) { + return null; + } + + $nodeBinary = (new ExecutableFinder)->find('node'); + + if ($nodeBinary === null) { + return null; + } + + $helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs'; + + if (! is_file($helperPath)) { + return null; + } + + $process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot); + $process->setTimeout(self::NODE_TIMEOUT_SECONDS); + + return $process; + } + + /** + * @return array>|null + */ + private static function parseNodeOutput(string $output): ?array + { + /** @var mixed $decoded */ + $decoded = json_decode($output, true); + + if (! is_array($decoded)) { + return null; + } + + $out = []; + + foreach ($decoded as $path => $components) { + if (! is_string($path)) { + continue; + } + if (! is_array($components)) { + continue; + } + $names = []; + + foreach ($components as $component) { + if (is_string($component) && $component !== '') { + $names[] = $component; + } + } + + if ($names !== []) { + sort($names); + $out[$path] = $names; + } + } + + ksort($out); + + return $out; + } + + private static function fingerprint(string $projectRoot): ?string + { + $parts = []; + + foreach (self::VITE_CONFIG_NAMES as $name) { + $path = $projectRoot.DIRECTORY_SEPARATOR.$name; + + if (! is_file($path)) { + continue; + } + + $stat = @stat($path); + $bytes = @file_get_contents($path); + + $parts[] = 'config:'.$name + .':'.($stat === false ? '0' : (string) $stat['mtime']) + .':'.($stat === false ? '0' : (string) $stat['size']) + .':'.($bytes === false ? '' : hash('sha256', $bytes)); + } + + if ($parts === []) { + return null; + } + + $override = getenv('TIA_VITE_PAGES_DIR'); + + if (is_string($override) && $override !== '') { + $parts[] = 'pagesDirOverride:'.$override; + } + + $pagesDir = self::firstExistingPagesDir($projectRoot); + + if ($pagesDir !== null) { + $parts[] = 'pagesDir:'.str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $pagesDir); + } + + $jsRoot = $pagesDir !== null ? dirname($pagesDir) : null; + + if ($jsRoot !== null && is_dir($jsRoot)) { + $entries = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY, + ); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $entries[] = $file->getPathname() + .':'.$file->getSize() + .':'.$file->getMTime(); + } + + sort($entries); + + $parts[] = 'js:'.hash('sha256', implode("\n", $entries)); + } + + return hash('sha256', implode('|', $parts)); + } + + /** + * @return array>|null + */ + private static function readCache(string $projectRoot, string $fingerprint): ?array + { + $path = self::cachePath($projectRoot); + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + /** @var mixed $decoded */ + $decoded = json_decode($raw, true); + + if (! is_array($decoded)) { + return null; + } + + if (($decoded['fingerprint'] ?? null) !== $fingerprint) { + return null; + } + + $graph = $decoded['graph'] ?? null; + + if (! is_array($graph)) { + return null; + } + + $out = []; + + foreach ($graph as $key => $value) { + if (! is_string($key)) { + continue; + } + if (! is_array($value)) { + continue; + } + $names = []; + + foreach ($value as $name) { + if (is_string($name) && $name !== '') { + $names[] = $name; + } + } + + $out[$key] = $names; + } + + return $out; + } + + /** + * @param array> $graph + */ + private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void + { + $path = self::cachePath($projectRoot); + $dir = dirname($path); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + return; + } + + $payload = json_encode([ + 'fingerprint' => $fingerprint, + 'graph' => $graph, + ]); + + if ($payload === false) { + return; + } + + $tmp = $path.'.tmp.'.bin2hex(random_bytes(4)); + + if (@file_put_contents($tmp, $payload) === false) { + return; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + } + } + + private static function cachePath(string $projectRoot): string + { + return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::CACHE_FILE; + } + + private static function hasViteConfig(string $projectRoot): bool + { + foreach (self::VITE_CONFIG_NAMES as $name) { + if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) { + return true; + } + } + + return false; + } +} diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php new file mode 100644 index 000000000..0240aa9f3 --- /dev/null +++ b/src/Plugins/Tia/Recorder.php @@ -0,0 +1,355 @@ +> */ + private array $perTestFiles = []; + + /** @var array> */ + private array $perTestTables = []; + + /** @var array> */ + private array $perTestInertiaComponents = []; + + /** @var array */ + private array $perTestUsesDatabase = []; + + /** @var array */ + private array $classFileCache = []; + + /** @var array */ + private array $classUsesDatabaseCache = []; + + private bool $active = false; + + private bool $driverChecked = false; + + private bool $driverAvailable = false; + + private string $driver = 'none'; + + private ?SourceScope $sourceScope = null; + + public function activate(): void + { + $this->active = true; + } + + public function isActive(): bool + { + return $this->active; + } + + public function driverAvailable(): bool + { + if (! $this->driverChecked) { + if (function_exists('pcov\\start')) { + $this->driver = 'pcov'; + $this->driverAvailable = true; + } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { + $modes = \xdebug_info('mode'); + + if (is_array($modes) && in_array('coverage', $modes, true)) { + $this->driver = 'xdebug'; + $this->driverAvailable = true; + } + } + + $this->driverChecked = true; + } + + return $this->driverAvailable; + } + + public function beginTest(string $className, string $methodName, string $fallbackFile): void + { + if (! $this->active || ! $this->driverAvailable()) { + return; + } + + if ($this->currentTestFile !== null) { + return; + } + + $file = $this->resolveTestFile($className, $fallbackFile); + + if ($file === null) { + return; + } + + $this->currentTestFile = $file; + + if ($this->classUsesDatabase($className)) { + $this->perTestUsesDatabase[$file] = true; + } + + if ($this->driver === 'pcov') { + \pcov\clear(); + \pcov\start(); + + return; + } + + \xdebug_start_code_coverage(); + } + + public function endTest(): void + { + if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) { + return; + } + + if ($this->driver === 'pcov') { + \pcov\stop(); + + $scope = $this->sourceScope(); + $filesToCollectCoverageFor = []; + + foreach (\pcov\waiting() as $file) { + if (is_string($file) && $scope->contains($file)) { + $filesToCollectCoverageFor[] = $file; + } + } + + /** @var array $data */ + $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor); + + $coveredFiles = $this->filesWithExecutedLines($data); + } else { + /** @var array $data */ + $data = \xdebug_get_code_coverage(); + \xdebug_stop_code_coverage(true); + + $coveredFiles = array_keys($data); + } + + foreach ($coveredFiles as $sourceFile) { + $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; + } + + $this->currentTestFile = null; + } + + public function linkSource(string $sourceFile): void + { + if (! $this->active) { + return; + } + + if ($this->currentTestFile === null) { + return; + } + + if ($sourceFile === '') { + return; + } + + $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; + } + + private function classUsesDatabase(string $className): bool + { + if (array_key_exists($className, $this->classUsesDatabaseCache)) { + return $this->classUsesDatabaseCache[$className]; + } + + if (! class_exists($className, false)) { + return $this->classUsesDatabaseCache[$className] = false; + } + + static $needles = [ + 'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true, + 'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true, + 'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true, + ]; + + $reflection = new ReflectionClass($className); + + do { + foreach (array_keys($reflection->getTraits()) as $traitName) { + if (isset($needles[$traitName])) { + return $this->classUsesDatabaseCache[$className] = true; + } + } + + $reflection = $reflection->getParentClass(); + } while ($reflection !== false && ! $reflection->isInternal()); + + return $this->classUsesDatabaseCache[$className] = false; + } + + public function linkTable(string $table): void + { + if (! $this->active) { + return; + } + + if ($this->currentTestFile === null) { + return; + } + + if ($table === '') { + return; + } + + $this->perTestTables[$this->currentTestFile][strtolower($table)] = true; + } + + public function linkInertiaComponent(string $component): void + { + if (! $this->active) { + return; + } + + if ($this->currentTestFile === null) { + return; + } + + if ($component === '') { + return; + } + + $this->perTestInertiaComponents[$this->currentTestFile][$component] = true; + } + + /** @return array> */ + public function perTestFiles(): array + { + $out = []; + + foreach ($this->perTestFiles as $testFile => $sources) { + $out[$testFile] = array_keys($sources); + } + + return $out; + } + + /** @return array> */ + public function perTestTables(): array + { + $out = []; + + foreach ($this->perTestTables as $testFile => $tables) { + $names = array_keys($tables); + sort($names); + $out[$testFile] = $names; + } + + return $out; + } + + /** @return array> */ + public function perTestInertiaComponents(): array + { + $out = []; + + foreach ($this->perTestInertiaComponents as $testFile => $components) { + $names = array_keys($components); + sort($names); + $out[$testFile] = $names; + } + + return $out; + } + + /** @return array */ + public function perTestUsesDatabase(): array + { + return $this->perTestUsesDatabase; + } + + private function resolveTestFile(string $className, string $fallbackFile): ?string + { + if (array_key_exists($className, $this->classFileCache)) { + $file = $this->classFileCache[$className]; + } else { + $file = $this->readPestFilename($className); + $this->classFileCache[$className] = $file; + } + + if ($file !== null) { + return $file; + } + + if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) { + return $fallbackFile; + } + + return null; + } + + private function readPestFilename(string $className): ?string + { + if (! class_exists($className, false)) { + return null; + } + + assert(property_exists($className, '__filename') && is_string($className::$__filename)); + + return $className::$__filename; + } + + /** + * @param array $data + * @return list + */ + private function filesWithExecutedLines(array $data): array + { + $out = []; + + foreach ($data as $file => $lines) { + if (! is_array($lines)) { + continue; + } + $covered = []; + foreach ($lines as $line => $count) { + if (is_int($count) && $count > 0) { + $covered[] = $line; + } + } + + if ($covered === []) { + continue; + } + + $lineKeys = array_keys($lines); + if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) { + continue; + } + + $out[] = $file; + } + + return $out; + } + + private function sourceScope(): SourceScope + { + return $this->sourceScope ??= SourceScope::fromProjectRoot(TestSuite::getInstance()->rootPath); + } + + public function reset(): void + { + $this->currentTestFile = null; + $this->perTestFiles = []; + $this->perTestTables = []; + $this->perTestInertiaComponents = []; + $this->perTestUsesDatabase = []; + $this->classFileCache = []; + $this->classUsesDatabaseCache = []; + $this->sourceScope = null; + $this->active = false; + } +} diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php new file mode 100644 index 000000000..1b84c8c82 --- /dev/null +++ b/src/Plugins/Tia/ResultCollector.php @@ -0,0 +1,149 @@ + + */ + private array $results = []; + + private ?string $currentTestId = null; + + private ?string $currentTestFile = null; + + private ?float $startTime = null; + + public function testPrepared(string $testId, ?string $testFile = null): void + { + $this->currentTestId = $testId; + $this->currentTestFile = $testFile; + $this->startTime = microtime(true); + } + + public function testPassed(): void + { + if ($this->currentTestId === null) { + return; + } + + $this->record(TestStatus::success()); + } + + public function testFailed(string $message): void + { + if ($this->currentTestId === null) { + return; + } + + $this->record(TestStatus::failure($message)); + } + + public function testErrored(string $message): void + { + if ($this->currentTestId === null) { + return; + } + + $this->record(TestStatus::error($message)); + } + + public function testSkipped(string $message): void + { + if ($this->currentTestId === null) { + return; + } + + $this->record(TestStatus::skipped($message)); + } + + public function testIncomplete(string $message): void + { + if ($this->currentTestId === null) { + return; + } + + $this->record(TestStatus::incomplete($message)); + } + + public function testRisky(string $message): void + { + if ($this->currentTestId === null) { + return; + } + + $this->record(TestStatus::risky($message)); + } + + /** + * @return array + */ + public function all(): array + { + return $this->results; + } + + public function recordAssertions(string $testId, int $assertions): void + { + if (isset($this->results[$testId])) { + $this->results[$testId]['assertions'] = $assertions; + } + } + + /** + * @param array $results + */ + public function merge(array $results): void + { + foreach ($results as $testId => $result) { + $this->results[$testId] = $result; + } + } + + public function reset(): void + { + $this->results = []; + $this->currentTestId = null; + $this->currentTestFile = null; + $this->startTime = null; + } + + public function finishTest(): void + { + $this->currentTestId = null; + $this->currentTestFile = null; + $this->startTime = null; + } + + private function record(TestStatus $status): void + { + if ($this->currentTestId === null) { + return; + } + + $time = $this->startTime !== null + ? round(microtime(true) - $this->startTime, 3) + : 0.0; + + $existing = $this->results[$this->currentTestId] ?? null; + + $this->results[$this->currentTestId] = [ + 'status' => $status->asInt(), + 'message' => $status->message(), + 'time' => $time, + 'assertions' => $existing['assertions'] ?? 0, + ]; + + if ($this->currentTestFile !== null) { + $this->results[$this->currentTestId]['file'] = $this->currentTestFile; + } + } +} diff --git a/src/Plugins/Tia/SourceScope.php b/src/Plugins/Tia/SourceScope.php new file mode 100644 index 000000000..325b3e12b --- /dev/null +++ b/src/Plugins/Tia/SourceScope.php @@ -0,0 +1,196 @@ + */ + private array $containsCache = []; + + private const array TOP_LEVEL_NOISE = [ + 'vendor', + 'node_modules', + '.git', + '.idea', + '.vscode', + '.github', + '.pest', + '.phpunit.cache', + '.cache', + ]; + + private const array NESTED_NOISE = [ + 'storage/framework', + 'storage/logs', + 'bootstrap/cache', + ]; + + /** + * @param list $includes Absolute, normalised directory paths. + * @param list $excludes Absolute, normalised directory paths. + */ + public function __construct( + private readonly array $includes, + private readonly array $excludes, + ) {} + + public static function fromProjectRoot(string $projectRoot): self + { + $phpunitIncludes = []; + $phpunitExcludes = []; + + try { + $source = Registry::get()->source(); + + foreach ($source->includeDirectories() as $dir) { + $phpunitIncludes[] = self::normalise($dir->path()); + } + + 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); + + $includes = array_values(array_unique([...$phpunitIncludes, ...$rootIncludes])); + $excludes = array_values(array_unique([ + ...$phpunitExcludes, + ...self::nestedNoiseDirs($projectRoot), + ])); + + if ($includes === []) { + $includes = [self::normalise($projectRoot)]; + } + + return new self($includes, $excludes); + } + + /** + * @return list Absolute, normalised paths to testsuite directories and files declared in phpunit.xml. + */ + public static function testPaths(): array + { + try { + $suites = Registry::get()->testSuite(); + } catch (Throwable) { + return []; + } + $out = []; + foreach ($suites as $suite) { + foreach ($suite->directories() as $directory) { + $out[] = self::normalise($directory->path()); + } + + foreach ($suite->files() as $file) { + $out[] = self::normalise($file->path()); + } + } + + return array_values(array_unique($out)); + } + + public function contains(string $absoluteFile): bool + { + if (isset($this->containsCache[$absoluteFile])) { + return $this->containsCache[$absoluteFile]; + } + + $real = @realpath($absoluteFile); + $candidate = $real === false ? $absoluteFile : $real; + $candidate = self::normalise($candidate); + + foreach ($this->excludes as $excluded) { + if ($this->startsWithDir($candidate, $excluded)) { + return $this->containsCache[$absoluteFile] = false; + } + } + + foreach ($this->includes as $included) { + if ($this->startsWithDir($candidate, $included)) { + return $this->containsCache[$absoluteFile] = true; + } + } + + return $this->containsCache[$absoluteFile] = false; + } + + /** + * @return list + */ + private static function topLevelProjectDirs(string $projectRoot): array + { + $entries = @scandir($projectRoot); + + if ($entries === false) { + return []; + } + + $out = []; + + foreach ($entries as $entry) { + if ($entry === '.') { + continue; + } + if ($entry === '..') { + continue; + } + if (in_array($entry, self::TOP_LEVEL_NOISE, true)) { + continue; + } + + if ($entry !== '' && $entry[0] === '.') { + continue; + } + + $abs = $projectRoot.DIRECTORY_SEPARATOR.$entry; + + if (! is_dir($abs)) { + continue; + } + + $out[] = self::normalise(@realpath($abs) ?: $abs); + } + + return $out; + } + + /** + * @return list + */ + private static function nestedNoiseDirs(string $projectRoot): array + { + $out = []; + + foreach (self::NESTED_NOISE as $relative) { + $abs = $projectRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative); + $out[] = self::normalise(@realpath($abs) ?: $abs); + } + + return $out; + } + + private static function normalise(string $path): string + { + return rtrim($path, '/\\'); + } + + private function startsWithDir(string $candidate, string $dir): bool + { + if ($candidate === $dir) { + return true; + } + + return str_starts_with($candidate, $dir.DIRECTORY_SEPARATOR); + } +} diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php new file mode 100644 index 000000000..e7deeb6f7 --- /dev/null +++ b/src/Plugins/Tia/Storage.php @@ -0,0 +1,146 @@ + Sorted, deduped table names referenced by the + */ + public static function fromSql(string $sql): array + { + $trimmed = ltrim($sql); + + if ($trimmed === '') { + return []; + } + + $prefix = strtolower(substr($trimmed, 0, 6)); + + $matched = false; + foreach (self::DML_PREFIXES as $dml) { + if (str_starts_with($prefix, $dml)) { + $matched = true; + + break; + } + } + + if (! $matched) { + return []; + } + + $pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i'; + + if (preg_match_all($pattern, $sql, $matches) === false) { + return []; + } + + $tables = []; + + for ($i = 0, $n = count($matches[0]); $i < $n; $i++) { + $name = $matches[1][$i] !== '' + ? $matches[1][$i] + : ($matches[2][$i] !== '' + ? $matches[2][$i] + : ($matches[3][$i] !== '' + ? $matches[3][$i] + : $matches[4][$i])); + if ($name === '') { + continue; + } + if (self::isSchemaMeta($name)) { + continue; + } + + $tables[strtolower($name)] = true; + } + + $out = array_keys($tables); + sort($out); + + return $out; + } + + /** + * @return list Table names referenced by `Schema::` calls, + */ + public static function fromMigrationSource(string $php): array + { + $tables = []; + + $schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/'; + + if (preg_match_all($schemaPattern, $php, $matches) !== false) { + foreach ($matches[1] as $i => $primary) { + $tables[strtolower($primary)] = true; + + $secondary = $matches[2][$i] ?? ''; + if ($secondary !== '') { + $tables[strtolower($secondary)] = true; + } + } + } + + $ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i'; + + if (preg_match_all($ddlPattern, $php, $matches) !== false) { + foreach ($matches[1] as $primary) { + $lower = strtolower($primary); + if (! self::isSchemaMeta($lower)) { + $tables[$lower] = true; + } + } + } + + $dmlPatterns = [ + '/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i', + '/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i', + '/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i', + '/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', + ]; + + foreach ($dmlPatterns as $pattern) { + if (preg_match_all($pattern, $php, $matches) === false) { + continue; + } + foreach ($matches[1] as $name) { + $lower = strtolower($name); + if (! self::isSchemaMeta($lower)) { + $tables[$lower] = true; + } + } + } + + $out = array_keys($tables); + sort($out); + + return $out; + } + + private static function isSchemaMeta(string $name): bool + { + $lower = strtolower($name); + + return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true) + || str_starts_with($lower, 'pg_') + || str_starts_with($lower, 'information_schema'); + } +} diff --git a/src/Plugins/Tia/TableTracker.php b/src/Plugins/Tia/TableTracker.php new file mode 100644 index 000000000..d83e3369a --- /dev/null +++ b/src/Plugins/Tia/TableTracker.php @@ -0,0 +1,86 @@ +isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER)) { + return; + } + + if (! $app->bound('db')) { + return; + } + + $app->instance(self::MARKER, true); + + $listener = static function (object $query) use ($recorder): void { + if (! property_exists($query, 'sql')) { + return; + } + + /** @var mixed $sql */ + $sql = $query->sql; + + if (! is_string($sql) || $sql === '') { + return; + } + + foreach (TableExtractor::fromSql($sql) as $table) { + $recorder->linkTable($table); + } + }; + + /** @var object $db */ + $db = $app->make('db'); + + if (is_callable([$db, 'listen'])) { + /** @var callable $listen */ + $listen = [$db, 'listen']; + $listen($listener); + + return; + } + + if (! $app->bound('events')) { + return; + } + + /** @var object $events */ + $events = $app->make('events'); + + if (! method_exists($events, 'listen')) { + return; + } + + $events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener); + } +} diff --git a/src/Plugins/Tia/TestPaths.php b/src/Plugins/Tia/TestPaths.php new file mode 100644 index 000000000..23ecd7bd0 --- /dev/null +++ b/src/Plugins/Tia/TestPaths.php @@ -0,0 +1,163 @@ +. Falls back to the runtime TestSuite + * configuration when no config file is present. + * + * @internal + */ +final readonly class TestPaths +{ + /** + * @param list $directories Project-relative directory prefixes (no trailing slash). + * @param list $files Project-relative file paths. + * @param list $suffixes Filename suffixes (e.g. '.php'). + */ + public function __construct( + private array $directories, + private array $files, + private array $suffixes, + ) {} + + public static function fromProjectRoot(string $projectRoot): self + { + $directories = []; + $files = []; + $suffixes = []; + + try { + $configuration = Registry::get(); + + foreach ($configuration->testSuite() as $suite) { + foreach ($suite->directories() as $directory) { + $rel = self::toRelative($directory->path(), $projectRoot); + + if ($rel !== null) { + $directories[] = $rel; + } + + $suffix = $directory->suffix(); + + if ($suffix !== '') { + $suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix; + } + } + + foreach ($suite->files() as $file) { + $rel = self::toRelative($file->path(), $projectRoot); + + if ($rel !== null) { + $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 === []) { + $fallback = self::testSuiteFallback($projectRoot); + + if ($fallback !== null) { + $directories[] = $fallback; + } + } + + return new self( + array_values(array_unique($directories)), + array_values(array_unique($files)), + array_values(array_unique($suffixes)), + ); + } + + public function isTestFile(string $relativePath): bool + { + if (in_array($relativePath, $this->files, true)) { + return true; + } + + $matchesSuffix = false; + foreach ($this->suffixes as $suffix) { + if (str_ends_with($relativePath, $suffix)) { + $matchesSuffix = true; + + break; + } + } + + if (! $matchesSuffix) { + return false; + } + + foreach ($this->directories as $dir) { + if ($dir === '') { + continue; + } + if (str_starts_with($relativePath, $dir.'/')) { + return true; + } + } + + return false; + } + + private static function toRelative(string $value, string $projectRoot): ?string + { + $value = trim($value); + + if ($value === '') { + return null; + } + + $real = @realpath($value); + $resolved = $real === false ? $value : $real; + + $resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved); + $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/'; + + if (! str_starts_with($resolved.'/', $root)) { + return null; + } + + return rtrim(substr($resolved, strlen($root)), '/'); + } + + private static function testSuiteFallback(string $projectRoot): ?string + { + try { + $testPath = TestSuite::getInstance()->testPath; + } catch (Throwable) { + return null; + } + + $real = @realpath($testPath); + $resolved = $real === false ? $testPath : $real; + $resolved = str_replace(DIRECTORY_SEPARATOR, '/', $resolved); + $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($projectRoot, '/\\')).'/'; + + if (! str_starts_with($resolved.'/', $root)) { + return null; + } + + return rtrim(substr($resolved, strlen($root)), '/'); + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Browser.php b/src/Plugins/Tia/WatchDefaults/Browser.php new file mode 100644 index 000000000..b03d60c44 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Browser.php @@ -0,0 +1,100 @@ + + */ + public static function detectBrowserTestTargets(string $projectRoot, string $testPath): array + { + $targets = []; + + $candidate = $testPath.'/Browser'; + + if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) { + $targets[] = $candidate; + } + + if (class_exists(BrowserTestIdentifier::class)) { + $repo = TestSuite::getInstance()->tests; + + foreach ($repo->getFilenames() as $filename) { + $factory = $repo->get($filename); + + if (! $factory instanceof TestCaseFactory) { + continue; + } + + foreach ($factory->methods as $method) { + if (BrowserTestIdentifier::isBrowserTest($method)) { + $rel = self::fileRelative($projectRoot, $filename); + + if ($rel !== null) { + $targets[] = $rel; + } + + break; + } + } + } + } + + return array_values(array_unique($targets)); + } + + private static function fileRelative(string $projectRoot, string $path): ?string + { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + $root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + if (! str_starts_with($real, $root)) { + return null; + } + + return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Inertia.php b/src/Plugins/Tia/WatchDefaults/Inertia.php new file mode 100644 index 000000000..77151f3d2 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Inertia.php @@ -0,0 +1,28 @@ + [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php new file mode 100644 index 000000000..e0bf6ac3c --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Laravel.php @@ -0,0 +1,41 @@ + [$testPath], + + 'storage/fixtures/**/*' => [$testPath], + + 'app/** !*.php' => [$testPath], + + 'resources/views/**' => [$testPath], + + 'lang/**' => [$testPath], + 'resources/lang/**' => [$testPath], + + 'vite.config.* !*.php' => [$testPath], + 'webpack.mix.* !*.php' => [$testPath], + 'tailwind.config.* !*.php' => [$testPath], + 'postcss.config.* !*.php' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Livewire.php b/src/Plugins/Tia/WatchDefaults/Livewire.php new file mode 100644 index 000000000..5487a450d --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Livewire.php @@ -0,0 +1,32 @@ + [$testPath], + 'resources/views/components/**/*.blade.php' => [$testPath], + 'resources/views/pages/**/*.blade.php' => [$testPath], + + 'resources/js/**/*.js' => [$testPath], + 'resources/js/**/*.ts' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php new file mode 100644 index 000000000..dbd95f489 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -0,0 +1,38 @@ + [$testPath], + '.env.testing' => [$testPath], + '.env.local' => [$testPath], + '.env.*.local' => [$testPath], + + 'docker-compose.yml' => [$testPath], + 'docker-compose.yaml' => [$testPath], + + 'phpunit.xml*' => [$testPath], + + $testPath.'/Fixtures/**/*' => [$testPath], + $testPath.'/**/Fixtures/**/*' => [$testPath], + + $testPath.'/.pest/snapshots/**/*.snap' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Symfony.php b/src/Plugins/Tia/WatchDefaults/Symfony.php new file mode 100644 index 000000000..bcbd3ddd9 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Symfony.php @@ -0,0 +1,42 @@ + [$testPath], + 'config/routes/** !*.php' => [$testPath], + + 'migrations/**/*.php' => [$testPath], + 'src/Migrations/**/*.php' => [$testPath], + + 'templates/** !*.php' => [$testPath], + + 'translations/** !*.php' => [$testPath], + + 'config/doctrine/**/*.xml' => [$testPath], + 'config/doctrine/**/*.yaml' => [$testPath], + + 'webpack.config.js' => [$testPath], + 'importmap.php' => [$testPath], + 'assets/** !*.php' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php new file mode 100644 index 000000000..6b3008574 --- /dev/null +++ b/src/Plugins/Tia/WatchPatterns.php @@ -0,0 +1,331 @@ +> + */ + private const array DEFAULTS = [ + WatchDefaults\Php::class, + WatchDefaults\Laravel::class, + WatchDefaults\Symfony::class, + WatchDefaults\Livewire::class, + WatchDefaults\Inertia::class, + WatchDefaults\Browser::class, + ]; + + private const array VCS_DIRS = ['.git', '.svn', '.hg']; + + /** + * @var array> raw pattern key → list of project-relative test dirs/files + */ + private array $patterns = []; + + /** + * @var array, allowDotfiles: bool}> + */ + private array $parsed = []; + + private bool $enabled = false; + + private bool $locally = false; + + private bool $filtered = false; + + private bool $baselined = false; + + public function useDefaults(string $projectRoot): void + { + $testPath = TestSuite::getInstance()->testPath; + + foreach (self::DEFAULTS as $class) { + $default = new $class; + + if (! $default->applicable()) { + continue; + } + + foreach ($default->defaults($projectRoot, $testPath) as $key => $dirs) { + $this->patterns[$key] = array_values(array_unique( + array_merge($this->patterns[$key] ?? [], $dirs), + )); + } + } + } + + /** + * @param array $patterns pattern key → project-relative test dir/file + */ + public function add(array $patterns): void + { + foreach ($patterns as $key => $dir) { + $this->patterns[$key] = array_values(array_unique( + array_merge($this->patterns[$key] ?? [], [$dir]), + )); + } + } + + /** + * @param string $projectRoot Absolute path. + * @param array $changedFiles Project-relative paths. + * @return array Project-relative test dirs/files. + */ + public function matchedDirectories(string $projectRoot, array $changedFiles): array + { + if ($this->patterns === []) { + return []; + } + + $matched = []; + + foreach ($changedFiles as $file) { + foreach ($this->patterns as $key => $dirs) { + if (! $this->keyMatches($key, $file)) { + continue; + } + + foreach ($dirs as $dir) { + $matched[$dir] = true; + } + } + } + + return array_keys($matched); + } + + /** + * @param array $directories Project-relative dirs/files. + * @param array $allTestFiles Project-relative test files from graph. + * @return array + */ + public function testsUnderDirectories(array $directories, array $allTestFiles): array + { + if ($directories === []) { + return []; + } + + $affected = []; + + foreach ($allTestFiles as $testFile) { + foreach ($directories as $target) { + if ($testFile === $target) { + $affected[] = $testFile; + + break; + } + + $prefix = rtrim($target, '/').'/'; + + if (str_starts_with($testFile, $prefix)) { + $affected[] = $testFile; + + break; + } + } + } + + return $affected; + } + + public function markEnabled(): void + { + $this->enabled = true; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function markLocally(): void + { + $this->locally = true; + } + + public function isLocally(): bool + { + return $this->locally; + } + + public function markFiltered(): void + { + $this->filtered = true; + } + + public function isFiltered(): bool + { + return $this->filtered; + } + + public function markBaselined(): void + { + $this->baselined = true; + } + + public function isBaselined(): bool + { + return $this->baselined; + } + + public function reset(): void + { + $this->patterns = []; + $this->parsed = []; + $this->enabled = false; + $this->locally = 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, 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 + { + $pattern = str_replace('\\', '/', $pattern); + $file = str_replace('\\', '/', $file); + + $regex = ''; + $len = strlen($pattern); + $i = 0; + + while ($i < $len) { + $c = $pattern[$i]; + + if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') { + $regex .= '.*'; + $i += 2; + + if (isset($pattern[$i]) && $pattern[$i] === '/') { + $i++; + } + } elseif ($c === '*') { + $regex .= '[^/]*'; + $i++; + } elseif ($c === '?') { + $regex .= '[^/]'; + $i++; + } else { + $regex .= preg_quote($c, '#'); + $i++; + } + } + + return (bool) preg_match('#^'.$regex.'$#', $file); + } +} diff --git a/src/Restarters/PcovRestarter.php b/src/Restarters/PcovRestarter.php new file mode 100644 index 000000000..1337397a7 --- /dev/null +++ b/src/Restarters/PcovRestarter.php @@ -0,0 +1,95 @@ + $arguments + */ + public function maybeRestart(string $projectRoot, array $arguments): void + { + if (! extension_loaded('pcov')) { + return; + } + + if (getenv(self::ENV_RESTARTED) === '1') { + putenv(self::ENV_RESTARTED); + unset($_ENV[self::ENV_RESTARTED]); + + return; + } + + if (! Tia::isEnabledForRun($arguments)) { + return; + } + + $desired = $this->normalise($projectRoot); + $current = $this->normalise((string) ini_get('pcov.directory')); + + if ($current === $desired) { + return; + } + + $this->restart($projectRoot, $arguments); + } + + /** + * @param array $arguments + */ + private function restart(string $projectRoot, array $arguments): void + { + $env = $this->inheritEnv(); + $env[self::ENV_RESTARTED] = '1'; + + $command = array_merge( + [PHP_BINARY, '-d', 'pcov.directory='.$projectRoot], + array_values($arguments), + ); + + $proc = @proc_open( + $command, + [STDIN, STDOUT, STDERR], + $pipes, + null, + $env, + ); + + if (! is_resource($proc)) { + return; + } + + $exitCode = proc_close($proc); + + exit($exitCode === -1 ? 1 : $exitCode); + } + + /** + * @return array + */ + private function inheritEnv(): array + { + $env = []; + + foreach (getenv() as $name => $value) { + $env[$name] = $value; + } + + return $env; + } + + private function normalise(string $path): string + { + return rtrim($path, '/\\'); + } +} diff --git a/src/Restarters/XdebugRestarter.php b/src/Restarters/XdebugRestarter.php new file mode 100644 index 000000000..a0db5bbed --- /dev/null +++ b/src/Restarters/XdebugRestarter.php @@ -0,0 +1,113 @@ + $arguments + */ + public function maybeRestart(string $projectRoot, array $arguments): void + { + if (! class_exists(XdebugHandler::class)) { + return; + } + + if (! extension_loaded('xdebug')) { + return; + } + + if (! $this->xdebugIsCoverageOnly()) { + return; + } + + if (! $this->runLooksDroppable($arguments, $projectRoot)) { + return; + } + + (new XdebugHandler('pest'))->check(); + } + + private function xdebugIsCoverageOnly(): bool + { + if (! function_exists('xdebug_info')) { + return false; + } + + $modes = @xdebug_info('mode'); + + if (! is_array($modes)) { + return false; + } + + $modes = array_values(array_filter($modes, is_string(...))); + + if ($modes === []) { + return true; + } + + return $modes === ['coverage']; + } + + /** + * @param array $arguments + */ + private function runLooksDroppable(array $arguments, string $projectRoot): bool + { + foreach ($arguments as $value) { + if ($value === '--coverage' + || str_starts_with($value, '--coverage=') + || str_starts_with($value, '--coverage-')) { + return false; + } + + if ($value === '--fresh') { + return false; + } + } + + if (! Tia::isEnabledForRun($arguments)) { + return false; + } + + return $this->tiaWillReplay($projectRoot); + } + + private function tiaWillReplay(string $projectRoot): bool + { + $path = Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; + + if (! is_file($path)) { + return false; + } + + $json = @file_get_contents($path); + + if ($json === false) { + return false; + } + + $graph = Graph::decode($json, $projectRoot); + + if (! $graph instanceof Graph) { + return false; + } + + return Fingerprint::structuralMatches( + $graph->fingerprint(), + Fingerprint::compute($projectRoot), + ); + } +} diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php new file mode 100644 index 000000000..46f92be86 --- /dev/null +++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php @@ -0,0 +1,32 @@ +test(); + + if ($test instanceof TestMethod) { + $this->collector->recordAssertions( + $test->className().'::'.$test->methodName(), + $event->numberOfAssertionsPerformed(), + ); + } + + $this->collector->finishTest(); + } +} diff --git a/src/Subscribers/EnsureTiaEnds.php b/src/Subscribers/EnsureTiaEnds.php new file mode 100644 index 000000000..5dba31f80 --- /dev/null +++ b/src/Subscribers/EnsureTiaEnds.php @@ -0,0 +1,22 @@ +recorder->endTest(); + } +} diff --git a/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php b/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php new file mode 100644 index 000000000..413e89569 --- /dev/null +++ b/src/Subscribers/EnsureTiaIsRunningPestTestsOnly.php @@ -0,0 +1,45 @@ +recorder->isActive()) { + return; + } + + $test = $event->test(); + + if (! $test instanceof TestMethod) { + return; + } + + $className = $test->className(); + + if (! class_exists($className, false)) { + return; + } + + if (method_exists($className, '__initializeTestCase')) { + return; + } + + Panic::with(new TiaRequiresPestTests($className, $test->file())); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php new file mode 100644 index 000000000..ecdf3833a --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php @@ -0,0 +1,22 @@ +collector->testErrored($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php new file mode 100644 index 000000000..29940e0c5 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php @@ -0,0 +1,22 @@ +collector->testFailed($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php new file mode 100644 index 000000000..330525e89 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php @@ -0,0 +1,22 @@ +collector->testIncomplete($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php new file mode 100644 index 000000000..09ebcc217 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php @@ -0,0 +1,22 @@ +collector->testPassed(); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php new file mode 100644 index 000000000..fe65f6ebf --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php @@ -0,0 +1,22 @@ +collector->testRisky($event->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php new file mode 100644 index 000000000..58de98add --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php @@ -0,0 +1,22 @@ +collector->testSkipped($event->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php new file mode 100644 index 000000000..90bc65821 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultsAreCollected.php @@ -0,0 +1,27 @@ +test(); + + if ($test instanceof TestMethod) { + $this->collector->testPrepared($test->className().'::'.$test->methodName(), $test->file()); + } + } +} diff --git a/src/Subscribers/EnsureTiaStarts.php b/src/Subscribers/EnsureTiaStarts.php new file mode 100644 index 000000000..e6aca8c4f --- /dev/null +++ b/src/Subscribers/EnsureTiaStarts.php @@ -0,0 +1,33 @@ +recorder->isActive()) { + return; + } + + $test = $event->test(); + + if (! $test instanceof TestMethod) { + return; + } + + $this->recorder->beginTest($test->className(), $test->methodName(), $test->file()); + } +} diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index a36dd8edb..370e492a6 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -5,6 +5,7 @@ namespace Pest\Support; use Pest\Exceptions\ShouldNotHappen; +use Pest\Plugins\Tia\CoverageMerger; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\File; @@ -88,6 +89,8 @@ public static function report(OutputInterface $output, bool $compact = false, bo throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath)); } + CoverageMerger::applyIfMarked($reportPath); + /** @var CodeCoverage $codeCoverage */ $codeCoverage = require $reportPath; unlink($reportPath); diff --git a/src/Support/StateGenerator.php b/src/Support/StateGenerator.php index 9872f52dc..f9b32d604 100644 --- a/src/Support/StateGenerator.php +++ b/src/Support/StateGenerator.php @@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\ThrowableBuilder; use PHPUnit\Event\Test\Errored; +use PHPUnit\Event\Test\Failed; use PHPUnit\Event\Test\PhpunitDeprecationTriggered; use PHPUnit\Event\Test\PhpunitErrorTriggered; use PHPUnit\Event\Test\PhpunitNoticeTriggered; @@ -40,11 +41,16 @@ public function fromPhpUnitTestResult(int $passedTests, PHPUnitTestResult $testR } foreach ($testResult->testFailedEvents() as $testResultEvent) { - $state->add(TestResult::fromPestParallelTestCase( - $testResultEvent->test(), - TestResult::FAIL, - $testResultEvent->throwable() - )); + if ($testResultEvent instanceof Failed) { + $state->add(TestResult::fromPestParallelTestCase( + $testResultEvent->test(), + TestResult::FAIL, + $testResultEvent->throwable() + )); + } else { + // @phpstan-ignore-next-line + $state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent)); + } } $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL); diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php new file mode 100644 index 000000000..38ca9c3be --- /dev/null +++ b/src/TestCaseFilters/TiaTestCaseFilter.php @@ -0,0 +1,55 @@ + $affectedTestFiles Keys are project-relative test file paths. + */ + public function __construct( + private string $projectRoot, + private Graph $graph, + private array $affectedTestFiles, + ) {} + + public function accept(string $testCaseFilename): bool + { + $rel = $this->relative($testCaseFilename); + + if ($rel === null) { + return true; + } + + if (! $this->graph->knowsTest($rel)) { + return true; + } + + return isset($this->affectedTestFiles[$rel]); + } + + private function relative(string $path): ?string + { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + if (! str_starts_with($real, $root)) { + return null; + } + + return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } +} diff --git a/tests/.pest/snapshots/Visual/Collision/collision_with_data_set_________.snap b/tests/.pest/snapshots/Visual/Collision/collision_with_data_set_________.snap index 1bc29248d..f1d62c463 100644 --- a/tests/.pest/snapshots/Visual/Collision/collision_with_data_set_________.snap +++ b/tests/.pest/snapshots/Visual/Collision/collision_with_data_set_________.snap @@ -1,4 +1,6 @@ + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. + FAIL Tests\Fixtures\CollisionTest ⨯ error ✓ success diff --git a/tests/.pest/snapshots/Visual/Todo/todo.snap b/tests/.pest/snapshots/Visual/Todo/todo.snap index 09710340c..3ac2a5389 100644 --- a/tests/.pest/snapshots/Visual/Todo/todo.snap +++ b/tests/.pest/snapshots/Visual/Todo/todo.snap @@ -1,4 +1,6 @@ + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. + TODO Tests\Features\BeforeEachProxiesToTestCallWithTodo - 4 todos ↓ is marked as todo 1 ↓ is marked as todo 2 diff --git a/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap b/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap index 09710340c..3ac2a5389 100644 --- a/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap +++ b/tests/.pest/snapshots/Visual/Todo/todo_in_parallel.snap @@ -1,4 +1,6 @@ + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. + TODO Tests\Features\BeforeEachProxiesToTestCallWithTodo - 4 todos ↓ is marked as todo 1 ↓ is marked as todo 2 diff --git a/tests/.pest/snapshots/Visual/Todo/todos.snap b/tests/.pest/snapshots/Visual/Todo/todos.snap index 09710340c..3ac2a5389 100644 --- a/tests/.pest/snapshots/Visual/Todo/todos.snap +++ b/tests/.pest/snapshots/Visual/Todo/todos.snap @@ -1,4 +1,6 @@ + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. + TODO Tests\Features\BeforeEachProxiesToTestCallWithTodo - 4 todos ↓ is marked as todo 1 ↓ is marked as todo 2 diff --git a/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap b/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap index 09710340c..3ac2a5389 100644 --- a/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap +++ b/tests/.pest/snapshots/Visual/Todo/todos_in_parallel.snap @@ -1,4 +1,6 @@ + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. + TODO Tests\Features\BeforeEachProxiesToTestCallWithTodo - 4 todos ↓ is marked as todo 1 ↓ is marked as todo 2 diff --git a/tests/.snapshots/Failure.php.inc b/tests/.snapshots/Failure.php.inc index efd423094..60ebd0a69 100644 --- a/tests/.snapshots/Failure.php.inc +++ b/tests/.snapshots/Failure.php.inc @@ -1,27 +1,57 @@ + + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. +##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234'] +##teamcity[testCount count='8' flowId='1234'] ##teamcity[testSuiteStarted name='Tests/tests/Failure' locationHint='pest_qn://tests/.tests/Failure.php' flowId='1234'] ##teamcity[testCount count='8' flowId='1234'] ##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234'] +##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234'] ##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234'] +##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at tests/.tests/Failure.php:6' type='comparisonFailure' actual='true' expected='false' flowId='1234'] +##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234'] ##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234'] ##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234'] +##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234'] +##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234'] ##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234'] ##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234'] +##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234'] +##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234'] ##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234'] ##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234'] +##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234'] +##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234'] ##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234'] ##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234'] +##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234'] +##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234'] ##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18' flowId='1234'] ##teamcity[testFinished name='it can fail' duration='100000' flowId='1234'] +##teamcity[testFinished name='it can fail' duration='100000' flowId='1234'] ##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234'] +##teamcity[testStarted name='it throws exception' locationHint='pest_qn://tests/.tests/Failure.php::it throws exception' flowId='1234'] +##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234'] ##teamcity[testFailed name='it throws exception' message='Exception: test error' details='at tests/.tests/Failure.php:22' flowId='1234'] ##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234'] +##teamcity[testFinished name='it throws exception' duration='100000' flowId='1234'] +##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234'] ##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234'] ##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234'] +##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234'] +##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234'] ##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234'] ##teamcity[testFinished name='build this one.' duration='100000' flowId='1234'] +##teamcity[testFinished name='build this one.' duration='100000' flowId='1234'] ##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234'] +##teamcity[testStarted name='it is passing' locationHint='pest_qn://tests/.tests/Failure.php::it is passing' flowId='1234'] +##teamcity[testFinished name='it is passing' duration='100000' flowId='1234'] ##teamcity[testFinished name='it is passing' duration='100000' flowId='1234'] ##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234'] +##teamcity[testSuiteFinished name='Tests/tests/Failure' flowId='1234'] + + Tests: 3 failed, 1 risky, 2 todos, 1 skipped, 1 passed (3 assertions) + Duration: 1.00s + Tests: 3 failed, 1 risky, 2 todos, 1 skipped, 1 passed (3 assertions) Duration: 1.00s diff --git a/tests/.snapshots/SuccessOnly.php.inc b/tests/.snapshots/SuccessOnly.php.inc index b940b7b63..ca616f714 100644 --- a/tests/.snapshots/SuccessOnly.php.inc +++ b/tests/.snapshots/SuccessOnly.php.inc @@ -1,18 +1,39 @@ + + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. +##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234'] +##teamcity[testCount count='4' flowId='1234'] ##teamcity[testSuiteStarted name='Tests/tests/SuccessOnly' locationHint='pest_qn://tests/.tests/SuccessOnly.php' flowId='1234'] ##teamcity[testCount count='4' flowId='1234'] ##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234'] +##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234'] +##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234'] ##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234'] ##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234'] +##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234'] +##teamcity[testFinished name='can also pass' duration='100000' flowId='1234'] ##teamcity[testFinished name='can also pass' duration='100000' flowId='1234'] ##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234'] +##teamcity[testSuiteStarted name='can pass with dataset' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset' flowId='1234'] ##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234'] +##teamcity[testStarted name='can pass with dataset with data set "(true)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can pass with dataset with data set "(true)"' flowId='1234'] +##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234'] ##teamcity[testFinished name='can pass with dataset with data set "(true)"' duration='100000' flowId='1234'] ##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234'] +##teamcity[testSuiteFinished name='can pass with dataset' flowId='1234'] +##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234'] ##teamcity[testSuiteStarted name='`block` → can pass with dataset in describe block' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block' flowId='1234'] ##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234'] +##teamcity[testStarted name='`block` → can pass with dataset in describe block with data set "(1)"' locationHint='pest_qn://tests/.tests/SuccessOnly.php::`block` → can pass with dataset in describe block with data set "(1)"' flowId='1234'] ##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234'] +##teamcity[testFinished name='`block` → can pass with dataset in describe block with data set "(1)"' duration='100000' flowId='1234'] +##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234'] ##teamcity[testSuiteFinished name='`block` → can pass with dataset in describe block' flowId='1234'] ##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234'] +##teamcity[testSuiteFinished name='Tests/tests/SuccessOnly' flowId='1234'] + + Tests: 4 passed (4 assertions) + Duration: 1.00s + Tests: 4 passed (4 assertions) Duration: 1.00s diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index c19ebf8b7..d7ab66096 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1,7 +1,9 @@ + ─ Running in TIA mode, however TIA as skipped as it needs Needs ext-pcov or Xdebug. + PASS Tests\Arch ✓ preset → php → ignoring ['Pest\Expectation', 'debug_backtrace', 'var_export', …] - ✓ preset → strict → ignoring ['usleep'] + ✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep'] ✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …] ✓ globals ✓ contracts @@ -1716,6 +1718,43 @@ PASS Tests\Unit\Plugins\Retry ✓ it orders by defects and stop on defects if when --retry is used + PASS Tests\Unit\Plugins\Tia\ContentHash + ✓ of() → it returns false when file does not exist + ✓ of() → it hashes an existing file + ✓ PHP files → it produces the same hash regardless of whitespace differences + ✓ PHP files → it ignores single-line comments + ✓ PHP files → it ignores hash-style comments + ✓ PHP files → it ignores multi-line comments + ✓ PHP files → it ignores doc comments + ✓ PHP files → it detects code changes + ✓ PHP files → it preserves whitespace inside string literals + ✓ PHP files → it treats variable renames as a change + ✓ PHP files → it falls back to a raw hash for unparseable PHP + ✓ PHP files → it is case-insensitive on the file extension + ✓ Blade files → it strips blade comments + ✓ Blade files → it strips multi-line blade comments + ✓ Blade files → it collapses whitespace + ✓ Blade files → it detects content changes + ✓ Blade files → it keeps blade directives intact + ✓ Blade files → it does not use the PHP tokenizer for blade files + ✓ JavaScript-like files → it strips line comments + ✓ JavaScript-like files → it strips block comments on their own lines + ✓ JavaScript-like files → it collapses whitespace + ✓ JavaScript-like files → it detects code changes + ✓ JavaScript-like files → it does not strip inline trailing comments + ✓ JavaScript-like files → it applies the same rules to .ts files + ✓ JavaScript-like files → it applies the same rules to .tsx files + ✓ JavaScript-like files → it applies the same rules to .jsx files + ✓ JavaScript-like files → it applies the same rules to .vue files + ✓ JavaScript-like files → it applies the same rules to .svelte files + ✓ JavaScript-like files → it applies the same rules to .mjs, .cjs, and .mts files + ✓ unknown extensions → it hashes the raw content for unknown extensions + ✓ unknown extensions → it does not normalise whitespace for unknown extensions + ✓ unknown extensions → it does not strip comments for unknown extensions + ✓ unknown extensions → it hashes files with no extension as raw content + ✓ output format → it returns a 32-character hex xxh128 hash + ✓ output format → it returns a stable hash for empty content + PASS Tests\Unit\Preset ✓ preset invalid name ✓ preset → myFramework @@ -1901,4 +1940,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1294 passed (2971 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1329 passed (3010 assertions) \ No newline at end of file diff --git a/tests/Arch.php b/tests/Arch.php index d0565216f..82aa93da4 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -1,15 +1,20 @@ preset()->php()->ignoring([ Expectation::class, 'debug_backtrace', 'var_export', 'xdebug_info', + 'xdebug_start_code_coverage', + 'xdebug_stop_code_coverage', + 'xdebug_get_code_coverage', ]); arch()->preset()->strict()->ignoring([ + BaselineSync::class, 'usleep', ]); diff --git a/tests/Unit/Plugins/Tia/ContentHash.php b/tests/Unit/Plugins/Tia/ContentHash.php new file mode 100644 index 000000000..6b2351674 --- /dev/null +++ b/tests/Unit/Plugins/Tia/ContentHash.php @@ -0,0 +1,261 @@ +toBeFalse(); + }); + + it('hashes an existing file', function () { + $path = tempnam(sys_get_temp_dir(), 'pest_').'.php'; + file_put_contents($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', "toBe($b); + }); + + it('ignores single-line comments', function () { + $a = ContentHash::ofContent('a.php', "toBe($b); + }); + + it('ignores hash-style comments', function () { + $a = ContentHash::ofContent('a.php', "toBe($b); + }); + + it('ignores multi-line comments', function () { + $a = ContentHash::ofContent('a.php', "toBe($b); + }); + + it('ignores doc comments', function () { + $a = ContentHash::ofContent('a.php', "toBe($b); + }); + + it('detects code changes', function () { + $a = ContentHash::ofContent('a.php', 'not->toBe($b); + }); + + it('preserves whitespace inside string literals', function () { + $a = ContentHash::ofContent('a.php', "not->toBe($b); + }); + + it('treats variable renames as a change', function () { + $a = ContentHash::ofContent('a.php', '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', "toBe($b); + }); +}); + +describe('Blade files', function () { + it('strips blade comments', function () { + $a = ContentHash::ofContent('a.blade.php', '
{{-- a comment --}}Hello
'); + $b = ContentHash::ofContent('a.blade.php', '
Hello
'); + + expect($a)->toBe($b); + }); + + it('strips multi-line blade comments', function () { + $a = ContentHash::ofContent('a.blade.php', "
\n{{--\n multi\n line\n--}}\nHello\n
"); + $b = ContentHash::ofContent('a.blade.php', '
Hello
'); + + expect($a)->toBe($b); + }); + + it('collapses whitespace', function () { + $a = ContentHash::ofContent('a.blade.php', "
\n Hello\n World\n
"); + $b = ContentHash::ofContent('a.blade.php', '
Hello World
'); + + expect($a)->toBe($b); + }); + + it('detects content changes', function () { + $a = ContentHash::ofContent('a.blade.php', '
Hello
'); + $b = ContentHash::ofContent('a.blade.php', '
Goodbye
'); + + 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', ' hello'); + $b = ContentHash::ofContent('a.blade.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 = () =>
;"); + $b = ContentHash::ofContent('a.tsx', 'const Foo = () =>
;'); + + expect($a)->toBe($b); + }); + + it('applies the same rules to .jsx files', function () { + $a = ContentHash::ofContent('a.jsx', "// comment\nconst Foo = () =>
;"); + $b = ContentHash::ofContent('a.jsx', 'const Foo = () =>
;'); + + expect($a)->toBe($b); + }); + + it('applies the same rules to .vue files', function () { + $a = ContentHash::ofContent('a.vue', ""); + $b = ContentHash::ofContent('a.vue', ''); + + expect($a)->toBe($b); + }); + + it('applies the same rules to .svelte files', function () { + $a = ContentHash::ofContent('a.svelte', ""); + $b = ContentHash::ofContent('a.svelte', ''); + + 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', '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); + }); +}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 0d1f4d64c..1055526b2 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -16,6 +16,7 @@ test('parallel', function () use ($run) { $output = $run('--exclude-group=integration'); + $output = implode("\n", array_slice(explode("\n", $output), -10)); if (getenv('REBUILD_SNAPSHOTS')) { preg_match('/Tests:\s+(.+\(\d+ assertions\))/', $output, $matches); @@ -23,13 +24,13 @@ $file = file_get_contents(__FILE__); $file = preg_replace( '/\$expected = \'.*?\';/', - "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1278 passed (2920 assertions)';", + "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1313 passed (2959 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1278 passed (2920 assertions)'; + $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1313 passed (2959 assertions)'; expect($output) ->toContain("Tests: {$expected}")