Compare commits

..

23 Commits

Author SHA1 Message Date
6407c4f78f wip 2026-05-02 01:58:39 +01:00
6e1bf63f6a wip 2026-05-02 01:40:35 +01:00
1d3e8bb5dd wip 2026-05-02 01:03:06 +01:00
3cc9b169e3 wip 2026-05-02 00:52:57 +01:00
c4911d046b wip 2026-05-02 00:06:04 +01:00
d0295f6168 wip 2026-05-01 23:59:25 +01:00
21efbc3107 wip 2026-05-01 22:55:38 +01:00
e59b99cd73 wip 2026-05-01 22:51:55 +01:00
bf48e20880 wip 2026-05-01 22:36:15 +01:00
53db68e005 wip 2026-05-01 22:31:00 +01:00
34f1e9a7f2 fix 2026-05-01 21:51:09 +01:00
57fd5ce042 wip 2026-05-01 21:50:56 +01:00
3bcabfb63b fix 2026-05-01 21:50:52 +01:00
aa3a7c303a wip 2026-05-01 21:32:59 +01:00
5c08a135f7 wip 2026-05-01 21:30:44 +01:00
6e0e030d71 wip 2026-05-01 21:22:33 +01:00
b2c07561e7 wip 2026-05-01 20:54:24 +01:00
97600b6f0b wip 2026-05-01 20:53:40 +01:00
8a51f15d65 wip 2026-05-01 20:45:51 +01:00
a349f53964 wip 2026-05-01 20:42:14 +01:00
a725e774c0 wip 2026-05-01 20:28:39 +01:00
bed5e5b54a wip 2026-05-01 20:02:46 +01:00
45b1d4ce20 wip 2026-05-01 19:50:54 +01:00
58 changed files with 1170 additions and 2439 deletions

View File

