mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
23 Commits
d106b70766
...
6407c4f78f
| Author | SHA1 | Date | |
|---|---|---|---|
| 6407c4f78f | |||
| 6e1bf63f6a | |||
| 1d3e8bb5dd | |||
| 3cc9b169e3 | |||
| c4911d046b | |||
| d0295f6168 | |||
| 21efbc3107 | |||
| e59b99cd73 | |||
| bf48e20880 | |||
| 53db68e005 | |||
| 34f1e9a7f2 | |||
| 57fd5ce042 | |||
| 3bcabfb63b | |||
| aa3a7c303a | |||
| 5c08a135f7 | |||
| 6e0e030d71 | |||
| b2c07561e7 | |||
| 97600b6f0b | |||
| 8a51f15d65 | |||
| a349f53964 | |||
| a725e774c0 | |||
| bed5e5b54a | |||
| 45b1d4ce20 |
25
bin/pest
25
bin/pest
@ -3,8 +3,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Contracts\Restarter;
|
||||
use Pest\Kernel;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestCaseFilters\GitDirtyTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\AssigneeTestCaseFilter;
|
||||
use Pest\TestCaseMethodFilters\IssueTestCaseFilter;
|
||||
@ -143,20 +145,6 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
// Get $rootPath based on $autoloadPath
|
||||
$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;
|
||||
|
||||
$testSuite = TestSuite::getInstance(
|
||||
@ -207,6 +195,15 @@ use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
try {
|
||||
$kernel = Kernel::boot($testSuite, $input, $output);
|
||||
|
||||
$container = Container::getInstance();
|
||||
|
||||
foreach (Kernel::RESTARTERS as $restarterClass) {
|
||||
$restarter = $container->get($restarterClass);
|
||||
assert($restarter instanceof Restarter);
|
||||
|
||||
$restarter->maybeRestart($rootPath, $originalArguments);
|
||||
}
|
||||
|
||||
$result = $kernel->handle($originalArguments, $arguments);
|
||||
|
||||
$kernel->terminate();
|
||||
|
||||
@ -1,23 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
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'
|
||||
|
||||
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 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 vitePath = projectRequire.resolve('vite')
|
||||
return await import(pathToFileURL(vitePath).href)
|
||||
const path = projectRequire.resolve('rolldown')
|
||||
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) {
|
||||
if (!existsSync(pagesDir)) return []
|
||||
@ -43,6 +70,14 @@ function componentNameFor(pageAbs, pagesDir) {
|
||||
return rel.slice(0, rel.length - ext.length)
|
||||
}
|
||||
|
||||
function isLocalSpecifier(source, aliasKeys) {
|
||||
if (source.startsWith('.') || source.startsWith('/')) return true
|
||||
for (const key of aliasKeys) {
|
||||
if (source === key || source.startsWith(key + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
|
||||
const pages = await listPageFiles(pagesDir)
|
||||
@ -52,72 +87,104 @@ async function main() {
|
||||
return
|
||||
}
|
||||
|
||||
const server = await createServer({
|
||||
configFile: undefined, // auto-detect vite.config.*
|
||||
root: PROJECT_ROOT,
|
||||
logLevel: 'silent',
|
||||
clearScreen: false,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: false,
|
||||
watch: null,
|
||||
const { rolldown } = await loadRolldown()
|
||||
const alias = await loadAliasFromTsconfig()
|
||||
const aliasKeys = Object.keys(alias)
|
||||
|
||||
const graph = new Map()
|
||||
|
||||
const collector = {
|
||||
name: 'pest-tia-collector',
|
||||
moduleParsed(info) {
|
||||
const id = info.id
|
||||
if (!id || id.startsWith('\0')) return
|
||||
const deps = new Set()
|
||||
for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i)
|
||||
graph.set(id, deps)
|
||||
},
|
||||
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 {
|
||||
for (const pagePath of pages) {
|
||||
const pageComponent = pageComponentCache.get(pagePath)
|
||||
const pageUrl = '/' + posix.relative(
|
||||
PROJECT_ROOT.split(sep).join('/'),
|
||||
pagePath.split(sep).join('/'),
|
||||
)
|
||||
const externalBare = {
|
||||
name: 'pest-tia-external-bare',
|
||||
resolveId(source) {
|
||||
if (!source) return null
|
||||
if (isLocalSpecifier(source, aliasKeys)) return null
|
||||
return { id: source, external: true }
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await server.transformRequest(pageUrl, { ssr: false })
|
||||
} catch {
|
||||
continue
|
||||
const assetStub = {
|
||||
name: 'pest-tia-asset-stub',
|
||||
load(id) {
|
||||
if (!id) return null
|
||||
if (ASSET_EXT_RE.test(id)) {
|
||||
return { code: 'export default null', moduleSideEffects: false }
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
|
||||
if (!pageModule) continue
|
||||
const input = Object.create(null)
|
||||
for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i]
|
||||
|
||||
const visited = new Set()
|
||||
const queue = [pageModule]
|
||||
while (queue.length) {
|
||||
const mod = queue.shift()
|
||||
for (const imported of mod.importedModules) {
|
||||
const id = imported.file ?? imported.id
|
||||
if (!id || visited.has(id)) continue
|
||||
visited.add(id)
|
||||
const bundle = await rolldown({
|
||||
input,
|
||||
cwd: PROJECT_ROOT,
|
||||
resolve: {
|
||||
alias,
|
||||
extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'],
|
||||
},
|
||||
transform: { jsx: 'preserve' },
|
||||
treeshake: false,
|
||||
plugins: [externalBare, assetStub, collector],
|
||||
logLevel: 'silent',
|
||||
onLog: () => {},
|
||||
})
|
||||
|
||||
if (id.startsWith('\0')) continue
|
||||
if (!id.startsWith(PROJECT_ROOT)) continue
|
||||
try {
|
||||
await bundle.generate({ format: 'esm' })
|
||||
} finally {
|
||||
await bundle.close()
|
||||
}
|
||||
|
||||
const rel = relative(PROJECT_ROOT, id).split(sep).join('/')
|
||||
const bucket = reverse.get(rel) ?? new Set()
|
||||
bucket.add(pageComponent)
|
||||
reverse.set(rel, bucket)
|
||||
const reverse = new Map()
|
||||
const transitiveCache = new Map()
|
||||
|
||||
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 {
|
||||
clearTimeout(killer)
|
||||
await server.close()
|
||||
stack.delete(id)
|
||||
transitiveCache.set(id, acc)
|
||||
return acc
|
||||
}
|
||||
|
||||
for (const page of pages) {
|
||||
const pageComponent = componentNameFor(page, pagesDir)
|
||||
const reachable = computeTransitive(page, new Set())
|
||||
if (!reachable) continue
|
||||
for (const rel of reachable) {
|
||||
const bucket = reverse.get(rel) ?? new Set()
|
||||
bucket.add(pageComponent)
|
||||
reverse.set(rel, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = Object.create(null)
|
||||
|
||||
@ -20,18 +20,19 @@
|
||||
"php": "^8.3.0",
|
||||
"brianium/paratest": "^7.20.0",
|
||||
"composer/xdebug-handler": "^3.0.5",
|
||||
"fidry/cpu-core-counter": "^1.3",
|
||||
"nunomaduro/collision": "^8.9.4",
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^4.0.0",
|
||||
"pestphp/pest-plugin-arch": "^4.0.2",
|
||||
"pestphp/pest-plugin-mutate": "^4.0.1",
|
||||
"pestphp/pest-plugin-profanity": "^4.2.1",
|
||||
"phpunit/phpunit": "^12.5.23",
|
||||
"phpunit/phpunit": "^12.5.24",
|
||||
"symfony/process": "^7.4.8|^8.0.8"
|
||||
},
|
||||
"conflict": {
|
||||
"filp/whoops": "<2.18.3",
|
||||
"phpunit/phpunit": ">12.5.23",
|
||||
"phpunit/phpunit": ">12.5.24",
|
||||
"sebastian/exporter": "<7.0.0",
|
||||
"webmozart/assert": "<1.11.0"
|
||||
},
|
||||
@ -59,7 +60,7 @@
|
||||
]
|
||||
},
|
||||
"require-dev": {
|
||||
"mrpunyapal/peststan": "^0.2.5",
|
||||
"mrpunyapal/peststan": "^0.2.9",
|
||||
"pestphp/pest-dev-tools": "^4.1.0",
|
||||
"pestphp/pest-plugin-browser": "^4.3.1",
|
||||
"pestphp/pest-plugin-type-coverage": "^4.0.4",
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
[$bgBadgeColor, $bgBadgeText] = match ($type) {
|
||||
'INFO' => ['blue', 'INFO'],
|
||||
'ERROR' => ['red', 'ERROR'],
|
||||
'WARN' => ['yellow', 'WARN'],
|
||||
'SUCCESS' => ['green', 'SUCCESS'],
|
||||
};
|
||||
|
||||
?>
|
||||
|
||||
@ -8,11 +8,10 @@ use Closure;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\AutoloadEdges;
|
||||
use Pest\Plugins\Tia\BladeEdges;
|
||||
use Pest\Plugins\Tia\InertiaEdges;
|
||||
use Pest\Plugins\Tia\Collectors;
|
||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\TableTracker;
|
||||
use Pest\Plugins\Tia\Replay;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\Container;
|
||||
@ -275,75 +274,39 @@ trait Testable
|
||||
self::$__latestIssues = $method->issues;
|
||||
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 */
|
||||
$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 ($cached->isSuccess()) {
|
||||
$this->__cachedPass = true;
|
||||
$this->__ran = true;
|
||||
if ($replay !== Replay::No) {
|
||||
assert($status !== null);
|
||||
|
||||
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.
|
||||
// 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');
|
||||
return;
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
$autoloadBeforeSetUp = $recorder instanceof Recorder && $recorder->isActive()
|
||||
$autoloadBeforeSetUp = $recorder->isActive()
|
||||
? AutoloadEdges::snapshot()
|
||||
: [];
|
||||
|
||||
parent::setUp();
|
||||
|
||||
// TIA blade-edge + table-edge recording (Laravel-only). Runs
|
||||
// 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);
|
||||
}
|
||||
Collectors::armAll($recorder);
|
||||
|
||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||
|
||||
@ -353,7 +316,7 @@ trait Testable
|
||||
|
||||
$this->__callClosure($beforeEach, $arguments);
|
||||
|
||||
if ($recorder instanceof Recorder && $recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
||||
if ($recorder->isActive() && $autoloadBeforeSetUp !== []) {
|
||||
$recorder->linkSourcesForTest(
|
||||
self::$__filename,
|
||||
AutoloadEdges::newProjectFiles(
|
||||
@ -366,6 +329,12 @@ trait Testable
|
||||
}
|
||||
}
|
||||
|
||||
private function __shortCircuitCachedPass(): void
|
||||
{
|
||||
$this->__cachedPass = true;
|
||||
$this->__ran = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize test case properties from TestSuite.
|
||||
*/
|
||||
@ -435,7 +404,7 @@ trait Testable
|
||||
// accurate on replay instead of collapsing to 1-per-test.
|
||||
/** @var Tia $tia */
|
||||
$tia = Container::getInstance()->get(Tia::class);
|
||||
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
|
||||
$assertions = $tia->getAssertionCount($this::class.'::'.$this->name());
|
||||
|
||||
if ($assertions === 0) {
|
||||
$this->expectNotToPerformAssertions();
|
||||
|
||||
16
src/Contracts/Restarter.php
Normal file
16
src/Contracts/Restarter.php
Normal 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;
|
||||
}
|
||||
@ -7,18 +7,12 @@ namespace Pest\Exceptions;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessEditor;
|
||||
use NunoMaduro\Collision\Contracts\RenderlessTrace;
|
||||
use Pest\Contracts\Panicable;
|
||||
use Pest\Support\View;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Console\Exception\ExceptionInterface;
|
||||
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
|
||||
*/
|
||||
final class BaselineFetchFailed extends RuntimeException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
@ -26,23 +20,35 @@ final class BaselineFetchFailed extends RuntimeException implements ExceptionInt
|
||||
public function __construct(
|
||||
private readonly string $headline,
|
||||
private readonly string $hint,
|
||||
private readonly bool $hasAnchor = false,
|
||||
) {
|
||||
parent::__construct($headline);
|
||||
}
|
||||
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> TIA </> '.$this->headline,
|
||||
' <fg=gray>'.$this->hint.'</>',
|
||||
' <fg=gray>Bypass with</> <fg=cyan>--fresh</> <fg=gray>to record locally and skip the baseline fetch.</>',
|
||||
'',
|
||||
]);
|
||||
View::renderUsing($output);
|
||||
|
||||
if (! $this->hasAnchor) {
|
||||
View::render('components.badge', ['type' => 'ERROR', 'content' => $this->headline]);
|
||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||
$output->writeln('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->renderChild($output, $this->headline);
|
||||
$this->renderChild($output, $this->hint.' Or use [--fresh] to record locally.');
|
||||
$output->writeln('');
|
||||
}
|
||||
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function renderChild(OutputInterface $output, string $text): void
|
||||
{
|
||||
$output->writeln(sprintf(' <fg=gray>─ %s</>', $text));
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,6 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
*/
|
||||
final class NoAffectedTestsFound extends InvalidArgumentException implements ExceptionInterface, Panicable, RenderlessEditor, RenderlessTrace
|
||||
{
|
||||
/**
|
||||
* Renders the panic on the given output.
|
||||
*/
|
||||
public function render(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln([
|
||||
@ -28,9 +25,6 @@ final class NoAffectedTestsFound extends InvalidArgumentException implements Exc
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit code to be used.
|
||||
*/
|
||||
public function exitCode(): int
|
||||
{
|
||||
return 0;
|
||||
|
||||
@ -44,6 +44,18 @@ final readonly class Kernel
|
||||
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.
|
||||
*/
|
||||
|
||||
@ -12,7 +12,9 @@ use PHPUnit\Event\Code\Test;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\Throwable;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodErrored;
|
||||
use PHPUnit\Event\Test\AfterLastTestMethodFailed;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodErrored;
|
||||
use PHPUnit\Event\Test\BeforeFirstTestMethodFailed;
|
||||
use PHPUnit\Event\Test\ConsideredRisky;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
@ -255,9 +257,11 @@ final readonly class Converter
|
||||
$numberOfNotPassedTests = count(
|
||||
array_unique(
|
||||
array_map(
|
||||
function (AfterLastTestMethodErrored|BeforeFirstTestMethodErrored|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
function (AfterLastTestMethodErrored|AfterLastTestMethodFailed|BeforeFirstTestMethodErrored|BeforeFirstTestMethodFailed|Errored|Failed|Skipped|ConsideredRisky|MarkedIncomplete $event): string {
|
||||
if ($event instanceof BeforeFirstTestMethodErrored
|
||||
|| $event instanceof AfterLastTestMethodErrored) {
|
||||
|| $event instanceof AfterLastTestMethodErrored
|
||||
|| $event instanceof BeforeFirstTestMethodFailed
|
||||
|| $event instanceof AfterLastTestMethodFailed) {
|
||||
return $event->testClassName();
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,8 @@ use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter;
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Exceptions\NoAffectedTestsFound;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia\BaselineSync;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
@ -20,9 +22,8 @@ use Pest\Plugins\Tia\ResultCollector;
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
use Pest\Plugins\Tia\TableExtractor;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
use Pest\Exceptions\NoAffectedTestsFound;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\View;
|
||||
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
@ -31,11 +32,6 @@ use Symfony\Component\Process\Process;
|
||||
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
|
||||
*/
|
||||
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-';
|
||||
|
||||
/** Sentinel dropped by a recording worker without a usable coverage 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';
|
||||
|
||||
@ -71,10 +66,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||
|
||||
/** Tells workers to apply TiaTestCaseFilter instead of cache short-circuiting. */
|
||||
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 bool $graphWritten = false;
|
||||
@ -97,6 +90,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/** @var array<string, true> */
|
||||
private array $affectedFiles = [];
|
||||
|
||||
/** @var array{structural: array<string, mixed>, environmental: array<string, mixed>}|null */
|
||||
private ?array $startFingerprint = null;
|
||||
|
||||
private function workerEdgesKey(string $token): string
|
||||
{
|
||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||
@ -113,10 +109,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
private bool $forceRefetch = false;
|
||||
|
||||
/** Prevents fetching the same stale baseline twice after structural drift. */
|
||||
private bool $baselineFetchAttemptedForDrift = false;
|
||||
|
||||
/** Gates `Graph::pruneMissingTests()` — only safe on full `--fresh` rebuilds. */
|
||||
private bool $freshRebuild = false;
|
||||
|
||||
private bool $filteredMode = false;
|
||||
@ -130,6 +124,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
@ -196,7 +229,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getCachedAssertions(string $testId): int
|
||||
public function getAssertionCount(string $testId): int
|
||||
{
|
||||
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';
|
||||
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
|
||||
|
||||
/** @var Tia\WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(Tia\WatchPatterns::class);
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$cliEnabled = $this->hasArgument(self::OPTION, $arguments);
|
||||
$alwaysEnabled = $watchPatterns->isEnabled()
|
||||
&& (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL);
|
||||
$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);
|
||||
$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::FRESH_OPTION, $arguments);
|
||||
$arguments = $this->popArgument(self::REFETCH_OPTION, $arguments);
|
||||
@ -300,8 +332,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
$currentFingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
if ($this->structuralFingerprintShifted($currentFingerprint)) {
|
||||
$this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.');
|
||||
$this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.');
|
||||
$recorder->reset();
|
||||
$this->coverageCollector->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setFingerprint($currentFingerprint);
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
$graph->setLastRunTree(
|
||||
$this->branch,
|
||||
@ -319,17 +362,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->seedResultsInto($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();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files).',
|
||||
count($perTest),
|
||||
));
|
||||
|
||||
$recorder->reset();
|
||||
$this->coverageCollector->reset();
|
||||
}
|
||||
@ -370,11 +408,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
$currentFingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
if ($this->structuralFingerprintShifted($currentFingerprint)) {
|
||||
$this->renderBadge('WARN', 'Project files changed during the run — discarding recorded edges.');
|
||||
$this->renderChild('Re-run --tia after your edits settle to record a fresh dependency graph.');
|
||||
|
||||
foreach ($partialKeys as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setFingerprint($currentFingerprint);
|
||||
$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(
|
||||
$this->branch,
|
||||
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
|
||||
@ -449,12 +498,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <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.',
|
||||
'',
|
||||
]);
|
||||
$this->renderBadge('ERROR', 'Recorded zero edges — coverage driver likely missing.');
|
||||
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.');
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
@ -469,27 +514,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials).',
|
||||
count($finalised),
|
||||
count($partialKeys),
|
||||
));
|
||||
|
||||
$this->snapshotTestResults();
|
||||
|
||||
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
|
||||
*/
|
||||
private function reconcileFingerprint(Graph $graph, array $current): ?Graph
|
||||
@ -499,8 +534,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if (! Fingerprint::structuralMatches($stored, $current)) {
|
||||
$drift = Fingerprint::structuralDrift($stored, $current);
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> graph structure outdated (%s).',
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'Graph structure outdated (%s).',
|
||||
$this->formatStructuralDrift($drift),
|
||||
));
|
||||
|
||||
@ -512,7 +547,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$branchSha,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
$this->output->writeln(' <fg=yellow>TIA</> rebuilding graph from scratch.');
|
||||
|
||||
$this->state->delete(self::KEY_GRAPH);
|
||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||
|
||||
@ -534,8 +567,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$drift = Fingerprint::environmentalDrift($stored, $current);
|
||||
|
||||
if ($drift !== []) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> env differs from baseline (%s) — results dropped, edges reused.',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Env differs from baseline (%s) — results dropped, edges reused.',
|
||||
implode(', ', $drift),
|
||||
));
|
||||
|
||||
@ -558,20 +591,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
$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) {
|
||||
Storage::purge($projectRoot);
|
||||
}
|
||||
|
||||
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
||||
$graph = ($forceRebuild || $this->forceRefetch) ? null : $this->loadGraph($projectRoot);
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
$graph = $this->reconcileFingerprint($graph, $fingerprint);
|
||||
@ -584,17 +610,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if ($changedFiles->gitAvailable()
|
||||
&& $branchSha !== null
|
||||
&& $changedFiles->since($branchSha) === null) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.',
|
||||
);
|
||||
$this->renderBadge('WARN', 'Recorded commit is no longer reachable — graph will be rebuilt.');
|
||||
$graph = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
&& ! $forceRebuild
|
||||
&& ! $this->baselineFetchAttemptedForDrift
|
||||
@ -610,17 +630,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$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)) {
|
||||
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
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@ -759,9 +762,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
|
||||
if (! $changedFiles->gitAvailable()) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> git unavailable — running full suite.',
|
||||
);
|
||||
$this->renderBadge('WARN', 'Git unavailable — running full suite.');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@ -772,19 +773,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$changed = $changedFiles->filterUnchangedSinceLastRun(
|
||||
$changed,
|
||||
$graph->lastRunTree($this->branch),
|
||||
$branchSha,
|
||||
);
|
||||
|
||||
$hasProjectPhpSourceChanges = $this->hasProjectPhpSourceChanges($changed);
|
||||
$coverageAvailable = $this->piggybackCoverage || $this->recorder->driverAvailable();
|
||||
|
||||
if ($hasProjectPhpSourceChanges && ! $coverageAvailable) {
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=black;bg=yellow> WARNING </> TIA detected PHP source changes but no coverage driver is available.',
|
||||
' 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.',
|
||||
'',
|
||||
]);
|
||||
$this->renderBadge('WARN', 'Detected PHP source changes but no coverage driver is available.');
|
||||
$this->renderChild('Running the full suite to avoid using a stale dependency graph.');
|
||||
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) so edges can be safely refreshed after PHP refactors.');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@ -793,12 +790,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$failedFromCache = [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -838,9 +829,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if (! $this->persistAffectedSet($affected)) {
|
||||
$this->output->writeln(
|
||||
' <fg=red>TIA</> failed to persist affected set — running full suite.',
|
||||
);
|
||||
$this->renderBadge('ERROR', 'Could not persist affected set — running full suite.');
|
||||
|
||||
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> $affectedFromChanges
|
||||
* @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
|
||||
{
|
||||
$this->output->writeln('');
|
||||
|
||||
if ($affected === []) {
|
||||
$this->renderChild('TIA mode enabled.');
|
||||
|
||||
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 === []
|
||||
? 0
|
||||
: count(array_diff($failedFromCache, $affectedFromChanges));
|
||||
@ -922,32 +901,27 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
);
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> %d affected test file%s%s.',
|
||||
$this->renderChild(sprintf(
|
||||
'TIA mode enabled / %d affected test file%s%s.',
|
||||
count($affected),
|
||||
count($affected) === 1 ? '' : 's',
|
||||
$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;
|
||||
sort($sorted);
|
||||
|
||||
$previewLimit = $this->output->isVerbose() ? count($sorted) : 10;
|
||||
$preview = array_slice($sorted, 0, $previewLimit);
|
||||
|
||||
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);
|
||||
|
||||
if ($remainder > 0) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=gray> … +%d more</>',
|
||||
$remainder,
|
||||
));
|
||||
$this->output->writeln(sprintf(' <fg=gray>… +%d more</>', $remainder));
|
||||
}
|
||||
}
|
||||
|
||||
@ -988,11 +962,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1');
|
||||
}
|
||||
|
||||
$this->output->writeln($this->piggybackCoverage
|
||||
? ' <fg=cyan>TIA</> recording dependency graph in parallel via `--coverage` (first run) — '.
|
||||
'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.');
|
||||
$this->output->writeln('');
|
||||
$this->renderChild('TIA mode enabled / fresh graph.');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@ -1000,10 +971,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if ($this->piggybackCoverage) {
|
||||
$this->recordingActive = true;
|
||||
|
||||
$this->output->writeln(
|
||||
' <fg=cyan>TIA</> recording dependency graph via `--coverage` (first run) — '.
|
||||
'subsequent `--tia` runs will only re-execute affected tests.',
|
||||
);
|
||||
$this->output->writeln('');
|
||||
$this->renderChild('TIA mode enabled / fresh graph.');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
@ -1011,25 +980,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$recorder->activate();
|
||||
$this->recordingActive = true;
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> recording dependency graph via %s (first run) — '.
|
||||
'subsequent `--tia` runs will only re-execute affected tests.',
|
||||
$recorder->driver(),
|
||||
));
|
||||
$this->renderChild('Running in TIA mode.');
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
private function emitCoverageDriverMissing(): void
|
||||
{
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=black;bg=yellow> WARNING </> No coverage driver is available — TIA skipped.',
|
||||
'',
|
||||
' 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`.',
|
||||
'',
|
||||
]);
|
||||
$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.');
|
||||
$this->renderChild('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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectWorkerEdgesPartials(): array
|
||||
{
|
||||
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->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> %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.',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'%d worker(s) had no coverage driver — their per-test edges and results were dropped.',
|
||||
count($keys),
|
||||
));
|
||||
$this->renderChild('Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.');
|
||||
}
|
||||
|
||||
private function purgeWorkerPartials(): void
|
||||
@ -1111,6 +1074,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->state->write($this->workerResultsKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectWorkerReplayPartials(): array
|
||||
{
|
||||
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
|
||||
@ -1252,8 +1218,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function registerRecap(): void
|
||||
{
|
||||
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()) {
|
||||
$this->mergeWorkerReplayPartials();
|
||||
}
|
||||
@ -1343,15 +1307,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
// The collector occasionally hands us nothing usable: PHPUnit's
|
||||
// Prepared event can miss the file for Pest-generated classes,
|
||||
// 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);
|
||||
if ($file === null || str_contains($file, "eval()'d")) {
|
||||
$file = $this->resolveFailedTestFile($testId);
|
||||
}
|
||||
|
||||
$graph->setResult(
|
||||
@ -1369,35 +1326,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private function resolveFailedTestFile(string $testId): ?string
|
||||
{
|
||||
$class = strstr($testId, '::', true);
|
||||
|
||||
if (! is_string($class) || $class === '') {
|
||||
if (! is_string($class) || $class === '' || ! class_exists($class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionClass($class);
|
||||
} catch (\ReflectionException) {
|
||||
return null;
|
||||
}
|
||||
$reflection = new \ReflectionClass($class);
|
||||
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
try {
|
||||
@ -1416,7 +1353,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
while ($current !== false) {
|
||||
$file = $current->getFileName();
|
||||
|
||||
if (is_string($file) && $file !== '' && ! str_contains($file, "eval()'d")) {
|
||||
if ($file !== false && ! str_contains($file, "eval()'d")) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
@ -1439,20 +1376,82 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP source changes can introduce new dependencies. Without a coverage
|
||||
* driver, replay can run the currently affected tests but cannot refresh
|
||||
* the graph, so a later edit to the newly introduced dependency could be
|
||||
* missed. Treat those runs as full-suite unless coverage can self-heal.
|
||||
*
|
||||
* @param array<int, string> $arguments
|
||||
*/
|
||||
private function hasExplicitPathArgument(array $arguments): bool
|
||||
{
|
||||
static $valueTakingFlags = [
|
||||
'-c', '--configuration', '--bootstrap', '--cache-directory',
|
||||
'--filter', '--group', '--exclude-group', '--covers', '--uses',
|
||||
'--test-suffix', '--testsuite', '--exclude-testsuite',
|
||||
'--printer', '--columns', '--colors', '--order-by', '--random-order-seed',
|
||||
'--include-path', '--whitelist',
|
||||
'--log-junit', '--log-teamcity', '--testdox-html', '--testdox-text',
|
||||
'--coverage-clover', '--coverage-cobertura', '--coverage-crap4j',
|
||||
'--coverage-html', '--coverage-php', '--coverage-text', '--coverage-xml',
|
||||
'--coverage-filter', '--path-coverage',
|
||||
'--repeat', '--retry-times', '--memory-limit', '--seed',
|
||||
'--compact', '--ci-build-id', '--min',
|
||||
];
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$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
|
||||
*/
|
||||
private function hasProjectPhpSourceChanges(array $changedFiles): bool
|
||||
{
|
||||
foreach ($changedFiles as $rel) {
|
||||
if (! is_string($rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_ends_with($rel, '.php')) {
|
||||
continue;
|
||||
}
|
||||
@ -1460,11 +1459,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
if (str_ends_with($rel, '.blade.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($rel, 'tests/')
|
||||
|| str_starts_with($rel, 'vendor/')
|
||||
|| str_starts_with($rel, 'storage/framework/')
|
||||
|| str_starts_with($rel, 'bootstrap/cache/')) {
|
||||
if (str_starts_with($rel, 'tests/')) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($rel, 'vendor/')) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($rel, 'storage/framework/')) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($rel, 'bootstrap/cache/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1490,7 +1494,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$this->baselineFetchAttemptedForDrift = true;
|
||||
|
||||
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) {
|
||||
if (! $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch, hasAnchor: true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1501,16 +1505,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> fetched baseline still drifts — discarding.',
|
||||
);
|
||||
$this->renderBadge('WARN', 'Fetched baseline still drifts — discarding.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->output->writeln(
|
||||
' <fg=green>TIA</> fetched baseline matches — skipping local rebuild.',
|
||||
);
|
||||
$this->renderBadge('SUCCESS', 'Fetched baseline matches — skipping local rebuild.');
|
||||
|
||||
return $fetched;
|
||||
}
|
||||
|
||||
@ -9,17 +9,11 @@ use Pest\Exceptions\BaselineFetchFailed;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\View;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
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
|
||||
*/
|
||||
final readonly class BaselineSync
|
||||
@ -32,20 +26,10 @@ final readonly class BaselineSync
|
||||
|
||||
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';
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
public function __construct(
|
||||
@ -53,7 +37,17 @@ final readonly class BaselineSync
|
||||
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);
|
||||
|
||||
@ -62,9 +56,8 @@ final readonly class BaselineSync
|
||||
}
|
||||
|
||||
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
|
||||
.'Override with <fg=cyan>--refetch</>.',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Last fetch found no baseline — next auto-retry in %s. Override with --refetch.',
|
||||
$this->formatDuration($remaining),
|
||||
));
|
||||
|
||||
@ -72,14 +65,9 @@ final readonly class BaselineSync
|
||||
}
|
||||
|
||||
$failureKind = null;
|
||||
$payload = $this->download($repo, $projectRoot, $failureKind);
|
||||
$payload = $this->download($repo, $projectRoot, $failureKind, $hasAnchor);
|
||||
|
||||
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) {
|
||||
$this->startCooldown();
|
||||
$this->emitPublishInstructions($repo);
|
||||
@ -98,9 +86,9 @@ final readonly class BaselineSync
|
||||
|
||||
$this->clearCooldown();
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> baseline ready (%s).',
|
||||
$this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')),
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'Baseline ready (%s).',
|
||||
$this->formatSize($payload['sizeOnDisk']),
|
||||
));
|
||||
|
||||
return true;
|
||||
@ -153,9 +141,7 @@ final readonly class BaselineSync
|
||||
private function emitPublishInstructions(string $repo): void
|
||||
{
|
||||
if ($this->isCi()) {
|
||||
$this->output->writeln(
|
||||
' <fg=yellow>TIA</> no baseline yet — this run will produce one.',
|
||||
);
|
||||
$this->renderBadge('INFO', 'No baseline yet — this run will produce one.');
|
||||
|
||||
return;
|
||||
}
|
||||
@ -164,31 +150,21 @@ final readonly class BaselineSync
|
||||
? $this->laravelWorkflowYaml()
|
||||
: $this->genericWorkflowYaml();
|
||||
|
||||
$preamble = [
|
||||
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
|
||||
'',
|
||||
' To share the baseline with your team, add this workflow to the repo:',
|
||||
'',
|
||||
' <fg=cyan>.github/workflows/tia-baseline.yml</>',
|
||||
'',
|
||||
];
|
||||
$this->renderBadge('WARN', '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');
|
||||
|
||||
$indentedYaml = array_map(
|
||||
static fn (string $line): string => ' '.$line,
|
||||
explode("\n", $yaml),
|
||||
);
|
||||
|
||||
$trailer = [
|
||||
'',
|
||||
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(['', ...$indentedYaml, '']);
|
||||
|
||||
$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
|
||||
{
|
||||
return getenv('GITHUB_ACTIONS') === 'true'
|
||||
@ -309,7 +285,7 @@ YAML;
|
||||
*
|
||||
* @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;
|
||||
|
||||
@ -317,6 +293,7 @@ YAML;
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'GitHub CLI (gh) not found — cannot fetch baseline.',
|
||||
'Install it from https://cli.github.com.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
@ -324,6 +301,7 @@ YAML;
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
'GitHub CLI (gh) is not authenticated — cannot fetch baseline.',
|
||||
'Run `gh auth login` and retry.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
@ -332,20 +310,16 @@ YAML;
|
||||
if ($listError !== null) {
|
||||
$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)) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
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
|
||||
// the diagnostic but let the suite fall through to record mode.
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> failed to query baseline runs — %s',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Failed to query baseline runs — %s',
|
||||
$listError['message'],
|
||||
));
|
||||
|
||||
@ -353,7 +327,6 @@ YAML;
|
||||
}
|
||||
|
||||
if ($runId === null) {
|
||||
// Genuine missing baseline — caller emits publish instructions.
|
||||
$failureKind = 'no-runs';
|
||||
|
||||
return null;
|
||||
@ -361,16 +334,11 @@ YAML;
|
||||
|
||||
$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)) {
|
||||
// Bump the dir mtime so trimDownloadCache() treats this run
|
||||
// id as recently used and doesn't evict it later.
|
||||
@touch($runCacheDir);
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=cyan>TIA</> using cached baseline from <fg=white>%s</> (run %s).',
|
||||
$this->renderBadge('INFO', sprintf(
|
||||
'Using cached baseline from %s (run %s).',
|
||||
$repo,
|
||||
$runId,
|
||||
));
|
||||
@ -384,14 +352,14 @@ YAML;
|
||||
|
||||
$artifactSize = $this->artifactSize($repo, $runId);
|
||||
|
||||
$this->output->writeln($artifactSize !== null
|
||||
$this->renderBadge('INFO', $artifactSize !== null
|
||||
? sprintf(
|
||||
' <fg=cyan>TIA</> fetching baseline (%s) from <fg=white>%s</>…',
|
||||
'Fetching baseline (%s) from %s…',
|
||||
$this->formatSize($artifactSize),
|
||||
$repo,
|
||||
)
|
||||
: sprintf(
|
||||
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
|
||||
'Fetching baseline from %s…',
|
||||
$repo,
|
||||
));
|
||||
|
||||
@ -420,17 +388,16 @@ YAML;
|
||||
$diagnosis = $this->classifyGhError($process->getErrorOutput().$process->getOutput());
|
||||
$failureKind = $diagnosis['kind'];
|
||||
|
||||
// Tier 1 — actionable. Stop hard with a clear diagnostic.
|
||||
if (in_array($failureKind, ['forbidden', 'not-found'], true)) {
|
||||
Panic::with(new BaselineFetchFailed(
|
||||
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->output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> baseline download failed — %s',
|
||||
$this->renderBadge('WARN', sprintf(
|
||||
'Baseline download failed — %s',
|
||||
$diagnosis['message'],
|
||||
));
|
||||
|
||||
@ -442,12 +409,10 @@ YAML;
|
||||
if ($payload === null) {
|
||||
$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(
|
||||
'Baseline downloaded but the artifact is missing expected files (graph.json).',
|
||||
'Your CI publish step is broken — check the workflow that uploads pest-tia-baseline.',
|
||||
$hasAnchor,
|
||||
));
|
||||
}
|
||||
|
||||
@ -456,18 +421,13 @@ YAML;
|
||||
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
|
||||
{
|
||||
$process = new Process([
|
||||
'gh', 'api',
|
||||
sprintf('repos/%s/actions/runs/%s/artifacts', $repo, $runId),
|
||||
'--jq', sprintf(
|
||||
'.artifacts[] | select(.name == "%s") | .size_in_bytes',
|
||||
'.artifacts[] | select(.name == "%s") | .size_in_bytes', // @pest-ignore-type
|
||||
self::ARTIFACT_NAME,
|
||||
),
|
||||
]);
|
||||
@ -490,14 +450,9 @@ YAML;
|
||||
$speed = (int) ($current / $elapsed);
|
||||
|
||||
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));
|
||||
$message = sprintf(
|
||||
' <fg=cyan>TIA</> downloading %s / %s (%d%%, %s/s)',
|
||||
' <fg=cyan>Downloading</> %s / %s (%d%%, %s/s)',
|
||||
$this->formatSize($current),
|
||||
$this->formatSize($totalBytes),
|
||||
$percent,
|
||||
@ -505,14 +460,12 @@ YAML;
|
||||
);
|
||||
} else {
|
||||
$message = sprintf(
|
||||
' <fg=cyan>TIA</> downloading %s (%s/s)',
|
||||
' <fg=cyan>Downloading</> %s (%s/s)',
|
||||
$this->formatSize($current),
|
||||
$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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
@ -562,6 +515,7 @@ YAML;
|
||||
return [
|
||||
'graph' => $graph,
|
||||
'coverage' => $coverage === false ? null : $coverage,
|
||||
'sizeOnDisk' => $this->dirSize($dir),
|
||||
];
|
||||
}
|
||||
|
||||
@ -570,11 +524,6 @@ YAML;
|
||||
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
|
||||
{
|
||||
$sanitised = preg_replace('/[^A-Za-z0-9_-]/', '', $runId) ?? '';
|
||||
@ -582,13 +531,6 @@ YAML;
|
||||
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
|
||||
{
|
||||
$root = $this->downloadCacheDir($projectRoot);
|
||||
@ -606,10 +548,12 @@ YAML;
|
||||
$candidates = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $root.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
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}}
|
||||
*/
|
||||
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}
|
||||
*/
|
||||
private function classifyGhError(string $output): array
|
||||
@ -725,10 +659,7 @@ YAML;
|
||||
];
|
||||
}
|
||||
|
||||
// Unknown — surface the first informative line so the user has
|
||||
// *something* to act on.
|
||||
$first = strtok($output, "\n");
|
||||
$message = is_string($first) ? trim($first) : 'unknown error';
|
||||
$message = trim(strtok($output, "\n"));
|
||||
|
||||
return ['kind' => 'unknown', 'message' => $message];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -10,17 +10,6 @@ use Pest\Support\Container;
|
||||
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
|
||||
*/
|
||||
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
|
||||
* derivation and the home-dir-missing fallback.
|
||||
*/
|
||||
private function tempDir(): string
|
||||
{
|
||||
|
||||
@ -7,19 +7,6 @@ namespace Pest\Plugins\Tia;
|
||||
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
|
||||
*/
|
||||
final readonly class ChangedFiles
|
||||
@ -31,13 +18,12 @@ final readonly class ChangedFiles
|
||||
* @param array<string, string> $lastRunTree path → content hash from last run.
|
||||
* @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 === []) {
|
||||
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);
|
||||
|
||||
foreach (array_keys($lastRunTree) as $snapshotted) {
|
||||
@ -58,8 +44,6 @@ final readonly class ChangedFiles
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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
|
||||
* @return array<string, string> path → xxh128 content hash
|
||||
*/
|
||||
@ -99,9 +79,6 @@ final readonly class ChangedFiles
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
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] = '';
|
||||
|
||||
continue;
|
||||
@ -119,8 +96,6 @@ final readonly class ChangedFiles
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
@ -140,9 +115,6 @@ final readonly class ChangedFiles
|
||||
|
||||
$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 = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
@ -157,13 +129,6 @@ final readonly class ChangedFiles
|
||||
|
||||
$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 !== '') {
|
||||
return $this->filterBehaviourallyUnchanged($candidates, $sha);
|
||||
}
|
||||
@ -183,7 +148,6 @@ final readonly class ChangedFiles
|
||||
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Deleted on disk — a genuine change, keep it.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@ -200,8 +164,6 @@ final readonly class ChangedFiles
|
||||
$baselineContent = $this->contentAtSha($sha, $file);
|
||||
|
||||
if ($baselineContent === null) {
|
||||
// Couldn't read the baseline (new file, binary, `git show`
|
||||
// failed). Err on the side of re-running.
|
||||
$remaining[] = $file;
|
||||
|
||||
continue;
|
||||
@ -217,12 +179,6 @@ final readonly class ChangedFiles
|
||||
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
|
||||
{
|
||||
$process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot);
|
||||
@ -244,10 +200,6 @@ final readonly class ChangedFiles
|
||||
'.phpunit.result.cache',
|
||||
'vendor/',
|
||||
'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/',
|
||||
];
|
||||
|
||||
@ -294,9 +246,6 @@ final readonly class ChangedFiles
|
||||
);
|
||||
$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;
|
||||
}
|
||||
|
||||
@ -323,14 +272,6 @@ final readonly class ChangedFiles
|
||||
*/
|
||||
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(
|
||||
['git', 'status', '--porcelain', '-z', '--untracked-files=all'],
|
||||
$this->projectRoot,
|
||||
@ -361,8 +302,6 @@ final readonly class ChangedFiles
|
||||
$status = substr($record, 0, 2);
|
||||
$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') {
|
||||
$files[] = $path;
|
||||
|
||||
|
||||
28
src/Plugins/Tia/Collectors.php
Normal file
28
src/Plugins/Tia/Collectors.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,26 +7,11 @@ namespace Pest\Plugins\Tia;
|
||||
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
|
||||
*/
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* Activates TIA for every run without requiring the `--tia` CLI flag.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
* @return $this
|
||||
*/
|
||||
|
||||
@ -5,33 +5,10 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
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
|
||||
{
|
||||
$raw = @file_get_contents($absolute);
|
||||
@ -43,11 +20,6 @@ final class ContentHash
|
||||
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
|
||||
{
|
||||
$lower = strtolower($path);
|
||||
@ -69,13 +41,6 @@ final class ContentHash
|
||||
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
|
||||
{
|
||||
$tokens = @token_get_all($raw);
|
||||
@ -106,14 +71,6 @@ final class ContentHash
|
||||
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
|
||||
{
|
||||
$stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw;
|
||||
@ -122,17 +79,6 @@ final class ContentHash
|
||||
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
|
||||
{
|
||||
$stripped = preg_replace('/^\s*\/\/[^\n]*$/m', '', $raw) ?? $raw;
|
||||
|
||||
@ -5,43 +5,19 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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 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>
|
||||
*/
|
||||
public function keysWithPrefix(string $prefix): array;
|
||||
|
||||
@ -9,34 +9,16 @@ use ReflectionClass;
|
||||
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
|
||||
*/
|
||||
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>
|
||||
*/
|
||||
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>>
|
||||
*/
|
||||
public function perTestFiles(): array
|
||||
@ -58,9 +40,6 @@ final class CoverageCollector
|
||||
$edges = [];
|
||||
|
||||
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 = [];
|
||||
|
||||
foreach ($lines as $hits) {
|
||||
@ -100,9 +79,6 @@ final class CoverageCollector
|
||||
|
||||
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, '#');
|
||||
$identifier = $hash === false ? $testId : substr($testId, 0, $hash);
|
||||
|
||||
@ -130,9 +106,6 @@ final class CoverageCollector
|
||||
|
||||
$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')) {
|
||||
$property = $reflection->getProperty('__filename');
|
||||
|
||||
|
||||
@ -11,33 +11,6 @@ use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
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
|
||||
*/
|
||||
final class CoverageMerger
|
||||
@ -55,19 +28,24 @@ final class CoverageMerger
|
||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
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);
|
||||
|
||||
if ($current instanceof CodeCoverage) {
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current)));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) {
|
||||
@ -80,21 +58,27 @@ final class CoverageMerger
|
||||
|
||||
$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(
|
||||
$reportPath,
|
||||
'<?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
|
||||
{
|
||||
$currentIds = self::collectTestIds($current);
|
||||
|
||||
@ -2,15 +2,9 @@
|
||||
|
||||
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
|
||||
*/
|
||||
final readonly class AutoloadEdges
|
||||
@ -23,7 +17,7 @@ final readonly class AutoloadEdges
|
||||
$files = [];
|
||||
|
||||
foreach (get_included_files() as $file) {
|
||||
if (is_string($file) && $file !== '') {
|
||||
if ($file !== '') {
|
||||
$files[$file] = true;
|
||||
}
|
||||
}
|
||||
@ -86,7 +80,7 @@ final readonly class AutoloadEdges
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($relative, $prefix)) {
|
||||
if (str_starts_with($relative, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
66
src/Plugins/Tia/Edges/BladeEdges.php
Normal file
66
src/Plugins/Tia/Edges/BladeEdges.php
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -2,53 +2,19 @@
|
||||
|
||||
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
|
||||
*/
|
||||
final class InertiaEdges
|
||||
{
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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
|
||||
{
|
||||
// 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)) {
|
||||
$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);
|
||||
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lookahead pair handles arbitrary attribute order on the
|
||||
// `<script>` tag.
|
||||
if (str_contains($content, 'type="application/json"')
|
||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $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=')
|
||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
@ -187,12 +122,6 @@ final class InertiaEdges
|
||||
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
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
@ -7,23 +7,10 @@ namespace Pest\Plugins\Tia;
|
||||
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
|
||||
*/
|
||||
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;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
@ -57,8 +44,6 @@ final readonly class FileState implements State
|
||||
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)) {
|
||||
@unlink($tmp);
|
||||
|
||||
@ -108,22 +93,11 @@ final readonly class FileState implements State
|
||||
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
|
||||
{
|
||||
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
|
||||
{
|
||||
$resolved = @realpath($this->rootDir);
|
||||
@ -131,10 +105,6 @@ final readonly class FileState implements State
|
||||
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
|
||||
{
|
||||
if (is_dir($this->rootDir)) {
|
||||
|
||||
@ -5,72 +5,10 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
@ -84,7 +22,6 @@ final readonly class Fingerprint
|
||||
return [
|
||||
'structural' => [
|
||||
'schema' => self::SCHEMA_VERSION,
|
||||
'composer_lock' => self::composerLockHash($projectRoot),
|
||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||
@ -96,10 +33,6 @@ final readonly class Fingerprint
|
||||
'composer_json' => self::composerJsonHash($projectRoot),
|
||||
],
|
||||
'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');
|
||||
}
|
||||
|
||||
// Legacy flat-shape fingerprints (schema ≤ 3) return empty, causing structuralMatches to fail → rebuild.
|
||||
/**
|
||||
* @param array<string, mixed> $fingerprint
|
||||
* @return array<string, mixed>
|
||||
@ -309,54 +241,6 @@ final readonly class Fingerprint
|
||||
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
|
||||
{
|
||||
$path = $projectRoot.'/composer.json';
|
||||
@ -404,86 +288,6 @@ final readonly class Fingerprint
|
||||
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
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
@ -522,66 +326,4 @@ final readonly class Fingerprint
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,16 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\View;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\Attributes\Group;
|
||||
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
|
||||
*/
|
||||
final class Graph
|
||||
@ -48,7 +46,6 @@ final class Graph
|
||||
*/
|
||||
private array $baselines = [];
|
||||
|
||||
// Resolved via realpath() so coverage driver paths (always real targets) match even when CWD is a symlink.
|
||||
private readonly string $projectRoot;
|
||||
|
||||
/** @var array<string, true>|null */
|
||||
@ -97,9 +94,6 @@ final class Graph
|
||||
|
||||
$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 = [];
|
||||
$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 = [];
|
||||
|
||||
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 = [];
|
||||
foreach ($nonMigrationPaths as $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 = [];
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($globalFrontendRuntimeFiles[$rel])) {
|
||||
@ -231,21 +218,18 @@ final class Graph
|
||||
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||
|
||||
if ($freshMap === null) {
|
||||
// Vite resolver unavailable — falling back to watch pattern; surface a line so the user
|
||||
// knows precision was downgraded rather than leaving the slower replay unexplained.
|
||||
$output = Container::getInstance()->get(OutputInterface::class);
|
||||
if ($output instanceof OutputInterface) {
|
||||
$output->writeln(sprintf(
|
||||
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
View::render('components.badge', [
|
||||
'type' => 'WARN',
|
||||
'content' => sprintf(
|
||||
'Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||
count($newJsFiles),
|
||||
));
|
||||
}
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
foreach ($newJsFiles as $rel) {
|
||||
$pages = $freshMap[$rel] ?? [];
|
||||
|
||||
if ($pages === []) {
|
||||
// Vite confirms no page imports this file — suppress the watch broadcast.
|
||||
$sharedFilesResolved[$rel] = true;
|
||||
|
||||
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 = [];
|
||||
$unknownSourceDirs = [];
|
||||
$sourcePhpChanged = false;
|
||||
@ -303,7 +285,6 @@ final class Graph
|
||||
$absolute = $this->projectRoot.'/'.$rel;
|
||||
|
||||
if (! is_file($absolute)) {
|
||||
// Deleted source file unknown to the graph — no edge ever pointed to it.
|
||||
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) {
|
||||
foreach (array_keys($this->edges) as $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
|
||||
// ancestors and invalidate only tests that covered them.
|
||||
$staticallyHandledBlade = [];
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($this->fileIds[$rel])) {
|
||||
@ -360,13 +355,10 @@ final class Graph
|
||||
|
||||
$staticallyHandledBlade[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
// Anonymous component with no static usages — treat as orphan rather than broadcasting.
|
||||
$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;
|
||||
foreach ($nonMigrationPaths as $rel) {
|
||||
if (isset($preciselyHandledPages[$rel])) {
|
||||
@ -380,7 +372,6 @@ final class Graph
|
||||
}
|
||||
if (! isset($this->fileIds[$rel])) {
|
||||
if (! is_file($this->projectRoot.'/'.$rel)) {
|
||||
// Deleted file unknown to the graph — no edge ever pointed to it.
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -398,9 +389,6 @@ final class Graph
|
||||
$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 !== []) {
|
||||
foreach ($this->edges as $testFile => $ids) {
|
||||
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
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
$this->baselines[$branch]['results'][$testId] = [
|
||||
|
||||
$entry = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'time' => $time,
|
||||
@ -482,9 +471,11 @@ final class Graph
|
||||
$rel = $this->relative($file);
|
||||
|
||||
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
|
||||
@ -508,9 +499,6 @@ final class Graph
|
||||
|
||||
$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']) {
|
||||
0 => TestStatus::success(),
|
||||
1 => TestStatus::skipped($r['message']),
|
||||
@ -534,15 +522,15 @@ final class Graph
|
||||
$files = [];
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
$status = $result['status'] ?? null;
|
||||
|
||||
if ($status !== 7 && $status !== 8) {
|
||||
if ($result['status'] !== 7 && $result['status'] !== 8) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '') {
|
||||
if ($file === null) {
|
||||
continue;
|
||||
}
|
||||
if ($file === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -561,15 +549,13 @@ final class Graph
|
||||
$baseline = $this->baselineFor($branch, $fallbackBranch);
|
||||
|
||||
foreach ($baseline['results'] as $result) {
|
||||
$status = $result['status'] ?? null;
|
||||
|
||||
if ($status !== 7 && $status !== 8) {
|
||||
if ($result['status'] !== 7 && $result['status'] !== 8) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if (! is_string($file) || $file === '' || $this->relative($file) === null) {
|
||||
if ($file === null || $file === '' || $this->relative($file) === null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -586,7 +572,6 @@ final class Graph
|
||||
$this->baselines[$branch]['tree'] = $tree;
|
||||
}
|
||||
|
||||
// Edges and tree snapshot stay intact; only the run-state is reset.
|
||||
public function clearResults(string $branch): void
|
||||
{
|
||||
$this->ensureBaseline($branch);
|
||||
@ -642,7 +627,6 @@ final class Graph
|
||||
$this->link($testFile, $source);
|
||||
}
|
||||
|
||||
// Deduplicate ids for this test.
|
||||
$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
|
||||
*/
|
||||
@ -770,7 +753,7 @@ final class Graph
|
||||
];
|
||||
|
||||
foreach ($prefixes as $prefix) {
|
||||
if (str_starts_with($rel, $prefix)) {
|
||||
if (str_starts_with($rel, (string) $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -808,7 +791,7 @@ final class Graph
|
||||
foreach ($repo->getFilenames() as $filename) {
|
||||
$factory = $repo->get($filename);
|
||||
|
||||
if ($factory === null) {
|
||||
if (! $factory instanceof TestCaseFactory) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -853,7 +836,10 @@ final class Graph
|
||||
if (! is_object($attribute)) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'name') || $attribute->name !== Group::class) {
|
||||
if (! property_exists($attribute, 'name')) {
|
||||
continue;
|
||||
}
|
||||
if ($attribute->name !== Group::class) {
|
||||
continue;
|
||||
}
|
||||
if (! property_exists($attribute, 'arguments')) {
|
||||
@ -992,10 +978,12 @@ final class Graph
|
||||
);
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file instanceof \SplFileInfo || ! $file->isFile()) {
|
||||
if (! $file instanceof \SplFileInfo) {
|
||||
continue;
|
||||
}
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $file->getPathname();
|
||||
if (! str_ends_with($path, '.blade.php')) {
|
||||
continue;
|
||||
@ -1082,7 +1070,6 @@ final class Graph
|
||||
return TableExtractor::fromMigrationSource($content);
|
||||
}
|
||||
|
||||
// Both `Pages/` and `pages/` are accepted — git paths are case-sensitive on Linux.
|
||||
private function componentForInertiaPage(string $rel): ?string
|
||||
{
|
||||
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
|
||||
@ -1271,8 +1258,6 @@ final class Graph
|
||||
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
|
||||
{
|
||||
if ($path === '' || $path === 'unknown') {
|
||||
@ -1286,8 +1271,7 @@ final class Graph
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
$isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR)
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'); // Windows drive
|
||||
|
||||
|| (strlen($path) >= 2 && $path[1] === ':');
|
||||
if ($isAbsolute) {
|
||||
$real = @realpath($path);
|
||||
|
||||
@ -1299,7 +1283,6 @@ final class Graph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Always forward slashes — git always uses them; Windows backslashes would never match.
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
} else {
|
||||
$relative = str_replace(DIRECTORY_SEPARATOR, '/', $path);
|
||||
|
||||
@ -5,29 +5,6 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
final class JsImportParser
|
||||
@ -39,12 +16,6 @@ final class JsImportParser
|
||||
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>>
|
||||
*/
|
||||
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
|
||||
{
|
||||
$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>
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -250,10 +209,6 @@ final class JsImportParser
|
||||
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
|
||||
{
|
||||
if (is_file($path)) {
|
||||
|
||||
@ -8,108 +8,16 @@ use Symfony\Component\Process\ExecutableFinder;
|
||||
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
|
||||
*/
|
||||
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';
|
||||
|
||||
/** 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
|
||||
* `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
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
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
|
||||
*/
|
||||
public static function buildStrict(string $projectRoot): ?array
|
||||
@ -133,22 +34,12 @@ final class JsModuleGraph
|
||||
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
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
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) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
return true;
|
||||
@ -169,79 +60,13 @@ final class JsModuleGraph
|
||||
$cached = self::readCache($projectRoot, $fingerprint);
|
||||
|
||||
if ($cached !== null) {
|
||||
self::reapWarmer();
|
||||
|
||||
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);
|
||||
|
||||
if ($process === null) {
|
||||
if (! $process instanceof Process) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -251,7 +76,13 @@ final class JsModuleGraph
|
||||
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
|
||||
@ -276,21 +107,7 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tell the Node helper which casing this project uses for its
|
||||
// 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 = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
|
||||
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||
|
||||
return $process;
|
||||
@ -336,47 +153,6 @@ final class JsModuleGraph
|
||||
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
|
||||
{
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
@ -475,10 +251,12 @@ final class JsModuleGraph
|
||||
$out = [];
|
||||
|
||||
foreach ($graph as $key => $value) {
|
||||
if (! is_string($key) || ! is_array($value)) {
|
||||
if (! is_string($key)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@ -4,13 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Edges\AutoloadEdges;
|
||||
use Pest\TestSuite;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Captures per-test file coverage. Singleton because PCOV/Xdebug have a single global state
|
||||
* shared across the `Prepared` and `Finished` subscribers.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Recorder
|
||||
@ -35,9 +33,6 @@ final class Recorder
|
||||
/** @var array<string, bool> */
|
||||
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>> */
|
||||
private array $fileToClassNames = [];
|
||||
|
||||
@ -80,8 +75,6 @@ final class Recorder
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} 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');
|
||||
|
||||
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
||||
@ -126,8 +119,6 @@ final class Recorder
|
||||
$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->linkImportedFiles($file);
|
||||
|
||||
@ -138,7 +129,6 @@ final class Recorder
|
||||
return;
|
||||
}
|
||||
|
||||
// Xdebug
|
||||
\xdebug_start_code_coverage();
|
||||
}
|
||||
|
||||
@ -151,15 +141,6 @@ final class Recorder
|
||||
if ($this->driver === 'pcov') {
|
||||
\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();
|
||||
$filesToCollectCoverageFor = [];
|
||||
|
||||
@ -172,11 +153,10 @@ final class Recorder
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \pcov\collect(\pcov\inclusive, $filesToCollectCoverageFor);
|
||||
|
||||
$coveredFiles = self::filesWithExecutedLines($data);
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
} else {
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = \xdebug_get_code_coverage();
|
||||
// `true` resets Xdebug's buffer; without it the next start() accumulates prior test coverage.
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$coveredFiles = array_keys($data);
|
||||
@ -195,8 +175,6 @@ final class Recorder
|
||||
$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->currentTestFile = null;
|
||||
@ -336,7 +314,6 @@ final class Recorder
|
||||
$files[$f] = true;
|
||||
};
|
||||
|
||||
// getInterfaceNames() is transitive — includes parents' interfaces — so one pass suffices.
|
||||
foreach ($reflection->getInterfaceNames() as $iname) {
|
||||
$linkSymbol($iname);
|
||||
}
|
||||
@ -475,7 +452,7 @@ final class Recorder
|
||||
}
|
||||
|
||||
$parts = preg_split('/\s+as\s+/i', $import);
|
||||
if ($parts === false || $parts === []) {
|
||||
if ($parts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -487,10 +464,12 @@ final class Recorder
|
||||
private function findAutoloadFile(string $className): ?string
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (! method_exists($loader[0], 'findFile')) {
|
||||
continue;
|
||||
}
|
||||
@ -639,8 +618,6 @@ final class Recorder
|
||||
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
|
||||
{
|
||||
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
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function filesWithExecutedLines(array $data): array
|
||||
private function filesWithExecutedLines(array $data): array
|
||||
{
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $file => $lines) {
|
||||
if (! is_string($file) || ! is_array($lines)) {
|
||||
if (! is_array($lines)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$covered = [];
|
||||
foreach ($lines as $line => $count) {
|
||||
if (is_int($count) && $count > 0) {
|
||||
@ -701,10 +666,8 @@ final class Recorder
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip files where the only "executed" line is the implicit
|
||||
// ZEND_RETURN at end-of-file (pcov artifact from being included
|
||||
// but never actually run).
|
||||
if (count($covered) === 1 && max($covered) === max(array_keys($lines))) {
|
||||
$lineKeys = array_keys($lines);
|
||||
if ($lineKeys !== [] && count($covered) === 1 && $covered[0] === max($lineKeys)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
33
src/Plugins/Tia/Replay.php
Normal file
33
src/Plugins/Tia/Replay.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,6 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
public function merge(array $results): void
|
||||
@ -122,11 +114,6 @@ final class ResultCollector
|
||||
$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
|
||||
{
|
||||
$this->currentTestId = null;
|
||||
@ -144,10 +131,6 @@ final class ResultCollector
|
||||
? round(microtime(true) - $this->startTime, 3)
|
||||
: 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;
|
||||
|
||||
$this->results[$this->currentTestId] = [
|
||||
|
||||
@ -5,31 +5,10 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
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 = [
|
||||
'vendor',
|
||||
'node_modules',
|
||||
@ -42,13 +21,6 @@ final class SourceScope
|
||||
'.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 = [
|
||||
'storage/framework',
|
||||
'storage/logs',
|
||||
@ -60,9 +32,8 @@ final class SourceScope
|
||||
* @param list<string> $excludes Absolute, normalised directory paths.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
private readonly array $includes,
|
||||
private readonly array $excludes,
|
||||
private array $includes,
|
||||
private array $excludes,
|
||||
) {}
|
||||
|
||||
public static function fromProjectRoot(string $projectRoot): self
|
||||
@ -94,15 +65,34 @@ final class SourceScope
|
||||
$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>`
|
||||
* 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.
|
||||
* @return list<string> Absolute, normalised paths to testsuite directories and files declared in phpunit.xml.
|
||||
*/
|
||||
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
|
||||
{
|
||||
$real = @realpath($absoluteFile);
|
||||
@ -110,13 +100,13 @@ final class SourceScope
|
||||
$candidate = self::normalise($candidate);
|
||||
|
||||
foreach ($this->excludes as $excluded) {
|
||||
if (self::startsWithDir($candidate, $excluded)) {
|
||||
if ($this->startsWithDir($candidate, $excluded)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->includes as $included) {
|
||||
if (self::startsWithDir($candidate, $included)) {
|
||||
if ($this->startsWithDir($candidate, $included)) {
|
||||
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>
|
||||
*/
|
||||
public function includes(): array
|
||||
@ -169,24 +155,13 @@ final class SourceScope
|
||||
continue;
|
||||
}
|
||||
|
||||
$absolute = self::resolveRelative($value, $configDir);
|
||||
|
||||
if ($absolute === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $absolute;
|
||||
$out[] = self::resolveRelative($value, $configDir);
|
||||
}
|
||||
|
||||
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>
|
||||
*/
|
||||
private static function topLevelProjectDirs(string $projectRoot): array
|
||||
@ -200,10 +175,12 @@ final class SourceScope
|
||||
$out = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($entry, self::TOP_LEVEL_NOISE, true)) {
|
||||
continue;
|
||||
}
|
||||
@ -239,7 +216,7 @@ final class SourceScope
|
||||
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] === '/'
|
||||
|| (strlen($path) >= 2 && $path[1] === ':'));
|
||||
@ -249,8 +226,6 @@ final class SourceScope
|
||||
$real = @realpath($combined);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -262,7 +237,7 @@ final class SourceScope
|
||||
return rtrim($path, '/\\');
|
||||
}
|
||||
|
||||
private static function startsWithDir(string $candidate, string $dir): bool
|
||||
private function startsWithDir(string $candidate, string $dir): bool
|
||||
{
|
||||
if ($candidate === $dir) {
|
||||
return true;
|
||||
|
||||
@ -5,36 +5,10 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
final class Storage
|
||||
{
|
||||
/**
|
||||
* Directory where TIA's State blobs live for `$projectRoot`.
|
||||
*/
|
||||
public static function tempDir(string $projectRoot): string
|
||||
{
|
||||
$home = self::homeDir();
|
||||
@ -51,15 +25,6 @@ final class Storage
|
||||
.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
|
||||
{
|
||||
$dir = self::tempDir($projectRoot);
|
||||
@ -80,10 +45,12 @@ final class Storage
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir.DIRECTORY_SEPARATOR.$entry;
|
||||
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
@ -98,11 +65,6 @@ final class Storage
|
||||
@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
|
||||
{
|
||||
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`
|
||||
* 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
|
||||
{
|
||||
@ -152,12 +94,6 @@ final class Storage
|
||||
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
|
||||
{
|
||||
$url = self::rawOriginUrl($projectRoot);
|
||||
@ -176,8 +112,6 @@ final class Storage
|
||||
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);
|
||||
}
|
||||
|
||||
@ -202,11 +136,6 @@ final class Storage
|
||||
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
|
||||
{
|
||||
$slug = strtolower($name);
|
||||
|
||||
@ -5,46 +5,14 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
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'];
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
@ -69,9 +37,6 @@ final class TableExtractor
|
||||
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';
|
||||
|
||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||
@ -106,35 +71,11 @@ final class TableExtractor
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$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*[\'"]([^\'"]+)[\'"])?/';
|
||||
|
||||
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';
|
||||
|
||||
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 = [
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
||||
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
||||
@ -196,11 +125,6 @@ final class TableExtractor
|
||||
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
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
|
||||
@ -5,38 +5,12 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
final class TableTracker
|
||||
{
|
||||
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';
|
||||
|
||||
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 */
|
||||
$db = $app->make('db');
|
||||
|
||||
@ -102,11 +70,6 @@ final class TableTracker
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
@ -118,11 +81,6 @@ final class TableTracker
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
170
src/Plugins/Tia/TestPaths.php
Normal file
170
src/Plugins/Tia/TestPaths.php
Normal 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)), '/');
|
||||
}
|
||||
}
|
||||
@ -10,20 +10,12 @@ use Pest\Factories\TestCaseFactory;
|
||||
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
|
||||
*/
|
||||
final readonly class Browser implements WatchDefault
|
||||
{
|
||||
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)
|
||||
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
|
||||
}
|
||||
@ -42,12 +34,8 @@ final readonly class Browser implements WatchDefault
|
||||
'resources/css/**/*.css',
|
||||
'resources/css/**/*.scss',
|
||||
'resources/css/**/*.less',
|
||||
// Vite / Webpack build output that browser tests may consume.
|
||||
'public/build/**/*.js',
|
||||
'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/**/*.css',
|
||||
'public/**/*.svg',
|
||||
@ -84,9 +72,6 @@ final readonly class Browser implements WatchDefault
|
||||
$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)) {
|
||||
$repo = TestSuite::getInstance()->tests;
|
||||
|
||||
|
||||
@ -7,12 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
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
|
||||
*/
|
||||
final readonly class Inertia implements WatchDefault
|
||||
@ -28,19 +22,6 @@ final readonly class Inertia implements WatchDefault
|
||||
{
|
||||
$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 = [];
|
||||
|
||||
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.ts'] = $browserTargets;
|
||||
$patterns['resources/js/app.js'] = $browserTargets;
|
||||
|
||||
@ -7,14 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
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
|
||||
*/
|
||||
final readonly class Laravel implements WatchDefault
|
||||
@ -28,42 +20,23 @@ final readonly class Laravel implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
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],
|
||||
|
||||
// Routes — loaded during boot. HTTP/Feature tests depend on them.
|
||||
'routes/*.php' => [$testPath],
|
||||
'routes/**/*.php' => [$testPath],
|
||||
|
||||
// Service providers / bootstrap — loaded during boot, affect
|
||||
// bindings, middleware, event listeners, scheduled tasks.
|
||||
'bootstrap/app.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],
|
||||
|
||||
// Seeders — often run globally via Pest.php beforeEach.
|
||||
'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],
|
||||
|
||||
// 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],
|
||||
|
||||
// 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/**/*.stub' => [$testPath],
|
||||
'app/**/*.json' => [$testPath],
|
||||
@ -71,25 +44,16 @@ final readonly class Laravel implements WatchDefault
|
||||
'app/**/*.yml' => [$testPath],
|
||||
'app/**/*.txt' => [$testPath],
|
||||
|
||||
// Blade templates — compiled to cache, source file not executed.
|
||||
'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],
|
||||
// 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/emails/**/*.blade.php' => [$testPath],
|
||||
|
||||
// Translations — JSON translations read via file_get_contents,
|
||||
// PHP translations loaded via include (but during boot).
|
||||
'lang/**/*.php' => [$testPath],
|
||||
'lang/**/*.json' => [$testPath],
|
||||
'resources/lang/**/*.php' => [$testPath],
|
||||
'resources/lang/**/*.json' => [$testPath],
|
||||
|
||||
// Build tool config — affects compiled assets consumed by
|
||||
// browser and Inertia tests.
|
||||
'vite.config.js' => [$testPath],
|
||||
'vite.config.ts' => [$testPath],
|
||||
'webpack.mix.js' => [$testPath],
|
||||
|
||||
@ -7,12 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
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
|
||||
*/
|
||||
final readonly class Livewire implements WatchDefault
|
||||
@ -26,15 +20,10 @@ final readonly class Livewire implements WatchDefault
|
||||
public function defaults(string $projectRoot, string $testPath): array
|
||||
{
|
||||
return [
|
||||
// Livewire views live alongside Blade views or in a dedicated dir.
|
||||
'resources/views/livewire/**/*.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],
|
||||
|
||||
// Livewire JS interop / Alpine plugins.
|
||||
'resources/js/**/*.js' => [$testPath],
|
||||
'resources/js/**/*.ts' => [$testPath],
|
||||
];
|
||||
|
||||
@ -5,8 +5,6 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
/**
|
||||
* Baseline watch patterns for any PHP project.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Php implements WatchDefault
|
||||
@ -18,55 +16,24 @@ final readonly class Php implements WatchDefault
|
||||
|
||||
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 [
|
||||
// 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.testing' => [$testPath],
|
||||
'.env.local' => [$testPath],
|
||||
'.env.*.local' => [$testPath],
|
||||
|
||||
// Docker / CI — can affect integration test infrastructure.
|
||||
'docker-compose.yml' => [$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],
|
||||
|
||||
// `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],
|
||||
|
||||
// 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],
|
||||
|
||||
// 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],
|
||||
|
||||
// Pest snapshots — external edits to snapshot files invalidate
|
||||
// snapshot assertions.
|
||||
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
|
||||
];
|
||||
}
|
||||
|
||||
@ -7,8 +7,6 @@ namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
use Composer\InstalledVersions;
|
||||
|
||||
/**
|
||||
* Watch patterns for Symfony projects.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final readonly class Symfony implements WatchDefault
|
||||
@ -21,12 +19,7 @@ final readonly class Symfony implements WatchDefault
|
||||
|
||||
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 [
|
||||
// Config — YAML, XML, and PHP. All loaded during kernel boot.
|
||||
'config/*.yaml' => [$testPath],
|
||||
'config/*.yml' => [$testPath],
|
||||
'config/*.php' => [$testPath],
|
||||
@ -36,37 +29,27 @@ final readonly class Symfony implements WatchDefault
|
||||
'config/**/*.php' => [$testPath],
|
||||
'config/**/*.xml' => [$testPath],
|
||||
|
||||
// Routes — loaded during boot.
|
||||
'config/routes/*.yaml' => [$testPath],
|
||||
'config/routes/*.php' => [$testPath],
|
||||
'config/routes/*.xml' => [$testPath],
|
||||
'config/routes/**/*.yaml' => [$testPath],
|
||||
|
||||
// Kernel / bootstrap — loaded during boot.
|
||||
'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],
|
||||
'src/Migrations/**/*.php' => [$testPath],
|
||||
|
||||
// Twig templates — compiled, source not PHP-executed.
|
||||
'templates/**/*.html.twig' => [$testPath],
|
||||
'templates/**/*.twig' => [$testPath],
|
||||
|
||||
// Translations (YAML / XLF / XLIFF).
|
||||
'translations/**/*.yaml' => [$testPath],
|
||||
'translations/**/*.yml' => [$testPath],
|
||||
'translations/**/*.xlf' => [$testPath],
|
||||
'translations/**/*.xliff' => [$testPath],
|
||||
|
||||
// Doctrine XML/YAML mappings.
|
||||
'config/doctrine/**/*.xml' => [$testPath],
|
||||
'config/doctrine/**/*.yaml' => [$testPath],
|
||||
|
||||
// Webpack Encore / asset-mapper config + frontend sources.
|
||||
'webpack.config.js' => [$testPath],
|
||||
'importmap.php' => [$testPath],
|
||||
'assets/**/*.js' => [$testPath],
|
||||
|
||||
@ -5,20 +5,10 @@ declare(strict_types=1);
|
||||
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
|
||||
*/
|
||||
interface WatchDefault
|
||||
{
|
||||
/**
|
||||
* Whether this default set applies to the current project.
|
||||
*/
|
||||
public function applicable(): bool;
|
||||
|
||||
/**
|
||||
|
||||
@ -8,25 +8,11 @@ use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
||||
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
|
||||
*/
|
||||
final class WatchPatterns
|
||||
{
|
||||
/**
|
||||
* All known default providers, in evaluation order.
|
||||
*
|
||||
* @var array<int, class-string<WatchDefault>>
|
||||
*/
|
||||
private const array DEFAULTS = [
|
||||
@ -49,12 +35,6 @@ final class WatchPatterns
|
||||
|
||||
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
|
||||
{
|
||||
$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
|
||||
*/
|
||||
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 array<int, string> $changedFiles Project-relative paths.
|
||||
* @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> $allTestFiles Project-relative test files from graph.
|
||||
* @return array<int, string>
|
||||
@ -193,11 +164,6 @@ final class WatchPatterns
|
||||
$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
|
||||
{
|
||||
$pattern = str_replace('\\', '/', $pattern);
|
||||
|
||||
92
src/Restarters/PcovRestarter.php
Normal file
92
src/Restarters/PcovRestarter.php
Normal 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, '/\\');
|
||||
}
|
||||
}
|
||||
113
src/Restarters/XdebugRestarter.php
Normal file
113
src/Restarters/XdebugRestarter.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,10 +10,6 @@ use PHPUnit\Event\Test\Finished;
|
||||
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
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,9 +9,6 @@ use PHPUnit\Event\Test\Finished;
|
||||
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
|
||||
*/
|
||||
final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
|
||||
|
||||
@ -10,10 +10,6 @@ use PHPUnit\Event\Test\Prepared;
|
||||
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
|
||||
*/
|
||||
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
|
||||
|
||||
@ -10,14 +10,6 @@ use PHPUnit\Event\Test\Prepared;
|
||||
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
|
||||
*/
|
||||
final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber
|
||||
|
||||
18
src/Support/Cpu.php
Normal file
18
src/Support/Cpu.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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, '/\\');
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ use PHPUnit\Event\Code\TestDoxBuilder;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Code\ThrowableBuilder;
|
||||
use PHPUnit\Event\Test\Errored;
|
||||
use PHPUnit\Event\Test\Failed;
|
||||
use PHPUnit\Event\Test\PhpunitDeprecationTriggered;
|
||||
use PHPUnit\Event\Test\PhpunitErrorTriggered;
|
||||
use PHPUnit\Event\Test\PhpunitNoticeTriggered;
|
||||
@ -40,11 +41,16 @@ final class StateGenerator
|
||||
}
|
||||
|
||||
foreach ($testResult->testFailedEvents() as $testResultEvent) {
|
||||
$state->add(TestResult::fromPestParallelTestCase(
|
||||
$testResultEvent->test(),
|
||||
TestResult::FAIL,
|
||||
$testResultEvent->throwable()
|
||||
));
|
||||
if ($testResultEvent instanceof Failed) {
|
||||
$state->add(TestResult::fromPestParallelTestCase(
|
||||
$testResultEvent->test(),
|
||||
TestResult::FAIL,
|
||||
$testResultEvent->throwable()
|
||||
));
|
||||
} else {
|
||||
// @phpstan-ignore-next-line
|
||||
$state->add(TestResult::fromBeforeFirstTestMethodErrored($testResultEvent));
|
||||
}
|
||||
}
|
||||
|
||||
$this->addTriggeredPhpunitEvents($state, $testResult->testTriggeredPhpunitErrorEvents(), TestResult::FAIL);
|
||||
|
||||
@ -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 30–50% 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;
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,6 @@ use Pest\Contracts\TestCaseFilter;
|
||||
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
|
||||
*/
|
||||
final readonly class TiaTestCaseFilter implements TestCaseFilter
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
|
||||
PASS Tests\Arch
|
||||
✓ preset → php → ignoring ['Pest\Expectation', 'debug_backtrace', 'var_export', …]
|
||||
✓ preset → strict → ignoring ['usleep']
|
||||
✓ preset → strict → ignoring ['Pest\Plugins\Tia\BaselineSync', 'usleep']
|
||||
✓ preset → security → ignoring ['eval', 'str_shuffle', 'exec', …]
|
||||
✓ globals
|
||||
✓ contracts
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Pest\Expectation;
|
||||
use Pest\Plugins\Tia\BaselineSync;
|
||||
|
||||
arch()->preset()->php()->ignoring([
|
||||
Expectation::class,
|
||||
@ -13,6 +14,7 @@ arch()->preset()->php()->ignoring([
|
||||
]);
|
||||
|
||||
arch()->preset()->strict()->ignoring([
|
||||
BaselineSync::class,
|
||||
'usleep',
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user