@ -3,8 +3,10 @@
declare(strict_types=1); declare(strict_types=1);
use Pest\Contracts\Restarter;
use Pest\Kernel; use Pest\Kernel;
use Pest\Panic; use Pest\Panic;
use Pest\Support\Container;
use Pest\TestCaseFilters\GitDirtyTestCaseFilter; use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter; use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
use Pest\TestCaseMethodFilters\IssueTestCaseFilter; use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
@ -143,20 +145,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
// Get $rootPath based on $autoloadPath // Get $rootPath based on $autoloadPath
$rootPath = dirname($autoloadPath, 2); $rootPath = dirname($autoloadPath, 2);
// Re-execs PHP without Xdebug on TIA replay runs so repeat `--tia`
// invocations aren't slowed by a coverage driver they don't use. Plain
// `pest` runs are left alone — users may rely on Xdebug for IDE
// breakpoints, step-through debugging, or custom tooling. See
// XdebugGuard for the full decision (coverage / tia-rebuild / Xdebug
// mode gates).
\Pest\Support\XdebugGuard::maybeDrop($rootPath);
// Restarts PHP with `pcov.directory=<root>` when `--tia` is active and
// pcov is loaded, so the driver never instruments anything outside the
// project (vendor, system includes). Idempotent — guarded by an env
// sentinel so a single round-trip is enough.
\Pest\Support\PcovGuard::maybeRestart($rootPath);
$input = new ArgvInput; $input = new ArgvInput;
$testSuite = TestSuite::getInstance( $testSuite = TestSuite::getInstance(
@ -207,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
try { try {
$kernel = Kernel::boot($testSuite, $input, $output); $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); $result = $kernel->handle($originalArguments, $arguments);
$kernel->terminate(); $kernel->terminate();

View File

@ -1,23 +1,50 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readdir } from 'node:fs/promises' import { readdir, readFile } from 'node:fs/promises'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { resolve, relative, extname, posix, sep, join } from 'node:path' import { resolve, relative, extname, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte']) const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte'])
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 PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/') const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10)
async function loadVite() { async function loadRolldown() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const vitePath = projectRequire.resolve('vite') const path = projectRequire.resolve('rolldown')
return await import(pathToFileURL(vitePath).href) return await import(pathToFileURL(path).href)
} }
const { createServer } = await loadVite() 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) { async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return [] if (!existsSync(pagesDir)) return []
@ -43,6 +70,14 @@ function componentNameFor(pageAbs, pagesDir) {
return rel.slice(0, rel.length - ext.length) 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() { async function main() {
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL) const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
const pages = await listPageFiles(pagesDir) const pages = await listPageFiles(pagesDir)
@ -52,72 +87,104 @@ async function main() {
return return
} }
const server = await createServer({ const { rolldown } = await loadRolldown()
configFile: undefined, // auto-detect vite.config.* const alias = await loadAliasFromTsconfig()
root: PROJECT_ROOT, const aliasKeys = Object.keys(alias)
logLevel: 'silent',
clearScreen: false, const graph = new Map()
server: {
middlewareMode: true, const collector = {
hmr: false, name: 'pest-tia-collector',
watch: null, 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)
}, },
appType: 'custom',
optimizeDeps: { disabled: true },
})
const killer = setTimeout(() => {
server.close().catch(() => {}).finally(() => process.exit(2))
}, TIMEOUT_MS)
const reverse = new Map()
const pageComponentCache = new Map()
for (const page of pages) {
pageComponentCache.set(page, componentNameFor(page, pagesDir))
} }
try { const externalBare = {
for (const pagePath of pages) { name: 'pest-tia-external-bare',
const pageComponent = pageComponentCache.get(pagePath) resolveId(source) {
const pageUrl = '/' + posix.relative( if (!source) return null
PROJECT_ROOT.split(sep).join('/'), if (isLocalSpecifier(source, aliasKeys)) return null
pagePath.split(sep).join('/'), return { id: source, external: true }
) },
}
try { const assetStub = {
await server.transformRequest(pageUrl, { ssr: false }) name: 'pest-tia-asset-stub',
} catch { load(id) {
continue if (!id) return null
if (ASSET_EXT_RE.test(id)) {
return { code: 'export default null', moduleSideEffects: false }
} }
return null
},
}
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false) const input = Object.create(null)
if (!pageModule) continue for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
const visited = new Set() const bundle = await rolldown({
const queue = [pageModule] input,
while (queue.length) { cwd: PROJECT_ROOT,
const mod = queue.shift() resolve: {
for (const imported of mod.importedModules) { alias,
const id = imported.file ?? imported.id extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
if (!id || visited.has(id)) continue },
visited.add(id) transform: { jsx: 'preserve' },
treeshake: false,
plugins: [externalBare, assetStub, collector],
logLevel: 'silent',
onLog: () => {},
})
if (id.startsWith('\0')) continue try {
if (!id.startsWith(PROJECT_ROOT)) continue await bundle.generate({ format: 'esm' })
} finally {
await bundle.close()
}
const rel = relative(PROJECT_ROOT, id).split(sep).join('/') const reverse = new Map()
const bucket = reverse.get(rel) ?? new Set() const transitiveCache = new Map()
bucket.add(pageComponent)
reverse.set(rel, bucket)
queue.push(imported) 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)
} }
} }
} finally { stack.delete(id)
clearTimeout(killer) transitiveCache.set(id, acc)
await server.close() 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 payload = Object.create(null)

View File

@ -20,18 +20,19 @@
"php": "^8.3.0", "php": "^8.3.0",
"brianium/paratest": "^7.20.0", "brianium/paratest": "^7.20.0",
"composer/xdebug-handler": "^3.0.5", "composer/xdebug-handler": "^3.0.5",
"fidry/cpu-core-counter": "^1.3",
"nunomaduro/collision": "^8.9.4", "nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0", "nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin": "^4.0.0",
"pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.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" "symfony/process": "^7.4.8|^8.0.8"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.5.23", "phpunit/phpunit": ">12.5.24",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@ -59,7 +60,7 @@
] ]
}, },
"require-dev": { "require-dev": {
"mrpunyapal/peststan": "^0.2.5", "mrpunyapal/peststan": "^0.2.9",
"pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-dev-tools": "^4.1.0",
"pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-browser": "^4.3.1",
"pestphp/pest-plugin-type-coverage": "^4.0.4", "pestphp/pest-plugin-type-coverage": "^4.0.4",

View File

@ -5,6 +5,8 @@
[$bgBadgeColor, $bgBadgeText] = match ($type) { [$bgBadgeColor, $bgBadgeText] = match ($type) {
'INFO' => ['blue', 'INFO'], 'INFO' => ['blue', 'INFO'],
'ERROR' => ['red', 'ERROR'], 'ERROR' => ['red', 'ERROR'],
'WARN' => ['yellow', 'WARN'],
'SUCCESS' => ['green', 'SUCCESS'],
}; };
?> ?>

View File

@ -8,11 +8,10 @@ use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\AutoloadEdges; use Pest\Plugins\Tia\Collectors;
use Pest\Plugins\Tia\BladeEdges; use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\Plugins\Tia\InertiaEdges;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\TableTracker; use Pest\Plugins\Tia\Replay;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\Container; use Pest\Support\Container;
@ -275,75 +274,39 @@ trait Testable
self::$__latestIssues = $method->issues; self::$__latestIssues = $method->issues;
self::$__latestPrs = $method->prs; self::$__latestPrs = $method->prs;
// TIA replay short-circuit. Runs AFTER dataset/description/
// assignee metadata is populated so output and filtering still
// see the correct test name + tags on a cache hit, but BEFORE
// `parent::setUp()` and `beforeEach` so we skip the user's
// fixture setup (which is the whole point of replay — avoid
// paying for work whose outcome we already know).
/** @var Tia $tia */ /** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class); $tia = Container::getInstance()->get(Tia::class);
$cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); $status = $tia->getStatus(self::$__filename, $this::class.'::'.$this->name());
$replay = Replay::fromStatus($status);
if ($cached !== null) { if ($replay !== Replay::No) {
if ($cached->isSuccess()) { assert($status !== null);
$this->__cachedPass = true;
$this->__ran = true;
return; match ($replay) {
} Replay::Pass => $this->__shortCircuitCachedPass(),
Replay::Skipped => $this->markTestSkipped($status->message()),
Replay::Incomplete => $this->markTestIncomplete($status->message()),
Replay::Failure => throw new AssertionFailedError($status->message() ?: 'Cached failure'),
Replay::No => null,
};
// Risky tests have no public PHPUnit hook to replay as-risky. return;
// Best available: short-circuit as a pass so the test doesn't
// misreport as a failure. Aggregate risky totals won't
// survive replay — accepted trade-off until PHPUnit grows a
// programmatic risky-marker API.
if ($cached->isRisky()) {
$this->__cachedPass = true;
$this->__ran = true;
return;
}
// Non-success: throw the matching PHPUnit exception. Runner
// catches it and marks the test with the correct status so
// skips, failures, incompletes and todos appear in output
// exactly as they did in the cached run.
if ($cached->isSkipped()) {
$this->markTestSkipped($cached->message());
}
if ($cached->isIncomplete()) {
$this->markTestIncomplete($cached->message());
$this->__ran = true;
}
throw new AssertionFailedError($cached->message() ?: 'Cached failure');
} }
$recorder = Container::getInstance()->get(Recorder::class); $recorder = Container::getInstance()->get(Recorder::class);
assert($recorder instanceof Recorder);
if ($recorder instanceof Recorder && $recorder->isActive()) { if ($recorder->isActive()) {
$recorder->beginTest($this::class, $this->name(), self::$__filename); $recorder->beginTest($this::class, $this->name(), self::$__filename);
} }
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive() $autoloadBeforeSetUp = $recorder->isActive()
? AutoloadEdges::snapshot() ? AutoloadEdges::snapshot()
: []; : [];
parent::setUp(); parent::setUp();
// TIA blade-edge + table-edge recording (Laravel-only). Runs Collectors::armAll($recorder);
// right after `parent::setUp()` so the Laravel app exists and
// the View / DB facades are bound; each arm call is
// idempotent against the current app instance so the 774-test
// suite doesn't stack 774 composers / listeners when Laravel
// keeps the same app across tests.
if ($recorder instanceof Recorder) {
BladeEdges::arm($recorder);
TableTracker::arm($recorder);
InertiaEdges::arm($recorder);
}
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
@ -353,7 +316,7 @@ trait Testable
$this->__callClosure($beforeEach, $arguments); $this->__callClosure($beforeEach, $arguments);
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) { if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
$recorder->linkSourcesForTest( $recorder->linkSourcesForTest(
self::$__filename, self::$__filename,
AutoloadEdges::newProjectFiles( AutoloadEdges::newProjectFiles(
@ -366,6 +329,12 @@ trait Testable
} }
} }
private function __shortCircuitCachedPass(): void
{
$this->__cachedPass = true;
$this->__ran = true;
}
/** /**
* Initialize test case properties from TestSuite. * Initialize test case properties from TestSuite.
*/ */
@ -435,7 +404,7 @@ trait Testable
// accurate on replay instead of collapsing to 1-per-test. // accurate on replay instead of collapsing to 1-per-test.
/** @var Tia $tia */ /** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class); $tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name()); $assertions = $tia->getAssertionCount($this::class.'::'.$this->name());
if ($assertions === 0) { if ($assertions === 0) {
$this->expectNotToPerformAssertions(); $this->expectNotToPerformAssertions();

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts;
/**
* @internal
*/
interface Restarter
{
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void;
}

View File

@ -7,18 +7,12 @@ namespace Pest\Exceptions;
use NunoMaduro\Collision\Contracts\RenderlessEditor; use NunoMaduro\Collision\Contracts\RenderlessEditor;
use NunoMaduro\Collision\Contracts\RenderlessTrace; use NunoMaduro\Collision\Contracts\RenderlessTrace;
use Pest\Contracts\Panicable; use Pest\Contracts\Panicable;
use Pest\Support\View;
use RuntimeException; use RuntimeException;
use Symfony\Component\Console\Exception\ExceptionInterface; use Symfony\Component\Console\Exception\ExceptionInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
/** /**
* Raised when fetching the team-shared TIA baseline hits an error
* that's actionable rather than transient — missing `gh`, broken
* auth, scope/perms misconfiguration, or a CI publish that produced
* an unreadable artifact. Silently falling through to a full record
* would paper over the bug and waste minutes; better to stop, tell
* the user what to fix, and offer the `--fresh` escape hatch.
*
* @internal * @internal
*/ */
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
@ -26,23 +20,35 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt
public function __construct( public function __construct(
private readonly string $headline, private readonly string $headline,
private readonly string $hint, private readonly string $hint,
private readonly bool $hasAnchor = false,
) { ) {
parent::__construct($headline); parent::__construct($headline);
} }
public function render(OutputInterface $output): void public function render(OutputInterface $output): void
{ {
$output->writeln([ View::renderUsing($output);
'',
' <fg=white;options=bold;bg=red> TIA </> '.$this->headline, if (! $this->hasAnchor) {
' <fg=gray>'.$this->hint.'</>', View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
' <fg=gray>Bypass with</> <fg=cyan>--fresh</> <fg=gray>to record locally and skip the baseline fetch.</>', $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 public function exitCode(): int
{ {
return 1; return 1;
} }
private function renderChild(OutputInterface $output, string $text): void
{
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
}
} }

View File

@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
*/ */
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
{ {
/**
* Renders the panic on the given output.
*/
public function render(OutputInterface $output): void public function render(OutputInterface $output): void
{ {
$output->writeln([ $output->writeln([
@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc
]); ]);
} }
/**
* The exit code to be used.
*/
public function exitCode(): int public function exitCode(): int
{ {
return 0; return 0;

View File

@ -44,6 +44,18 @@ final readonly class Kernel
Bootstrappers\BootExcludeList::class, 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<int, class-string<Contracts\Restarter>>
*/
public const array RESTARTERS = [
Restarters\XdebugRestarter::class,
Restarters\PcovRestarter::class,
];
/** /**
* Creates a new Kernel instance. * Creates a new Kernel instance.
*/ */

View File

@ -12,7 +12,9 @@ use PHPUnit\Event\Code\Test;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\Throwable; use PHPUnit\Event\Code\Throwable;
use PHPUnit\Event\Test\AfterLastTestMethodErrored; use PHPUnit\Event\Test\AfterLastTestMethodErrored;
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored; use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
use PHPUnit\Event\Test\ConsideredRisky; use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed; use PHPUnit\Event\Test\Failed;
@ -255,9 +257,11 @@ final readonly class Converter
$numberOfNotPassedTests = count( $numberOfNotPassedTests = count(
array_unique( array_unique(
array_map( 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 if ($event instanceof BeforeFirstTestMethodErrored
|| $event instanceof AfterLastTestMethodErrored) { || $event instanceof AfterLastTestMethodErrored
|| $event instanceof BeforeFirstTestMethodFailed
|| $event instanceof AfterLastTestMethodFailed) {
return $event->testClassName(); return $event->testClassName();
} }

View File

@ -8,6 +8,8 @@ use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable; use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Panic;
use Pest\Plugins\Tia\BaselineSync; use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
@ -20,9 +22,8 @@ use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\Storage; use Pest\Plugins\Tia\Storage;
use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\TableExtractor;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
use Pest\Exceptions\NoAffectedTestsFound;
use Pest\Panic;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
@ -31,11 +32,6 @@ use Symfony\Component\Process\Process;
use Throwable; use Throwable;
/** /**
* Test Impact Analysis plugin — record/replay, parallel-aware.
*
* Must be registered before `Parallel` — Parallel exits on `--parallel`,
* so later plugins never execute.
*
* @internal * @internal
*/ */
final class Tia implements AddsOutput, HandlesArguments, Terminable final class Tia implements AddsOutput, HandlesArguments, Terminable
@ -58,10 +54,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
/** Sentinel dropped by a recording worker without a usable coverage driver. */
private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-'; private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-';
public const string KEY_COVERAGE_CACHE = 'coverage.bin'; public const string KEY_COVERAGE_CACHE = 'coverage.bin.gz';
public const string KEY_COVERAGE_MARKER = 'coverage.marker'; public const string KEY_COVERAGE_MARKER = 'coverage.marker';
@ -71,10 +66,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
/** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */
private const string FILTERED_GLOBAL = 'TIA_FILTERED'; private const string FILTERED_GLOBAL = 'TIA_FILTERED';
/** Workers can't detect `--coverage` from their own argv — paratest strips it. */
private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE';
private bool $graphWritten = false; private bool $graphWritten = false;
@ -97,6 +90,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var array<string, true> */ /** @var array<string, true> */
private array $affectedFiles = []; private array $affectedFiles = [];
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
private ?array $startFingerprint = null;
private function workerEdgesKey(string $token): string private function workerEdgesKey(string $token): string
{ {
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json'; return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
@ -113,10 +109,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private bool $forceRefetch = false; private bool $forceRefetch = false;
/** Prevents fetching the same stale baseline twice after structural drift. */
private bool $baselineFetchAttemptedForDrift = false; private bool $baselineFetchAttemptedForDrift = false;
/** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */
private bool $freshRebuild = false; private bool $freshRebuild = false;
private bool $filteredMode = false; private bool $filteredMode = false;
@ -130,6 +124,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private readonly BaselineSync $baselineSync, 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(' <fg=gray>─ %s</>', $text));
}
/**
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/
private function structuralFingerprintShifted(array $current): bool
{
assert($this->startFingerprint !== null);
return ! Fingerprint::structuralMatches($this->startFingerprint, $current);
}
private function loadGraph(string $projectRoot): ?Graph private function loadGraph(string $projectRoot): ?Graph
{ {
$json = $this->state->read(self::KEY_GRAPH); $json = $this->state->read(self::KEY_GRAPH);
@ -152,7 +166,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->state->write(self::KEY_GRAPH, $json); return $this->state->write(self::KEY_GRAPH, $json);
} }
public function getCachedResult(string $filename, string $testId): ?TestStatus /**
* @param array<int, string> $arguments
*/
public static function isEnabledForRun(array $arguments): bool
{
if (in_array(self::OPTION, $arguments, true)) {
return true;
}
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
assert($watchPatterns instanceof WatchPatterns);
if (! $watchPatterns->isEnabled()) {
return false;
}
return ! ($watchPatterns->isLocally() && in_array('--ci', $arguments, true));
}
public function getStatus(string $filename, string $testId): ?TestStatus
{ {
if (! $this->replayGraph instanceof Graph) { if (! $this->replayGraph instanceof Graph) {
return null; return null;
@ -196,7 +229,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $result; return $result;
} }
public function getCachedAssertions(string $testId): int public function getAssertionCount(string $testId): int
{ {
return $this->cachedAssertionsByTestId[$testId] ?? 0; return $this->cachedAssertionsByTestId[$testId] ?? 0;
} }
@ -210,18 +243,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
/** @var Tia\WatchPatterns $watchPatterns */ /** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class); $watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$cliEnabled = $this->hasArgument(self::OPTION, $arguments); $cliEnabled = $this->hasArgument(self::OPTION, $arguments);
$alwaysEnabled = $watchPatterns->isEnabled() $alwaysEnabled = $watchPatterns->isEnabled()
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL); && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
$enabled = $cliEnabled || $alwaysEnabled; $enabled = $cliEnabled || $alwaysEnabled;
$this->filteredMode = $this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered(); $this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered())
&& ! $this->hasExplicitPathArgument($arguments);
$freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments);
$this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments);
// Always strip TIA-owned flags so they never reach PHPUnit, even when
// TIA is not active for this run.
$arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::OPTION, $arguments);
$arguments = $this->popArgument(self::FRESH_OPTION, $arguments); $arguments = $this->popArgument(self::FRESH_OPTION, $arguments);
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
@ -300,8 +332,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha(); $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 = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setFingerprint($currentFingerprint);
$graph->setRecordedAtSha($this->branch, $currentSha); $graph->setRecordedAtSha($this->branch, $currentSha);
$graph->setLastRunTree( $graph->setLastRunTree(
$this->branch, $this->branch,
@ -319,17 +362,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->seedResultsInto($graph); $this->seedResultsInto($graph);
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.'); $this->renderBadge('ERROR', 'Could not write the dependency graph.');
$recorder->reset(); $recorder->reset();
return; return;
} }
$this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files).',
count($perTest),
));
$recorder->reset(); $recorder->reset();
$this->coverageCollector->reset(); $this->coverageCollector->reset();
} }
@ -370,11 +408,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha(); $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 = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setFingerprint($currentFingerprint);
$graph->setRecordedAtSha($this->branch, $currentSha); $graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot any currently-dirty files so the first replay run
// doesn't mis-report them as changed. See the series record path.
$graph->setLastRunTree( $graph->setLastRunTree(
$this->branch, $this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
@ -449,12 +498,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode; return $exitCode;
} }
$this->output->writeln([ $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.');
' <fg=white;bg=red> ERROR </> TIA recorded zero edges — coverage driver likely missing.',
' Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and retry.',
'',
]);
return $exitCode; return $exitCode;
} }
@ -469,27 +514,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! $this->saveGraph($graph)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph.'); $this->renderBadge('ERROR', 'Could not write the dependency graph.');
return $exitCode; return $exitCode;
} }
$this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials).',
count($finalised),
count($partialKeys),
));
$this->snapshotTestResults(); $this->snapshotTestResults();
return $exitCode; return $exitCode;
} }
/** /**
* Structural drift → discard graph, return null (caller enters record mode).
* Environmental drift → drop results, keep edges, return updated graph.
* Match → return graph unchanged.
*
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current * @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
*/ */
private function reconcileFingerprint(Graph $graph, array $current): ?Graph private function reconcileFingerprint(Graph $graph, array $current): ?Graph
@ -499,8 +534,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (! Fingerprint::structuralMatches($stored, $current)) { if (! Fingerprint::structuralMatches($stored, $current)) {
$drift = Fingerprint::structuralDrift($stored, $current); $drift = Fingerprint::structuralDrift($stored, $current);
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=yellow>TIA</> graph structure outdated (%s).', 'Graph structure outdated (%s).',
$this->formatStructuralDrift($drift), $this->formatStructuralDrift($drift),
)); ));
@ -512,7 +547,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$branchSha, $branchSha,
); );
if ($summary !== '') { if ($summary !== '') {
$this->output->writeln(' <fg=gray>'.$summary.'</>'); $this->renderChild($summary);
} }
} }
} }
@ -523,8 +558,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $this->reconcileFingerprint($rebuilt, $current); return $this->reconcileFingerprint($rebuilt, $current);
} }
$this->output->writeln(' <fg=yellow>TIA</> rebuilding graph from scratch.');
$this->state->delete(self::KEY_GRAPH); $this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE); $this->state->delete(self::KEY_COVERAGE_CACHE);
@ -534,8 +567,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$drift = Fingerprint::environmentalDrift($stored, $current); $drift = Fingerprint::environmentalDrift($stored, $current);
if ($drift !== []) { if ($drift !== []) {
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> env differs from baseline (%s) — results dropped, edges reused.', 'Env differs from baseline (%s) — results dropped, edges reused.',
implode(', ', $drift), implode(', ', $drift),
)); ));
@ -558,20 +591,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$fingerprint = Fingerprint::compute($projectRoot); $fingerprint = Fingerprint::compute($projectRoot);
$this->startFingerprint = $fingerprint;
// `--fresh` is meant to be a clean slate: nuke the entire per-project
// state dir up front (graph, baseline, worker partials, fingerprint,
// JS module cache, coverage marker, etc.). Wiping per-key in code
// would leave room for stale entries we forgot about — most
// recently, status-7/8 result entries with no `file` that survived
// a rebuild and kept tripping `hasUnlocatedFailuresOrErrors()` on
// subsequent `--filtered` runs. Safe here because `handleParent`
// runs in the parent before any worker is spawned.
if ($forceRebuild) { if ($forceRebuild) {
Storage::purge($projectRoot); Storage::purge($projectRoot);
} }
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot); $graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot);
if ($graph instanceof Graph) { if ($graph instanceof Graph) {
$graph = $this->reconcileFingerprint($graph, $fingerprint); $graph = $this->reconcileFingerprint($graph, $fingerprint);
@ -584,17 +610,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($changedFiles->gitAvailable() if ($changedFiles->gitAvailable()
&& $branchSha !== null && $branchSha !== null
&& $changedFiles->since($branchSha) === null) { && $changedFiles->since($branchSha) === null) {
$this->output->writeln( $this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.',
);
$graph = null; $graph = null;
} }
} }
// No local graph and not being forced to rebuild from scratch: try
// to pull a team-shared baseline so fresh checkouts (new devs, CI
// containers) don't pay the full record cost. If the pull succeeds
// the graph is re-read and reconciled against the local env.
if (! $graph instanceof Graph if (! $graph instanceof Graph
&& ! $forceRebuild && ! $forceRebuild
&& ! $this->baselineFetchAttemptedForDrift && ! $this->baselineFetchAttemptedForDrift
@ -610,17 +630,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write(self::KEY_COVERAGE_MARKER, ''); $this->state->write(self::KEY_COVERAGE_MARKER, '');
} }
// Kick off the JS module graph resolver in the background so it
// runs in parallel with the test suite. By the time the flush
// path calls `JsModuleGraph::build()`, the result is usually
// already on stdout and `wait()` returns instantly. Cheap when
// the cache is fresh — the warmer fingerprint-checks first and
// skips spawning Node entirely.
if (! Parallel::isWorker() && JsModuleGraph::isApplicable($projectRoot)) {
JsModuleGraph::warmInBackground($projectRoot);
}
// First `--tia --coverage` run: no cache to merge against yet, must record the full suite.
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
return $this->enterRecordMode($arguments); return $this->enterRecordMode($arguments);
} }
@ -716,12 +725,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* During replay, affected tests execute normally. If a coverage driver is
* available, record those executions too so refactors that introduce new
* dependencies update the graph without requiring a full `--fresh` run.
* Cached tests short-circuit before `Recorder::beginTest()`, so they don't
* produce empty replacement edges.
*
* @param array<int, string> $arguments * @param array<int, string> $arguments
* @return array<int, string> * @return array<int, string>
*/ */
@ -759,9 +762,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
if (! $changedFiles->gitAvailable()) { if (! $changedFiles->gitAvailable()) {
$this->output->writeln( $this->renderBadge('WARN', 'Git unavailable — running full suite.');
' <fg=yellow>TIA</> git unavailable — running full suite.',
);
return $arguments; return $arguments;
} }
@ -772,19 +773,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changed = $changedFiles->filterUnchangedSinceLastRun( $changed = $changedFiles->filterUnchangedSinceLastRun(
$changed, $changed,
$graph->lastRunTree($this->branch), $graph->lastRunTree($this->branch),
$branchSha,
); );
$hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed); $hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed);
$coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable(); $coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) { if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
$this->output->writeln([ $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.');
' <fg=black;bg=yellow> WARNING </> TIA detected PHP source changes but no coverage driver is available.', $this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
' Running the full suite to avoid using a stale dependency graph. Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) so TIA can safely refresh edges after PHP refactors.',
'',
]);
return $arguments; return $arguments;
} }
@ -793,12 +790,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$failedFromCache = []; $failedFromCache = [];
if ($this->filteredMode) { if ($this->filteredMode) {
// `failedOrErroredTestFiles()` only yields failures that have a
// mapped file — the snapshot path now reflects on the class
// when the collector loses the path, so an unlocated failure
// is no longer expected. If one slips through, doing the best
// we can with the located ones is strictly better than bailing
// to a full suite.
$failedFromCache = $graph->failedOrErroredTestFiles($this->branch); $failedFromCache = $graph->failedOrErroredTestFiles($this->branch);
} }
@ -838,9 +829,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! $this->persistAffectedSet($affected)) { if (! $this->persistAffectedSet($affected)) {
$this->output->writeln( $this->renderBadge('ERROR', 'Could not persist affected set — running full suite.');
' <fg=red>TIA</> failed to persist affected set — running full suite.',
);
return $arguments; return $arguments;
} }
@ -861,17 +850,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* Surfaces what TIA decided to run and why, before the suite
* starts. Two pieces a developer wants at a glance:
*
* 1. *How many* tests are about to run — the deciding factor for
* whether they wait for the run or kick off something else.
* 2. *Why* — which changed files drove the affected set, and how
* many came in via cached failures (filtered mode).
*
* Stays quiet when nothing is affected: the existing
* `NoAffectedTestsFound` panic / recap line covers that path.
*
* @param array<int, string> $changedFiles * @param array<int, string> $changedFiles
* @param array<int, string> $affectedFromChanges * @param array<int, string> $affectedFromChanges
* @param array<int, string> $failedFromCache * @param array<int, string> $failedFromCache
@ -879,13 +857,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void private function reportAffectedSummary(array $changedFiles, array $affectedFromChanges, array $failedFromCache, array $affected): void
{ {
$this->output->writeln('');
if ($affected === []) { if ($affected === []) {
$this->renderChild('TIA mode enabled.');
return; return;
} }
// Failures that overlap with the change-driven set are already
// pulled in by edges — don't double-count them as a separate
// reason in the breakdown.
$newFailures = $failedFromCache === [] $newFailures = $failedFromCache === []
? 0 ? 0
: count(array_diff($failedFromCache, $affectedFromChanges)); : count(array_diff($failedFromCache, $affectedFromChanges));
@ -922,32 +901,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
); );
} }
$this->output->writeln(sprintf( $this->renderChild(sprintf(
' <fg=cyan>TIA</> %d affected test file%s%s.', 'TIA mode enabled / %d affected test file%s%s.',
count($affected), count($affected),
count($affected) === 1 ? '' : 's', count($affected) === 1 ? '' : 's',
$reasons === [] ? '' : ' ('.implode(', ', $reasons).')', $reasons === [] ? '' : ' ('.implode(', ', $reasons).')',
)); ));
// List the first few affected test files so the developer can see
// *which* tests are about to run, not just the count. Capped at 10
// to keep the line tight on large impact sets.
$previewLimit = 10;
$sorted = $affected; $sorted = $affected;
sort($sorted); sort($sorted);
$previewLimit = $this->output->isVerbose() ? count($sorted) : 10;
$preview = array_slice($sorted, 0, $previewLimit); $preview = array_slice($sorted, 0, $previewLimit);
foreach ($preview as $file) { foreach ($preview as $file) {
$this->output->writeln(sprintf(' <fg=gray>%s</>', $file)); $this->output->writeln(sprintf(' <fg=gray>%s</>', $file));
} }
$remainder = count($sorted) - count($preview); $remainder = count($sorted) - count($preview);
if ($remainder > 0) { if ($remainder > 0) {
$this->output->writeln(sprintf( $this->output->writeln(sprintf(' <fg=gray>… +%d more</>', $remainder));
' <fg=gray> … +%d more</>',
$remainder,
));
} }
} }
@ -988,11 +962,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1'); Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
} }
$this->output->writeln($this->piggybackCoverage $this->output->writeln('');
? ' <fg=cyan>TIA</> recording dependency graph in parallel via `--coverage` (first run) — '. $this->renderChild('TIA mode enabled / fresh graph.');
'subsequent `--tia` runs will only re-execute affected tests.'
: ' <fg=cyan>TIA</> recording dependency graph in parallel (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.');
return $arguments; return $arguments;
} }
@ -1000,10 +971,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($this->piggybackCoverage) { if ($this->piggybackCoverage) {
$this->recordingActive = true; $this->recordingActive = true;
$this->output->writeln( $this->output->writeln('');
' <fg=cyan>TIA</> recording dependency graph via `--coverage` (first run) — '. $this->renderChild('TIA mode enabled / fresh graph.');
'subsequent `--tia` runs will only re-execute affected tests.',
);
return $arguments; return $arguments;
} }
@ -1011,25 +980,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recorder->activate(); $recorder->activate();
$this->recordingActive = true; $this->recordingActive = true;
$this->output->writeln(sprintf( $this->renderChild('Running in TIA mode.');
' <fg=cyan>TIA</> recording dependency graph via %s (first run) — '.
'subsequent `--tia` runs will only re-execute affected tests.',
$recorder->driver(),
));
return $arguments; return $arguments;
} }
private function emitCoverageDriverMissing(): void private function emitCoverageDriverMissing(): void
{ {
$this->output->writeln([ $this->renderBadge('WARN', 'No coverage driver is available — skipped.');
'', $this->renderChild('Needs ext-pcov or Xdebug with coverage mode enabled to record the dependency graph.');
' <fg=black;bg=yellow> WARNING </> No coverage driver is available — TIA skipped.', $this->renderChild('Install or enable one and rerun with --tia.');
'',
' TIA needs <fg=cyan>ext-pcov</> or <fg=cyan>Xdebug</> with <fg=cyan>coverage</> mode enabled to record',
' the dependency graph. Install or enable one and rerun with `--tia`.',
'',
]);
} }
/** /**
@ -1052,6 +1012,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write($this->workerEdgesKey($this->workerToken()), $json); $this->state->write($this->workerEdgesKey($this->workerToken()), $json);
} }
/**
* @return list<string>
*/
private function collectWorkerEdgesPartials(): array private function collectWorkerEdgesPartials(): array
{ {
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
@ -1069,11 +1032,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->delete($key); $this->state->delete($key);
} }
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> %d worker(s) had no coverage driver — their per-test edges and results were dropped. ' '%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
.'Install / enable <fg=cyan>pcov</> or <fg=cyan>xdebug</> (mode: coverage) in the worker PHP and rerun.',
count($keys), count($keys),
)); ));
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
} }
private function purgeWorkerPartials(): void private function purgeWorkerPartials(): void
@ -1111,6 +1074,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->state->write($this->workerResultsKey($this->workerToken()), $json); $this->state->write($this->workerResultsKey($this->workerToken()), $json);
} }
/**
* @return list<string>
*/
private function collectWorkerReplayPartials(): array private function collectWorkerReplayPartials(): array
{ {
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX); return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
@ -1252,8 +1218,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function registerRecap(): void private function registerRecap(): void
{ {
DefaultPrinter::addRecap(function (): string { DefaultPrinter::addRecap(function (): string {
// mergeWorkerReplayPartials fires before addOutput on --parallel, which is intentional:
// partial keys are deleted on read so the later addOutput call becomes a no-op.
if (Parallel::isEnabled() && ! Parallel::isWorker()) { if (Parallel::isEnabled() && ! Parallel::isWorker()) {
$this->mergeWorkerReplayPartials(); $this->mergeWorkerReplayPartials();
} }
@ -1343,15 +1307,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
foreach ($results as $testId => $result) { foreach ($results as $testId => $result) {
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
// The collector occasionally hands us nothing usable: PHPUnit's if ($file === null || str_contains($file, "eval()'d")) {
// Prepared event can miss the file for Pest-generated classes, $file = $this->resolveFailedTestFile($testId);
// and an eval'd class path (".../IndexTest.php(1) : eval()'d code")
// would be rejected later by Graph::relative(). Recover the real
// path from the class embedded in the test ID — without it,
// filtered runs lose the ability to re-run only the failing test
// next time.
if ($file === null || (is_string($file) && str_contains($file, "eval()'d"))) {
$file = self::resolveFailedTestFile($testId);
} }
$graph->setResult( $graph->setResult(
@ -1369,35 +1326,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$collector->reset(); $collector->reset();
} }
/** private function resolveFailedTestFile(string $testId): ?string
* Resolves the source file for a Pest-generated test class.
*
* Pest synthesises a per-test class via `eval()` and writes the
* original test file path to a `private static $__filename` property
* (see `src/Factories/TestCaseFactory.php`). Reflecting on the class
* with `getFileName()` would return the eval'd location, which
* `Graph::relative()` rejects — losing the file mapping.
*
* Strategy:
* 1. Read the `__filename` static if the class declares it (Pest
* tests).
* 2. Otherwise use `getFileName()` and skip eval'd frames by
* walking up the parent class chain — a plain PHPUnit test
* lives in a real file at the top of that chain.
*/
private static function resolveFailedTestFile(string $testId): ?string
{ {
$class = strstr($testId, '::', true); $class = strstr($testId, '::', true);
if (! is_string($class) || $class === '') { if (! is_string($class) || $class === '' || ! class_exists($class)) {
return null; return null;
} }
try { $reflection = new \ReflectionClass($class);
$reflection = new \ReflectionClass($class);
} catch (\ReflectionException) {
return null;
}
if ($reflection->hasProperty('__filename')) { if ($reflection->hasProperty('__filename')) {
try { try {
@ -1416,7 +1353,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
while ($current !== false) { while ($current !== false) {
$file = $current->getFileName(); $file = $current->getFileName();
if (is_string($file) && $file !== '' && ! str_contains($file, "eval()'d")) { if ($file !== false && ! str_contains($file, "eval()'d")) {
return $file; return $file;
} }
@ -1439,20 +1376,82 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
/** /**
* PHP source changes can introduce new dependencies. Without a coverage * @param array<int, string> $arguments
* driver, replay can run the currently affected tests but cannot refresh */
* the graph, so a later edit to the newly introduced dependency could be private function hasExplicitPathArgument(array $arguments): bool
* missed. Treat those runs as full-suite unless coverage can self-heal. {
* static $valueTakingFlags = [
'-c', '--configuration', '--bootstrap', '--cache-directory',
'--filter', '--group', '--exclude-group', '--covers', '--uses',
'--test-suffix', '--testsuite', '--exclude-testsuite',
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
'--include-path', '--whitelist',
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
'--coverage-filter', '--path-coverage',
'--repeat', '--retry-times', '--memory-limit', '--seed',
'--compact', '--ci-build-id', '--min',
];
$projectRoot = TestSuite::getInstance()->rootPath;
$testPaths = \Pest\Plugins\Tia\SourceScope::testPaths($projectRoot);
if ($testPaths === []) {
return false;
}
foreach ($arguments as $index => $arg) {
if ($arg === '' || str_starts_with($arg, '-')) {
continue;
}
if ($index > 0) {
$previous = $arguments[$index - 1] ?? '';
if (in_array($previous, $valueTakingFlags, 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<int, string> $changedFiles * @param array<int, string> $changedFiles
*/ */
private function hasProjectPhpSourceChanges(array $changedFiles): bool private function hasProjectPhpSourceChanges(array $changedFiles): bool
{ {
foreach ($changedFiles as $rel) { foreach ($changedFiles as $rel) {
if (! is_string($rel)) {
continue;
}
if (! str_ends_with($rel, '.php')) { if (! str_ends_with($rel, '.php')) {
continue; continue;
} }
@ -1460,11 +1459,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if (str_ends_with($rel, '.blade.php')) { if (str_ends_with($rel, '.blade.php')) {
continue; continue;
} }
if (str_starts_with($rel, 'tests/')) {
if (str_starts_with($rel, 'tests/') continue;
|| str_starts_with($rel, 'vendor/') }
|| str_starts_with($rel, 'storage/framework/') if (str_starts_with($rel, 'vendor/')) {
|| str_starts_with($rel, 'bootstrap/cache/')) { continue;
}
if (str_starts_with($rel, 'storage/framework/')) {
continue;
}
if (str_starts_with($rel, 'bootstrap/cache/')) {
continue; continue;
} }
@ -1490,7 +1494,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$this->baselineFetchAttemptedForDrift = true; $this->baselineFetchAttemptedForDrift = true;
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) { if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
return null; return null;
} }
@ -1501,16 +1505,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) { if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
$this->output->writeln( $this->renderBadge('WARN', 'Fetched baseline still drifts — discarding.');
' <fg=yellow>TIA</> fetched baseline still drifts — discarding.',
);
return null; return null;
} }
$this->output->writeln( $this->renderBadge('SUCCESS', 'Fetched baseline matches — skipping local rebuild.');
' <fg=green>TIA</> fetched baseline matches — skipping local rebuild.',
);
return $fetched; return $fetched;
} }

View File

@ -9,17 +9,11 @@ use Pest\Exceptions\BaselineFetchFailed;
use Pest\Panic; use Pest\Panic;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\View;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
* Downloads a team-shared TIA baseline from GitHub workflow artifacts so new contributors and
* fresh CI workspaces start in replay mode. Artifacts are used instead of releases because they
* produce no tag (no push cascade), support tunable retention, and can only be published by CI.
*
* Fingerprint validation happens in `Tia::handleParent` after the blobs land; a mismatched
* environment falls through to the normal record path.
*
* @internal * @internal
*/ */
final readonly class BaselineSync final readonly class BaselineSync
@ -32,20 +26,10 @@ final readonly class BaselineSync
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
// Subdirectory under the per-project state dir (`~/.pest/tia/<project>/`)
// where artifacts from previous downloads are kept (one subfolder per
// workflow run id). Hitting the same run id on a later fetch skips
// the `gh run download` round trip entirely — artifacts are immutable
// per run id, so the cached bytes are exactly what gh would re-download.
private const string DOWNLOAD_CACHE_DIR = 'artifacts'; private const string DOWNLOAD_CACHE_DIR = 'artifacts';
// Most recently downloaded artifacts to retain on disk. Branch
// switches and partial baseline rollouts hop across run ids — keeping
// the last few avoids re-downloading when the user toggles between
// them. Older entries get evicted on the next download.
private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5; private const int DOWNLOAD_CACHE_MAX_ENTRIES = 5;
// 24 h cooldown after a failed fetch so repeated `pest --tia` calls don't re-hit `gh run list`.
private const int FETCH_COOLDOWN_SECONDS = 86400; private const int FETCH_COOLDOWN_SECONDS = 86400;
public function __construct( public function __construct(
@ -53,7 +37,17 @@ final readonly class BaselineSync
private OutputInterface $output, private OutputInterface $output,
) {} ) {}
public function fetchIfAvailable(string $projectRoot, bool $force = false): bool 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(' <fg=gray>─ %s</>', $text));
}
public function fetchIfAvailable(string $projectRoot, bool $force = false, bool $hasAnchor = false): bool
{ {
$repo = $this->detectGitHubRepo($projectRoot); $repo = $this->detectGitHubRepo($projectRoot);
@ -62,9 +56,8 @@ final readonly class BaselineSync
} }
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
$this->output->writeln(sprintf( $this->renderBadge('WARN', sprintf(
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. ' 'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
.'Override with <fg=cyan>--refetch</>.',
$this->formatDuration($remaining), $this->formatDuration($remaining),
)); ));
@ -72,14 +65,9 @@ final readonly class BaselineSync
} }
$failureKind = null; $failureKind = null;
$payload = $this->download($repo, $projectRoot, $failureKind); $payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
if ($payload === null) { if ($payload === null) {
// Genuine "no baseline published yet" → cool down and show
// the publish-instructions YAML so the user can wire CI.
// Anything else (missing gh, auth, network, mid-download
// error) is transient and gets a one-line diagnostic
// instead — no cooldown, no noisy YAML.
if ($failureKind === 'no-runs' || $failureKind === null) { if ($failureKind === 'no-runs' || $failureKind === null) {
$this->startCooldown(); $this->startCooldown();
$this->emitPublishInstructions($repo); $this->emitPublishInstructions($repo);
@ -98,9 +86,9 @@ final readonly class BaselineSync
$this->clearCooldown(); $this->clearCooldown();
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=green>TIA</> baseline ready (%s).', 'Baseline ready (%s).',
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), $this->formatSize($payload['sizeOnDisk']),
)); ));
return true; return true;
@ -153,9 +141,7 @@ final readonly class BaselineSync
private function emitPublishInstructions(string $repo): void private function emitPublishInstructions(string $repo): void
{ {
if ($this->isCi()) { if ($this->isCi()) {
$this->output->writeln( $this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
);
return; return;
} }
@ -164,31 +150,21 @@ final readonly class BaselineSync
? $this->laravelWorkflowYaml() ? $this->laravelWorkflowYaml()
: $this->genericWorkflowYaml(); : $this->genericWorkflowYaml();
$preamble = [ $this->renderBadge('WARN', 'No baseline published yet — recording locally.');
' <fg=yellow>TIA</> no baseline published yet — recording locally.', $this->renderChild('To share the baseline with your team, add this workflow to the repo:');
'', $this->renderChild('.github/workflows/tia-baseline.yml');
' To share the baseline with your team, add this workflow to the repo:',
'',
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
'',
];
$indentedYaml = array_map( $indentedYaml = array_map(
static fn (string $line): string => ' '.$line, static fn (string $line): string => ' '.$line,
explode("\n", $yaml), explode("\n", $yaml),
); );
$trailer = [ $this->output->writeln(['', ...$indentedYaml, '']);
'',
sprintf(' Commit, push, then run once: <fg=cyan>gh workflow run tia-baseline.yml -R %s</>', $repo),
' Details: <fg=gray>https://pestphp.com/docs/tia/ci</>',
'',
];
$this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]); $this->renderChild(sprintf('Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo));
$this->renderChild('Details: https://pestphp.com/docs/tia/ci');
} }
// `CI=true` alone is ambiguous (users set it locally) — require a provider-specific env var.
private function isCi(): bool private function isCi(): bool
{ {
return getenv('GITHUB_ACTIONS') === 'true' return getenv('GITHUB_ACTIONS') === 'true'
@ -309,7 +285,7 @@ YAML;
* *
* @return array{graph: string, coverage: ?string}|null * @return array{graph: string, coverage: ?string}|null
*/ */
private function download(string $repo, string $projectRoot, ?string &$failureKind = null): ?array private function download(string $repo, string $projectRoot, ?string &$failureKind = null, bool $hasAnchor = false): ?array
{ {
$failureKind = null; $failureKind = null;
@ -317,6 +293,7 @@ YAML;
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) not found — cannot fetch baseline.', 'GitHub CLI (gh) not found — cannot fetch baseline.',
'Install it from https://cli.github.com.', 'Install it from https://cli.github.com.',
$hasAnchor,
)); ));
} }
@ -324,6 +301,7 @@ YAML;
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.', 'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
'Run `gh auth login` and retry.', 'Run `gh auth login` and retry.',
$hasAnchor,
)); ));
} }
@ -332,20 +310,16 @@ YAML;
if ($listError !== null) { if ($listError !== null) {
$failureKind = $listError['kind']; $failureKind = $listError['kind'];
// Tier 1 — actionable misconfiguration. Stop the suite and
// tell the user what to fix; a silent fall-through to a
// full record would just paper over the bug.
if (in_array($failureKind, ['forbidden', 'not-found'], true)) { if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
sprintf('Failed to query baseline runs — %s', $listError['message']), sprintf('Failed to query baseline runs — %s', $listError['message']),
'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.', 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
)); ));
} }
// Tier 2 — transient (network, rate-limit, unknown). Surface $this->renderBadge('WARN', sprintf(
// the diagnostic but let the suite fall through to record mode. 'Failed to query baseline runs — %s',
$this->output->writeln(sprintf(
' <fg=yellow>TIA</> failed to query baseline runs — %s',
$listError['message'], $listError['message'],
)); ));
@ -353,7 +327,6 @@ YAML;
} }
if ($runId === null) { if ($runId === null) {
// Genuine missing baseline — caller emits publish instructions.
$failureKind = 'no-runs'; $failureKind = 'no-runs';
return null; return null;
@ -361,16 +334,11 @@ YAML;
$runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId); $runCacheDir = $this->downloadCacheDir($projectRoot).DIRECTORY_SEPARATOR.$this->safeRunId($runId);
// Cache hit: a previous fetch already extracted this run id's
// artifact into the run-specific dir. Read the assets straight
// out of it and skip `gh run download` entirely.
if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) { if (is_file($runCacheDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET)) {
// Bump the dir mtime so trimDownloadCache() treats this run
// id as recently used and doesn't evict it later.
@touch($runCacheDir); @touch($runCacheDir);
$this->output->writeln(sprintf( $this->renderBadge('INFO', sprintf(
' <fg=cyan>TIA</> using cached baseline from <fg=white>%s</> (run %s).', 'Using cached baseline from %s (run %s).',
$repo, $repo,
$runId, $runId,
)); ));
@ -384,14 +352,14 @@ YAML;
$artifactSize = $this->artifactSize($repo, $runId); $artifactSize = $this->artifactSize($repo, $runId);
$this->output->writeln($artifactSize !== null $this->renderBadge('INFO', $artifactSize !== null
? sprintf( ? sprintf(
' <fg=cyan>TIA</> fetching baseline (%s) from <fg=white>%s</>…', 'Fetching baseline (%s) from %s…',
$this->formatSize($artifactSize), $this->formatSize($artifactSize),
$repo, $repo,
) )
: sprintf( : sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…', 'Fetching baseline from %s…',
$repo, $repo,
)); ));
@ -420,17 +388,16 @@ YAML;
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput()); $diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
$failureKind = $diagnosis['kind']; $failureKind = $diagnosis['kind'];
// Tier 1 — actionable. Stop hard with a clear diagnostic.
if (in_array($failureKind, ['forbidden', 'not-found'], true)) { if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
sprintf('Baseline download failed — %s', $diagnosis['message']), sprintf('Baseline download failed — %s', $diagnosis['message']),
'Check the workflow file name (tia-baseline.yml), artifact name (pest-tia-baseline), and your gh token scope.', 'Verify workflow tia-baseline.yml, artifact pest-tia-baseline, and gh token scope.',
$hasAnchor,
)); ));
} }
// Tier 2 — transient. Diagnostic + fall through to record mode. $this->renderBadge('WARN', sprintf(
$this->output->writeln(sprintf( 'Baseline download failed — %s',
' <fg=yellow>TIA</> baseline download failed — %s',
$diagnosis['message'], $diagnosis['message'],
)); ));
@ -442,12 +409,10 @@ YAML;
if ($payload === null) { if ($payload === null) {
$this->cleanup($runCacheDir); $this->cleanup($runCacheDir);
// Artifact present but malformed — CI's publish step is
// broken. Falling through would silently waste the next
// run; surface the bug instead.
Panic::with(new BaselineFetchFailed( Panic::with(new BaselineFetchFailed(
'Baseline downloaded but the artifact is missing expected files (graph.json).', '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.', 'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
$hasAnchor,
)); ));
} }
@ -456,18 +421,13 @@ YAML;
return $payload; return $payload;
} }
/**
* Looks up the artifact's compressed size so the progress bar has a
* denominator. Returns null on any failure — callers fall back to a
* size-less spinner.
*/
private function artifactSize(string $repo, string $runId): ?int private function artifactSize(string $repo, string $runId): ?int
{ {
$process = new Process([ $process = new Process([
'gh', 'api', 'gh', 'api',
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId), sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
'--jq', sprintf( '--jq', sprintf(
'.artifacts[] | select(.name == "%s") | .size_in_bytes', '.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
self::ARTIFACT_NAME, self::ARTIFACT_NAME,
), ),
]); ]);
@ -490,14 +450,9 @@ YAML;
$speed = (int) ($current / $elapsed); $speed = (int) ($current / $elapsed);
if ($totalBytes !== null && $totalBytes > 0) { if ($totalBytes !== null && $totalBytes > 0) {
// gh extracts as it downloads, so disk size can briefly exceed
// the compressed `size_in_bytes` for multi-file artifacts. Cap
// the percentage at 99% until the process actually exits — the
// cleared line + completion message take care of the final
// "100%" message naturally.
$percent = min(99, (int) floor(($current / $totalBytes) * 100)); $percent = min(99, (int) floor(($current / $totalBytes) * 100));
$message = sprintf( $message = sprintf(
' <fg=cyan>TIA</> downloading %s / %s (%d%%, %s/s)', ' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
$this->formatSize($current), $this->formatSize($current),
$this->formatSize($totalBytes), $this->formatSize($totalBytes),
$percent, $percent,
@ -505,14 +460,12 @@ YAML;
); );
} else { } else {
$message = sprintf( $message = sprintf(
' <fg=cyan>TIA</> downloading %s (%s/s)', ' <fg=cyan>Downloading</> %s (%s/s)',
$this->formatSize($current), $this->formatSize($current),
$this->formatSize($speed), $this->formatSize($speed),
); );
} }
// \r returns to start of line, \033[K erases from cursor to end —
// safe regardless of message length, no ANSI-aware padding needed.
$this->output->write("\r\033[K".$message); $this->output->write("\r\033[K".$message);
} }
@ -544,7 +497,7 @@ YAML;
} }
/** /**
* @return array{graph: string, coverage: ?string}|null * @return array{graph: string, coverage: ?string, sizeOnDisk: int}|null
*/ */
private function readArtifact(string $dir): ?array private function readArtifact(string $dir): ?array
{ {
@ -562,6 +515,7 @@ YAML;
return [ return [
'graph' => $graph, 'graph' => $graph,
'coverage' => $coverage === false ? null : $coverage, 'coverage' => $coverage === false ? null : $coverage,
'sizeOnDisk' => $this->dirSize($dir),
]; ];
} }
@ -570,11 +524,6 @@ YAML;
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR; return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.self::DOWNLOAD_CACHE_DIR;
} }
/**
* Run ids returned by `gh` are numeric strings, but defend against a
* surprising response by stripping anything non-alphanumeric — the
* value is used as a directory name.
*/
private function safeRunId(string $runId): string private function safeRunId(string $runId): string
{ {
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? ''; $sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
@ -582,13 +531,6 @@ YAML;
return $sanitised === '' ? 'unknown' : $sanitised; return $sanitised === '' ? 'unknown' : $sanitised;
} }
/**
* Keep the N most recently used cached artifacts and evict the rest.
* Recency is taken from the directory mtime — `mkdir`/`gh run download`
* stamps it on a fresh entry, and a cache hit `touch`es it back to
* the front of the line, so a frequently-reused run id won't be
* evicted just because newer ids have been seen between uses.
*/
private function trimDownloadCache(string $projectRoot): void private function trimDownloadCache(string $projectRoot): void
{ {
$root = $this->downloadCacheDir($projectRoot); $root = $this->downloadCacheDir($projectRoot);
@ -606,10 +548,12 @@ YAML;
$candidates = []; $candidates = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
$path = $root.DIRECTORY_SEPARATOR.$entry; $path = $root.DIRECTORY_SEPARATOR.$entry;
if (! is_dir($path)) { if (! is_dir($path)) {
@ -635,12 +579,6 @@ YAML;
} }
/** /**
* Returns `[runId|null, errorOrNull]`. Distinguishes "no runs yet"
* (runId null, error null) from "couldn't ask GitHub" (error
* populated with kind + message). Lets the caller pick between
* showing publish instructions and emitting a transient-failure
* diagnostic.
*
* @return array{0: ?string, 1: ?array{kind: string, message: string}} * @return array{0: ?string, 1: ?array{kind: string, message: string}}
*/ */
private function latestSuccessfulRunIdWithError(string $repo): array private function latestSuccessfulRunIdWithError(string $repo): array
@ -676,10 +614,6 @@ YAML;
} }
/** /**
* Maps a chunk of `gh` stderr/stdout to a coarse kind + a short,
* actionable message. Falls back to the first non-empty line of
* the output so even unrecognised errors aren't reduced to "unknown".
*
* @return array{kind: string, message: string} * @return array{kind: string, message: string}
*/ */
private function classifyGhError(string $output): array private function classifyGhError(string $output): array
@ -725,10 +659,7 @@ YAML;
]; ];
} }
// Unknown — surface the first informative line so the user has $message = trim(strtok($output, "\n"));
// *something* to act on.
$first = strtok($output, "\n");
$message = is_string($first) ? trim($first) : 'unknown error';
return ['kind' => 'unknown', 'message' => $message]; return ['kind' => 'unknown', 'message' => $message];
} }

View File

@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Laravel-only collaborator: during record mode, attributes every
* rendered Blade view to the currently-running test.
*
* Why this exists: the coverage driver only sees compiled view files
* under `storage/framework/views/<hash>.php`, not the `.blade.php`
* source. Without a dedicated hook TIA has no edges for blade files,
* so it leans on the Laravel WatchDefault's broad "any .blade.php
* change → every feature test" fallback. Safe but noisy — editing a
* single partial re-runs the whole suite.
*
* With this armed at record time, each test's edge set grows to
* include the precise `.blade.php` files it rendered (directly or
* through `@include`, layouts, components, Livewire, Inertia root
* views — anything that goes through Laravel's view factory fires
* `View::composer('*')`). Replay then invalidates exactly the tests
* that rendered the changed template.
*
* Implementation note: everything Laravel-touching goes through
* string class names, `class_exists`, and `method_exists` so Pest
* core doesn't pull `illuminate/container` into its `require`.
*
* @internal
*/
final class BladeEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* App-scoped marker that makes `arm()` idempotent. Tests call it
* from every `setUp()`, and Laravel reuses the same app instance
* across tests in most configurations — without this guard we'd
* stack one composer per test and replay every one of them on
* every view render.
*/
private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->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('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);
}
});
}
}

View File

@ -10,17 +10,6 @@ use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
* Plugin-level container registrations for TIA. Runs as part of Kernel's
* bootstrapper chain so Tia's own service graph is set up without Kernel
* having to know about any of its internals.
*
* Most Tia services (`Recorder`, `CoverageCollector`, `WatchPatterns`,
* `ResultCollector`, `BaselineSync`) are auto-buildable — Pest's container
* resolves them lazily via constructor reflection. The only service that
* requires an explicit binding is the `State` contract, because the
* filesystem implementation needs a root-directory string that reflection
* can't infer.
*
* @internal * @internal
*/ */
final readonly class Bootstrapper implements BootstrapperContract final readonly class Bootstrapper implements BootstrapperContract
@ -33,11 +22,7 @@ final readonly class Bootstrapper implements BootstrapperContract
} }
/** /**
* TIA's per-project state directory. Default layout is
* `~/.pest/tia/<project-key>/` so the graph survives `composer
* install`, stays out of the project tree, and is naturally shared
* across worktrees of the same repo. See {@see Storage} for the key * across worktrees of the same repo. See {@see Storage} for the key
* derivation and the home-dir-missing fallback.
*/ */
private function tempDir(): string private function tempDir(): string
{ {

View File

@ -7,19 +7,6 @@ namespace Pest\Plugins\Tia;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
* Detects files that changed between the last recorded TIA run and the
* current working tree.
*
* Strategy:
* 1. If we have a `recordedAtSha`, `git diff <sha>..HEAD` captures committed
* changes on top of the recording point.
* 2. `git status --short` captures unstaged + staged + untracked changes on
* top of that.
*
* We return relative paths to the project root. Deletions are included so the
* caller can decide whether to invalidate: a deleted source file may still
* appear in the graph and should mark its dependents as affected.
*
* @internal * @internal
*/ */
final readonly class ChangedFiles final readonly class ChangedFiles
@ -31,13 +18,12 @@ final readonly class ChangedFiles
* @param array<string, string> $lastRunTree path → content hash from last run. * @param array<string, string> $lastRunTree path → content hash from last run.
* @return array<int, string> * @return array<int, string>
*/ */
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{ {
if ($lastRunTree === []) { if ($lastRunTree === []) {
return $files; return $files;
} }
// Union with last-run snapshot: catches reverts that git reports clean but are new vs the snapshot.
$candidates = array_fill_keys($files, true); $candidates = array_fill_keys($files, true);
foreach (array_keys($lastRunTree) as $snapshotted) { foreach (array_keys($lastRunTree) as $snapshotted) {
@ -58,8 +44,6 @@ final readonly class ChangedFiles
} }
if (! $exists) { if (! $exists) {
// Always invalidate deletions — a stale cached result from before the deletion
// would persist forever otherwise, even if the snapshot recorded the empty sentinel.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -84,10 +68,6 @@ final readonly class ChangedFiles
} }
/** /**
* Computes content hashes for the given project-relative files. Used to
* snapshot the working tree after a successful run so the next run can
* detect which files are actually different.
*
* @param array<int, string> $files * @param array<int, string> $files
* @return array<string, string> path → xxh128 content hash * @return array<string, string> path → xxh128 content hash
*/ */
@ -99,9 +79,6 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Record the deletion with an empty-string sentinel so the
// next run recognises "still deleted" as unchanged rather
// than re-flagging the file as a fresh change.
$out[$file] = ''; $out[$file] = '';
continue; continue;
@ -119,8 +96,6 @@ final readonly class ChangedFiles
/** /**
* @return array<int, string>|null `null` when git is unavailable, or when * @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push).
*/ */
public function since(?string $sha): ?array public function since(?string $sha): ?array
{ {
@ -140,9 +115,6 @@ final readonly class ChangedFiles
$files = array_merge($files, $this->workingTreeChanges()); $files = array_merge($files, $this->workingTreeChanges());
// Normalise + dedupe, filtering out paths that can never belong to the
// graph: vendor (caught by the fingerprint instead), cache dirs, and
// anything starting with a dot we don't care about.
$unique = []; $unique = [];
foreach ($files as $file) { foreach ($files as $file) {
@ -157,13 +129,6 @@ final readonly class ChangedFiles
$candidates = array_keys($unique); $candidates = array_keys($unique);
// Behavioural de-noising: for every file git calls "changed", hash
// the current content and the content at `$sha` through
// `ContentHash::of()`. A change that only touched comments /
// whitespace / blade `{{-- --}}` blocks produces the same hash on
// both sides and gets dropped before it can invalidate any test.
// Without this, a single-comment edit on a migration re-runs the
// entire DB-touching suite.
if ($sha !== null && $sha !== '') { if ($sha !== null && $sha !== '') {
return $this->filterBehaviourallyUnchanged($candidates, $sha); return $this->filterBehaviourallyUnchanged($candidates, $sha);
} }
@ -183,7 +148,6 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Deleted on disk — a genuine change, keep it.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -200,8 +164,6 @@ final readonly class ChangedFiles
$baselineContent = $this->contentAtSha($sha, $file); $baselineContent = $this->contentAtSha($sha, $file);
if ($baselineContent === null) { if ($baselineContent === null) {
// Couldn't read the baseline (new file, binary, `git show`
// failed). Err on the side of re-running.
$remaining[] = $file; $remaining[] = $file;
continue; continue;
@ -217,12 +179,6 @@ final readonly class ChangedFiles
return $remaining; return $remaining;
} }
/**
* Reads `$path` at `$sha` via `git show`. Returns null when the file
* didn't exist at that SHA, when git errors, or when the content
* isn't valid UTF-8-safe bytes (rare — binary files that happen to
* be tracked).
*/
private function contentAtSha(string $sha, string $path): ?string private function contentAtSha(string $sha, string $path): ?string
{ {
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
@ -244,10 +200,6 @@ final readonly class ChangedFiles
'.phpunit.result.cache', '.phpunit.result.cache',
'vendor/', 'vendor/',
'node_modules/', 'node_modules/',
// Laravel regenerates these from manifest state
// (package.json, service providers) at boot — they're
// fully derived, not authored. Treating them as
// "changes" just flaps the diff noisily.
'bootstrap/cache/', 'bootstrap/cache/',
]; ];
@ -294,9 +246,6 @@ final readonly class ChangedFiles
); );
$process->run(); $process->run();
// Exit 0 → ancestor; 1 → not ancestor; anything else → git error
// (e.g. unknown commit after a rebase/gc). Treat non-zero as
// "unreachable" and force a rebuild.
return $process->getExitCode() === 0; return $process->getExitCode() === 0;
} }
@ -323,14 +272,6 @@ final readonly class ChangedFiles
*/ */
private function workingTreeChanges(): array private function workingTreeChanges(): array
{ {
// `-z` produces NUL-terminated records with no path quoting, so paths
// that contain spaces, tabs, unicode or other special characters
// are passed through verbatim. Without `-z`, git wraps such paths in
// quotes with backslash escapes, which would corrupt our lookup keys.
//
// Record format: `XY <SP> <path> <NUL>` for most entries, and
// `R <new> <NUL> <orig> <NUL>` for renames/copies (two NUL-separated
// fields).
$process = new Process( $process = new Process(
['git', 'status', '--porcelain', '-z', '--untracked-files=all'], ['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
$this->projectRoot, $this->projectRoot,
@ -361,8 +302,6 @@ final readonly class ChangedFiles
$status = substr($record, 0, 2); $status = substr($record, 0, 2);
$path = substr($record, 3); $path = substr($record, 3);
// Renames/copies emit two records: the new path first, then the
// original. Consume both.
if ($status[0] === 'R' || $status[0] === 'C') { if ($status[0] === 'R' || $status[0] === 'C') {
$files[] = $path; $files[] = $path;

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\BladeEdges;
use Pest\Plugins\Tia\Edges\InertiaEdges;
/**
* @internal
*/
final class Collectors
{
/** @var list<class-string> */
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);
}
}
}

View File

@ -7,26 +7,11 @@ namespace Pest\Plugins\Tia;
use Pest\Support\Container; use Pest\Support\Container;
/** /**
* User-facing TIA configuration, returned by `pest()->tia()`.
*
* Usage in `tests/Pest.php`:
*
* pest()->tia()->watch([
* 'resources/js/**\/*.tsx' => 'tests/Browser',
* 'public/build/**\/*' => 'tests/Browser',
* ]);
*
* Patterns are merged with the built-in defaults (config, routes, views,
* frontend assets, migrations). Duplicate glob keys overwrite the default
* mapping so users can redirect a pattern to a narrower directory.
*
* @internal * @internal
*/ */
final class Configuration final class Configuration
{ {
/** /**
* Activates TIA for every run without requiring the `--tia` CLI flag.
*
* @return $this * @return $this
*/ */
public function always(): self public function always(): self
@ -39,10 +24,6 @@ final class Configuration
} }
/** /**
* Restricts the `always()` activation to local environments only.
* On CI (`--ci` flag or `CI` env var), TIA is skipped even if `always()` is set.
* Explicit `--tia` on the CLI always takes effect regardless.
*
* @return $this * @return $this
*/ */
public function locally(): self public function locally(): self
@ -56,10 +37,6 @@ final class Configuration
} }
/** /**
* In replay mode, instead of short-circuiting cached results for unaffected
* tests, narrows PHPUnit to only the affected files — unaffected tests are
* never loaded. Can also be enabled with the `--filtered` CLI flag.
*
* @return $this * @return $this
*/ */
public function filtered(): self public function filtered(): self
@ -72,9 +49,6 @@ final class Configuration
} }
/** /**
* Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults.
*
* @param array<string, string> $patterns glob → project-relative test dir * @param array<string, string> $patterns glob → project-relative test dir
* @return $this * @return $this
*/ */

View File

@ -5,33 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Per-file hashing that ignores changes which can't alter behaviour —
* comments and whitespace for PHP, `{{-- … --}}` comments and whitespace
* runs for Blade templates. Every other file type falls back to a plain
* xxh128 of the raw bytes.
*
* Why it matters: TIA's file diff signals drive which tests re-run. A
* one-line comment tweak on a migration is a behavioural no-op, but the
* raw-bytes hash still differs, so every test that talks to the DB would
* currently re-execute. Normalising to the parsed-token / compiled-shape
* keeps the drift signal honest: edits that can't change runtime
* behaviour don't invalidate the replay cache.
*
* Important: this hash is stored in the graph's last-run tree, so any
* format change here must be paired with a `Fingerprint::SCHEMA_VERSION`
* bump — otherwise stale hashes from older graphs would be compared
* against normalised hashes from the new code and everything would
* appear changed.
*
* @internal * @internal
*/ */
final class ContentHash final class ContentHash
{ {
/**
* xxh128 hex of the file's "behavioural" shape, or `false` when the
* file can't be read. Callers should treat `false` the same way they
* treated a failed `hash_file()` previously.
*/
public static function of(string $absolute): string|false public static function of(string $absolute): string|false
{ {
$raw = @file_get_contents($absolute); $raw = @file_get_contents($absolute);
@ -43,11 +20,6 @@ final class ContentHash
return self::ofContent($absolute, $raw); return self::ofContent($absolute, $raw);
} }
/**
* Same as `of()` but accepts the file contents in memory. Used when
* we already have the bytes (e.g. from `git show <sha>:<path>`) and
* want to avoid a disk round-trip.
*/
public static function ofContent(string $path, string $raw): string public static function ofContent(string $path, string $raw): string
{ {
$lower = strtolower($path); $lower = strtolower($path);
@ -69,13 +41,6 @@ final class ContentHash
return hash('xxh128', $raw); return hash('xxh128', $raw);
} }
/**
* Tokenise the content and hash the concatenated values of every
* token except whitespace / comment / docblock. `token_get_all()`
* is built-in, fast, and enough to collapse any formatting-only
* edit. If tokenisation fails (rare syntax error), fall back to
* the raw hash so the caller still gets a deterministic signal.
*/
private static function hashPhpContent(string $raw): string private static function hashPhpContent(string $raw): string
{ {
$tokens = @token_get_all($raw); $tokens = @token_get_all($raw);
@ -106,14 +71,6 @@ final class ContentHash
return hash('xxh128', $normalised); return hash('xxh128', $normalised);
} }
/**
* Blade templates aren't PHP syntactically, so `token_get_all()`
* doesn't help. Strip `{{-- … --}}` comments (the only Blade-native
* comment form) and collapse whitespace runs. Output differences
* that would survive the Blade compiler (markup reordering, new
* directives, changed interpolation) still flip the hash; pure
* reformatting does not.
*/
private static function hashBladeContent(string $raw): string private static function hashBladeContent(string $raw): string
{ {
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw; $stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
@ -122,17 +79,6 @@ final class ContentHash
return hash('xxh128', trim($stripped)); return hash('xxh128', trim($stripped));
} }
/**
* Conservative JS/TS/Vue/Svelte normaliser. Strips `//` line
* comments and `/* … *\/` block comments that appear on their own
* lines (including leading indentation), then collapses
* whitespace. Deliberately leaves trailing comments after code
* alone — a string literal like `'http://foo'` would be unsafe to
* split on `//` without a full lexer. The direction of error is
* over-detection (we may not strip a trailing comment that's
* purely cosmetic), never under-detection. Blank lines and
* indentation changes are erased regardless.
*/
private static function hashJsContent(string $raw): string private static function hashJsContent(string $raw): string
{ {
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw; $stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;

View File

@ -5,43 +5,19 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\Contracts; namespace Pest\Plugins\Tia\Contracts;
/** /**
* Storage contract for TIA's persistent state (graph, baselines, affected
* set, worker partials, coverage snapshots). Modelled as a flat key/value
* store of raw byte blobs so implementations can sit on top of whatever
* backend fits — a directory, a shared cache, a remote object store — and
* TIA's logic stays identical.
*
* @internal * @internal
*/ */
interface State interface State
{ {
/**
* Returns the stored blob for `$key`, or `null` when the key is unset
* or cannot be read.
*/
public function read(string $key): ?string; public function read(string $key): ?string;
/**
* Atomically stores `$content` under `$key`. Existing value (if any) is
* replaced. Implementations SHOULD guarantee that concurrent readers
* never observe partial writes.
*/
public function write(string $key, string $content): bool; public function write(string $key, string $content): bool;
/**
* Removes `$key`. Returns true whether or not the key existed beforehand
* — callers should treat a `true` result as "the key is now absent",
* not "the key was present and has been removed."
*/
public function delete(string $key): bool; public function delete(string $key): bool;
public function exists(string $key): bool; public function exists(string $key): bool;
/** /**
* Returns every key whose name starts with `$prefix`. Used to collect
* paratest worker partials (`worker-edges-<token>.json`, etc.) without
* exposing backend-specific glob semantics.
*
* @return list<string> * @return list<string>
*/ */
public function keysWithPrefix(string $prefix): array; public function keysWithPrefix(string $prefix): array;

View File

@ -9,34 +9,16 @@ use ReflectionClass;
use Throwable; use Throwable;
/** /**
* Extracts per-test file coverage from PHPUnit's shared `CodeCoverage`
* instance. Used when TIA piggybacks on `--coverage` instead of starting
* its own driver session — both share the same PCOV / Xdebug state, so
* running two recorders in parallel would corrupt each other's data.
*
* PHPUnit tags every coverage sample with the current test's id
* (`$test->valueObjectForEvents()->id()`, e.g. `Foo\BarTest::baz`). The
* per-file / per-line coverage map therefore already carries everything
* we need to rebuild TIA edges at the end of the run.
*
* @internal * @internal
*/ */
final class CoverageCollector final class CoverageCollector
{ {
/** /**
* Cached `className → test file` lookups. Class reflection is cheap
* individually but the record run can visit tens of thousands of
* samples, so the cache matters.
*
* @var array<string, string|null> * @var array<string, string|null>
*/ */
private array $classFileCache = []; private array $classFileCache = [];
/** /**
* Rebuilds the same `absolute test file → list<absolute source file>`
* shape that `Recorder::perTestFiles()` exposes, so callers can treat
* the two collectors interchangeably when feeding the graph.
*
* @return array<string, array<int, string>> * @return array<string, array<int, string>>
*/ */
public function perTestFiles(): array public function perTestFiles(): array
@ -58,9 +40,6 @@ final class CoverageCollector
$edges = []; $edges = [];
foreach ($lineCoverage as $sourceFile => $lines) { foreach ($lineCoverage as $sourceFile => $lines) {
// Collect the set of tests that hit any line in this file once,
// then emit one edge per (testFile, sourceFile) pair. Walking
// the lines per test would re-resolve the test file repeatedly.
$testIds = []; $testIds = [];
foreach ($lines as $hits) { foreach ($lines as $hits) {
@ -100,9 +79,6 @@ final class CoverageCollector
private function testIdToFile(string $testId): ?string private function testIdToFile(string $testId): ?string
{ {
// PHPUnit's test id is `ClassName::methodName` with an optional
// `#dataSetName` suffix for data-provider runs. Strip the dataset
// part — we only need the class.
$hash = strpos($testId, '#'); $hash = strpos($testId, '#');
$identifier = $hash === false ? $testId : substr($testId, 0, $hash); $identifier = $hash === false ? $testId : substr($testId, 0, $hash);
@ -130,9 +106,6 @@ final class CoverageCollector
$reflection = new ReflectionClass($className); $reflection = new ReflectionClass($className);
// Pest's eval'd test classes expose the original `.php` path on a
// static `$__filename`. The eval'd class itself has no file of its
// own, so prefer this property when present.
if ($reflection->hasProperty('__filename')) { if ($reflection->hasProperty('__filename')) {
$property = $reflection->getProperty('__filename'); $property = $reflection->getProperty('__filename');

View File

@ -11,33 +11,6 @@ use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable; use Throwable;
/** /**
* Merges the current run's PHPUnit coverage into a cached full-suite
* snapshot so `--tia --coverage` can produce a complete report after
* executing only the affected tests.
*
* Invoked from `Pest\Support\Coverage::report()` right before the coverage
* file is consumed. A marker dropped by the `Tia` plugin gates the
* behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
* and therefore keep their existing semantics.
*
* Algorithm
* ---------
* The PHPUnit coverage PHP file unserialises to a `CodeCoverage` object.
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the
* list of test IDs that covered that line. We:
*
* 1. Load the cached snapshot from `State` (serialised bytes).
* 2. Strip every test id that re-ran this time from the cached map —
* the tests that ran now are the ones whose attribution is fresh.
* 3. Merge the current run into the stripped cached snapshot via
* `CodeCoverage::merge()`.
* 4. Write the merged result back to the report path (so Pest's report
* generator sees the full suite) and back into `State` (for the
* next invocation).
*
* If no cache exists yet (first `--tia --coverage` run on this machine)
* we serialise the current object and save it — nothing to merge yet.
*
* @internal * @internal
*/ */
final class CoverageMerger final class CoverageMerger
@ -55,19 +28,24 @@ final class CoverageMerger
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if ($cachedBytes === null) { if ($cachedBytes === null) {
// First `--tia --coverage` run: nothing cached yet, so the
// current file already represents the full suite. Capture it
// verbatim (as serialised bytes) for next time.
$current = self::requireCoverage($reportPath); $current = self::requireCoverage($reportPath);
if ($current instanceof CodeCoverage) { if ($current instanceof CodeCoverage) {
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current)); $state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
} }
return; return;
} }
$cached = self::unserializeCoverage($cachedBytes); $decoded = self::decompress($cachedBytes);
if ($decoded === null) {
$state->delete(Tia::KEY_COVERAGE_CACHE);
return;
}
$cached = self::unserializeCoverage($decoded);
$current = self::requireCoverage($reportPath); $current = self::requireCoverage($reportPath);
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) { if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
@ -80,21 +58,27 @@ final class CoverageMerger
$serialised = serialize($cached); $serialised = serialize($cached);
// Write back to the PHPUnit-style `.cov` path so the report reader
// can `require` it, and to the state cache for the next run.
@file_put_contents( @file_put_contents(
$reportPath, $reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n", '<?php return unserialize('.var_export($serialised, true).");\n",
); );
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised); $state->write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised));
}
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;
} }
/**
* Removes from `$cached`'s per-line test attribution any test id that
* appears in `$current`. Those tests just ran, so the fresh slice is
* authoritative — keeping stale attribution in the cache would claim
* a test still covers a line it no longer touches.
*/
private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCoverage $current): void
{ {
$currentIds = self::collectTestIds($current); $currentIds = self::collectTestIds($current);

View File

@ -2,15 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia\Edges;
/** /**
* Captures PHP files that were included while a test was running.
*
* Coverage drivers miss declaration-only files (classes, enums, interfaces,
* traits) and files loaded before the coverage window opens. Diffing
* `get_included_files()` gives TIA an explicit edge for those autoloaded files.
*
* @internal * @internal
*/ */
final readonly class AutoloadEdges final readonly class AutoloadEdges
@ -23,7 +17,7 @@ final readonly class AutoloadEdges
$files = []; $files = [];
foreach (get_included_files() as $file) { foreach (get_included_files() as $file) {
if (is_string($file) && $file !== '') { if ($file !== '') {
$files[$file] = true; $files[$file] = true;
} }
} }
@ -86,7 +80,7 @@ final readonly class AutoloadEdges
]; ];
foreach ($prefixes as $prefix) { foreach ($prefixes as $prefix) {
if (str_starts_with($relative, $prefix)) { if (str_starts_with($relative, (string) $prefix)) {
return true; return true;
} }
} }

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/**
* @internal
*/
final class BladeEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string MARKER = 'pest.tia.blade-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->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('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);
}
});
}
}

View File

@ -2,53 +2,19 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia\Edges;
use Pest\Plugins\Tia\Recorder;
/** /**
* Inertia-aware collaborator: during record mode, attributes every
* Inertia component the test server-side renders to the currently-
* running test file.
*
* Why this exists: a change to `resources/js/Pages/Users/Show.vue`
* should only invalidate tests that actually rendered `Users/Show`.
* The Laravel `WatchDefaults\Inertia` glob is a broad fallback fine
* for brand-new pages, but noisy once the graph has real data. With
* this armed, each test's recorded edge set grows to include the
* component names it returned through `Inertia::render()`, and
* subsequent replay intersects page-file changes against that set.
*
* Mechanism: listen for `Illuminate\Foundation\Http\Events\RequestHandled`
* on Laravel's event dispatcher. Inertia responses are identifiable by
* either an `X-Inertia` header (XHR / JSON shape) or a `data-page`
* attribute on the root `<div id="app">` (full HTML shape). Both carry
* the component name in a structured payload we can parse cheaply.
*
* Same dep-free handshake as `BladeEdges` / `TableTracker`: string
* class lookup + method-capability probes so Pest's `require` stays
* Laravel-free.
*
* @internal * @internal
*/ */
final class InertiaEdges final class InertiaEdges
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* Event class name used as the listener key. Stored *without* a
* leading backslash because Laravel's `Dispatcher` keys
* `$listeners[$eventName]` by the literal string passed to
* `listen()`, and looks up incoming events by their PHP-class
* name (`get_class($event)`), which never has a leading
* backslash. A `\Illuminate\…` key would silently never match.
*/
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled'; private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
/**
* App-scoped marker that makes `arm()` idempotent across per-test
* `setUp()` calls. Laravel reuses the same app across tests in
* most configurations without this guard we'd stack one
* listener per test.
*/
private const string MARKER = 'pest.tia.inertia-edges-armed'; private const string MARKER = 'pest.tia.inertia-edges-armed';
public static function arm(Recorder $recorder): void public static function arm(Recorder $recorder): void
@ -107,16 +73,8 @@ final class InertiaEdges
}); });
} }
/**
* Pulls the Inertia component name out of a Laravel response,
* handling both XHR (`X-Inertia` + JSON body) and full HTML
* (`<div id="app" data-page="…">`) shapes. Returns null for any
* non-Inertia response so the caller can ignore it cheaply.
*/
private static function extractComponent(object $response): ?string private static function extractComponent(object $response): ?string
{ {
// XHR path: Inertia sets an `X-Inertia: true` header and the
// body is JSON with a `component` key.
if (property_exists($response, 'headers') && is_object($response->headers)) { if (property_exists($response, 'headers') && is_object($response->headers)) {
$headers = $response->headers; $headers = $response->headers;
@ -137,32 +95,12 @@ final class InertiaEdges
} }
} }
// Initial-load HTML path. Inertia ships two shapes here and
// we honour both:
//
// 1. SSR-safe script tag — `<script data-page="app"
// type="application/json">{…JSON…}</script>`. The
// Laravel React starter kit (and modern Inertia-React)
// use this so the JSON survives server-rendered
// hydration without HTML-encoding the payload into an
// attribute. The `data-page="app"` *attribute value* is
// the literal string `"app"` — only the tag *body*
// carries the page JSON.
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
// Inertia-Vue and Inertia-React still emit this. Here
// `data-page` IS the JSON, HTML-entity-encoded.
//
// Try the script-tag shape first; if the response uses it,
// the classic regex would also see a `data-page="app"` token
// and try to JSON-decode the literal string `"app"`.
$content = self::readContent($response); $content = self::readContent($response);
if ($content === null) { if ($content === null) {
return null; return null;
} }
// Lookahead pair handles arbitrary attribute order on the
// `<script>` tag.
if (str_contains($content, 'type="application/json"') if (str_contains($content, 'type="application/json"')
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) { && preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1])); $component = self::componentFromJson(html_entity_decode($match[1]));
@ -172,9 +110,6 @@ final class InertiaEdges
} }
} }
// Classic: only accept a value that looks like a JSON object
// (`{…}`). Avoids matching the script-tag form's
// `data-page="app"` attribute when both shapes coexist.
if (str_contains($content, 'data-page=') if (str_contains($content, 'data-page=')
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) { && preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
$component = self::componentFromJson(html_entity_decode($match[1])); $component = self::componentFromJson(html_entity_decode($match[1]));
@ -187,12 +122,6 @@ final class InertiaEdges
return null; return null;
} }
/**
* Parses an Inertia page JSON blob and returns the `component`
* field if it's a non-empty string. Used by both the script-tag
* and the `data-page`-attribute paths so the success criteria are
* identical.
*/
private static function componentFromJson(string $json): ?string private static function componentFromJson(string $json): ?string
{ {
/** @var mixed $decoded */ /** @var mixed $decoded */

View File

@ -7,23 +7,10 @@ namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\Contracts\State;
/** /**
* Filesystem-backed implementation of the TIA `State` contract. Each key
* maps verbatim to a file name under `$rootDir`, so existing `.temp/*.json`
* layouts are preserved exactly.
*
* The root directory is created lazily on first write — callers don't have
* to pre-provision it, and reads against a missing directory simply return
* `null` rather than throwing.
*
* @internal * @internal
*/ */
final readonly class FileState implements State final readonly class FileState implements State
{ {
/**
* Configured root. May not exist on disk yet; resolved + created on
* the first write. Keeping the raw string lets the instance be built
* before Pest's temp dir has been materialised.
*/
private string $rootDir; private string $rootDir;
public function __construct(string $rootDir) public function __construct(string $rootDir)
@ -57,8 +44,6 @@ final readonly class FileState implements State
return false; return false;
} }
// Atomic rename — on POSIX filesystems this is a single-step
// replacement, so concurrent readers never see a half-written file.
if (! @rename($tmp, $path)) { if (! @rename($tmp, $path)) {
@unlink($tmp); @unlink($tmp);
@ -108,22 +93,11 @@ final readonly class FileState implements State
return $keys; return $keys;
} }
/**
* Absolute path for `$key`. Not part of the interface — used by the
* coverage merger and similar callers that need direct filesystem
* access (e.g. `require` on a cached PHP file). Consumers that only
* deal in bytes should go through `read()` / `write()`.
*/
public function pathFor(string $key): string public function pathFor(string $key): string
{ {
return $this->rootDir.DIRECTORY_SEPARATOR.$key; return $this->rootDir.DIRECTORY_SEPARATOR.$key;
} }
/**
* Returns the resolved root if it exists already, otherwise `null`.
* Used by read-side helpers so they don't eagerly create the directory
* just to find nothing inside.
*/
private function resolvedRoot(): ?string private function resolvedRoot(): ?string
{ {
$resolved = @realpath($this->rootDir); $resolved = @realpath($this->rootDir);
@ -131,10 +105,6 @@ final readonly class FileState implements State
return $resolved === false ? null : $resolved; return $resolved === false ? null : $resolved;
} }
/**
* Creates the root dir on demand. Returns false only when creation
* fails and the directory still isn't there afterwards.
*/
private function ensureRoot(): bool private function ensureRoot(): bool
{ {
if (is_dir($this->rootDir)) { if (is_dir($this->rootDir)) {

View File

@ -5,72 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Two-bucket fingerprint for TIA staleness detection.
*
* - **structural**: inputs whose drift means graph *edges* may be wrong → full rebuild.
* `tests/TestCase.php` and `tests/Pest.php` are intentionally absent; they're covered by
* `Recorder::linkAncestorFiles` and the watch pattern, giving precise per-test invalidation.
* - **environmental**: runtime inputs (PHP version, extensions, env files) whose drift means
* edges are still valid but cached results may not reproduce → drop results and re-run.
* Pest's own version is absent; `composer.lock` moves whenever Pest is upgraded.
*
* @internal * @internal
*/ */
final readonly class Fingerprint final readonly class Fingerprint
{ {
// Bump this whenever the set of inputs or the hash algorithm changes,
// so older graphs are invalidated automatically.
//
// v5: ChangedFiles now hashes via `ContentHash` (normalises PHP
// tokens + Blade whitespace/comments) instead of raw bytes.
// Old graphs' run-tree hashes are incompatible and must be
// rebuilt.
// v6: Graph gained per-test table edges (`$testTables`) powering
// surgical migration invalidation. Worker partial shape
// changed to `{files, tables}`. Old graphs have no table
// coverage, which would leave every DB test invalidated by
// any migration change — force a rebuild so the new edges
// are populated.
// v7: Graph gained per-test Inertia page-component edges
// (`$testInertiaComponents`) for surgical page-file
// invalidation. Worker partial now includes an `inertia`
// section. Old graphs have no component edges; without a
// rebuild Vue/React page edits would fall through to the
// broad watch pattern even when precise matching could have
// worked.
// v8: Graph gained `$jsFileToComponents` — reverse dependency
// map computed at record time from Vite's module graph (or
// the PHP fallback) so shared components / layouts /
// composables invalidate the specific pages they're used
// by, not every browser test.
// v9: `ContentHash` now normalises JS/TS/Vue/Svelte comments +
// whitespace. Old graphs' run-tree hashes for those files
// were raw-byte; mixing formats would flag every JS file as
// changed on first run.
// v10: `vite.config.*` hashed into the structural bucket. A
// Vite config change reshapes the module dependency graph
// that `JsModuleGraph` records; without a graph rebuild
// the stored `$jsFileToComponents` map silently goes stale.
// v11: `composer.json` added (autoload-dev / extra discovery
// changes). `tests/TestCase.php` and `tests/Pest.php` are
// intentionally NOT fingerprinted — they're handled by the
// watch pattern + `Recorder::linkAncestorFiles` reflection
// walk, which gives precise per-test invalidation rather
// than a wholesale rebuild that trashes the entire graph.
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
// now hash via `ContentHash::of()` so cosmetic comment +
// whitespace edits don't fire rebuilds. composer.json and
// composer.lock hash a behavioural subset — description,
// keywords, scripts, authors, install timestamps, dist
// URLs etc. no longer drift the structural fingerprint.
// v13: Environment files (`.env`, `.env.testing`, local variants)
// are included in the environmental bucket. They are commonly
// git-ignored, so watch patterns alone cannot reliably notice
// edits; a drift drops cached results and re-executes the suite.
// v14: Node/Vite resolver inputs (`package*.json`, `tsconfig.*`,
// `jsconfig.*`) are included in the structural bucket. They can
// reshape the persisted JS module graph without touching
// `vite.config.*` itself.
private const int SCHEMA_VERSION = 14; private const int SCHEMA_VERSION = 14;
/** /**
@ -84,7 +22,6 @@ final readonly class Fingerprint
return [ return [
'structural' => [ 'structural' => [
'schema' => self::SCHEMA_VERSION, 'schema' => self::SCHEMA_VERSION,
'composer_lock' => self::composerLockHash($projectRoot),
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'), 'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
@ -96,10 +33,6 @@ final readonly class Fingerprint
'composer_json' => self::composerJsonHash($projectRoot), 'composer_json' => self::composerJsonHash($projectRoot),
], ],
'environmental' => [ 'environmental' => [
// Minor only (8.4, not 8.4.19) — CI's patch rarely matches dev installs.
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
'extensions' => self::extensionsFingerprint($projectRoot),
'env_files' => self::envFilesHash($projectRoot),
], ],
]; ];
} }
@ -197,7 +130,6 @@ final readonly class Fingerprint
return self::bucket($fingerprint, 'environmental'); return self::bucket($fingerprint, 'environmental');
} }
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
/** /**
* @param array<string, mixed> $fingerprint * @param array<string, mixed> $fingerprint
* @return array<string, mixed> * @return array<string, mixed>
@ -309,54 +241,6 @@ final readonly class Fingerprint
return $parts === [] ? null : hash('xxh128', implode("\n", $parts)); return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
} }
private static function envFilesHash(string $projectRoot): ?string
{
$paths = [
$projectRoot.'/.env',
$projectRoot.'/.env.testing',
$projectRoot.'/.env.local',
];
$localVariants = glob($projectRoot.'/.env.*.local');
if (is_array($localVariants)) {
foreach ($localVariants as $path) {
$paths[] = $path;
}
}
$parts = [];
$seen = [];
foreach ($paths as $path) {
if (isset($seen[$path])) {
continue;
}
$seen[$path] = true;
if (! is_file($path)) {
continue;
}
$contents = @file_get_contents($path);
if ($contents === false) {
continue;
}
$parts[] = basename($path).':'.hash('xxh128', $contents);
}
if ($parts === []) {
return null;
}
sort($parts);
return hash('xxh128', implode("\n", $parts));
}
private static function composerJsonHash(string $projectRoot): ?string private static function composerJsonHash(string $projectRoot): ?string
{ {
$path = $projectRoot.'/composer.json'; $path = $projectRoot.'/composer.json';
@ -404,86 +288,6 @@ final readonly class Fingerprint
return $json === false ? null : hash('xxh128', $json); return $json === false ? null : hash('xxh128', $json);
} }
private static function composerLockHash(string $projectRoot): ?string
{
$path = $projectRoot.'/composer.lock';
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 = [
'platform' => $data['platform'] ?? null,
'platform-dev' => $data['platform-dev'] ?? null,
];
foreach (['packages', 'packages-dev'] as $section) {
if (! isset($data[$section])) {
continue;
}
if (! is_array($data[$section])) {
continue;
}
$packages = [];
foreach ($data[$section] as $package) {
if (! is_array($package)) {
continue;
}
$name = $package['name'] ?? null;
if (! is_string($name)) {
continue;
}
$packages[$name] = [
'version' => $package['version'] ?? null,
'reference' => self::lockReference($package),
'autoload' => $package['autoload'] ?? null,
'autoload-dev' => $package['autoload-dev'] ?? null,
'extra' => $package['extra'] ?? null,
];
}
ksort($packages);
$relevant[$section] = $packages;
}
self::sortRecursively($relevant);
$json = json_encode($relevant);
return $json === false ? null : hash('xxh128', $json);
}
/**
* @param array<string, mixed> $package
*/
private static function lockReference(array $package): ?string
{
$dist = is_array($package['dist'] ?? null) ? $package['dist'] : [];
$source = is_array($package['source'] ?? null) ? $package['source'] : [];
$reference = $dist['reference'] ?? $source['reference'] ?? null;
return is_string($reference) ? $reference : null;
}
private static function sortRecursively(mixed &$value): void private static function sortRecursively(mixed &$value): void
{ {
if (! is_array($value)) { if (! is_array($value)) {
@ -522,66 +326,4 @@ final readonly class Fingerprint
return $hash === false ? null : $hash; return $hash === false ? null : $hash;
} }
// Only hashes `ext-*` entries declared in composer.json — incidental extensions loaded on the
// machine but not declared can't affect suite correctness, so they're excluded to reduce noise.
private static function extensionsFingerprint(string $projectRoot): string
{
$extensions = self::declaredExtensions($projectRoot);
if ($extensions === []) {
return hash('xxh128', '');
}
sort($extensions);
$parts = [];
foreach ($extensions as $name) {
$version = phpversion($name);
$parts[] = $name.'@'.($version === false ? 'missing' : $version);
}
return hash('xxh128', implode("\n", $parts));
}
/** @return list<string> */
private static function declaredExtensions(string $projectRoot): array
{
$path = $projectRoot.'/composer.json';
if (! is_file($path)) {
return [];
}
$raw = @file_get_contents($path);
if ($raw === false) {
return [];
}
$data = json_decode($raw, true);
if (! is_array($data)) {
return [];
}
$extensions = [];
foreach (['require', 'require-dev'] as $section) {
$packages = $data[$section] ?? null;
if (! is_array($packages)) {
continue;
}
foreach (array_keys($packages) as $package) {
if (is_string($package) && str_starts_with($package, 'ext-')) {
$extensions[] = substr($package, 4);
}
}
}
return array_values(array_unique($extensions));
}
} }

View File

@ -4,16 +4,14 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Factories\TestCaseFactory;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\View;
use Pest\TestSuite; use Pest\TestSuite;
use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Symfony\Component\Console\Output\OutputInterface;
/** /**
* Dependency graph: test file → set<source file>. Skips unchanged tests on replay.
* Source files are indexed by numeric id to keep the on-disk JSON compact.
*
* @internal * @internal
*/ */
final class Graph final class Graph
@ -48,7 +46,6 @@ final class Graph
*/ */
private array $baselines = []; private array $baselines = [];
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
private readonly string $projectRoot; private readonly string $projectRoot;
/** @var array<string, true>|null */ /** @var array<string, true>|null */
@ -97,9 +94,6 @@ final class Graph
$affectedSet = []; $affectedSet = [];
// Migrations can't flow through coverage edges: `RefreshDatabase` gives every test an edge to
// every migration, so any migration change would re-run the whole DB suite. Route them via
// table-intersection instead; unparseable migrations fall through to the watch pattern.
$migrationPaths = []; $migrationPaths = [];
$nonMigrationPaths = []; $nonMigrationPaths = [];
@ -144,8 +138,6 @@ final class Graph
} }
} }
// Inertia page routing: map changed page files to component names and intersect with recorded
// component edges. Pages with no captured edges fall through to the watch pattern.
$globalFrontendRuntimeFiles = []; $globalFrontendRuntimeFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
@ -176,8 +168,6 @@ final class Graph
} }
} }
// Shared JS files: resolve via the recorded Vite module graph to their dependent page components.
// Files absent from the map fall through to the watch pattern.
$sharedFilesResolved = []; $sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
@ -204,9 +194,6 @@ final class Graph
} }
} }
// New JS files absent from the record-time map: ask Vite (strict, no PHP fallback) which pages
// import them. A negative answer suppresses the broad watch broadcast; Node is the only resolver
// trustworthy enough to honour a negative (PHP parser can miss custom aliases).
$newJsFiles = []; $newJsFiles = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($globalFrontendRuntimeFiles[$rel])) { if (isset($globalFrontendRuntimeFiles[$rel])) {
@ -231,21 +218,18 @@ final class Graph
$freshMap = JsModuleGraph::buildStrict($this->projectRoot); $freshMap = JsModuleGraph::buildStrict($this->projectRoot);
if ($freshMap === null) { if ($freshMap === null) {
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user View::render('components.badge', [
// knows precision was downgraded rather than leaving the slower replay unexplained. 'type' => 'WARN',
$output = Container::getInstance()->get(OutputInterface::class); 'content' => sprintf(
if ($output instanceof OutputInterface) { 'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
$output->writeln(sprintf(
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
count($newJsFiles), count($newJsFiles),
)); ),
} ]);
} else { } else {
foreach ($newJsFiles as $rel) { foreach ($newJsFiles as $rel) {
$pages = $freshMap[$rel] ?? []; $pages = $freshMap[$rel] ?? [];
if ($pages === []) { if ($pages === []) {
// Vite confirms no page imports this file — suppress the watch broadcast.
$sharedFilesResolved[$rel] = true; $sharedFilesResolved[$rel] = true;
continue; continue;
@ -282,8 +266,6 @@ final class Graph
} }
} }
// Coverage-edge lookup (PHP → PHP). Migrations already handled above; skipping here prevents
// their always-on edges from re-running the whole DB suite.
$changedIds = []; $changedIds = [];
$unknownSourceDirs = []; $unknownSourceDirs = [];
$sourcePhpChanged = false; $sourcePhpChanged = false;
@ -303,7 +285,6 @@ final class Graph
$absolute = $this->projectRoot.'/'.$rel; $absolute = $this->projectRoot.'/'.$rel;
if (! is_file($absolute)) { if (! is_file($absolute)) {
// Deleted source file unknown to the graph — no edge ever pointed to it.
continue; continue;
} }
@ -313,8 +294,6 @@ final class Graph
} }
} }
// Arch tests inspect structure by namespace/path, never producing coverage edges for the files
// they examine — so a new class can fail an arch expectation without any edge to it.
if ($sourcePhpChanged) { if ($sourcePhpChanged) {
foreach (array_keys($this->edges) as $testFile) { foreach (array_keys($this->edges) as $testFile) {
if ($this->isArchTestFile($testFile)) { if ($this->isArchTestFile($testFile)) {
@ -337,8 +316,24 @@ final class Graph
} }
} }
// A changed file inside the configured test suites is itself the unit
// of work — always run it (new untracked tests, edited tests, renames).
$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, <x-*>) up to rendered // Unknown Blade files: walk static references (@include, @extends, <x-*>) up to rendered
// ancestors and invalidate only tests that covered them.
$staticallyHandledBlade = []; $staticallyHandledBlade = [];
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($this->fileIds[$rel])) { if (isset($this->fileIds[$rel])) {
@ -360,13 +355,10 @@ final class Graph
$staticallyHandledBlade[$rel] = true; $staticallyHandledBlade[$rel] = true;
} elseif ($this->isBladeComponentPath($rel)) { } elseif ($this->isBladeComponentPath($rel)) {
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
$staticallyHandledBlade[$rel] = true; $staticallyHandledBlade[$rel] = true;
} }
} }
// Watch-pattern fallback: files with no precise edges. Already-resolved files are excluded
// to avoid re-broadcasting via the watch pattern and defeating the surgical match.
$unknownToGraph = $unparseableMigrations; $unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) { foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) { if (isset($preciselyHandledPages[$rel])) {
@ -380,7 +372,6 @@ final class Graph
} }
if (! isset($this->fileIds[$rel])) { if (! isset($this->fileIds[$rel])) {
if (! is_file($this->projectRoot.'/'.$rel)) { if (! is_file($this->projectRoot.'/'.$rel)) {
// Deleted file unknown to the graph — no edge ever pointed to it.
continue; continue;
} }
@ -398,9 +389,6 @@ final class Graph
$affectedSet[$testFile] = true; $affectedSet[$testFile] = true;
} }
// Sibling heuristic: unknown PHP source files may be new files whose graph was inherited from
// another branch. Run tests that cover neighbouring files in the same directory so framework-
// discovered files (Listeners, Events, Policies, etc.) aren't silently missed.
if ($unknownSourceDirs !== []) { if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) { foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) { if (isset($affectedSet[$testFile])) {
@ -471,7 +459,8 @@ final class Graph
public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0, ?string $file = null): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [
$entry = [
'status' => $status, 'status' => $status,
'message' => $message, 'message' => $message,
'time' => $time, 'time' => $time,
@ -482,9 +471,11 @@ final class Graph
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel !== null) { if ($rel !== null) {
$this->baselines[$branch]['results'][$testId]['file'] = $rel; $entry['file'] = $rel;
} }
} }
$this->baselines[$branch]['results'][$testId] = $entry;
} }
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
@ -508,9 +499,6 @@ final class Graph
$r = $baseline['results'][$testId]; $r = $baseline['results'][$testId];
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
// each variant via its specific factory. Keeps the stored message
// intact (important for skips/failures shown to the user).
return match ($r['status']) { return match ($r['status']) {
0 => TestStatus::success(), 0 => TestStatus::success(),
1 => TestStatus::skipped($r['message']), 1 => TestStatus::skipped($r['message']),
@ -534,15 +522,15 @@ final class Graph
$files = []; $files = [];
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null; if ($result['status'] !== 7 && $result['status'] !== 8) {
if ($status !== 7 && $status !== 8) {
continue; continue;
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if ($file === null) {
if (! is_string($file) || $file === '') { continue;
}
if ($file === '') {
continue; continue;
} }
@ -561,15 +549,13 @@ final class Graph
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
foreach ($baseline['results'] as $result) { foreach ($baseline['results'] as $result) {
$status = $result['status'] ?? null; if ($result['status'] !== 7 && $result['status'] !== 8) {
if ($status !== 7 && $status !== 8) {
continue; continue;
} }
$file = $result['file'] ?? null; $file = $result['file'] ?? null;
if (! is_string($file) || $file === '' || $this->relative($file) === null) { if ($file === null || $file === '' || $this->relative($file) === null) {
return true; return true;
} }
} }
@ -586,7 +572,6 @@ final class Graph
$this->baselines[$branch]['tree'] = $tree; $this->baselines[$branch]['tree'] = $tree;
} }
// Edges and tree snapshot stay intact; only the run-state is reset.
public function clearResults(string $branch): void public function clearResults(string $branch): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
@ -642,7 +627,6 @@ final class Graph
$this->link($testFile, $source); $this->link($testFile, $source);
} }
// Deduplicate ids for this test.
$this->edges[$testRel] = array_values(array_unique($this->edges[$testRel])); $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel]));
} }
} }
@ -703,7 +687,6 @@ final class Graph
} }
} }
// Empty input is treated as a resolver failure (not "no JS pages") — keep the previous map.
/** /**
* @param array<string, array<int, string>> $fileToComponents * @param array<string, array<int, string>> $fileToComponents
*/ */
@ -770,7 +753,7 @@ final class Graph
]; ];
foreach ($prefixes as $prefix) { foreach ($prefixes as $prefix) {
if (str_starts_with($rel, $prefix)) { if (str_starts_with($rel, (string) $prefix)) {
return true; return true;
} }
} }
@ -808,7 +791,7 @@ final class Graph
foreach ($repo->getFilenames() as $filename) { foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename); $factory = $repo->get($filename);
if ($factory === null) { if (! $factory instanceof TestCaseFactory) {
continue; continue;
} }
@ -853,7 +836,10 @@ final class Graph
if (! is_object($attribute)) { if (! is_object($attribute)) {
continue; continue;
} }
if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) { if (! property_exists($attribute, 'name')) {
continue;
}
if ($attribute->name !== Group::class) {
continue; continue;
} }
if (! property_exists($attribute, 'arguments')) { if (! property_exists($attribute, 'arguments')) {
@ -992,10 +978,12 @@ final class Graph
); );
foreach ($iterator as $file) { foreach ($iterator as $file) {
if (! $file instanceof \SplFileInfo || ! $file->isFile()) { if (! $file instanceof \SplFileInfo) {
continue;
}
if (! $file->isFile()) {
continue; continue;
} }
$path = $file->getPathname(); $path = $file->getPathname();
if (! str_ends_with($path, '.blade.php')) { if (! str_ends_with($path, '.blade.php')) {
continue; continue;
@ -1082,7 +1070,6 @@ final class Graph
return TableExtractor::fromMigrationSource($content); return TableExtractor::fromMigrationSource($content);
} }
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
private function componentForInertiaPage(string $rel): ?string private function componentForInertiaPage(string $rel): ?string
{ {
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) { foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
@ -1271,8 +1258,6 @@ final class Graph
return $json === false ? null : $json; return $json === false ? null : $json;
} }
// Accepts both absolute paths (from coverage drivers) and project-relative paths (from git diff).
// Relative paths are NOT resolved via realpath() because CWD is not guaranteed to be the project root.
private function relative(string $path): ?string private function relative(string $path): ?string
{ {
if ($path === '' || $path === 'unknown') { if ($path === '' || $path === 'unknown') {
@ -1286,8 +1271,7 @@ final class Graph
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive || (strlen($path) >= 2 && $path[1] === ':');
if ($isAbsolute) { if ($isAbsolute) {
$real = @realpath($path); $real = @realpath($path);
@ -1299,7 +1283,6 @@ final class Graph
return null; return null;
} }
// Always forward slashes — git always uses them; Windows backslashes would never match.
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
} else { } else {
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);

View File

@ -5,29 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Fallback parser for ES module imports under `resources/js/`.
*
* Used only when the Node helper (`bin/pest-tia-vite-deps.mjs`) is
* unavailable — typically when Node isn't on `PATH` or the user's
* `vite.config.*` can't be loaded. Pure PHP, so it degrades
* gracefully on locked-down environments but cannot match the
* full-fidelity Vite resolver.
*
* Known limits (intentional — preserving correctness over precision):
* - Only `@/` and `~/` aliases recognised (both resolve to
* `resources/js/`, the community default). Custom aliases from
* `vite.config.ts` are ignored; anything we can't resolve is
* simply skipped and falls through to the watch-pattern safety
* net.
* - Dynamic imports with variable expressions
* (`import(`./${name}`.vue)`) can't be resolved; the literal
* prefix is ignored and the caller over-runs. Safe.
* - Vue SFC `<script>` blocks parsed whole; imports inside
* `<template>` blocks (rare but legal) are not scanned.
*
* Output shape mirrors the Node helper: project-relative source path
* → sorted list of component names of pages that depend on it.
*
* @internal * @internal
*/ */
final class JsImportParser final class JsImportParser
@ -39,12 +16,6 @@ final class JsImportParser
private const string JS_DIR = 'resources/js'; private const string JS_DIR = 'resources/js';
/** /**
* Walks the project's pages directory (`resources/js/Pages` or its
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
* and, for each page, collects its transitive file imports. Returns
* the inverted graph so callers can look up "what pages depend on
* this shared file".
*
* @return array<string, list<string>> * @return array<string, list<string>>
*/ */
public static function parse(string $projectRoot): array public static function parse(string $projectRoot): array
@ -165,11 +136,6 @@ final class JsImportParser
} }
} }
/**
* Loads the importable region of a file. For Vue SFCs, only the
* `<script>` block is relevant for imports; ignoring the rest
* avoids false-positive matches inside `<template>` attributes.
*/
private static function loadSource(string $fileAbs): ?string private static function loadSource(string $fileAbs): ?string
{ {
$content = @file_get_contents($fileAbs); $content = @file_get_contents($fileAbs);
@ -192,10 +158,6 @@ final class JsImportParser
} }
/** /**
* Picks out every `import … from '…'` / `import '…'` / `import('…')`
* target. We strip line comments first so a commented-out import
* doesn't bloat the dep set.
*
* @return list<string> * @return list<string>
*/ */
private static function extractImports(string $source): array private static function extractImports(string $source): array
@ -232,9 +194,6 @@ final class JsImportParser
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail)); return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
} }
// Anything else is either a node_modules package or an
// unrecognised alias — skip. The watch-pattern fallback
// handles the safety-net case for non-matched paths.
return null; return null;
} }
@ -250,10 +209,6 @@ final class JsImportParser
return self::withExtension($path); return self::withExtension($path);
} }
/**
* Imports may omit the extension or point at a directory (index.vue,
* index.ts). Probe the common targets in order.
*/
private static function withExtension(string $path): ?string private static function withExtension(string $path): ?string
{ {
if (is_file($path)) { if (is_file($path)) {

View File

@ -8,108 +8,16 @@ use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
/** /**
* Builds a reverse dependency map for the project's JS sources under
* `resources/js/**` — for every source file, the list of Inertia page
* components that transitively import it.
*
* Backed by a Node helper (`bin/pest-tia-vite-deps.mjs`) that boots a
* headless Vite server in middleware mode, walks Vite's own module
* graph for each page entry, and outputs JSON. Uses the project's real
* `vite.config.*`, so aliases, plugins, and SFC transformers produce
* the exact graph Vite itself would use.
*
* Two latency mitigations:
*
* 1. **Content-hash cache** keyed by every file under `resources/js/`
* (path + size + mtime) plus the bytes of `vite.config.*` and the
* pages-directory casing. When inputs are unchanged, the 13s+ Node
* bootstrap is skipped entirely and the previous result is reused.
*
* 2. **Background warmer** — `warmInBackground()` is called at suite
* start. It computes the fingerprint, checks the cache, and only
* spawns Node if a refresh is needed. The subprocess runs in
* parallel with the test suite. By the time `build()` is called at
* flush time, the result is usually already on stdout — `wait()`
* returns instantly. If tests finish faster than Vite boots,
* `build()` simply pays the remainder, never the full bootstrap.
*
* Callers invoke `build()` at record time; results are persisted into
* the graph so replay never re-runs the resolver. On stale-map detection
* the callers decide whether to rebuild.
*
* @internal * @internal
*/ */
final class JsModuleGraph final class JsModuleGraph
{ {
private const int NODE_TIMEOUT_SECONDS = 25; private const int NODE_TIMEOUT_SECONDS = 180;
private const string CACHE_FILE = 'js-module-graph.cache.json'; private const string CACHE_FILE = 'js-module-graph.cache.json';
/** Active warmer subprocess, or null when none is in flight. */
private static ?Process $warmer = null;
/** Fingerprint the warmer was started against — used to detect drift between warm and build. */
private static ?string $warmerFingerprint = null;
/** True when the warmer found a fresh cache and skipped spawning Node. */
private static bool $warmerCacheHit = false;
/** Project root the warmer was launched for. */
private static ?string $warmerProjectRoot = null;
/** /**
* Kicks off the Node helper in the background, so by the time * @return array<string, list<string>>
* `build()` is called at flush time the result is (usually) already
* sitting on stdout. Idempotent — a second call while a warmer is
* already in flight is a no-op. Cheap when the cache is fresh: it
* checks the fingerprint first and skips the subprocess.
*
* Safe to call from any TIA entry point that will eventually write
* the graph from the main process. Workers must NOT call this — they
* don't flush the graph and would duplicate the Node bootstrap on
* every worker.
*/
public static function warmInBackground(string $projectRoot): void
{
if (self::$warmer !== null || self::$warmerCacheHit) {
return;
}
if (! self::isApplicable($projectRoot)) {
return;
}
$fingerprint = self::fingerprint($projectRoot);
if ($fingerprint !== null && self::readCache($projectRoot, $fingerprint) !== null) {
self::$warmerCacheHit = true;
self::$warmerFingerprint = $fingerprint;
self::$warmerProjectRoot = $projectRoot;
return;
}
$process = self::buildNodeProcess($projectRoot);
if ($process === null) {
return;
}
try {
$process->start();
} catch (\Throwable) {
return;
}
self::$warmer = $process;
self::$warmerFingerprint = $fingerprint;
self::$warmerProjectRoot = $projectRoot;
register_shutdown_function(self::reapWarmer(...));
}
/**
* @return array<string, list<string>> project-relative source path → sorted list of page component names
*/ */
public static function build(string $projectRoot): array public static function build(string $projectRoot): array
{ {
@ -119,13 +27,6 @@ final class JsModuleGraph
} }
/** /**
* Strict variant — returns null when the Node resolver isn't
* available, so callers can distinguish "Vite says nothing imports
* this file" (empty list) from "we couldn't ask Vite" (null).
*
* Used at replay time when we need to *trust a negative result*
* (i.e., "no page imports this file, so it's orphan, safe to skip").
*
* @return array<string, list<string>>|null * @return array<string, list<string>>|null
*/ */
public static function buildStrict(string $projectRoot): ?array public static function buildStrict(string $projectRoot): ?array
@ -133,22 +34,12 @@ final class JsModuleGraph
return self::resolve($projectRoot); return self::resolve($projectRoot);
} }
/**
* True when the project looks like a Vite + Node project we can
* ask for a module graph. Gate for callers that want to skip the
* resolver entirely on non-Vite apps.
*/
public static function isApplicable(string $projectRoot): bool public static function isApplicable(string $projectRoot): bool
{ {
if (! self::hasViteConfig($projectRoot)) { if (! self::hasViteConfig($projectRoot)) {
return false; return false;
} }
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
// starter kit (`pages/`) conventions are accepted — projects
// running on a case-sensitive filesystem (Linux CI) get
// exactly one of the two, and we shouldn't refuse to walk the
// graph based on which one it picks.
foreach (['Pages', 'pages'] as $dir) { foreach (['Pages', 'pages'] as $dir) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) { if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
return true; return true;
@ -169,79 +60,13 @@ final class JsModuleGraph
$cached = self::readCache($projectRoot, $fingerprint); $cached = self::readCache($projectRoot, $fingerprint);
if ($cached !== null) { if ($cached !== null) {
self::reapWarmer();
return $cached; return $cached;
} }
} }
// Pick up the warmer when it was launched against the same
// fingerprint and project root. Drift between warm and build
// (rare — would require a JS file to change mid-test-run)
// discards the warmer and re-runs synchronously.
if (self::$warmerCacheHit
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot
&& $fingerprint !== null) {
$cached = self::readCache($projectRoot, $fingerprint);
self::$warmerCacheHit = false;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
if ($cached !== null) {
return $cached;
}
}
if (self::$warmer !== null
&& self::$warmerFingerprint === $fingerprint
&& self::$warmerProjectRoot === $projectRoot) {
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
try {
$process->wait();
} catch (\Throwable) {
// fall through to synchronous run
$process = null;
}
if ($process !== null && $process->isSuccessful()) {
$result = self::parseNodeOutput($process->getOutput());
if ($result !== null) {
if ($fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
}
}
} else {
// Different fingerprint or different project root: discard
// any stale warmer before we start a fresh run.
self::reapWarmer();
}
$viaNode = self::runNodeSync($projectRoot);
if ($viaNode !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $viaNode);
}
return $viaNode;
}
/**
* @return array<string, list<string>>|null
*/
private static function runNodeSync(string $projectRoot): ?array
{
$process = self::buildNodeProcess($projectRoot); $process = self::buildNodeProcess($projectRoot);
if ($process === null) { if (! $process instanceof Process) {
return null; return null;
} }
@ -251,7 +76,13 @@ final class JsModuleGraph
return null; return null;
} }
return self::parseNodeOutput($process->getOutput()); $result = self::parseNodeOutput($process->getOutput());
if ($result !== null && $fingerprint !== null) {
self::writeCache($projectRoot, $fingerprint, $result);
}
return $result;
} }
private static function buildNodeProcess(string $projectRoot): ?Process private static function buildNodeProcess(string $projectRoot): ?Process
@ -276,21 +107,7 @@ final class JsModuleGraph
return null; return null;
} }
// Tell the Node helper which casing this project uses for its $process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
// pages directory. The helper defaults to `resources/js/Pages`;
// the Laravel React starter ships lowercase `resources/js/pages`,
// and on a case-sensitive filesystem the helper would otherwise
// walk a non-existent directory and emit an empty module graph.
$env = [];
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$env['TIA_VITE_PAGES_DIR'] = $candidate;
break;
}
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS); $process->setTimeout(self::NODE_TIMEOUT_SECONDS);
return $process; return $process;
@ -336,47 +153,6 @@ final class JsModuleGraph
return $out; return $out;
} }
/**
* Stop and discard a leftover warmer subprocess (e.g. on shutdown,
* or when `build()` resolved from cache without needing the warmer).
*/
private static function reapWarmer(): void
{
$process = self::$warmer;
self::$warmer = null;
self::$warmerFingerprint = null;
self::$warmerProjectRoot = null;
self::$warmerCacheHit = false;
if ($process === null) {
return;
}
try {
if ($process->isRunning()) {
$process->stop(0.5);
}
} catch (\Throwable) {
// best-effort cleanup
}
}
/**
* Content fingerprint of every input that can change the Node/Vite
* module graph: each `resources/js/**` source (path + size + mtime),
* each `vite.config.*` (path + size + mtime + sha-of-bytes), and
* the chosen pages-directory casing. Returns null only when no
* `vite.config.*` exists — i.e. the resolver itself wouldn't run.
*
* File inputs use `mtime+size` rather than full content hashes —
* walking thousands of SFCs and re-hashing them on every flush
* would defeat the point of the cache. mtime/size collisions on
* an edited file are theoretically possible but vanishingly rare,
* and the cost of a rare miss (one extra Node run) is exactly what
* the cache costs anyway. The vite config itself is small and
* load-bearing for plugin/alias behaviour, so we hash its bytes
* outright.
*/
private static function fingerprint(string $projectRoot): ?string private static function fingerprint(string $projectRoot): ?string
{ {
if (! self::hasViteConfig($projectRoot)) { if (! self::hasViteConfig($projectRoot)) {
@ -475,10 +251,12 @@ final class JsModuleGraph
$out = []; $out = [];
foreach ($graph as $key => $value) { foreach ($graph as $key => $value) {
if (! is_string($key) || ! is_array($value)) { if (! is_string($key)) {
continue;
}
if (! is_array($value)) {
continue; continue;
} }
$names = []; $names = [];
foreach ($value as $name) { foreach ($value as $name) {
@ -494,7 +272,7 @@ final class JsModuleGraph
} }
/** /**
* @param array<string, list<string>> $graph * @param array<string, list<string>> $graph
*/ */
private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void private static function writeCache(string $projectRoot, string $fingerprint, array $graph): void
{ {

View File

@ -4,13 +4,11 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\Edges\AutoloadEdges;
use Pest\TestSuite; use Pest\TestSuite;
use ReflectionClass; use ReflectionClass;
/** /**
* Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state
* shared across the `Prepared` and `Finished` subscribers.
*
* @internal * @internal
*/ */
final class Recorder final class Recorder
@ -35,9 +33,6 @@ final class Recorder
/** @var array<string, bool> */ /** @var array<string, bool> */
private array $classUsesDatabaseCache = []; private array $classUsesDatabaseCache = [];
// Source file → declared class names. Built incrementally as classes are autoloaded.
// Used to walk the interface/trait/parent hierarchy which coverage drivers miss
// (interfaces and empty traits emit no executable bytecode).
/** @var array<string, list<string>> */ /** @var array<string, list<string>> */
private array $fileToClassNames = []; private array $fileToClassNames = [];
@ -80,8 +75,6 @@ final class Recorder
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) { } elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
// Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents
// (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent.
$modes = \xdebug_info('mode'); $modes = \xdebug_info('mode');
if (is_array($modes) && in_array('coverage', $modes, true)) { if (is_array($modes) && in_array('coverage', $modes, true)) {
@ -126,8 +119,6 @@ final class Recorder
$this->perTestUsesDatabase[$file] = true; $this->perTestUsesDatabase[$file] = true;
} }
// Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only
// TestCase) emit no executable bytecode, so the coverage driver never records them.
$this->linkAncestorFiles($className); $this->linkAncestorFiles($className);
$this->linkImportedFiles($file); $this->linkImportedFiles($file);
@ -138,7 +129,6 @@ final class Recorder
return; return;
} }
// Xdebug
\xdebug_start_code_coverage(); \xdebug_start_code_coverage();
} }
@ -151,15 +141,6 @@ final class Recorder
if ($this->driver === 'pcov') { if ($this->driver === 'pcov') {
\pcov\stop(); \pcov\stop();
// pcov\waiting() lists every file pcov has tracked but not
// yet collected for. Filter that list down to the project's
// source scope (phpunit.xml's `<source>` plus other
// top-level project dirs, minus vendor / caches), then ask
// pcov to collect *only* for those — `pcov\inclusive`
// narrows the result set at the driver level instead of us
// post-filtering after a full collect. Anything pcov saw
// outside the scope is dropped before any line counts come
// back.
$scope = $this->sourceScope(); $scope = $this->sourceScope();
$filesToCollectCoverageFor = []; $filesToCollectCoverageFor = [];
@ -172,11 +153,10 @@ final class Recorder
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor); $data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
$coveredFiles = self::filesWithExecutedLines($data); $coveredFiles = $this->filesWithExecutedLines($data);
} else { } else {
/** @var array<string, mixed> $data */ /** @var array<string, mixed> $data */
$data = \xdebug_get_code_coverage(); $data = \xdebug_get_code_coverage();
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
\xdebug_stop_code_coverage(true); \xdebug_stop_code_coverage(true);
$coveredFiles = array_keys($data); $coveredFiles = array_keys($data);
@ -195,8 +175,6 @@ final class Recorder
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true; $this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
} }
// Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode,
// so a signature change would leave implementing-class tests stale without this walk.
$this->linkSourceDependencies($coveredFiles); $this->linkSourceDependencies($coveredFiles);
$this->currentTestFile = null; $this->currentTestFile = null;
@ -336,7 +314,6 @@ final class Recorder
$files[$f] = true; $files[$f] = true;
}; };
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
foreach ($reflection->getInterfaceNames() as $iname) { foreach ($reflection->getInterfaceNames() as $iname) {
$linkSymbol($iname); $linkSymbol($iname);
} }
@ -475,7 +452,7 @@ final class Recorder
} }
$parts = preg_split('/\s+as\s+/i', $import); $parts = preg_split('/\s+as\s+/i', $import);
if ($parts === false || $parts === []) { if ($parts === false) {
return null; return null;
} }
@ -487,10 +464,12 @@ final class Recorder
private function findAutoloadFile(string $className): ?string private function findAutoloadFile(string $className): ?string
{ {
foreach (spl_autoload_functions() as $loader) { foreach (spl_autoload_functions() as $loader) {
if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) { if (! is_array($loader)) {
continue;
}
if (! is_object($loader[0])) {
continue; continue;
} }
if (! method_exists($loader[0], 'findFile')) { if (! method_exists($loader[0], 'findFile')) {
continue; continue;
} }
@ -639,8 +618,6 @@ final class Recorder
return null; return null;
} }
// Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName()
// (which returns the trait file for methods brought in via `uses SharedTestBehavior`).
private function readPestFilename(string $className): ?string private function readPestFilename(string $className): ?string
{ {
if (! class_exists($className, false)) { if (! class_exists($className, false)) {
@ -667,29 +644,17 @@ final class Recorder
} }
/** /**
* Filters pcov's `file => line => executionCount` map to files that
* actually had executed code AND live inside the configured source
* scope (`phpunit.xml`'s `<source>` block, or the project root with
* vendor/etc. excluded as fallback).
*
* pcov reports `-1` for "executable but not run" and a positive
* count for executed lines. We also skip files where the *only*
* positive line is the implicit `ZEND_RETURN` at end-of-file: pcov
* surfaces that as a one-line artifact for files that were merely
* included (autoloaded) without any real code running.
*
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return list<string> * @return list<string>
*/ */
private static function filesWithExecutedLines(array $data): array private function filesWithExecutedLines(array $data): array
{ {
$out = []; $out = [];
foreach ($data as $file => $lines) { foreach ($data as $file => $lines) {
if (! is_string($file) || ! is_array($lines)) { if (! is_array($lines)) {
continue; continue;
} }
$covered = []; $covered = [];
foreach ($lines as $line => $count) { foreach ($lines as $line => $count) {
if (is_int($count) && $count > 0) { if (is_int($count) && $count > 0) {
@ -701,10 +666,8 @@ final class Recorder
continue; continue;
} }
// Skip files where the only "executed" line is the implicit $lineKeys = array_keys($lines);
// ZEND_RETURN at end-of-file (pcov artifact from being included if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
// but never actually run).
if (count($covered) === 1 && max($covered) === max(array_keys($lines))) {
continue; continue;
} }

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* @internal
*/
enum Replay
{
case No;
case Pass;
case Skipped;
case Incomplete;
case Failure;
public static function fromStatus(?TestStatus $status): self
{
if (! $status instanceof TestStatus) {
return self::No;
}
return match (true) {
$status->isSuccess(), $status->isRisky() => self::Pass,
$status->isSkipped() => self::Skipped,
$status->isIncomplete() => self::Incomplete,
default => self::Failure,
};
}
}

View File

@ -5,10 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Collects per-test status + message during the run so the graph can persist
* them for faithful replay. PHPUnit's own result cache discards messages
* during serialisation — this collector retains them.
*
* @internal * @internal
*/ */
final class ResultCollector final class ResultCollector
@ -101,10 +97,6 @@ final class ResultCollector
} }
/** /**
* Injects externally-collected results (e.g. partials flushed by parallel
* workers) into this collector so the parent can persist them in the same
* snapshot pass as non-parallel runs.
*
* @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results * @param array<string, array{status: int, message: string, time: float, assertions: int, file?: string}> $results
*/ */
public function merge(array $results): void public function merge(array $results): void
@ -122,11 +114,6 @@ final class ResultCollector
$this->startTime = null; $this->startTime = null;
} }
/**
* Called by the Finished subscriber after a test's outcome + assertion
* events have all fired. Clears the "currently recording" pointer so
* the next test's events don't get mis-attributed.
*/
public function finishTest(): void public function finishTest(): void
{ {
$this->currentTestId = null; $this->currentTestId = null;
@ -144,10 +131,6 @@ final class ResultCollector
? round(microtime(true) - $this->startTime, 3) ? round(microtime(true) - $this->startTime, 3)
: 0.0; : 0.0;
// PHPUnit can fire more than one outcome event per test — the
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
// Last-wins semantics preserve the most specific status; the
// existing assertion count (if any) survives the overwrite.
$existing = $this->results[$this->currentTestId] ?? null; $existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [ $this->results[$this->currentTestId] = [

View File

@ -5,31 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Scopes coverage collection to project source — the directories
* declared in `phpunit.xml`'s `<source>` config plus any other
* top-level project directories that aren't on a hard-coded noise
* list (vendor, caches, IDE/git metadata).
*
* Used by `Recorder` as the per-test filter passed to
* `\pcov\collect(\pcov\inclusive, …)` — pcov tracks every file PHP
* loads, but we only ask for coverage on files inside the project
* source scope, so anything outside (vendor / caches / etc.) is
* dropped before any line counts come back.
*
* Falls back to "every top-level project dir minus the noise list"
* when no `phpunit.xml` / `phpunit.xml.dist` is present or it has no
* `<source>` block — Pest projects without explicit phpunit config
* still get sensible scoping.
*
* @internal * @internal
*/ */
final class SourceScope final readonly class SourceScope
{ {
/**
* Top-level directory names always treated as out-of-scope. These
* mirror what a Laravel app considers "not source": dependencies,
* editor metadata, framework artefacts, the TIA state itself.
*/
private const array TOP_LEVEL_NOISE = [ private const array TOP_LEVEL_NOISE = [
'vendor', 'vendor',
'node_modules', 'node_modules',
@ -42,13 +21,6 @@ final class SourceScope
'.cache', '.cache',
]; ];
/**
* Nested paths (relative to project root) that must be excluded
* even when their top-level parent is in scope. Laravel writes
* compiled views, route caches, and packaged manifests here on
* every framework boot — instrumenting them would burn cycles
* and create noisy edges.
*/
private const array NESTED_NOISE = [ private const array NESTED_NOISE = [
'storage/framework', 'storage/framework',
'storage/logs', 'storage/logs',
@ -60,9 +32,8 @@ final class SourceScope
* @param list<string> $excludes Absolute, normalised directory paths. * @param list<string> $excludes Absolute, normalised directory paths.
*/ */
public function __construct( public function __construct(
private readonly string $projectRoot, private array $includes,
private readonly array $includes, private array $excludes,
private readonly array $excludes,
) {} ) {}
public static function fromProjectRoot(string $projectRoot): self public static function fromProjectRoot(string $projectRoot): self
@ -94,15 +65,34 @@ final class SourceScope
$includes = [self::normalise($projectRoot)]; $includes = [self::normalise($projectRoot)];
} }
return new self($projectRoot, $includes, $excludes); return new self($includes, $excludes);
} }
/** /**
* True when the absolute file path is inside an `<include>` * @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
* directory and not under any exclude. Symlinks are resolved on
* the input so a `realpath()`'d coverage entry still matches a
* config that pointed at the unresolved tree.
*/ */
public static function testPaths(string $projectRoot): array
{
$configPath = self::configPath($projectRoot);
if ($configPath === null) {
return [];
}
$xml = @simplexml_load_file($configPath);
if ($xml === false) {
return [];
}
$configDir = dirname($configPath);
return array_values(array_unique([
...self::extractDirectories($xml, 'testsuites/testsuite/directory', $configDir),
...self::extractDirectories($xml, 'testsuites/testsuite/file', $configDir),
]));
}
public function contains(string $absoluteFile): bool public function contains(string $absoluteFile): bool
{ {
$real = @realpath($absoluteFile); $real = @realpath($absoluteFile);
@ -110,13 +100,13 @@ final class SourceScope
$candidate = self::normalise($candidate); $candidate = self::normalise($candidate);
foreach ($this->excludes as $excluded) { foreach ($this->excludes as $excluded) {
if (self::startsWithDir($candidate, $excluded)) { if ($this->startsWithDir($candidate, $excluded)) {
return false; return false;
} }
} }
foreach ($this->includes as $included) { foreach ($this->includes as $included) {
if (self::startsWithDir($candidate, $included)) { if ($this->startsWithDir($candidate, $included)) {
return true; return true;
} }
} }
@ -125,10 +115,6 @@ final class SourceScope
} }
/** /**
* Project-relative directories the resolver considers in scope.
* Useful for setting `pcov.directory` (a single common ancestor)
* or `\pcov\collect()`'s file filter.
*
* @return list<string> * @return list<string>
*/ */
public function includes(): array public function includes(): array
@ -169,24 +155,13 @@ final class SourceScope
continue; continue;
} }
$absolute = self::resolveRelative($value, $configDir); $out[] = self::resolveRelative($value, $configDir);
if ($absolute === null) {
continue;
}
$out[] = $absolute;
} }
return array_values(array_unique($out)); return array_values(array_unique($out));
} }
/** /**
* Every top-level directory under `$projectRoot` except those on
* the noise list. Hidden entries (dotdirs) are skipped unless
* they're explicitly project source — keeping `.git/`, `.idea/`
* etc. out without an explicit allowlist.
*
* @return list<string> * @return list<string>
*/ */
private static function topLevelProjectDirs(string $projectRoot): array private static function topLevelProjectDirs(string $projectRoot): array
@ -200,10 +175,12 @@ final class SourceScope
$out = []; $out = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) { if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
continue; continue;
} }
@ -239,7 +216,7 @@ final class SourceScope
return $out; return $out;
} }
private static function resolveRelative(string $path, string $configDir): ?string private static function resolveRelative(string $path, string $configDir): string
{ {
$isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/' $isAbsolute = $path !== '' && ($path[0] === DIRECTORY_SEPARATOR || $path[0] === '/'
|| (strlen($path) >= 2 && $path[1] === ':')); || (strlen($path) >= 2 && $path[1] === ':'));
@ -249,8 +226,6 @@ final class SourceScope
$real = @realpath($combined); $real = @realpath($combined);
if ($real === false) { if ($real === false) {
// Directory may not exist yet (e.g. generated source) — keep
// the unresolved path so a future file under it still matches.
return self::normalise($combined); return self::normalise($combined);
} }
@ -262,7 +237,7 @@ final class SourceScope
return rtrim($path, '/\\'); return rtrim($path, '/\\');
} }
private static function startsWithDir(string $candidate, string $dir): bool private function startsWithDir(string $candidate, string $dir): bool
{ {
if ($candidate === $dir) { if ($candidate === $dir) {
return true; return true;

View File

@ -5,36 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Resolves TIA's on-disk state directory.
*
* Default location: `$HOME/.pest/tia/<project-key>/`. Keeping state in the
* user's home directory (rather than under `vendor/pestphp/pest/`) means:
*
* - `composer install` / path-repo reinstalls don't wipe the graph.
* - The state lives outside the project tree, so there is nothing for
* users to gitignore or accidentally commit.
* - Multiple worktrees of the same repo share one cache naturally.
*
* The project key is derived from the git origin URL when available — a
* CI workflow running on `github.com/org/repo` and a developer's clone
* of the same remote both compute the *same* key, which is what lets the
* CI-uploaded baseline line up with the dev-side reader. When the project
* is not in git, the key falls back to a hash of the absolute path so
* unrelated projects on the same machine stay isolated.
*
* When no home directory is resolvable (`HOME` / `USERPROFILE` both
* unset — the tests-tia sandboxes strip these deliberately, and some
* locked-down CI environments do the same), state falls back to
* `<projectRoot>/.pest/tia/`. That path is project-local but still
* survives composer installs, so the degradation is graceful.
*
* @internal * @internal
*/ */
final class Storage final class Storage
{ {
/**
* Directory where TIA's State blobs live for `$projectRoot`.
*/
public static function tempDir(string $projectRoot): string public static function tempDir(string $projectRoot): string
{ {
$home = self::homeDir(); $home = self::homeDir();
@ -51,15 +25,6 @@ final class Storage
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot); .DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
} }
/**
* Wipes the on-disk state directory for `$projectRoot`. Called by
* `--fresh` so a rebuild starts from a truly empty cache: no stale
* baseline, no leftover worker partials, no fingerprint, no JS
* module cache. Subsequent writes recreate the directory on demand.
*
* Per-project (project key is part of the path) — sibling projects'
* caches under `~/.pest/tia/` are untouched.
*/
public static function purge(string $projectRoot): void public static function purge(string $projectRoot): void
{ {
$dir = self::tempDir($projectRoot); $dir = self::tempDir($projectRoot);
@ -80,10 +45,12 @@ final class Storage
} }
foreach ($entries as $entry) { foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') { if ($entry === '.') {
continue;
}
if ($entry === '..') {
continue; continue;
} }
$path = $dir.DIRECTORY_SEPARATOR.$entry; $path = $dir.DIRECTORY_SEPARATOR.$entry;
if (is_dir($path) && ! is_link($path)) { if (is_dir($path) && ! is_link($path)) {
@ -98,11 +65,6 @@ final class Storage
@rmdir($dir); @rmdir($dir);
} }
/**
* OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on
* Windows. Returns null if neither resolves to an existing
* directory, in which case callers fall back to project-local state.
*/
private static function homeDir(): ?string private static function homeDir(): ?string
{ {
foreach (['HOME', 'USERPROFILE'] as $key) { foreach (['HOME', 'USERPROFILE'] as $key) {
@ -117,27 +79,7 @@ final class Storage
} }
/** /**
* Folder name for `$projectRoot` under `~/.pest/tia/`.
*
* Strategy — each step rules out a class of collision:
*
* 1. If the project has a git origin URL, use a **normalised** form
* (`host/org/repo`, lowercased, no `.git` suffix) as the input.
* `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar` * `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar`
* and `https://github.com/foo/bar` all collapse to
* `github.com/foo/bar` — three developers cloning the same repo
* by different transports share one cache, which is what we want.
* 2. Otherwise, use the canonicalised absolute path (`realpath`).
* Two unrelated `app/` checkouts under different parent folders
* have different realpaths → different hashes → isolated.
* 3. Hash the chosen input with sha256 and keep the first 16 hex
* chars — 64 bits of entropy makes accidental collision
* astronomically unlikely even across thousands of projects.
* 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/`
* is readable; the slug is cosmetic only, all isolation comes
* from the hash.
*
* Result: `myapp-a1b2c3d4e5f67890`.
*/ */
private static function projectKey(string $projectRoot): string private static function projectKey(string $projectRoot): string
{ {
@ -152,12 +94,6 @@ final class Storage
return $slug === '' ? $hash : $slug.'-'.$hash; return $slug === '' ? $hash : $slug.'-'.$hash;
} }
/**
* Canonical git origin identity for `$projectRoot`, or null when
* no origin URL can be parsed. The returned form is
* `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git
* protocol clones of the same remote produce the same value.
*/
private static function originIdentity(string $projectRoot): ?string private static function originIdentity(string $projectRoot): ?string
{ {
$url = self::rawOriginUrl($projectRoot); $url = self::rawOriginUrl($projectRoot);
@ -176,8 +112,6 @@ final class Storage
return strtolower($m[1].'/'.$m[2]); return strtolower($m[1].'/'.$m[2]);
} }
// Unrecognised form — hash the raw URL so different inputs still
// diverge, but lowercased so the only variance is intentional.
return strtolower($url); return strtolower($url);
} }
@ -202,11 +136,6 @@ final class Storage
return null; return null;
} }
/**
* Filesystem-safe kebab of `$name`. Cosmetic only — used as a
* human-readable prefix on the hash so `~/.pest/tia/` lists
* recognisable folders.
*/
private static function slug(string $name): string private static function slug(string $name): string
{ {
$slug = strtolower($name); $slug = strtolower($name);

View File

@ -5,46 +5,14 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Extracts table names from SQL statements and migration PHP sources.
*
* Two callers, two methods:
*
* - `fromSql()` runs against query strings Laravel's `DB::listen`
* hands us at record time. We only look at DML (`SELECT`, `INSERT`,
* `UPDATE`, `DELETE`) because DDL emitted by `RefreshDatabase` in
* `setUp()` is noise — we don't want every test to end up linked
* to every migration's `CREATE TABLE`.
* - `fromMigrationSource()` reads a migration file on disk at
* replay time and pulls table names out of `Schema::` calls.
* Used in two places:
* 1. For every migration file reported as changed — what
* tables does the current version of this file touch?
* 2. For brand-new migration files that weren't in the graph
* yet, so we never had a chance to observe their DDL.
*
* Regex isn't a parser. CTEs, subqueries, and raw `DB::statement()`
* that reference tables only inside exotic syntax can slip through.
* The direction of that error is under-attribution (a table the test
* genuinely touches but we missed), so the safety net is to keep the
* broad `database/migrations/**` watch pattern as a last resort for
* files that produce an empty extraction.
*
* @internal * @internal
*/ */
final class TableExtractor final class TableExtractor
{ {
/**
* DML prefixes we accept. DDL (`CREATE`, `ALTER`, `DROP`,
* `TRUNCATE`, `RENAME`) is deliberately excluded — those come
* from migrations fired by `RefreshDatabase`, and capturing them
* here would attribute every migration table to every test.
*/
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete']; private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
/** /**
* @return list<string> Sorted, deduped table names referenced by the * @return list<string> Sorted, deduped table names referenced by the
* SQL statement. Empty when the statement is
* DDL, empty, or unparseable.
*/ */
public static function fromSql(string $sql): array public static function fromSql(string $sql): array
{ {
@ -69,9 +37,6 @@ final class TableExtractor
return []; return [];
} }
// Match `from`, `into`, `update`, `join` and capture the
// following identifier, tolerating the common quoting
// styles: "double", `back`, [bracket], or bare.
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i'; $pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
if (preg_match_all($pattern, $sql, $matches) === false) { if (preg_match_all($pattern, $sql, $matches) === false) {
@ -106,35 +71,11 @@ final class TableExtractor
/** /**
* @return list<string> Table names referenced by `Schema::` calls, * @return list<string> Table names referenced by `Schema::` calls,
* raw DDL, or DML inside the given migration
* file contents. Empty when nothing matches —
* callers treat that as "fall back to the
* broad watch pattern".
*
* Three passes:
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
* captures the conventional Laravel migration shape.
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
* TRUNCATE / RENAME TABLE <name>` patterns inside string
* literals (i.e. `DB::statement('CREATE TABLE …')`,
* `DB::unprepared('ALTER TABLE …')`).
* 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
* `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
* Catches the seeded-lookup-table case where a migration
* populates rows that tests later read.
*
* False positives possible when the same syntax appears in a
* comment or unrelated string, but over-attribution is
* correctness-safe.
*/ */
public static function fromMigrationSource(string $php): array public static function fromMigrationSource(string $php): array
{ {
$tables = []; $tables = [];
// Pass 1: Schema:: calls. `dropColumn` (singular) covers
// `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
// — the closure body's column op is on Blueprint, but the
// outer `Schema::table('users', …)` is what we capture here.
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/'; $schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
if (preg_match_all($schemaPattern, $php, $matches) !== false) { if (preg_match_all($schemaPattern, $php, $matches) !== false) {
@ -148,10 +89,6 @@ final class TableExtractor
} }
} }
// Pass 2: raw DDL fallback. Matches the table name following
// `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres'
// `IF EXISTS` / `IF NOT EXISTS` variants), with optional
// ANSI / MySQL / SQL Server quoting.
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i'; $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) { if (preg_match_all($ddlPattern, $php, $matches) !== false) {
@ -163,14 +100,6 @@ final class TableExtractor
} }
} }
// Pass 3: DML inside migration bodies. Migrations that seed
// lookup tables via `DB::statement('INSERT INTO roles …')`,
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
// `DELETE FROM bar` are common in Laravel. Without picking
// these up, an edit to the seed payload would route through
// only the schema'd tables and silently skip every test that
// reads from the populated table. Fluent-builder calls
// (`DB::table('x')`) and raw SQL strings are both covered.
$dmlPatterns = [ $dmlPatterns = [
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i', '/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i', '/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
@ -196,11 +125,6 @@ final class TableExtractor
return $out; return $out;
} }
/**
* Filters out driver-internal tables that show up as DB::listen
* targets without representing user schema: SQLite's master
* catalogue, Laravel's own `migrations` metadata.
*/
private static function isSchemaMeta(string $name): bool private static function isSchemaMeta(string $name): bool
{ {
$lower = strtolower($name); $lower = strtolower($name);

View File

@ -5,38 +5,12 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
/** /**
* Laravel-only collaborator: during record mode, attributes every SQL
* table the test body queries to the currently-running test.
*
* Why this exists: the coverage graph can tell us which PHP files a
* test touched but cannot distinguish "this test depends on the
* `users` table" from "this test depends on `questions`". That
* distinction is the whole point of surgical migration invalidation —
* a column rename in `create_questions_table.php` should only re-run
* tests whose body actually queried `questions`.
*
* Mechanism: install a listener on Laravel's event dispatcher that
* subscribes to `Illuminate\Database\Events\QueryExecuted`. Each
* query string is piped through `TableExtractor::fromSql()`; DDL is
* filtered at extraction time so migrations running in `setUp` don't
* attribute every table to every test.
*
* Same dep-free handshake as `BladeEdges`: string class lookup +
* method-capability probes so Pest's `require` stays Laravel-free.
*
* @internal * @internal
*/ */
final class TableTracker final class TableTracker
{ {
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
/**
* App-scoped marker that makes `arm()` idempotent across the 774
* per-test `setUp()` calls — Laravel reuses the same app instance
* within a single test run, so without this guard we'd stack
* one listener per test and each query would fire the closure
* hundreds of times.
*/
private const string MARKER = 'pest.tia.table-tracker-armed'; private const string MARKER = 'pest.tia.table-tracker-armed';
public static function arm(Recorder $recorder): void public static function arm(Recorder $recorder): void
@ -85,12 +59,6 @@ final class TableTracker
} }
}; };
// Preferred path: `DatabaseManager::listen(Closure $callback)`.
// It's a real method — `method_exists` returns false because
// some Laravel versions compose it via a trait the reflection
// probe can't always see, so we gate via `is_callable` instead.
// This path pushes the listener onto every existing AND future
// connection, which is what we want for a process-wide capture.
/** @var object $db */ /** @var object $db */
$db = $app->make('db'); $db = $app->make('db');
@ -102,11 +70,6 @@ final class TableTracker
return; return;
} }
// Fallback: register directly on the event dispatcher. Works
// as long as every connection shares the same dispatcher
// instance this app resolved to — true in vanilla setups,
// but not guaranteed with connections instantiated pre-arm
// that captured an older dispatcher.
if (! $app->bound('events')) { if (! $app->bound('events')) {
return; return;
} }
@ -118,11 +81,6 @@ final class TableTracker
return; return;
} }
// Event class key intentionally has no leading backslash —
// `Dispatcher::listen()` stores by the literal string and the
// lookup at dispatch time uses `get_class($event)` (no
// leading backslash), so a `\Illuminate\…` key would never
// match the fired event.
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener); $events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
} }
} }

View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\TestSuite;
/**
* Resolves the set of project-relative paths that are considered test files,
* driven by phpunit.xml's <testsuites>. Falls back to the runtime TestSuite
* configuration when no config file is present.
*
* @internal
*/
final readonly class TestPaths
{
/**
* @param list<string> $directories Project-relative directory prefixes (no trailing slash).
* @param list<string> $files Project-relative file paths.
* @param list<string> $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
{
$configPath = self::configPath($projectRoot);
$directories = [];
$files = [];
$suffixes = ['.php'];
if ($configPath !== null) {
$xml = @simplexml_load_file($configPath);
if ($xml !== false) {
$configDir = dirname($configPath);
foreach ($xml->xpath('testsuites/testsuite/directory') ?: [] as $node) {
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
if ($rel !== null) {
$directories[] = $rel;
}
$suffix = (string) ($node['suffix'] ?? '');
if ($suffix !== '' && ! in_array($suffix, $suffixes, true)) {
$suffixes[] = str_starts_with($suffix, '.') ? $suffix : '.'.$suffix;
}
}
foreach ($xml->xpath('testsuites/testsuite/file') ?: [] as $node) {
$rel = self::toRelative((string) $node, $configDir, $projectRoot);
if ($rel !== null) {
$files[] = $rel;
}
}
}
}
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 configPath(string $projectRoot): ?string
{
foreach (['phpunit.xml', 'phpunit.xml.dist'] as $name) {
$candidate = $projectRoot.DIRECTORY_SEPARATOR.$name;
if (is_file($candidate)) {
return $candidate;
}
}
return null;
}
private static function toRelative(string $value, string $configDir, string $projectRoot): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$isAbsolute = $value[0] === '/' || $value[0] === DIRECTORY_SEPARATOR
|| (strlen($value) >= 2 && $value[1] === ':');
$combined = $isAbsolute ? $value : $configDir.DIRECTORY_SEPARATOR.$value;
$real = @realpath($combined);
$resolved = $real === false ? $combined : $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)), '/');
}
}

View File

@ -10,20 +10,12 @@ use Pest\Factories\TestCaseFactory;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
* Watch patterns for frontend assets that affect browser tests.
*
* Uses `BrowserTestIdentifier` from pest-plugin-browser to auto-discover tests
* using `visit()`. Also keeps the `tests/Browser` convention when present.
*
* @internal * @internal
*/ */
final readonly class Browser implements WatchDefault final readonly class Browser implements WatchDefault
{ {
public function applicable(): bool public function applicable(): bool
{ {
// Browser tests can exist in any PHP project. We only activate when
// there is an actual `tests/Browser` directory OR pest-plugin-browser
// is installed.
return class_exists(InstalledVersions::class) return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser'); && InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
} }
@ -42,12 +34,8 @@ final readonly class Browser implements WatchDefault
'resources/css/**/*.css', 'resources/css/**/*.css',
'resources/css/**/*.scss', 'resources/css/**/*.scss',
'resources/css/**/*.less', 'resources/css/**/*.less',
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js', 'public/build/**/*.js',
'public/build/**/*.css', 'public/build/**/*.css',
// Static public assets can affect browser-rendered pages without
// any PHP file changing (favicons, robots, images, downloaded
// manifests, etc.). Only browser-test targets are invalidated.
'public/**/*.js', 'public/**/*.js',
'public/**/*.css', 'public/**/*.css',
'public/**/*.svg', 'public/**/*.svg',
@ -84,9 +72,6 @@ final readonly class Browser implements WatchDefault
$targets[] = $candidate; $targets[] = $candidate;
} }
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find exact tests using `visit()` outside the
// conventional Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) { if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests; $repo = TestSuite::getInstance()->tests;

View File

@ -7,12 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; use Composer\InstalledVersions;
/** /**
* Watch patterns for Inertia.js projects (Laravel or otherwise).
*
* Inertia bridges PHP controllers with JS/TS page components. A change to
* a React / Vue / Svelte page can break assertions in browser tests or
* Inertia-specific feature tests.
*
* @internal * @internal
*/ */
final readonly class Inertia implements WatchDefault final readonly class Inertia implements WatchDefault
@ -28,19 +22,6 @@ final readonly class Inertia implements WatchDefault
{ {
$browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath); $browserTargets = Browser::detectBrowserTestTargets($projectRoot, $testPath);
// Inertia page components (React / Vue / Svelte). Scoped to
// browser tests only — a Vue/React edit cannot change the
// output of a server-side Inertia test (those assert on the
// component *name* returned by `Inertia::render()`, not its
// client-side implementation). Broad invalidation is only
// meaningful for tests that actually render the DOM. Precise
// per-component edges come from `InertiaEdges` at record
// time and replace this fallback when available.
//
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
// React starter kit, and other lowercase-by-default setups)
// are emitted — paths from git are case-sensitive on Linux,
// so a single casing would silently miss the other convention.
$patterns = []; $patterns = [];
foreach (['Pages', 'pages'] as $pages) { foreach (['Pages', 'pages'] as $pages) {
@ -55,7 +36,6 @@ final readonly class Inertia implements WatchDefault
} }
} }
// SSR entry point.
$patterns['resources/js/ssr.js'] = $browserTargets; $patterns['resources/js/ssr.js'] = $browserTargets;
$patterns['resources/js/ssr.ts'] = $browserTargets; $patterns['resources/js/ssr.ts'] = $browserTargets;
$patterns['resources/js/app.js'] = $browserTargets; $patterns['resources/js/app.js'] = $browserTargets;

View File

@ -7,14 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; use Composer\InstalledVersions;
/** /**
* Watch patterns for Laravel projects.
*
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
* `Prepared` event where TIA's coverage window opens). That means PHP files
* loaded during boot — config, routes, service providers, migrations — are
* invisible to the coverage driver. Watch patterns are the only way to
* track them.
*
* @internal * @internal
*/ */
final readonly class Laravel implements WatchDefault final readonly class Laravel implements WatchDefault
@ -28,42 +20,23 @@ final readonly class Laravel implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
// Config — loaded during app boot (setUp), invisible to coverage.
// Affects both Feature and Unit: Pest.php commonly binds fakes
// and seeds DB based on config values.
'config/*.php' => [$testPath], 'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath], 'config/**/*.php' => [$testPath],
// Routes — loaded during boot. HTTP/Feature tests depend on them.
'routes/*.php' => [$testPath], 'routes/*.php' => [$testPath],
'routes/**/*.php' => [$testPath], 'routes/**/*.php' => [$testPath],
// Service providers / bootstrap — loaded during boot, affect
// bindings, middleware, event listeners, scheduled tasks.
'bootstrap/app.php' => [$testPath], 'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath], 'bootstrap/providers.php' => [$testPath],
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
// setUp. Schema changes can break any test that touches DB.
'database/migrations/**/*.php' => [$testPath], 'database/migrations/**/*.php' => [$testPath],
// Seeders — often run globally via Pest.php beforeEach.
'database/seeders/**/*.php' => [$testPath], 'database/seeders/**/*.php' => [$testPath],
// Factories — loaded lazily but still PHP that coverage may miss
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath], 'database/factories/**/*.php' => [$testPath],
// Project fixture data. Laravel apps often keep fake repository
// lockfiles / API payloads here and read them via `storage_path()`
// + `file_get_contents()`, which neither PHP coverage nor static
// import edges can observe.
'storage/fixtures/**/*' => [$testPath], 'storage/fixtures/**/*' => [$testPath],
// Non-PHP templates/data living beside app code. These are often
// read dynamically by services (Dockerfile templates, stubs,
// payload examples) and never appear in coverage because PHP only
// sees the reader method, not the external file.
'app/**/*.tpl' => [$testPath], 'app/**/*.tpl' => [$testPath],
'app/**/*.stub' => [$testPath], 'app/**/*.stub' => [$testPath],
'app/**/*.json' => [$testPath], 'app/**/*.json' => [$testPath],
@ -71,25 +44,16 @@ final readonly class Laravel implements WatchDefault
'app/**/*.yml' => [$testPath], 'app/**/*.yml' => [$testPath],
'app/**/*.txt' => [$testPath], 'app/**/*.txt' => [$testPath],
// Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$testPath], 'resources/views/**/*.blade.php' => [$testPath],
// Mail / view-adjacent themes can be read dynamically by
// mailables (for example Laravel's markdown mail theme CSS).
'resources/views/**/*.css' => [$testPath], 'resources/views/**/*.css' => [$testPath],
// Email templates are nested under views/email or views/emails
// by convention and power mailable tests that render markup.
'resources/views/email/**/*.blade.php' => [$testPath], 'resources/views/email/**/*.blade.php' => [$testPath],
'resources/views/emails/**/*.blade.php' => [$testPath], 'resources/views/emails/**/*.blade.php' => [$testPath],
// Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot).
'lang/**/*.php' => [$testPath], 'lang/**/*.php' => [$testPath],
'lang/**/*.json' => [$testPath], 'lang/**/*.json' => [$testPath],
'resources/lang/**/*.php' => [$testPath], 'resources/lang/**/*.php' => [$testPath],
'resources/lang/**/*.json' => [$testPath], 'resources/lang/**/*.json' => [$testPath],
// Build tool config — affects compiled assets consumed by
// browser and Inertia tests.
'vite.config.js' => [$testPath], 'vite.config.js' => [$testPath],
'vite.config.ts' => [$testPath], 'vite.config.ts' => [$testPath],
'webpack.mix.js' => [$testPath], 'webpack.mix.js' => [$testPath],

View File

@ -7,12 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; use Composer\InstalledVersions;
/** /**
* Watch patterns for projects using Livewire.
*
* Livewire components pair a PHP class with a Blade view. A view change can
* break rendering or assertions in feature / browser tests even though the
* PHP side is untouched.
*
* @internal * @internal
*/ */
final readonly class Livewire implements WatchDefault final readonly class Livewire implements WatchDefault
@ -26,15 +20,10 @@ final readonly class Livewire implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
return [ return [
// Livewire views live alongside Blade views or in a dedicated dir.
'resources/views/livewire/**/*.blade.php' => [$testPath], 'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath], 'resources/views/components/**/*.blade.php' => [$testPath],
// Volt's second default mount — single-file components used as
// full-page routes. Missing this means editing a Volt page
// doesn't re-run its tests.
'resources/views/pages/**/*.blade.php' => [$testPath], 'resources/views/pages/**/*.blade.php' => [$testPath],
// Livewire JS interop / Alpine plugins.
'resources/js/**/*.js' => [$testPath], 'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath], 'resources/js/**/*.ts' => [$testPath],
]; ];

View File

@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
/** /**
* Baseline watch patterns for any PHP project.
*
* @internal * @internal
*/ */
final readonly class Php implements WatchDefault final readonly class Php implements WatchDefault
@ -18,55 +16,24 @@ final readonly class Php implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
// NOTE: composer.json / composer.lock changes are caught by the
// fingerprint (which hashes composer.lock). PHP files are tracked by
// the coverage driver. Only non-PHP, non-fingerprinted files that
// can silently alter test behaviour belong here.
return [ return [
// Environment files — can change DB drivers, feature flags,
// queue connections, etc. Not PHP, not fingerprinted. Covers
// the local-override variants (`.env.local`, `.env.testing.local`)
// that both Laravel and Symfony recommend for machine-specific
// config.
'.env' => [$testPath], '.env' => [$testPath],
'.env.testing' => [$testPath], '.env.testing' => [$testPath],
'.env.local' => [$testPath], '.env.local' => [$testPath],
'.env.*.local' => [$testPath], '.env.*.local' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath], 'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath], 'docker-compose.yaml' => [$testPath],
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
// phpunit.xml.dist and other XML overrides are not individually
// tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath], 'phpunit.xml.dist' => [$testPath],
// `tests/Pest.php` is loaded once per suite (during BootFiles)
// so its `pest()->extend()`, `expect()->extend()`, helpers,
// etc. execute outside the per-test coverage window — no
// edge captures it. Watch-pattern broadcast triggers a
// replay of every test (results refresh) without a full
// record-mode graph rebuild.
$testPath.'/Pest.php' => [$testPath], $testPath.'/Pest.php' => [$testPath],
// Pest dataset definitions are loaded once at boot, outside
// the per-test coverage window — no edge captures them. A
// change to a shared dataset can flip the result of any test
// that uses it, so broadcast every dataset edit to the full
// suite.
$testPath.'/Datasets/**/*.php' => [$testPath], $testPath.'/Datasets/**/*.php' => [$testPath],
// Test fixtures — data/source snippets consumed by assertions or
// external analysers. Nested `Fixtures/` directories are common
// beside a single test class, and PHP fixtures may be parsed by
// tools without being `require`d, so coverage cannot see them.
$testPath.'/Fixtures/**/*' => [$testPath], $testPath.'/Fixtures/**/*' => [$testPath],
$testPath.'/**/Fixtures/**/*' => [$testPath], $testPath.'/**/Fixtures/**/*' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath], $testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
]; ];
} }

View File

@ -7,8 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; use Composer\InstalledVersions;
/** /**
* Watch patterns for Symfony projects.
*
* @internal * @internal
*/ */
final readonly class Symfony implements WatchDefault final readonly class Symfony implements WatchDefault
@ -21,12 +19,7 @@ final readonly class Symfony implements WatchDefault
public function defaults(string $projectRoot, string $testPath): array public function defaults(string $projectRoot, string $testPath): array
{ {
// Symfony boots the kernel in setUp() (before the coverage window).
// PHP config, routes, kernel, and migrations are loaded during boot
// and invisible to the coverage driver. Same reasoning as Laravel.
return [ return [
// Config — YAML, XML, and PHP. All loaded during kernel boot.
'config/*.yaml' => [$testPath], 'config/*.yaml' => [$testPath],
'config/*.yml' => [$testPath], 'config/*.yml' => [$testPath],
'config/*.php' => [$testPath], 'config/*.php' => [$testPath],
@ -36,37 +29,27 @@ final readonly class Symfony implements WatchDefault
'config/**/*.php' => [$testPath], 'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$testPath], 'config/**/*.xml' => [$testPath],
// Routes — loaded during boot.
'config/routes/*.yaml' => [$testPath], 'config/routes/*.yaml' => [$testPath],
'config/routes/*.php' => [$testPath], 'config/routes/*.php' => [$testPath],
'config/routes/*.xml' => [$testPath], 'config/routes/*.xml' => [$testPath],
'config/routes/**/*.yaml' => [$testPath], 'config/routes/**/*.yaml' => [$testPath],
// Kernel / bootstrap — loaded during boot.
'src/Kernel.php' => [$testPath], 'src/Kernel.php' => [$testPath],
// Migrations — run during setUp (before coverage window).
// DoctrineMigrationsBundle's default is `migrations/` at the
// project root; many Symfony projects relocate to
// `src/Migrations/` — both covered.
'migrations/**/*.php' => [$testPath], 'migrations/**/*.php' => [$testPath],
'src/Migrations/**/*.php' => [$testPath], 'src/Migrations/**/*.php' => [$testPath],
// Twig templates — compiled, source not PHP-executed.
'templates/**/*.html.twig' => [$testPath], 'templates/**/*.html.twig' => [$testPath],
'templates/**/*.twig' => [$testPath], 'templates/**/*.twig' => [$testPath],
// Translations (YAML / XLF / XLIFF).
'translations/**/*.yaml' => [$testPath], 'translations/**/*.yaml' => [$testPath],
'translations/**/*.yml' => [$testPath], 'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath], 'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath], 'translations/**/*.xliff' => [$testPath],
// Doctrine XML/YAML mappings.
'config/doctrine/**/*.xml' => [$testPath], 'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath], 'config/doctrine/**/*.yaml' => [$testPath],
// Webpack Encore / asset-mapper config + frontend sources.
'webpack.config.js' => [$testPath], 'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath], 'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath], 'assets/**/*.js' => [$testPath],

View File

@ -5,20 +5,10 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
/** /**
* A set of file-watch patterns that apply when a particular framework,
* library or project layout is detected.
*
* Each implementation probes for the presence of the tool it covers
* (`applicable`) and returns glob → test-directory mappings (`defaults`)
* that are merged into `WatchPatterns`.
*
* @internal * @internal
*/ */
interface WatchDefault interface WatchDefault
{ {
/**
* Whether this default set applies to the current project.
*/
public function applicable(): bool; public function applicable(): bool;
/** /**

View File

@ -8,25 +8,11 @@ use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\TestSuite; use Pest\TestSuite;
/** /**
* Maps non-PHP file globs to the tests they should invalidate.
*
* Coverage drivers only see `.php` files. Frontend assets, config files,
* Blade templates, routes and environment files are invisible to the graph.
* Watch patterns bridge the gap: when a changed file matches a glob, every
* test under the associated directory (or the exact associated test file) is
* marked as affected.
*
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
* each implementation probes the current project and contributes patterns
* when applicable. Users extend via `pest()->tia()->watch(…)`.
*
* @internal * @internal
*/ */
final class WatchPatterns final class WatchPatterns
{ {
/** /**
* All known default providers, in evaluation order.
*
* @var array<int, class-string<WatchDefault>> * @var array<int, class-string<WatchDefault>>
*/ */
private const array DEFAULTS = [ private const array DEFAULTS = [
@ -49,12 +35,6 @@ final class WatchPatterns
private bool $filtered = false; private bool $filtered = false;
/**
* Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
* calls are already in `$this->patterns`).
*/
public function useDefaults(string $projectRoot): void public function useDefaults(string $projectRoot): void
{ {
$testPath = TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
@ -75,9 +55,6 @@ final class WatchPatterns
} }
/** /**
* Adds user-defined patterns. Merges with existing entries so a single
* glob can map to multiple directories.
*
* @param array<string, string> $patterns glob → project-relative test dir/file * @param array<string, string> $patterns glob → project-relative test dir/file
*/ */
public function add(array $patterns): void public function add(array $patterns): void
@ -90,9 +67,6 @@ final class WatchPatterns
} }
/** /**
* Returns all test targets whose watch patterns match at least one of
* the given changed files.
*
* @param string $projectRoot Absolute path. * @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths. * @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test dirs/files. * @return array<int, string> Project-relative test dirs/files.
@ -119,9 +93,6 @@ final class WatchPatterns
} }
/** /**
* Given the affected targets, returns every test file in the graph that
* either matches an exact file target or lives under a directory target.
*
* @param array<int, string> $directories Project-relative dirs/files. * @param array<int, string> $directories Project-relative dirs/files.
* @param array<int, string> $allTestFiles Project-relative test files from graph. * @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string> * @return array<int, string>
@ -193,11 +164,6 @@ final class WatchPatterns
$this->filtered = false; $this->filtered = false;
} }
/**
* Matches a project-relative file against a glob pattern.
*
* Supports `*` (single segment), `**` (any depth) and `?`.
*/
private function globMatches(string $pattern, string $file): bool private function globMatches(string $pattern, string $file): bool
{ {
$pattern = str_replace('\\', '/', $pattern); $pattern = str_replace('\\', '/', $pattern);

View File

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
/**
* @internal
*/
final class PcovRestarter implements Restarter
{
private const string ENV_RESTARTED = 'PEST_PCOV_RESTARTER_RESTARTED';
/**
* @param array<int, string> $arguments
*/
public function maybeRestart(string $projectRoot, array $arguments): void
{
if (! extension_loaded('pcov')) {
return;
}
if (getenv(self::ENV_RESTARTED) === '1') {
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<int, string> $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<string, string>
*/
private function inheritEnv(): array
{
$env = [];
foreach (getenv() as $name => $value) {
$env[$name] = $value;
}
return $env;
}
private function normalise(string $path): string
{
return rtrim($path, '/\\');
}
}

View File

@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Pest\Restarters;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Contracts\Restarter;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* @internal
*/
final class XdebugRestarter implements Restarter
{
/**
* @param array<int, string> $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<int, string> $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),
);
}
}

View File

@ -10,10 +10,6 @@ use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber; use PHPUnit\Event\Test\FinishedSubscriber;
/** /**
* Fires last for each test, after the outcome subscribers. Records the exact
* assertion count so replay can emit the same `addToAssertionCount()` instead
* of a hardcoded value.
*
* @internal * @internal
*/ */
final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber
@ -31,10 +27,6 @@ final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements Finishe
); );
} }
// Close the "currently recording" window on Finished so the next
// test's events don't get mis-attributed. Keeping the pointer open
// through the outcome subscribers is what lets a late-firing
// `ConsideredRisky` overwrite an earlier `Passed`.
$this->collector->finishTest(); $this->collector->finishTest();
} }
} }

View File

@ -9,9 +9,6 @@ use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber; use PHPUnit\Event\Test\FinishedSubscriber;
/** /**
* Stops PCOV collection after each test and merges the covered files into the
* TIA recorder's aggregate map. No-op unless the recorder is active.
*
* @internal * @internal
*/ */
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber

View File

@ -10,10 +10,6 @@ use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber; use PHPUnit\Event\Test\PreparedSubscriber;
/** /**
* Starts PCOV collection before each test. Pest tests start from
* `Testable::setUp()` so Laravel boot is covered; this subscriber remains the
* fallback for PHPUnit-style tests and is idempotent for Pest tests.
*
* @internal * @internal
*/ */
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber

View File

@ -10,14 +10,6 @@ use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber; use PHPUnit\Event\Test\PreparedSubscriber;
/** /**
* Starts a per-test recording window on Prepared. Sibling subscribers
* (`EnsureTia*`) close it with the outcome and the assertion count so the
* graph can persist everything needed for faithful replay.
*
* Why one subscriber per event: PHPUnit's `TypeMap::map()` picks only the
* first subscriber interface it finds on a class, so one class cannot fan
* out to multiple events — each event needs its own subscriber class.
*
* @internal * @internal
*/ */
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber

18
src/Support/Cpu.php Normal file
View File

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

View File

@ -1,182 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
/**
* Re-execs the PHP process with `pcov.directory` pinned to the project
* root so pcov never instruments anything outside it (vendor, system
* includes, etc.).
*
* pcov reads `pcov.directory` once, on the first file it instruments —
* setting it via `ini_set()` from inside the test runner is too late
* for files already compiled by Composer's autoloader. Restarting the
* process with `-dpcov.directory=<root>` from the very top of `bin/pest`
* means *every* file pcov sees is filtered correctly.
*
* Only fires when ALL of these hold:
* 1. The pcov extension is loaded.
* 2. `--tia` is present in argv (plain `pest` runs are unaffected).
* 3. The current `pcov.directory` differs from the project root.
* 4. We are not already the restarted process — guarded by an env
* sentinel so a single round-trip is enough.
*
* Modelled after {@see XdebugGuard}: the same "check before doing real
* work in `bin/pest`" position, the same conservative gating around
* `--tia`. They are independent — both can fire on the same invocation
* (the user has pcov *and* xdebug loaded), in which case Xdebug is
* dropped first and the pcov restart inherits the slimmer process.
*
* @internal
*/
final class PcovGuard
{
private const string ENV_RESTARTED = 'PEST_PCOV_GUARD_RESTARTED';
/**
* Call as early as possible after Composer autoload, before any
* Pest class beyond the autoloader is touched. Idempotent and
* defensive — returns silently when pcov isn't installed, when the
* INI is already correct, or when we've already restarted.
*/
public static function maybeRestart(string $projectRoot): void
{
if (! extension_loaded('pcov')) {
return;
}
if (getenv(self::ENV_RESTARTED) === '1') {
return;
}
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
if (! self::hasTiaFlag($argv)) {
return;
}
$desired = self::normalise($projectRoot);
$current = self::normalise((string) ini_get('pcov.directory'));
if ($current === $desired) {
return;
}
self::restart($projectRoot, $argv);
}
/**
* @param array<int, mixed> $argv
*/
private static function hasTiaFlag(array $argv): bool
{
foreach ($argv as $value) {
if (is_string($value) && $value === '--tia') {
return true;
}
}
return false;
}
/**
* Spawns a child PHP process inheriting our stdin/stdout/stderr and
* exits with its status. `pcntl_exec` would be the cleanest path
* (replaces the current process, no double-buffering) but it isn't
* available on Windows or in environments that disable it; the
* `proc_open` fallback works everywhere PHP runs.
*
* @param array<int, mixed> $argv
*/
private static function restart(string $projectRoot, array $argv): void
{
$script = self::scriptArgv($argv);
if ($script === null) {
return;
}
$env = self::inheritEnv();
$env[self::ENV_RESTARTED] = '1';
$command = array_merge(
[PHP_BINARY, '-d', 'pcov.directory='.$projectRoot],
$script,
);
if (function_exists('pcntl_exec')) {
// `pcntl_exec` returns false on failure and replaces the
// process on success — no `exit` needed in the success path.
// Pass the env explicitly because pcntl_exec doesn't inherit
// by default.
$binary = array_shift($command);
if (is_string($binary)) {
@pcntl_exec($binary, $command, $env);
}
// If we're still here, pcntl_exec failed; fall through.
}
$proc = @proc_open(
$command,
[STDIN, STDOUT, STDERR],
$pipes,
null,
$env,
);
if (! is_resource($proc)) {
return;
}
$exitCode = proc_close($proc);
exit($exitCode === -1 ? 1 : $exitCode);
}
/**
* Reconstructs the argv we want the child process to receive: the
* script path followed by every original argument. Returns null
* when argv is malformed and we can't safely restart.
*
* @param array<int, mixed> $argv
* @return list<string>|null
*/
private static function scriptArgv(array $argv): ?array
{
$out = [];
foreach ($argv as $value) {
if (! is_string($value)) {
return null;
}
$out[] = $value;
}
return $out === [] ? null : $out;
}
/**
* @return array<string, string>
*/
private static function inheritEnv(): array
{
$env = [];
foreach (getenv() as $name => $value) {
if (is_string($name) && is_string($value)) {
$env[$name] = $value;
}
}
return $env;
}
private static function normalise(string $path): string
{
return rtrim($path, '/\\');
}
}

View File

@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestDoxBuilder;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Code\ThrowableBuilder; use PHPUnit\Event\Code\ThrowableBuilder;
use PHPUnit\Event\Test\Errored; use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\PhpunitDeprecationTriggered; use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
use PHPUnit\Event\Test\PhpunitErrorTriggered; use PHPUnit\Event\Test\PhpunitErrorTriggered;
use PHPUnit\Event\Test\PhpunitNoticeTriggered; use PHPUnit\Event\Test\PhpunitNoticeTriggered;
@ -40,11 +41,16 @@ final class StateGenerator
} }
foreach ($testResult->testFailedEvents() as $testResultEvent) { foreach ($testResult->testFailedEvents() as $testResultEvent) {
$state->add(TestResult::fromPestParallelTestCase( if ($testResultEvent instanceof Failed) {
$testResultEvent->test(), $state->add(TestResult::fromPestParallelTestCase(
TestResult::FAIL, $testResultEvent->test(),
$testResultEvent->throwable() TestResult::FAIL,
)); $testResultEvent->throwable()
));
} else {
// @phpstan-ignore-next-line
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
}
} }
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL); $this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);

View File

@ -1,178 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Support;
use Composer\XdebugHandler\XdebugHandler;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Storage;
/**
* Re-execs the PHP process without Xdebug on TIA replay runs, matching the
* behaviour of composer, phpstan, rector, psalm and pint.
*
* Xdebug imposes a 3050% runtime tax on every PHP process that loads it —
* even when nothing is actively tracing, profiling or breaking. Plain `pest`
* users might rely on Xdebug being loaded (IDE breakpoints, step-through
* debugging, custom tooling), so we intentionally leave non-TIA runs alone.
*
* The guard engages only when ALL of these hold:
* 1. `--tia` is present in argv.
* 2. No `--fresh` flag (forced record always drives the coverage
* driver; dropping Xdebug would break the recording).
* 3. No `--coverage*` flag (coverage runs need the driver regardless).
* 4. A valid graph already exists on disk AND its structural fingerprint
* matches the current environment — i.e. TIA will replay rather than
* record. Record runs need the driver.
* 5. Xdebug's configured mode is either empty or exactly `['coverage']`.
* Any other mode (debug, develop, trace, profile, gcstats) signals the
* user wants Xdebug for reasons unrelated to coverage, so we leave it
* alone even on replay.
*
* `PEST_ALLOW_XDEBUG=1` remains an explicit manual override; it is honoured
* natively by `composer/xdebug-handler`.
*
* @internal
*/
final class XdebugGuard
{
/**
* Call as early as possible after composer autoload, before any Pest
* class beyond the autoloader is touched. Safe when Xdebug is not
* loaded (returns immediately) and when `composer/xdebug-handler` is
* unavailable (defensive `class_exists` check).
*/
public static function maybeDrop(string $projectRoot): void
{
if (! class_exists(XdebugHandler::class)) {
return;
}
if (! extension_loaded('xdebug')) {
return;
}
if (! self::xdebugIsCoverageOnly()) {
return;
}
$argv = is_array($_SERVER['argv'] ?? null) ? $_SERVER['argv'] : [];
if (! self::runLooksDroppable($argv, $projectRoot)) {
return;
}
(new XdebugHandler('pest'))->check();
}
/**
* True when Xdebug 3+ is running in coverage-only mode (or empty). False
* for older Xdebug without `xdebug_info` — be conservative and leave it
* loaded; we can't prove the mode is safe to drop.
*/
private static 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'];
}
/**
* Encodes the argv-based rules: `--tia` must be present, no coverage
* flag, no forced rebuild, and TIA must be about to replay rather than
* record. Plain `pest` (and anything else without `--tia`) keeps Xdebug
* loaded so non-TIA users aren't surprised by behaviour changes.
*
* @param array<int, mixed> $argv
*/
private static function runLooksDroppable(array $argv, string $projectRoot): bool
{
$hasTia = false;
foreach ($argv as $value) {
if (! is_string($value)) {
continue;
}
if ($value === '--coverage'
|| str_starts_with($value, '--coverage=')
|| str_starts_with($value, '--coverage-')) {
return false;
}
if ($value === '--fresh') {
return false;
}
if ($value === '--tia') {
$hasTia = true;
}
}
if (! $hasTia) {
return false;
}
return self::tiaWillReplay($projectRoot);
}
/**
* True when a valid TIA graph already lives on disk AND its structural
* fingerprint matches the current environment. Any other outcome
* (missing graph, unreadable JSON, structural drift) means TIA will
* record and the driver must stay loaded.
*/
private static function tiaWillReplay(string $projectRoot): bool
{
$path = self::graphPath($projectRoot);
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),
);
}
/**
* On-disk location of the TIA graph — delegates to {@see Storage} so
* the writer (TIA's bootstrapper) and this reader stay in sync
* without a runtime container lookup (the container isn't booted yet
* at this point).
*/
private static function graphPath(string $projectRoot): string
{
return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH;
}
}

View File

@ -8,11 +8,6 @@ use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Graph;
/** /**
* Accepts a test file only if it is in the TIA-computed affected set.
*
* Falls back to accepting when the graph has no record of the file (new tests
* must always run) or when the file is outside the project root.
*
* @internal * @internal
*/ */
final readonly class TiaTestCaseFilter implements TestCaseFilter final readonly class TiaTestCaseFilter implements TestCaseFilter

View File

@ -1,7 +1,7 @@
PASS Tests\Arch PASS Tests\Arch
✓ preset → php → ignoring ['Pest\Expectation', 'debug_backtrace', 'var_export', …] ✓ 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', …] ✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
✓ globals ✓ globals
✓ contracts ✓ contracts

View File

@ -1,6 +1,7 @@
<?php <?php
use Pest\Expectation; use Pest\Expectation;
use Pest\Plugins\Tia\BaselineSync;
arch()->preset()->php()->ignoring([ arch()->preset()->php()->ignoring([
Expectation::class, Expectation::class,
@ -13,6 +14,7 @@ arch()->preset()->php()->ignoring([
]); ]);
arch()->preset()->strict()->ignoring([ arch()->preset()->strict()->ignoring([
BaselineSync::class,
'usleep', 'usleep',
]); ]);